diff --git a/.claude/agents/code-inline-reviewer.md b/.claude/agents/code-inline-reviewer.md index 27e5b2bbefa5..c9d9e8f72dde 100644 --- a/.claude/agents/code-inline-reviewer.md +++ b/.claude/agents/code-inline-reviewer.md @@ -1,7 +1,7 @@ --- name: code-inline-reviewer -description: Reviews code and creates inline comments for specific rule violations. +description: Reviews Expensify App code and creates inline comments for specific rule violations. tools: Glob, Grep, Read, Bash, BashOutput model: inherit --- @@ -14,9 +14,9 @@ Your job is to scan through changed files and create **inline comments** for spe ## Rules -Coding standards are defined as individual rule files in `.claude/skills/coding-standards/rules/`. +Coding standards are defined as individual rule files in `.claude/skills/app-coding-standards/rules/`. -**Always use the `coding-standards` skill to review changed files.** +**Always use the `app-coding-standards` skill to review changed files.** Each rule file contains: @@ -28,7 +28,7 @@ Each rule file contains: ## Instructions 1. **Load all rules:** - - Use Glob to list all `.md` files in `.claude/skills/coding-standards/rules/` + - Use Glob to list all `.md` files in `.claude/skills/app-coding-standards/rules/` - Read ALL rule files - Build an explicit checklist of all rules (ruleId + title) from the YAML frontmatter - Build a ruleId-to-filename mapping for creating docs links in comments @@ -62,7 +62,7 @@ Each rule file contains: Use this format for the `body` field of each violation: ``` -### ❌ [(docs)](https://github.com/Expensify/App/blob/main/.claude/skills/coding-standards/rules/.md) +### ❌ [(docs)](https://github.com/Expensify/App/blob/main/.claude/skills/app-coding-standards/rules/.md) diff --git a/.claude/commands/review-code-pr.md b/.claude/commands/review-code-pr.md index fa51b41c6e9f..db29934e5238 100644 --- a/.claude/commands/review-code-pr.md +++ b/.claude/commands/review-code-pr.md @@ -1,6 +1,6 @@ --- allowed-tools: Bash(gh pr diff:*),Bash(gh pr view:*),Bash(check-compiler.sh:*) -description: Run the coding-standards rule linter on a PR diff. Use when user wants to review their changes against our custom rules. +description: Run the app-coding-standards rule linter on an Expensify App PR diff. Use when user wants to review their App changes against our custom rules. --- Perform a comprehensive PR review using a specialized subagent: diff --git a/.claude/skills/agent-device/SKILL.md b/.claude/skills/agent-device/SKILL.md index 85b83531e1e2..c63c07a6e862 100644 --- a/.claude/skills/agent-device/SKILL.md +++ b/.claude/skills/agent-device/SKILL.md @@ -10,7 +10,7 @@ allowed-tools: Bash(agent-device *) Bash(npm root *) Bash(scripts/is-hybrid-app. These checks evaluate at skill load. If any line shows `FAIL`, stop and surface the fix before running any device command. -`agent-device` version: !`R=0.18.0; V=$(agent-device --version 2>/dev/null); [ -n "$V" ] && [ "$(printf '%s\n%s\n' "$R" "$V" | sort -V | head -1)" = "$R" ] && echo "OK ($V)" || echo "FAIL (need v$R+, got: ${V:-not installed}). Fix: npm install -g agent-device@latest"` +`agent-device` version: !`R=0.20.0; V=$(agent-device --version 2>/dev/null); [ -n "$V" ] && [ "$(printf '%s\n%s\n' "$R" "$V" | sort -V | head -1)" = "$R" ] && echo "OK ($V)" || echo "FAIL (need v$R+, got: ${V:-not installed}). Fix: npm install -g agent-device@latest"` Bundled CLI skills dir: !`D="$(npm root -g)/agent-device/skills/agent-device"; test -s "$D/SKILL.md" && echo "OK ($D)" || echo "FAIL (missing $D/SKILL.md). Fix: npm install -g agent-device@latest"` @@ -121,4 +121,6 @@ Read these files directly for device automation guidance (bootstrap, exploration ## Flows -Repeatable steps (sign-in, onboarding, etc.) are captured as composable `.ad` snippets under [`flows/`](flows/README.md). For interactive usage, propose and run only `flows/macros/` helpers. `flows/tests/` belongs to a separate QA workflow and must not be proposed by this skill; QA/perf runs execute them via `agent-device test `. +This skill owns interactive automation only: reusable setup and navigation macros under [`flows/macros/`](flows/README.md), with platform overrides under `flows/macros//`. Propose and run macros through the [Agent decision loop](flows/README.md). `flows/README.md` is also the reference for the `.ad` metadata spec, selector rules, and recording workflow, so read it before authoring any `.ad` file in this repository. + +Measurement flows live in the [`measure-telemetry-span`](../measure-telemetry-span/SKILL.md) skill, which owns its own `flows/` and runner. Do not add measurement flows here. diff --git a/.claude/skills/agent-device/flows/README.md b/.claude/skills/agent-device/flows/README.md index d9e8701228cb..c00fe55d7f03 100644 --- a/.claude/skills/agent-device/flows/README.md +++ b/.claude/skills/agent-device/flows/README.md @@ -1,12 +1,13 @@ # Flows +This file is the reference for every `.ad` file in the repository: the metadata spec, selector rules, recording, and maintenance. + ## Directory layout -- `macros/` - reusable helpers for common setup/navigation actions that stop in a navigable state for further interactive work. -- `macros//` - platform-specific overrides of a `macros/` flow, for flows whose selectors differ per platform. See [Platform scoping](#platform-scoping). -- `tests/` - critical-scenario scripts for QA/perf verification that assert explicit outcomes (for example Sentry spans) and then stop. +1. `macros/` contains reusable setup and navigation helpers for interactive work. These are the only flows an interactive agent may propose. +2. `macros//` contains platform-specific overrides of a `macros/` flow, for flows whose selectors differ per platform. See [Platform scoping](#platform-scoping). -Composable `.ad` snippets - bounded units of work. A flow may span one or multiple screens as long as it represents a coherent, reusable action with clear start (`@pre`) and completion (`@post`) checkpoints. Each flow advertises machine-matchable metadata (`@pre`, `@post`, `@tag`, `@param`) via `# @`-prefixed comment headers, while flow type is derived from location (`flows/macros/` or `flows/tests/`). +`macros/` contains composable `.ad` snippets. The caller owns application startup, authentication, platform selection, session state, and cleanup. A flow may span one or multiple screens as long as it represents a coherent action with clear start (`@pre`) and completion (`@post`) checkpoints. ## Platform scoping @@ -34,29 +35,24 @@ Before manually navigating, use this human-in-the-loop loop: 7. `agent-device replay -e KEY=VALUE ...`. 8. If the flow declares `@post`, verify each `@post` with `is exists`. On success, re-enter the loop only if the user's stated goal is not complete; otherwise stop and report completion. On failure, propose peer flow/manual fallback options and ask before continuing. If no `@post` is declared (utility flow), rely on explicit user confirmation or the next snapshot before continuing. -## QA workflow (separate) +## Replay lifecycle -`flows/tests/` is reserved for dedicated QA automation and should not be offered to users as part of the interactive helper loop above. Run these flows with the dedicated test runner: - -```bash -agent-device test .claude/skills/agent-device/flows/tests/.ad -e KEY=VALUE ... -``` +No flow in this repository owns application lifecycle, so none of them is a self-contained `agent-device test` input. `agent-device test` creates an isolated session per attempt; use it only for scripts that own `context`, `open`, and cleanup. Repository flows expect the caller to open the app and prepare a named session first. ## Metadata header spec Each flow starts with `# @key value` comment lines. The `.ad` parser treats `#` lines as no-ops, so headers cost nothing at replay time. -| Field | Cardinality | Value | -| -------- | ----------- | ------------------------------------------------------------------------------------------------ | -| `@desc` | 1 | One-line human summary. | -| `@pre` | 1..N | Selector that must resolve in the current snapshot. Multiple lines are ANDed. | -| `@post` | 0..N | Selector expected after replay. Multiple lines are ANDed. Used for chaining + success. | -| `@tag` | 0..N | Free-form category (`auth`, `onboarding`, ...) or scoped (`sentry-`). | -| `@param` | 0..N | Runtime input contract: `@param KEY description.` Use with `${KEY}` in flow body. | +| Field | Cardinality | Value | +| -------- | ----------- | -------------------------------------------------------------------------------------- | +| `@desc` | 1 | One-line human summary. | +| `@pre` | 1..N | Selector that must resolve in the current snapshot. Multiple lines are ANDed. | +| `@post` | 0..N | Selector expected after replay. Multiple lines are ANDed. The flow body enforces them. | +| `@param` | 0..N | Runtime input contract: `@param KEY description.` Use with `${KEY}` in flow body. | Selector syntax matches the body: `id="..."`, `role="..." label="..."`, `text="..."`, `||` for fallbacks. -## Parametrization (`agent-device` v0.13.0+) +## Parametrization Declare runtime inputs via metadata (`@param`) and reference them in the body with `${VAR}` interpolation. Values are supplied by caller arguments (`-e`) or shell imports (`AD_VAR_*`) - never by in-file `env` directives. @@ -78,11 +74,16 @@ agent-device replay .ad -e EMAIL=other@example.com ## Authoring rules - **No `open`, no `close`, no `context` header.** Caller owns lifecycle. -- **No fixed `wait` calls.** `fill`/`press` resolve selectors with retry. Only add `wait ` for real post-action blocks. +- **No fixed-duration `wait` calls.** Guard every asynchronous transition with `wait "" `, which polls until the selector resolves. A bare `wait ` only burns wall clock. +- **No command flags in a flow body.** The `.ad` parser only reads flags for `press`, `fill`, `click`, and a few capture commands; for `is`, `wait`, and `find` it treats `--first` and friends as positionals. Disambiguate with a tighter selector instead. - **Durable selectors.** Prefer `id=...` first, then `role=... label=...`, with `||` fallbacks. Avoid `@eN` refs. +- **A `@pre` must distinguish the start screen from the destination.** A bottom-tab label such as `text="Home"` is rendered on every tab, so asserting it proves nothing. Anchor on a screen-scoped `id=` instead - `id="HomePage"` for Home, `id="BaseSidebarScreen"` for Inbox - otherwise a flow that starts on the wrong screen reports success while the action it measures never happens. - **Confirm every selector on the platform it is written for.** `snapshot -i` prints display tags, which are not selector values - a node printed as `[text-field]` may only match `role="textbox"`. Check the exit code of `agent-device is visible ""` before committing a selector, and never carry one across platforms unchecked. -- **Every flow declares `@desc` and `@pre`.** Add `@post` for outcome-bearing flows; utility flows (for example `go-back`) may omit it. Add `@tag` when applicable. -- **Choose directory intentionally.** Put reusable setup/navigation steps in `flows/macros/`; put outcome verification scenarios in `flows/tests/`. +- **Every flow declares `@desc` and `@pre`.** Outcome-bearing flows also declare at least one `@post`. Utility macros (for example `go-back`) may omit `@post`. +- **Every declared `@pre` and `@post` runs as a body assertion.** Use `is exists` or `wait`. Metadata documents the contract but does not execute it. +- **Assertion order decides whether a pass means anything.** Enforce every `@pre` before the first mutation and every `@post` after the last one. An assertion on the wrong side of a mutation lets a flow report success from the screen it started on. +- **No `find "click"`.** It resolves at runtime and hides which element was hit. Press an exact selector instead. +- **A bare `label=` alternative must carry `hittable=true`.** Without it the match can land on an off-screen or non-interactive node and the press silently does nothing. - **Keep scope coherent, not artificially tiny.** Flows can span multiple screens when that sequence is the reusable intent (for example "create and submit manual expense"). - **Peers share `@pre` and differ on `@post`.** One flow per narrow outcome is better than a mega-flow with conditional branches. - **Use `@param` for substituted values.** If a literal is interpolated into the body, declare `# @param KEY description.` and reference it as `${KEY}`. @@ -94,21 +95,24 @@ agent-device replay .ad -e EMAIL=other@example.com 1. Drive the target screen manually. 2. Start a session with `--save-script`: ```bash - agent-device open --save-script .claude/skills/agent-device/flows//.ad + agent-device open --save-script .claude/skills/agent-device/flows/macros/.ad ``` 3. Perform the steps. 4. `agent-device close` - flushes the `.ad`. 5. Edit the generated file: - Delete the `context` line, leading `open ... --relaunch`, trailing `close`, and eyeballing `wait`s. - - Move file to `flows/macros/` or `flows/tests/`, then add `@desc`, `@pre`, optional `@post`, optional `@tag`, and any needed `@param` headers. -6. Verify: pre-check from a matching state, replay, post-check. + - Move the file to `flows/macros/`, then add `@desc`, `@pre`, optional `@post`, and any needed `@param` headers. +6. Add executable checks for every declared `@pre` and `@post`. +7. Verify: pre-check from a matching state, replay, post-check. ## Maintenance -Heal selector drift in place: +Heal selector drift by hand. Replay the flow and read the divergence report: ```bash -agent-device replay -u .claude/skills/agent-device/flows//.ad +agent-device replay .ad --session ``` -Re-verify `@pre`/`@post` still hold, then commit. Note: `replay -u` can rewrite interpolated lines to concrete selectors/values; review diffs and restore `${KEY}` placeholders where needed. Keep runtime inputs in `@param` + `-e`/`AD_VAR_*`; do not reintroduce in-file `env` directives. +Every divergence carries ranked selector suggestions. Apply them yourself: `replay --update`/`-u` no longer rewrites the `.ad` file. Editing by hand also keeps `${KEY}` placeholders intact. Re-verify `@pre`/`@post` still hold, then commit. Keep runtime inputs in `@param` + `-e`/`AD_VAR_*`; do not reintroduce in-file `env` directives. + +After editing a flow, re-read the authoring rules above and replay it from a matching start state. A green replay plus enforced `@pre`/`@post` is the check. diff --git a/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad index c940dcbfbee6..39d5e35c849b 100644 --- a/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad +++ b/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad @@ -1,14 +1,14 @@ context platform=android # @desc Complete onboarding with minimal choices (skip work email, pick "Something else" purpose, enter generic name). Lands on Home. # @pre text="What’s your work email?" -# @post text="Home" # @post role="button" label="Search" -# @tag onboarding # @param FIRST_NAME First name to enter on onboarding profile step. # @param LAST_NAME Last name to enter on onboarding profile step. press "id=\"onboardingPrivateEmailSkipButton\" || role=\"button\" label=\"Skip\" || label=\"Skip\"" +wait "label=\"Something else\"" press "role=\"button\" label=\"Something else\" || label=\"Something else\"" +wait "label=\"First name\" editable=true" fill "role=\"textfield\" label=\"First name\" editable=true || label=\"First name\" editable=true" "${FIRST_NAME}" fill "role=\"textfield\" label=\"Last name\" editable=true || label=\"Last name\" editable=true" "${LAST_NAME}" press "role=\"button\" label=\"Continue\" || label=\"Continue\"" diff --git a/.claude/skills/agent-device/flows/macros/android/send-message.ad b/.claude/skills/agent-device/flows/macros/android/send-message.ad index bdc93542173c..d5481ac7b581 100644 --- a/.claude/skills/agent-device/flows/macros/android/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/android/send-message.ad @@ -1,8 +1,7 @@ context platform=android -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. # @pre label="Write something..." editable=true # @post label="Write something..." editable=true -# @tag chat # @param MESSAGE Message text to send in the currently open chat. is exists "label=\"Write something...\" editable=true" diff --git a/.claude/skills/agent-device/flows/macros/android/sign-in.ad b/.claude/skills/agent-device/flows/macros/android/sign-in.ad index 61a7e0b1fc2b..157af9ff27a2 100644 --- a/.claude/skills/agent-device/flows/macros/android/sign-in.ad +++ b/.claude/skills/agent-device/flows/macros/android/sign-in.ad @@ -3,8 +3,8 @@ context platform=android # @pre id="username" # @pre role="button" label="Continue" # @post role="button" label="Join" || role="button" label="Search" -# @tag auth # @param EMAIL Login email. Use randomized alias format `agent-device-testing+<9digits>@gmail.com` to avoid account flagging. -fill "id=\"username\" || label=\"Phone or email\" editable=true" "${EMAIL}" +press "id=\"username\" || role=\"edittext\" label=\"Phone or email\" editable=true || label=\"Phone or email\" editable=true" +fill "id=\"username\" || role=\"edittext\" label=\"Phone or email\" editable=true || label=\"Phone or email\" editable=true" "${EMAIL}" press "role=\"button\" label=\"Continue\" || label=\"Continue\"" diff --git a/.claude/skills/agent-device/flows/macros/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/complete-onboarding.ad index 990eb49cb2d2..bd72173f3d95 100644 --- a/.claude/skills/agent-device/flows/macros/complete-onboarding.ad +++ b/.claude/skills/agent-device/flows/macros/complete-onboarding.ad @@ -2,7 +2,6 @@ # @pre text="What’s your work email?" # @post text="Home" # @post role="button" label="Search" -# @tag onboarding # @param FIRST_NAME First name to enter on onboarding profile step. # @param LAST_NAME Last name to enter on onboarding profile step. diff --git a/.claude/skills/agent-device/flows/macros/go-back.ad b/.claude/skills/agent-device/flows/macros/go-back.ad index 621a4f477851..20bddae74078 100644 --- a/.claude/skills/agent-device/flows/macros/go-back.ad +++ b/.claude/skills/agent-device/flows/macros/go-back.ad @@ -1,4 +1,3 @@ # @desc Generic back navigation via the HeaderWithBackButton chevron. Works on any screen that mounts HeaderWithBackButton (Create Expense, Search, Report details, settings pages, etc.). # @pre role="button" label="Back" -# @tag navigation press "role=\"button\" label=\"Back\"" diff --git a/.claude/skills/agent-device/flows/macros/join.ad b/.claude/skills/agent-device/flows/macros/join.ad new file mode 100644 index 000000000000..a45f5fb7c102 --- /dev/null +++ b/.claude/skills/agent-device/flows/macros/join.ad @@ -0,0 +1,6 @@ +# @desc Press Join on the post-sign-in welcome screen to create the account and enter onboarding. +# @pre role="button" label="Join" +# @post text="What’s your work email?" +# @tag auth + +press "role=\"button\" label=\"Join\" || label=\"Join\"" diff --git a/.claude/skills/agent-device/flows/macros/return-to-inbox.ad b/.claude/skills/agent-device/flows/macros/return-to-inbox.ad new file mode 100644 index 000000000000..d85405adf298 --- /dev/null +++ b/.claude/skills/agent-device/flows/macros/return-to-inbox.ad @@ -0,0 +1,8 @@ +# @desc Return from a child screen to the Inbox and wait for its sidebar anchor. +# @pre role="button" label="Back" +# @post id="BaseSidebarScreen" +is exists "role=\"button\" label=\"Back\"" +press "role=\"button\" label=\"Back\"" +wait "role=\"tab\" label=\"Inbox\" || role=\"tab\" label=\"Inbox. Your review is required\" || label=\"Inbox\" hittable=true || label=\"Inbox. Your review is required\" hittable=true" 10000 +press "role=\"tab\" label=\"Inbox\" || role=\"tab\" label=\"Inbox. Your review is required\" || label=\"Inbox\" hittable=true || label=\"Inbox. Your review is required\" hittable=true" +wait "id=\"BaseSidebarScreen\"" 10000 diff --git a/.claude/skills/agent-device/flows/macros/send-message.ad b/.claude/skills/agent-device/flows/macros/send-message.ad index 98f869fbdc79..7ba4b8af49a8 100644 --- a/.claude/skills/agent-device/flows/macros/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/send-message.ad @@ -1,7 +1,6 @@ -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. # @pre label="Write something..." editable=true # @post label="Write something..." editable=true -# @tag chat # @param MESSAGE Message text to send in the currently open chat. is exists "label=\"Write something...\" editable=true" diff --git a/.claude/skills/agent-device/flows/macros/switch-to-home.ad b/.claude/skills/agent-device/flows/macros/switch-to-home.ad new file mode 100644 index 000000000000..4cfb30f86053 --- /dev/null +++ b/.claude/skills/agent-device/flows/macros/switch-to-home.ad @@ -0,0 +1,6 @@ +# @desc Switch to the Home tab and wait for the Home screen anchor. +# @pre role="tab" label="Home" || label="Home" hittable=true +# @post id="HomePage" +is exists "role=\"tab\" label=\"Home\" || label=\"Home\" hittable=true" +press "role=\"tab\" label=\"Home\" || label=\"Home\" hittable=true" +wait "id=\"HomePage\"" 10000 diff --git a/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad index b64ec1529da7..227bcd65cabd 100644 --- a/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad +++ b/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad @@ -1,9 +1,8 @@ context platform=web # @desc Complete onboarding with minimal choices (skip work email, pick "Something else" purpose, enter generic name). Lands on Home. # @pre text="What’s your work email?" -# @post text="Home" +# @post role="heading" label="Home" # @post role="button" label="Search" -# @tag onboarding # @param FIRST_NAME First name to enter on onboarding profile step. # @param LAST_NAME Last name to enter on onboarding profile step. diff --git a/.claude/skills/agent-device/flows/macros/web/send-message.ad b/.claude/skills/agent-device/flows/macros/web/send-message.ad index d34d111b9e7a..7e80e2bf6388 100644 --- a/.claude/skills/agent-device/flows/macros/web/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/web/send-message.ad @@ -1,8 +1,7 @@ context platform=web -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. # @pre role="textbox" label="Write something..." # @post role="textbox" label="Write something..." -# @tag chat # @param MESSAGE Message text to send in the currently open chat. is exists "role=\"textbox\" label=\"Write something...\"" diff --git a/.claude/skills/agent-device/flows/macros/web/sign-in.ad b/.claude/skills/agent-device/flows/macros/web/sign-in.ad index e13ea2a5d200..6e55713f8df7 100644 --- a/.claude/skills/agent-device/flows/macros/web/sign-in.ad +++ b/.claude/skills/agent-device/flows/macros/web/sign-in.ad @@ -2,9 +2,8 @@ context platform=web # @desc Sign in with the shared agent-device test account. Supports both new-account and returning-account outcomes. Caller MUST randomize EMAIL via `-e EMAIL=agent-device-testing+<9digits>@gmail.com` to avoid account flagging. # @pre role="textbox" label="Phone or email" # @pre role="button" label="Continue" -# @post text="Welcome!" || text="Home" +# @post text="Welcome!" || role="heading" label="Home" # @post role="button" label="Join" || role="button" label="Search" -# @tag auth # @param EMAIL Login email. Use randomized alias format `agent-device-testing+<9digits>@gmail.com` to avoid account flagging. fill "role=\"textbox\" label=\"Phone or email\" || label=\"Phone or email\"" "${EMAIL}" diff --git a/.claude/skills/agent-device/flows/tests/create-expense-manual.ad b/.claude/skills/agent-device/flows/tests/create-expense-manual.ad deleted file mode 100644 index 01f514b188c7..000000000000 --- a/.claude/skills/agent-device/flows/tests/create-expense-manual.ad +++ /dev/null @@ -1,28 +0,0 @@ -# @desc Open Create Expense from the FAB, enter an amount on the Manual tab, fill the Merchant field, and submit. Supports configurable currency-aware submit button matching. Exercises the full submit-chain Sentry spans end-to-end. Assumes a workspace where Category is not required. -# @pre role="button" label="Open actions menu" -# @post text="Reports" -# @post role="button" label="Expenses" -# @tag iou -# @tag navigation -# @tag perf -# @tag sentry-ManualCreateExpenseSubmit -# @tag sentry-ManualSubmitToDestinationVisible -# @tag sentry-ManualCreateExpenseServerResponse -# @tag sentry-ManualConfirmationMount -# @tag sentry-ManualConfirmationListReady -# @tag sentry-ManualConfirmationReceiptLoad -# @tag sentry-ManualGeolocationWait -# @param AMOUNT Whole-number amount to type into the Manual amount input (for example `22`). -# @param CURRENCY Currency symbol used by the submit CTA selector (for example `PLN` or `USD`). -# @param MERCHANT Merchant name to populate before submit. - -press "role=\"button\" label=\"Open actions menu\"" -press "role=\"button\" label=\"Create expense\" || label=\"Create expense\"" -press "label=\"Manual\"" -is exists "role=\"button\" label=\"Flip\"" -fill "editable=true" "${AMOUNT}" -press "role=\"button\" label=\"Next\" || label=\"Next\"" -press "role=\"button\" label=\"Merchant\" || label=\"Merchant\"" -fill "role=\"text-field\" label=\"Merchant,\" editable=true || label=\"Merchant,\" editable=true" "${MERCHANT}" -press "role=\"button\" label=\"Save\" || label=\"Save\"" -press "label=\"Create ${CURRENCY} ${AMOUNT}.00 expense\" || label=\"Create expense\"" diff --git a/.claude/skills/agent-device/flows/tests/open-create-expense.ad b/.claude/skills/agent-device/flows/tests/open-create-expense.ad deleted file mode 100644 index 0ae40718d23d..000000000000 --- a/.claude/skills/agent-device/flows/tests/open-create-expense.ad +++ /dev/null @@ -1,11 +0,0 @@ -# @desc From the Inbox tab, open the FAB actions menu and tap "Create expense". Triggers the ManualOpenCreateExpense Sentry span (startMoneyRequest -> IOURequestStart/Participants/Confirmation page). Assumes the user is signed in and the Inbox bottom nav with the floating action button is visible. -# @pre text="Inbox" -# @pre role="button" label="Open actions menu" -# @post text="Manual" -# @tag iou -# @tag navigation -# @tag perf -# @tag sentry-ManualOpenCreateExpense -find "Inbox" "click" -press "role=\"button\" label=\"Open actions menu\"" -press "role=\"button\" label=\"Create expense\" || label=\"Create expense\"" diff --git a/.claude/skills/agent-device/flows/tests/open-report.ad b/.claude/skills/agent-device/flows/tests/open-report.ad deleted file mode 100644 index 7e76648c6199..000000000000 --- a/.claude/skills/agent-device/flows/tests/open-report.ad +++ /dev/null @@ -1,12 +0,0 @@ -# @desc From the Inbox tab, open a chat/report matching TARGET_CHAT in the LHN, with fallback to the first available chat row. Triggers the ManualOpenReport Sentry span (LHN row press -> report screen mounted with composer visible). Assumes the user is signed in with at least one chat in the Inbox. -# @pre text="Inbox" -# @pre role="button" label="Search" -# @post role="textview" label="Write something..." -# @tag chat -# @tag navigation -# @tag perf -# @tag sentry-ManualOpenReport -# @param TARGET_CHAT Visible report/chat title to open from Inbox LHN. - -find "Inbox" "click" -find "${TARGET_CHAT}" "click" diff --git a/.claude/skills/agent-device/flows/tests/open-search-router.ad b/.claude/skills/agent-device/flows/tests/open-search-router.ad deleted file mode 100644 index 78d0a413ea09..000000000000 --- a/.claude/skills/agent-device/flows/tests/open-search-router.ad +++ /dev/null @@ -1,10 +0,0 @@ -# @desc Open the SearchRouter from the Inbox tab. Triggers the ManualOpenSearchRouter Sentry span (Search button tap -> SearchRouterPage visible). Assumes the user is signed in. -# @pre text="Inbox" -# @pre role="button" label="Search" -# @post label="Search for something..." editable=true -# @tag navigation -# @tag perf -# @tag search -# @tag sentry-ManualOpenSearchRouter -press "label=\"Inbox\" || label=\"Inbox. Your review is required\"" -press "role=\"button\" label=\"Search\"" diff --git a/.claude/skills/agent-device/flows/tests/scan-receipt-init.ad b/.claude/skills/agent-device/flows/tests/scan-receipt-init.ad deleted file mode 100644 index 194eef727f5e..000000000000 --- a/.claude/skills/agent-device/flows/tests/scan-receipt-init.ad +++ /dev/null @@ -1,12 +0,0 @@ -# @desc From the Inbox tab, open the FAB -> Create expense -> switch to Scan tab. Triggers the ManualCameraInit Sentry span (Scan tab mount -> camera session ready). Assumes the user is signed in and camera permission is granted. -# @pre text="Inbox" -# @pre role="button" label="Open actions menu" -# @post text="Take a photo" -# @tag iou -# @tag scan -# @tag perf -# @tag sentry-ManualCameraInit -find "Inbox" "click" -press "role=\"button\" label=\"Open actions menu\"" -press "role=\"button\" label=\"Create expense\" || label=\"Create expense\"" -press "label=\"Scan\"" diff --git a/.claude/skills/agent-device/flows/tests/send-message.ad b/.claude/skills/agent-device/flows/tests/send-message.ad deleted file mode 100644 index 97c8a8db6100..000000000000 --- a/.claude/skills/agent-device/flows/tests/send-message.ad +++ /dev/null @@ -1,16 +0,0 @@ -# @desc From the Inbox tab, open a chat matching TARGET_CHAT in the LHN, type a message and send it. Triggers the ManualSendMessageVisible Sentry span (Send press -> optimistic message fragment rendered). Assumes the user is signed in with at least one chat in the Inbox. -# @pre text="Inbox" -# @pre role="button" label="Search" -# @post label="Write something..." editable=true -# @tag chat -# @tag navigation -# @tag perf -# @tag sentry-ManualSendMessageVisible -# @param MESSAGE Message text to send in the opened chat. -# @param TARGET_CHAT Visible report/chat title to open from Inbox LHN. - -find "Inbox" "click" -find "${TARGET_CHAT}" "click" -is exists "label=\"Write something...\" editable=true" -fill "label=\"Write something...\" editable=true" "${MESSAGE}" -press "role=\"button\" label=\"Send\"" diff --git a/.claude/skills/agent-device/flows/tests/submit-expense.ad b/.claude/skills/agent-device/flows/tests/submit-expense.ad deleted file mode 100644 index dfacc9f0c9dd..000000000000 --- a/.claude/skills/agent-device/flows/tests/submit-expense.ad +++ /dev/null @@ -1,10 +0,0 @@ -# @desc Tap the submit button on the IOU confirmation page (IOURequestStepConfirmation). Triggers ManualCreateExpenseSubmit, ManualSubmitToDestinationVisible, and ManualCreateExpenseServerResponse. If the device has location permission granted, ManualGeolocationWait also fires as a child of Submit. Requires being on the confirmation page already. -# @pre role="button" label="Upload receipt" || role="button" label="Create expense" -# @post role="button" label="Open actions menu" -# @tag iou -# @tag perf -# @tag sentry-ManualCreateExpenseSubmit -# @tag sentry-ManualSubmitToDestinationVisible -# @tag sentry-ManualCreateExpenseServerResponse -# @tag sentry-ManualGeolocationWait -press "role=\"button\" label=\"Create expense\" || label=\"Create expense\"" diff --git a/.claude/skills/agent-device/flows/tests/switch-home-to-inbox.ad b/.claude/skills/agent-device/flows/tests/switch-home-to-inbox.ad deleted file mode 100644 index ff18dd14ceed..000000000000 --- a/.claude/skills/agent-device/flows/tests/switch-home-to-inbox.ad +++ /dev/null @@ -1,9 +0,0 @@ -# @desc Switch the bottom navigation tab from Home to Inbox. Triggers the ManualNavigateToInboxTab Sentry span end-to-end (BottomTab click -> Inbox layout completion). Assumes the user is signed in and the bottom nav bar is visible. -# @pre text="Home" -# @pre text="Inbox" -# @post text="Inbox" -# @tag navigation -# @tag perf -# @tag sentry-ManualNavigateToInboxTab -press "label=\"Home\"" -press "label=\"Inbox\" || label=\"Inbox. Your review is required\"" diff --git a/.claude/skills/agent-device/flows/tests/switch-home-to-reports.ad b/.claude/skills/agent-device/flows/tests/switch-home-to-reports.ad deleted file mode 100644 index 437c22c8cb2a..000000000000 --- a/.claude/skills/agent-device/flows/tests/switch-home-to-reports.ad +++ /dev/null @@ -1,9 +0,0 @@ -# @desc Switch the bottom navigation tab from Home to Spend (legacy name: Reports). Triggers the ManualNavigateToReports Sentry span end-to-end (BottomTab click -> Spend screen layout completion). Assumes the user is signed in and the bottom nav bar is visible. -# @pre text="Home" -# @pre text="Spend" -# @post text="Spend" -# @tag navigation -# @tag perf -# @tag sentry-ManualNavigateToReports -press "label=\"Home\"" -press "label=\"Spend\"" diff --git a/.claude/skills/coding-standards/SKILL.md b/.claude/skills/app-coding-standards/SKILL.md similarity index 88% rename from .claude/skills/coding-standards/SKILL.md rename to .claude/skills/app-coding-standards/SKILL.md index 4cb47104661c..bef97f04d42f 100644 --- a/.claude/skills/coding-standards/SKILL.md +++ b/.claude/skills/app-coding-standards/SKILL.md @@ -1,10 +1,10 @@ --- -name: coding-standards -description: Provides coding standards for React Native — performance patterns, consistency rules, and clean React architecture. Use when writing, modifying, or reviewing code. +name: app-coding-standards +description: Provides coding standards for the Expensify App's React Native code, including performance patterns, consistency rules, and clean React architecture. Use when writing, modifying, or reviewing App code. alwaysApply: true --- -# Expensify Coding Standards +# Expensify App Coding Standards Coding standards for the Expensify App. Each standard is a standalone file in `rules/` with reasoning, examples, and applicability conditions. @@ -49,15 +49,14 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r - [CONSISTENCY-7](rules/consistency-7-localize-copy.md) — Localize all user-visible copy - [CONSISTENCY-8](rules/consistency-8-localize-numbers-dates.md) — Localize numbers, amounts, dates and phone numbers - [CONSISTENCY-9](rules/consistency-9-file-naming.md) — Name files after what they export -- [CONSISTENCY-10](rules/consistency-10-jsdoc.md) — Follow the JSDoc style guidelines +- [CONSISTENCY-10](rules/consistency-10-jsdoc.md) — Follow the JSDoc style guidelines and document props only where there is something to say - [CONSISTENCY-11](rules/consistency-11-no-todo-comments.md) — Track future work in an issue, not a TODO comment - [CONSISTENCY-12](rules/consistency-12-callback-named-for-action.md) — Name callbacks for what they do, not the event they handle -- [CONSISTENCY-13](rules/consistency-13-document-props.md) — Document component props with a JSDoc block comment -- [CONSISTENCY-14](rules/consistency-14-new-file-header.md) — Non-trivial new files start with a header description -- [CONSISTENCY-15](rules/consistency-15-comment-why.md) — Comments explain why the code exists, not what it does -- [CONSISTENCY-16](rules/consistency-16-plain-comment-style.md) — Write comments as plain, natural sentences -- [CONSISTENCY-17](rules/consistency-17-no-ai-jargon.md) — No AI-generated jargon in code or comments -- [CONSISTENCY-18](rules/consistency-18-plural-form.md) — Pluralize with PluralForm, not ternaries on a count +- [CONSISTENCY-13](rules/consistency-13-new-file-header.md) — Non-trivial new files start with a header description +- [CONSISTENCY-14](rules/consistency-14-comment-why.md) — Comments explain why the code exists, not what it does +- [CONSISTENCY-15](rules/consistency-15-plain-comment-style.md) — Write comments as plain, natural sentences +- [CONSISTENCY-16](rules/consistency-16-no-ai-jargon.md) — No AI-generated jargon in code or comments +- [CONSISTENCY-17](rules/consistency-17-plural-form.md) — Pluralize with PluralForm, not ternaries on a count ### Clean React Patterns - [CLEAN-REACT-PATTERNS-0](rules/clean-react-0-compiler.md) — React Compiler compliance diff --git a/.claude/skills/coding-standards/rules/clean-react-0-compiler.md b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md similarity index 96% rename from .claude/skills/coding-standards/rules/clean-react-0-compiler.md rename to .claude/skills/app-coding-standards/rules/clean-react-0-compiler.md index 303719f6e2cf..e43d5c7fb94a 100644 --- a/.claude/skills/coding-standards/rules/clean-react-0-compiler.md +++ b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md @@ -17,7 +17,7 @@ Manual memoization is therefore: The codebase enforces this via: - **Babel plugin**: `babel-plugin-react-compiler` in `babel.config.js` -- **ESLint processor**: `eslint-processor-react-compiler-compat` suppresses redundant lint rules when files compile successfully +- **Lint post-processor**: `scripts/lint/processors/ReactCompilerFilter.ts` suppresses redundant lint rules when both React Compilers memoize the file - **CI compliance check**: `scripts/react-compiler-compliance-check.ts` enforces that new components/hooks compile and that existing compiled files don't regress Reference: [React Compiler documentation](https://react.dev/learn/react-compiler) diff --git a/.claude/skills/coding-standards/rules/clean-react-1-composition-over-config.md b/.claude/skills/app-coding-standards/rules/clean-react-1-composition-over-config.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-1-composition-over-config.md rename to .claude/skills/app-coding-standards/rules/clean-react-1-composition-over-config.md diff --git a/.claude/skills/coding-standards/rules/clean-react-2-own-behavior.md b/.claude/skills/app-coding-standards/rules/clean-react-2-own-behavior.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-2-own-behavior.md rename to .claude/skills/app-coding-standards/rules/clean-react-2-own-behavior.md diff --git a/.claude/skills/coding-standards/rules/clean-react-3-context-free-contracts.md b/.claude/skills/app-coding-standards/rules/clean-react-3-context-free-contracts.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-3-context-free-contracts.md rename to .claude/skills/app-coding-standards/rules/clean-react-3-context-free-contracts.md diff --git a/.claude/skills/coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md b/.claude/skills/app-coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md rename to .claude/skills/app-coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md diff --git a/.claude/skills/coding-standards/rules/clean-react-5-narrow-state.md b/.claude/skills/app-coding-standards/rules/clean-react-5-narrow-state.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-5-narrow-state.md rename to .claude/skills/app-coding-standards/rules/clean-react-5-narrow-state.md diff --git a/.claude/skills/coding-standards/rules/clean-react-6-no-componentprops.md b/.claude/skills/app-coding-standards/rules/clean-react-6-no-componentprops.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-6-no-componentprops.md rename to .claude/skills/app-coding-standards/rules/clean-react-6-no-componentprops.md diff --git a/.claude/skills/coding-standards/rules/clean-react-7-no-inline-prop-types.md b/.claude/skills/app-coding-standards/rules/clean-react-7-no-inline-prop-types.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-7-no-inline-prop-types.md rename to .claude/skills/app-coding-standards/rules/clean-react-7-no-inline-prop-types.md diff --git a/.claude/skills/coding-standards/rules/clean-react-8-no-class-components.md b/.claude/skills/app-coding-standards/rules/clean-react-8-no-class-components.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-8-no-class-components.md rename to .claude/skills/app-coding-standards/rules/clean-react-8-no-class-components.md diff --git a/.claude/skills/coding-standards/rules/clean-react-9-no-proptypes.md b/.claude/skills/app-coding-standards/rules/clean-react-9-no-proptypes.md similarity index 100% rename from .claude/skills/coding-standards/rules/clean-react-9-no-proptypes.md rename to .claude/skills/app-coding-standards/rules/clean-react-9-no-proptypes.md diff --git a/.claude/skills/coding-standards/rules/consistency-1-no-platform-checks.md b/.claude/skills/app-coding-standards/rules/consistency-1-no-platform-checks.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-1-no-platform-checks.md rename to .claude/skills/app-coding-standards/rules/consistency-1-no-platform-checks.md diff --git a/.claude/skills/app-coding-standards/rules/consistency-10-jsdoc.md b/.claude/skills/app-coding-standards/rules/consistency-10-jsdoc.md new file mode 100644 index 000000000000..bf3db695c725 --- /dev/null +++ b/.claude/skills/app-coding-standards/rules/consistency-10-jsdoc.md @@ -0,0 +1,107 @@ +--- +ruleId: CONSISTENCY-10 +title: Follow the JSDoc style guidelines and document props only where there is something to say +--- + +## [CONSISTENCY-10] Follow the JSDoc style guidelines and document props only where there is something to say + +### Reasoning + +TypeScript encodes types, so JSDoc should not repeat them. Do not put types in `@param`/`@returns`, do not use `@private`/`@memberof`/`@implements`/`@enum`/`@override`, and use `@returns` (not `@return`). Omit a `@param` line entirely when it would carry no description. + +The same principle applies to component props and `src/types/onyx/**` properties. A member is documented with a `/** ... */` block comment above it (never a `//` comment) when there is a non-obvious fact to add: a unit, a default, a boundary condition, null/undefined semantics, ownership, an invariant, or how it differs from a sibling. A member whose name and type make it self-explanatory needs no comment, and a comment that only restates the de-camelCased name or the type adds nothing and buries the comments that do say something. So this rule cuts both ways: an undocumented member with a non-obvious fact is missing a comment, and a comment with no fact in it should be deleted. An undocumented self-explanatory member next to documented non-obvious ones is normal. + +ESLint's `jsdoc/require-jsdoc` only requires a comment on the `TSInterfaceDeclaration`/`TSTypeAliasDeclaration` itself for `src/types/onyx/**`; it does not check individual properties there. This rule is what catches an undocumented property that genuinely needs a comment. + +### Incorrect + +```tsx +/** + * @param {string} reportID - the report id + * @param {boolean} isArchived + * @return {string} + */ +function getReportName(reportID: string, isArchived: boolean): string { + // ... +} + +type TooltipProps = { + // Milliseconds before the tooltip auto-dismisses, defaults to 3000 + autoDismissDelay?: number; + + /** Icon width */ + iconWidth: number; + + /** Whether the tooltip is visible */ + isVisible: boolean; + + // No comment, but the unit and default are non-obvious + maxWidth?: number; +}; +``` + +### Correct + +```tsx +/** + * @param reportID - the report id + * @returns the human-readable report name + */ +function getReportName(reportID: string, isArchived: boolean): string { + // ... +} + +type TooltipProps = { + /** Milliseconds before the tooltip auto-dismisses, defaults to 3000 */ + autoDismissDelay?: number; + + /** Width in px */ + iconWidth: number; + + isVisible: boolean; + + /** In px, defaults to the window width */ + maxWidth?: number; +}; +``` + +Also flags a PR that deletes a member's comment and leaves a non-obvious fact undocumented: + +```tsx +type TooltipProps = { +- /** Milliseconds before the tooltip auto-dismisses, defaults to 3000 */ + autoDismissDelay?: number; +}; +``` + +--- + +### Review Metadata + +- A "type" below means any object member group with its own set of properties: a top-level `type`/`interface`, or a nested/anonymous inline object literal type. Each nesting level is judged on its own members; a documented outer property does not shield an undocumented member of its own nested object literal. +- "Member" below means a component prop in a `...Props` type or a property of a type in `src/types/onyx/**`. Only members this PR itself added, modified, or whose comment this PR added, modified, or removed are in scope; untouched pre-existing members are not. + +Flag ONLY when ANY of these is true: + +- A JSDoc `@param`/`@returns` includes a TypeScript type in braces (e.g. `@param {string}`) +- A JSDoc block uses `@return` instead of `@returns`, or uses `@private`/`@memberof`/`@implements`/`@enum`/`@override` +- A `@param` line has a name but no description (it should be omitted) +- A member is documented with a `//` comment instead of `/** ... */`. Convert it to `/** ... */` if it adds information; delete it instead if it adds nothing +- A member that this PR added, modified, or stripped the comment from is left undocumented AND has non-obvious information to add +- A `/** ... */` comment this PR added or modified says only what the de-camelCased member name and its type already say (e.g. "Whether X" / "Is X" / "Has X" restating a boolean name, "The ``" or "`` of/for the ``", a bare noun phrase equal to the name, or an echo of the type word like "Style", "Callback", "ID") and names none of a unit, default, boundary condition, null/undefined semantics, ownership, invariant, or distinction from a sibling. Rewrite it to state the fact if the member has one; delete it if it does not + +**DO NOT flag if:** + +- The function is a trivial inline arrow with no JSDoc and self-evident behavior (JSDoc not required) +- The undocumented member's name and type are already self-explanatory; it needs no comment even if siblings have one +- The comment adds non-obvious information +- The member is inherited/spread from a shared base type documented elsewhere, or the type only re-exports, extends, intersects, or spreads members from such a base type and declares no new members of its own +- The member and its comment are untouched by the change +- The file is a test or story + +**Search Patterns** (hints for reviewers): +- `@param {` / `@returns {` / `@return ` / `@private` / `@memberof` +- `//` comments directly above members of a `...Props` type, or above properties in `src/types/onyx/**` +- Added or modified members of a `...Props` type or an `src/types/onyx/**` type with no preceding `/** */` +- Added or modified `/** ... */` comments directly above such members +- A removed `/** ... */` block in the diff (a `-` line) above such a member diff --git a/.claude/skills/coding-standards/rules/consistency-11-no-todo-comments.md b/.claude/skills/app-coding-standards/rules/consistency-11-no-todo-comments.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-11-no-todo-comments.md rename to .claude/skills/app-coding-standards/rules/consistency-11-no-todo-comments.md diff --git a/.claude/skills/coding-standards/rules/consistency-12-callback-named-for-action.md b/.claude/skills/app-coding-standards/rules/consistency-12-callback-named-for-action.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-12-callback-named-for-action.md rename to .claude/skills/app-coding-standards/rules/consistency-12-callback-named-for-action.md diff --git a/.claude/skills/coding-standards/rules/consistency-14-new-file-header.md b/.claude/skills/app-coding-standards/rules/consistency-13-new-file-header.md similarity index 95% rename from .claude/skills/coding-standards/rules/consistency-14-new-file-header.md rename to .claude/skills/app-coding-standards/rules/consistency-13-new-file-header.md index 81ab0f4a589c..7af71cf3d6f5 100644 --- a/.claude/skills/coding-standards/rules/consistency-14-new-file-header.md +++ b/.claude/skills/app-coding-standards/rules/consistency-13-new-file-header.md @@ -1,9 +1,9 @@ --- -ruleId: CONSISTENCY-14 +ruleId: CONSISTENCY-13 title: Non-trivial new files start with a header description --- -## [CONSISTENCY-14] Non-trivial new files start with a header description +## [CONSISTENCY-13] Non-trivial new files start with a header description ### Reasoning diff --git a/.claude/skills/coding-standards/rules/consistency-15-comment-why.md b/.claude/skills/app-coding-standards/rules/consistency-14-comment-why.md similarity index 94% rename from .claude/skills/coding-standards/rules/consistency-15-comment-why.md rename to .claude/skills/app-coding-standards/rules/consistency-14-comment-why.md index 067c56a936ff..bee50e83d3f7 100644 --- a/.claude/skills/coding-standards/rules/consistency-15-comment-why.md +++ b/.claude/skills/app-coding-standards/rules/consistency-14-comment-why.md @@ -1,9 +1,9 @@ --- -ruleId: CONSISTENCY-15 +ruleId: CONSISTENCY-14 title: Commenting "Why" --- -## [CONSISTENCY-15] Commenting "Why" +## [CONSISTENCY-14] Commenting "Why" ### Reasoning diff --git a/.claude/skills/coding-standards/rules/consistency-16-plain-comment-style.md b/.claude/skills/app-coding-standards/rules/consistency-15-plain-comment-style.md similarity index 96% rename from .claude/skills/coding-standards/rules/consistency-16-plain-comment-style.md rename to .claude/skills/app-coding-standards/rules/consistency-15-plain-comment-style.md index c75679e69813..29d5e502bac7 100644 --- a/.claude/skills/coding-standards/rules/consistency-16-plain-comment-style.md +++ b/.claude/skills/app-coding-standards/rules/consistency-15-plain-comment-style.md @@ -1,9 +1,9 @@ --- -ruleId: CONSISTENCY-16 +ruleId: CONSISTENCY-15 title: Write comments as plain, natural sentences --- -## [CONSISTENCY-16] Write comments as plain, natural sentences +## [CONSISTENCY-15] Write comments as plain, natural sentences ### Reasoning diff --git a/.claude/skills/coding-standards/rules/consistency-17-no-ai-jargon.md b/.claude/skills/app-coding-standards/rules/consistency-16-no-ai-jargon.md similarity index 95% rename from .claude/skills/coding-standards/rules/consistency-17-no-ai-jargon.md rename to .claude/skills/app-coding-standards/rules/consistency-16-no-ai-jargon.md index 902942b12b1c..dae637a5dea7 100644 --- a/.claude/skills/coding-standards/rules/consistency-17-no-ai-jargon.md +++ b/.claude/skills/app-coding-standards/rules/consistency-16-no-ai-jargon.md @@ -1,9 +1,9 @@ --- -ruleId: CONSISTENCY-17 +ruleId: CONSISTENCY-16 title: No AI-generated jargon in code or comments --- -## [CONSISTENCY-17] No AI-generated jargon in code or comments +## [CONSISTENCY-16] No AI-generated jargon in code or comments ### Reasoning diff --git a/.claude/skills/coding-standards/rules/consistency-18-plural-form.md b/.claude/skills/app-coding-standards/rules/consistency-17-plural-form.md similarity index 98% rename from .claude/skills/coding-standards/rules/consistency-18-plural-form.md rename to .claude/skills/app-coding-standards/rules/consistency-17-plural-form.md index cb817206f63e..467c3c96168d 100644 --- a/.claude/skills/coding-standards/rules/consistency-18-plural-form.md +++ b/.claude/skills/app-coding-standards/rules/consistency-17-plural-form.md @@ -1,9 +1,9 @@ --- -ruleId: CONSISTENCY-18 +ruleId: CONSISTENCY-17 title: Pluralize with PluralForm, not ternaries on a count --- -## [CONSISTENCY-18] Pluralize with PluralForm, not ternaries on a count +## [CONSISTENCY-17] Pluralize with PluralForm, not ternaries on a count ### Reasoning diff --git a/.claude/skills/coding-standards/rules/consistency-2-no-magic-values.md b/.claude/skills/app-coding-standards/rules/consistency-2-no-magic-values.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-2-no-magic-values.md rename to .claude/skills/app-coding-standards/rules/consistency-2-no-magic-values.md diff --git a/.claude/skills/coding-standards/rules/consistency-3-no-code-duplication.md b/.claude/skills/app-coding-standards/rules/consistency-3-no-code-duplication.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-3-no-code-duplication.md rename to .claude/skills/app-coding-standards/rules/consistency-3-no-code-duplication.md diff --git a/.claude/skills/coding-standards/rules/consistency-4-no-unused-props.md b/.claude/skills/app-coding-standards/rules/consistency-4-no-unused-props.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-4-no-unused-props.md rename to .claude/skills/app-coding-standards/rules/consistency-4-no-unused-props.md diff --git a/.claude/skills/coding-standards/rules/consistency-5-justify-eslint-disable.md b/.claude/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-5-justify-eslint-disable.md rename to .claude/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md diff --git a/.claude/skills/coding-standards/rules/consistency-6-proper-error-handling.md b/.claude/skills/app-coding-standards/rules/consistency-6-proper-error-handling.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-6-proper-error-handling.md rename to .claude/skills/app-coding-standards/rules/consistency-6-proper-error-handling.md diff --git a/.claude/skills/coding-standards/rules/consistency-7-localize-copy.md b/.claude/skills/app-coding-standards/rules/consistency-7-localize-copy.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-7-localize-copy.md rename to .claude/skills/app-coding-standards/rules/consistency-7-localize-copy.md diff --git a/.claude/skills/coding-standards/rules/consistency-8-localize-numbers-dates.md b/.claude/skills/app-coding-standards/rules/consistency-8-localize-numbers-dates.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-8-localize-numbers-dates.md rename to .claude/skills/app-coding-standards/rules/consistency-8-localize-numbers-dates.md diff --git a/.claude/skills/coding-standards/rules/consistency-9-file-naming.md b/.claude/skills/app-coding-standards/rules/consistency-9-file-naming.md similarity index 100% rename from .claude/skills/coding-standards/rules/consistency-9-file-naming.md rename to .claude/skills/app-coding-standards/rules/consistency-9-file-naming.md diff --git a/.claude/skills/coding-standards/rules/perf-1-no-spread-in-renderitem.md b/.claude/skills/app-coding-standards/rules/perf-1-no-spread-in-renderitem.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-1-no-spread-in-renderitem.md rename to .claude/skills/app-coding-standards/rules/perf-1-no-spread-in-renderitem.md diff --git a/.claude/skills/coding-standards/rules/perf-10-no-useeffect-parent-comm.md b/.claude/skills/app-coding-standards/rules/perf-10-no-useeffect-parent-comm.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-10-no-useeffect-parent-comm.md rename to .claude/skills/app-coding-standards/rules/perf-10-no-useeffect-parent-comm.md diff --git a/.claude/skills/coding-standards/rules/perf-11-optimize-data-selection.md b/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-11-optimize-data-selection.md rename to .claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md diff --git a/.claude/skills/coding-standards/rules/perf-12-prevent-memory-leaks.md b/.claude/skills/app-coding-standards/rules/perf-12-prevent-memory-leaks.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-12-prevent-memory-leaks.md rename to .claude/skills/app-coding-standards/rules/perf-12-prevent-memory-leaks.md diff --git a/.claude/skills/coding-standards/rules/perf-13-hoist-iterator-calls.md b/.claude/skills/app-coding-standards/rules/perf-13-hoist-iterator-calls.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-13-hoist-iterator-calls.md rename to .claude/skills/app-coding-standards/rules/perf-13-hoist-iterator-calls.md diff --git a/.claude/skills/coding-standards/rules/perf-14-use-sync-external-store.md b/.claude/skills/app-coding-standards/rules/perf-14-use-sync-external-store.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-14-use-sync-external-store.md rename to .claude/skills/app-coding-standards/rules/perf-14-use-sync-external-store.md diff --git a/.claude/skills/coding-standards/rules/perf-15-cleanup-async-effects.md b/.claude/skills/app-coding-standards/rules/perf-15-cleanup-async-effects.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-15-cleanup-async-effects.md rename to .claude/skills/app-coding-standards/rules/perf-15-cleanup-async-effects.md diff --git a/.claude/skills/coding-standards/rules/perf-16-guard-double-init.md b/.claude/skills/app-coding-standards/rules/perf-16-guard-double-init.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-16-guard-double-init.md rename to .claude/skills/app-coding-standards/rules/perf-16-guard-double-init.md diff --git a/.claude/skills/coding-standards/rules/perf-17-pass-raw-index-on-demand.md b/.claude/skills/app-coding-standards/rules/perf-17-pass-raw-index-on-demand.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-17-pass-raw-index-on-demand.md rename to .claude/skills/app-coding-standards/rules/perf-17-pass-raw-index-on-demand.md diff --git a/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md b/.claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md similarity index 64% rename from .claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md rename to .claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md index 4f67ad417e8e..b33d34519d79 100644 --- a/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md +++ b/.claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md @@ -51,10 +51,22 @@ const handleSubmit = () => { ### Hook API +**Layout strategies:** + +- `PRE_INSERT` (narrow layout only): eagerly pre-mounts the destination behind the RHP after the open transition, at idle priority. +- `REVEAL`: skips eager pre-mount; the destination is inserted and revealed together when `reveal()` runs. This fallback is used: + - on wide layout always + - on narrow layout if pre-insert hasn't finished yet + **Reveal methods:** -- `reveal(afterTransition?)`: if the hook owns a pre-inserted narrow route, clears the pre-insert flag and dismisses the RHP over that route. Otherwise, inserts the destination under the RHP and then dismisses it. -- `cleanupPreMount()`: removes the owned pre-inserted destination before a back-out path closes the RHP without revealing the destination. +- `reveal(afterTransition?)`: if the hook owns a pre-inserted route, clears the pre-insert flag and dismisses the RHP over that route. Otherwise, inserts the destination under the RHP then dismisses it. +- `cleanupPreMount()`: removes the owned pre-inserted destination before a back-out path closes the RHP without revealing the destination. Safe to call unconditionally - no-ops if this instance never pre-inserted anything. + +**Other invariants:** + +- Only one component may own a pre-inserted route at a time. `reveal()` logs an alert if the global pre-insert flag is set by a different flow when it runs - a sign the previous owner didn't clean up. +- When the destination resolves to one of the app's root tabs (Home, Inbox, Search, Settings, or Workspaces), pre-insert switches to that tab instead of pushing (`[Tab(A), RHP] -> [Tab(B), RHP]`), with the original tab saved for restore-on-cancel. For any other destination, it pushes a new route between the origin and the RHP (`[origin, RHP] -> [origin, destination, RHP]`). Determined by the destination route, not caller-configured. **Caller responsibilities:** @@ -89,11 +101,15 @@ Use `usePreMountDestination` when **all** of these are true: ### When NOT to use +Pre-inserting is a real second screen mounted concurrently. Its effects run whether or not the user ever reveals it. If they back out, `cleanupPreMount()` removes the route but not the work that mount already triggered: + - Destination is not known in advance - There is no modal/RHP to dismiss - The destination is already the screen behind the modal -- The transition is already fast enough. Profile first, do not add complexity speculatively -- Flow-specific dismiss strategies that do not use pre-insert/reveal. Keep those helpers +- The destination is heavy or rarely actually reached from this dismiss path. In these cases, pre-inserting on every open pays the concurrent-mount cost more often than it pays off +- The transition is already fast enough on its own +- The dismiss is not RHP-to-fullscreen with a destination known at mount time. Helpers like `dismissModalWithReport` and the strategies in `submitDismissStrategies.ts` cover shapes the hook does not model - RHP-to-RHP transitions, and destinations resolved at dismiss time - so they are not call sites waiting to be migrated +- The caller is reaching for `shouldPreservePreInsertedRouteOnUnmount` to sidestep cleanup ordering. It exists only for the case where a genuinely different component finishes the dismiss after this one unmounts - anywhere else it leaves a pre-inserted route with no owner left to clean it up ### Review Metadata @@ -104,6 +120,7 @@ Flag when: - A caller relies on `reveal(afterTransition)` for work that must happen before navigation, such as validation, target-route selection, or a synchronous write needed before the destination is revealed - A back-out path closes the RHP without calling `cleanupPreMount()` when the component owns a pre-inserted route - A submit path unmounts the component before `reveal()` runs but does not preserve the pre-inserted route with `shouldPreservePreInsertedRouteOnUnmount` +- `shouldPreservePreInsertedRouteOnUnmount` is passed without a clear reason a *different* component finishes the dismiss - if nothing else picks up the pre-insert, this just delays cleanup rather than serving its purpose - New code reimplements pre-insert timing, back-out cleanup, or reveal-before-dismiss orchestration inline instead of using the hook, even if it avoids a direct `preInsertFullscreenUnderRHP` call **DO NOT flag if:** diff --git a/.claude/skills/coding-standards/rules/perf-19-scalar-collection-selectors.md b/.claude/skills/app-coding-standards/rules/perf-19-scalar-collection-selectors.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-19-scalar-collection-selectors.md rename to .claude/skills/app-coding-standards/rules/perf-19-scalar-collection-selectors.md diff --git a/.claude/skills/coding-standards/rules/perf-2-early-return.md b/.claude/skills/app-coding-standards/rules/perf-2-early-return.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-2-early-return.md rename to .claude/skills/app-coding-standards/rules/perf-2-early-return.md diff --git a/.claude/skills/coding-standards/rules/perf-3-onyx-list-item-provider.md b/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-3-onyx-list-item-provider.md rename to .claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md diff --git a/.claude/skills/coding-standards/rules/perf-5-shallow-comparison.md b/.claude/skills/app-coding-standards/rules/perf-5-shallow-comparison.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-5-shallow-comparison.md rename to .claude/skills/app-coding-standards/rules/perf-5-shallow-comparison.md diff --git a/.claude/skills/coding-standards/rules/perf-6-derive-state-from-props.md b/.claude/skills/app-coding-standards/rules/perf-6-derive-state-from-props.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-6-derive-state-from-props.md rename to .claude/skills/app-coding-standards/rules/perf-6-derive-state-from-props.md diff --git a/.claude/skills/coding-standards/rules/perf-7-reset-via-key-prop.md b/.claude/skills/app-coding-standards/rules/perf-7-reset-via-key-prop.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-7-reset-via-key-prop.md rename to .claude/skills/app-coding-standards/rules/perf-7-reset-via-key-prop.md diff --git a/.claude/skills/coding-standards/rules/perf-8-events-in-handlers.md b/.claude/skills/app-coding-standards/rules/perf-8-events-in-handlers.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-8-events-in-handlers.md rename to .claude/skills/app-coding-standards/rules/perf-8-events-in-handlers.md diff --git a/.claude/skills/coding-standards/rules/perf-9-no-useeffect-chains.md b/.claude/skills/app-coding-standards/rules/perf-9-no-useeffect-chains.md similarity index 100% rename from .claude/skills/coding-standards/rules/perf-9-no-useeffect-chains.md rename to .claude/skills/app-coding-standards/rules/perf-9-no-useeffect-chains.md diff --git a/.claude/skills/coding-standards/rules/ui-1-correct-loading-indicator.md b/.claude/skills/app-coding-standards/rules/ui-1-correct-loading-indicator.md similarity index 100% rename from .claude/skills/coding-standards/rules/ui-1-correct-loading-indicator.md rename to .claude/skills/app-coding-standards/rules/ui-1-correct-loading-indicator.md diff --git a/.claude/skills/coding-standards/rules/ui-2-new-page-scrollview.md b/.claude/skills/app-coding-standards/rules/ui-2-new-page-scrollview.md similarity index 100% rename from .claude/skills/coding-standards/rules/ui-2-new-page-scrollview.md rename to .claude/skills/app-coding-standards/rules/ui-2-new-page-scrollview.md diff --git a/.claude/skills/coding-standards/rules/ui-3-no-inline-styles.md b/.claude/skills/app-coding-standards/rules/ui-3-no-inline-styles.md similarity index 100% rename from .claude/skills/coding-standards/rules/ui-3-no-inline-styles.md rename to .claude/skills/app-coding-standards/rules/ui-3-no-inline-styles.md diff --git a/.claude/skills/coding-standards/rules/consistency-10-jsdoc.md b/.claude/skills/coding-standards/rules/consistency-10-jsdoc.md deleted file mode 100644 index fa20a772b536..000000000000 --- a/.claude/skills/coding-standards/rules/consistency-10-jsdoc.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -ruleId: CONSISTENCY-10 -title: Follow the JSDoc style guidelines ---- - -## [CONSISTENCY-10] Follow the JSDoc style guidelines - -### Reasoning - -Per `STYLE.md`, TypeScript already encodes types, so JSDoc must not repeat them. Do not put types in `@param`/`@returns`, do not use `@private`/`@memberof`/`@implements`/`@enum`/`@override`, and use `@returns` (not `@return`). Omit a `@param` line entirely when it would carry no description. Component props are documented with a `/** ... */` block comment above each prop, not `//` comments. - -### Incorrect - -```tsx -/** - * @param {string} reportID - the report id - * @param {boolean} isArchived - * @return {string} - */ -function getReportName(reportID: string, isArchived: boolean): string { - // ... -} - -type ButtonProps = { - // Whether the button is disabled - isDisabled: boolean; -}; -``` - -### Correct - -```tsx -/** - * @param reportID - the report id - * @returns the human-readable report name - */ -function getReportName(reportID: string, isArchived: boolean): string { - // ... -} - -type ButtonProps = { - /** Whether the button is disabled */ - isDisabled: boolean; -}; -``` - ---- - -### Review Metadata - -Flag ONLY when ANY of these is true: - -- A JSDoc `@param`/`@returns` includes a TypeScript type in braces (e.g. `@param {string}`) -- A JSDoc block uses `@return` instead of `@returns`, or uses `@private`/`@memberof`/`@implements`/`@enum`/`@override` -- A `@param` line has a name but no description (it should be omitted) -- A component prop in a `Props` type is documented with a `//` comment or left undocumented when sibling props use `/** */` blocks - -**DO NOT flag if:** - -- The function is a trivial inline arrow with no JSDoc and self-evident behavior (JSDoc not required) -- The prop is inherited/spread from a shared base type documented elsewhere -- The file is a test or story - -**Search Patterns** (hints for reviewers): -- `@param {` / `@returns {` / `@return ` / `@private` / `@memberof` -- `//` comments directly above members of a `...Props` type diff --git a/.claude/skills/coding-standards/rules/consistency-13-document-props.md b/.claude/skills/coding-standards/rules/consistency-13-document-props.md deleted file mode 100644 index 728f57e16d30..000000000000 --- a/.claude/skills/coding-standards/rules/consistency-13-document-props.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -ruleId: CONSISTENCY-13 -title: Document component props with a JSDoc block comment ---- - -## [CONSISTENCY-13] Document component props with a JSDoc block comment - -### Reasoning - -Per `contributingGuides/STYLE.md`, every component prop is documented with a `/** ... */` block comment above it so its purpose is clear at the definition site. This rule enforces the *presence* of that documentation when a new props type is added with no documented props at all. It is the companion to CONSISTENCY-10, which enforces JSDoc *style* and catches the mixed cases (a `//` comment on a prop, or an undocumented prop sitting next to documented siblings). Together they ensure every prop carries a `/** */` block. - -### Incorrect - -```tsx -type ButtonProps = { - isDisabled: boolean; - onPress: () => void; -}; -``` - -### Correct - -```tsx -type ButtonProps = { - /** Whether the button is disabled */ - isDisabled: boolean; - - /** Called when the button is pressed */ - onPress: () => void; -}; -``` - ---- - -### Review Metadata - -Flag ONLY when ALL of these are true: - -- The changed code adds (or newly introduces the members of) a component props type/interface - a `type`/`interface` whose name ends in `Props` -- It declares one or more of its own props -- **None** of those props has a `/** ... */` block comment above it - -**DO NOT flag if:** - -- At least one prop in the type is already documented with `/** */` (the mixed/undocumented-sibling and `//`-comment cases belong to CONSISTENCY-10, not here - avoid double-flagging) -- The type only re-exports, extends, intersects, or spreads props from a base type documented elsewhere and declares no new members of its own -- The props are inherited from a shared base type -- The file is a test or story - -**Search Patterns** (hints for reviewers): -- Added `type ...Props = {` / `interface ...Props {` blocks whose members have no preceding `/** */` diff --git a/.claude/skills/measure-telemetry-span/SKILL.md b/.claude/skills/measure-telemetry-span/SKILL.md index b097324524c5..a5e43de373c4 100644 --- a/.claude/skills/measure-telemetry-span/SKILL.md +++ b/.claude/skills/measure-telemetry-span/SKILL.md @@ -9,6 +9,19 @@ allowed-tools: Bash(.claude/skills/measure-telemetry-span/measure.sh) Read Grep **Pattern:** from repo root, run one command with a span name and platform → stdout is a small summary table (avg / min / max + sample ms list). The script measures whatever Git checkout is currently active: it never runs `git checkout` or otherwise switches branches. To compare this branch with `main` (or any other revision), check out each commit/branch in turn—or use two worktrees/clones—and run `measure.sh` separately, then compare the two printed summaries. +## Layout + +``` +measure-telemetry-span/ +├── measure.sh # the runner +├── flows/ # one .ad flow per measured journey +└── scripts/replay-with-deadline.mjs # wall-clock deadline around `agent-device replay` +``` + +Each flow under `flows/` declares the spans it owns with `# @span ` and names the span the runner looks up. Flows here do not own application lifecycle, so they are not inputs for `agent-device test`; the runner opens the app and prepares the session. + +Setup and navigation helpers live in `.claude/skills/agent-device/flows/macros/`, and `@reset` headers point at them. The `.ad` metadata spec, selector rules, and recording workflow live in [`agent-device/flows/README.md`](../agent-device/flows/README.md) — read it before adding or healing a flow here. + ## Command ```bash @@ -17,14 +30,14 @@ allowed-tools: Bash(.claude/skills/measure-telemetry-span/measure.sh) Read Grep | Argument | Default | Description | | -------------- | ------- | --------------------------------------------------------------------------- | -| `` | — | Must match `# @tag sentry-` on a flow under `.claude/skills/agent-device/flows/tests/` (sentry-tagged QA scenarios live there; the script searches `flows/` recursively, so `flows/macros/` is also scanned for completeness). | +| `` | — | Must match `# @span ` on a flow under `.claude/skills/measure-telemetry-span/flows/`. | | `[runs]` | `10` | Measured replays after **one** warmup inside the script. | | `[platform]` | `ios` | `ios` or `android` — must match the simulator/emulator you use. | | `--boot` | off | Before `open`, runs `agent-device boot --platform ` so a simulator/emulator is started when nothing was connected (`adb devices` empty, etc.). | To pick a **specific** Android AVD or iOS simulator, use the same global flags `agent-device` already supports (for example `--device "Pixel_7_API_34"`) on **`boot`** and on later commands — either run `agent-device boot --platform android --device "…"` yourself before `measure.sh`, or rely on `agent-device` config (`~/.agent-device/config.json`). `--boot` inside this script only passes `--platform` through to `boot`. -**Environment:** `APP_ID` overrides app bundle (default `com.expensify.chat.dev`). If the flow declares `# @param KEY …`, set **`AD_KEY`** to pass `-e KEY=VALUE` to replay. +**Environment:** `APP_ID` overrides the app bundle (default `com.expensify.chat.dev`). `REPLAY_TIMEOUT_MS` sets the per-replay wall-clock deadline and defaults to `120000`. The repository wrapper enforces it outside the Agent Device daemon and exits with code 124 on timeout, including on Agent Device 0.20.1 through 0.20.6 where the CLI timeout can leave the request running. `AGENT_DEVICE_STATE_DIR` selects the isolated session directory and defaults to `$HOME/.agent-device-expensify-headless`; do not point it at an interactive daemon because timeout cleanup stops that daemon. If the scenario declares `# @param KEY …`, set **`AD_KEY`** to pass `-e KEY=VALUE` to replay. **Output:** table + `Samples: …ms` line; stderr has progress (`Using flow:`, runs, optional reset). @@ -42,28 +55,58 @@ If you see **no Android device** (`adb devices` empty): append **`--boot`** to t `agent-device boot --platform android` (optional `--device ""`). For iOS, `agent-device boot --platform ios` or `agent-device ensure-simulator --boot` when you need a created simulator instance. +## Platform coverage + +The bottom tab items carry no unique selector on either native platform, so the three flows that must press a tab (`switch-home-to-inbox`, `switch-home-to-reports`, and the `switch-to-home` reset macro) can fail to resolve a single target. + +On narrow layouts the app renders two `NavigationTabBar` instances at once: one from `TabNavigatorBar` and a second from `TabBarBottomContent`, which `HomePage` and `BaseSidebarScreen` pass as `bottomContent` for the swipe-back animation. Both copies sit at the same rect and both are hittable, so `label="Inbox"` matches four nodes and `label="Inbox" hittable=true` still matches two. On iOS the tab items additionally carry no `role="tab"`, because React Native maps that role to no iOS accessibility trait. Newer Agent Device versions reject an ambiguous `press` outright, which is the correct outcome but means these three flows cannot run as written. + +Every other flow here asserts its start screen with a screen-scoped `id=` and never presses a tab, so they are unaffected. Making the three tab flows durable needs a `testID` on each tab item in the app. + ## Contract - App logs: `[Sentry][] Ending span (ms)` via `console.debug`. -- Flow file includes `# @tag sentry-` (same name, case-sensitive). -- Optional flow headers: `@reset ` (run by the script after warmup and each measured replay; if absent, the script relaunches the app instead so each run starts from `@pre`); `@param` keys overridable via `AD_*` (passed as `-e KEY=VALUE` to replay). +- Flow file includes `# @span ` (same name, case-sensitive). A flow may declare several spans when one journey emits a whole chain. +- Exactly one flow owns each span. The runner fails instead of choosing by filesystem order, so a flow that merely passes through a span must not declare it. +- Optional flow headers: `@reset ` (run before the warmup and after every replay; if absent, the script relaunches the app instead so each run starts from `@pre`); `@param` keys overridable via `AD_*` (passed as `-e KEY=VALUE` to replay). +- The runner re-checks every `@pre` before and every `@post` after each replay, independently of the assertions in the flow body. Selectors carrying an unresolved `${KEY}` are skipped and logged. This is a backstop against a flow whose headers and body have drifted, not a licence to omit the body assertions. +- Each replay must emit a new span line. The runner fails on the first replay that does not, and prints the current screen. A replay can pass every step and still emit nothing - pressing a tab the app already sits on short-circuits before the span starts - so a `@pre` that cannot distinguish the start screen from the destination turns a broken run into a silent one. - **Parsing:** stats take the **last** `RUNS` matching log lines from the capture. That matches one sample per measured replay only if each replay emits **one** such line for this span name. Extra matches (duplicate logs, nested/sub-spans with the same message pattern, noisy startup logging) can shift which samples are included—fix the app logging or tighten the grep if that happens. ## `@reset` and loop stability -`measure.sh` replays the **same** tagged flow every iteration. Treat `@reset` as “return to a known anchor,” not a second copy of the whole scenario: +`measure.sh` replays the **same** flow every iteration. Treat `@reset` as “return to a known anchor,” not a second copy of the whole flow: -- Prefer a **short** reset flow (tabs to Inbox, dismiss sheet, etc.). When agent-device splits **macros** vs **tests**, point `@reset` at a **macro** path so one file stays the source of truth. +- Prefer a **short** reset flow (tabs to Inbox, dismiss sheet, etc.). Point `@reset` at a **macro** path so one file stays the source of truth. +- **Declare `@reset` whenever you can.** Without it the runner relaunches the app between runs, which is slow and puts the first post-launch navigation inside the measured loop — on Android that navigation is where the app raises a LogBox that aborts the transition. With `@reset` the runner attaches to the running app and never relaunches. - If runs are flaky locally but fine for others, walk the bring-up checklist in `.claude/skills/agent-device/SKILL.md` (Metro, dev build, device boot, iOS + DevTools for `console.debug`) before blaming selectors. Optional: keep a tiny markdown table in your team notes mapping `SpanName` → one-line intent + `@pre` anchor; the span name still drives which `.ad` is picked — no need to repeat long repro prose in every chat. +## Single replay without measuring + +To debug one flow, or to run it as a QA check, open a session in the isolated state directory and drive the same wrapper `measure.sh` uses: + +```bash +export AGENT_DEVICE_STATE_DIR="$HOME/.agent-device-expensify-headless" +agent-device open --platform --session +node .claude/skills/measure-telemetry-span/scripts/replay-with-deadline.mjs \ + .claude/skills/measure-telemetry-span/flows/.ad \ + --session \ + --timeout 120000 \ + -e KEY=VALUE +``` + +Always keep a separate `AGENT_DEVICE_STATE_DIR` for this: on timeout the wrapper stops the daemon owning that directory, and pointing it at an interactive session would kill that session. + +Stop after the first failed replay or postcondition. Retry only after an explicit reset or another verified state change — replaying the same flow against unchanged state proves nothing. + ## If something fails | Symptom | Action | | ------- | ------ | | `No captured runs` | iOS: DevTools. Android: log level / package. Retry after clearing log pipeline. | -| `SESSION_NOT_FOUND` / empty `adb devices` | Use **`--boot`** on the measure script, or `agent-device boot --platform android|ios` (see `--device` if multiple AVDs/simulators). Then `open` again if needed. | +| `SESSION_NOT_FOUND` / empty `adb devices` | Confirm `AGENT_DEVICE_STATE_DIR` matches the directory used to open the session — `agent-device session state-dir` prints the effective one, and each state directory runs its own daemon with its own sessions. Then use **`--boot`** or `agent-device boot --platform android|ios` if no device is connected, and reopen the app if needed. | | Fewer samples than `runs` | Span not emitted or flow flaky — `agent-device replay --debug`; fix selectors (`ad-flow-author`). | For another app bundle, export `APP_ID` before the command. diff --git a/.claude/skills/measure-telemetry-span/flows/create-expense-manual.ad b/.claude/skills/measure-telemetry-span/flows/create-expense-manual.ad new file mode 100644 index 000000000000..0a237a4685bf --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/create-expense-manual.ad @@ -0,0 +1,29 @@ +# @desc Open Create Expense from the FAB, enter an amount on the Manual tab, fill the Merchant field, and submit. Supports configurable currency-aware submit button matching. Exercises the confirmation-page Sentry spans. Assumes a workspace where Category is not required. +# @pre id="BaseSidebarScreen" +# @pre id="floating-action-button" || role="button" label="Open actions menu" +# @post id="SearchPageNarrow" || id="Search" +# @post role="tab" label="Expenses" || role="button" label="Expenses" || label="Expenses" hittable=true +# @reset .claude/skills/agent-device/flows/macros/return-to-inbox.ad +# @span ManualConfirmationMount +# @span ManualConfirmationListReady +# @span ManualConfirmationReceiptLoad +# @param AMOUNT Whole-number amount to type into the Manual amount input (for example `22`). +# @param CURRENCY Currency symbol used by the submit CTA selector (for example `PLN` or `USD`). +# @param MERCHANT Merchant name to populate before submit. + +is exists "id=\"BaseSidebarScreen\"" +is exists "id=\"floating-action-button\" || role=\"button\" label=\"Open actions menu\"" +press "id=\"floating-action-button\" || role=\"button\" label=\"Open actions menu\"" +wait "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" 10000 +press "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" +wait "id=\"IOURequestStartPage\"" 10000 +press "role=\"tab\" label=\"Manual\" || label=\"Manual\" hittable=true" +wait "id=\"moneyRequestAmountInput\"" 10000 +fill "id=\"moneyRequestAmountInput\"" "${AMOUNT}" +press "role=\"button\" label=\"Next\" || label=\"Next\" hittable=true" +press "role=\"button\" label=\"Merchant\" || label=\"Merchant\" hittable=true" +fill "label=\"Merchant,\" editable=true || label=\"Merchant\" editable=true" "${MERCHANT}" +press "role=\"button\" label=\"Save\" || label=\"Save\" hittable=true" +press "role=\"button\" label=\"Create ${CURRENCY} ${AMOUNT}.00 expense\" || role=\"button\" label=\"Create expense\" || label=\"Create ${CURRENCY} ${AMOUNT}.00 expense\" hittable=true || label=\"Create expense\" hittable=true" +wait "id=\"SearchPageNarrow\" || id=\"Search\"" 15000 +is exists "role=\"tab\" label=\"Expenses\" || role=\"button\" label=\"Expenses\" || label=\"Expenses\" hittable=true" diff --git a/.claude/skills/measure-telemetry-span/flows/open-create-expense.ad b/.claude/skills/measure-telemetry-span/flows/open-create-expense.ad new file mode 100644 index 000000000000..2e8d3358ac77 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/open-create-expense.ad @@ -0,0 +1,12 @@ +# @desc From the Inbox tab, open the FAB actions menu and tap "Create expense". Triggers the ManualOpenCreateExpense Sentry span (startMoneyRequest -> IOURequestStart/Participants/Confirmation page). Assumes the user is signed in and the Inbox bottom nav with the floating action button is visible. +# @pre id="BaseSidebarScreen" +# @pre id="floating-action-button" || role="button" label="Open actions menu" +# @post id="IOURequestStartPage" +# @reset .claude/skills/agent-device/flows/macros/return-to-inbox.ad +# @span ManualOpenCreateExpense +is exists "id=\"BaseSidebarScreen\"" +is exists "id=\"floating-action-button\" || role=\"button\" label=\"Open actions menu\"" +press "id=\"floating-action-button\" || role=\"button\" label=\"Open actions menu\"" +wait "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" 10000 +press "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" +wait "id=\"IOURequestStartPage\"" 10000 diff --git a/.claude/skills/measure-telemetry-span/flows/open-report.ad b/.claude/skills/measure-telemetry-span/flows/open-report.ad new file mode 100644 index 000000000000..c5a77f89895d --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/open-report.ad @@ -0,0 +1,12 @@ +# @desc From the Inbox tab, open the chat or report matching TARGET_CHAT in the LHN. Triggers the ManualOpenReport Sentry span (LHN row press -> report screen mounted with composer visible). Assumes the user is signed in with at least one chat in the Inbox. +# @pre id="BaseSidebarScreen" +# @pre id="searchButton" || role="button" label="Search" +# @post label="Write something..." editable=true +# @reset .claude/skills/agent-device/flows/macros/return-to-inbox.ad +# @span ManualOpenReport +# @param TARGET_CHAT Exact accessible label of the report or chat row to open from the Inbox LHN. + +is exists "id=\"BaseSidebarScreen\"" +is exists "id=\"searchButton\" || role=\"button\" label=\"Search\"" +press "role=\"button\" label=\"${TARGET_CHAT}\" || label=\"${TARGET_CHAT}\" hittable=true" +wait "label=\"Write something...\" editable=true" 10000 diff --git a/.claude/skills/measure-telemetry-span/flows/open-search-router.ad b/.claude/skills/measure-telemetry-span/flows/open-search-router.ad new file mode 100644 index 000000000000..3beccafaf62f --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/open-search-router.ad @@ -0,0 +1,10 @@ +# @desc Open the SearchRouter from the Inbox tab. Triggers the ManualOpenSearchRouter Sentry span (Search button tap -> SearchRouterPage visible). Assumes the user is signed in. +# @pre id="BaseSidebarScreen" +# @pre id="searchButton" || role="button" label="Search" +# @post id="SearchRouterPage" +# @reset .claude/skills/agent-device/flows/macros/return-to-inbox.ad +# @span ManualOpenSearchRouter +is exists "id=\"BaseSidebarScreen\"" +is exists "id=\"searchButton\" || role=\"button\" label=\"Search\"" +press "id=\"searchButton\" || role=\"button\" label=\"Search\"" +wait "id=\"SearchRouterPage\"" 10000 diff --git a/.claude/skills/measure-telemetry-span/flows/scan-receipt-init.ad b/.claude/skills/measure-telemetry-span/flows/scan-receipt-init.ad new file mode 100644 index 000000000000..12c769e4bd41 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/scan-receipt-init.ad @@ -0,0 +1,14 @@ +# @desc From the Inbox tab, open the FAB, create an expense, and switch to the Scan tab. Triggers the ManualCameraInit Sentry span (Scan tab mount -> camera session ready). Assumes the user is signed in and camera permission is granted. +# @pre id="BaseSidebarScreen" +# @pre id="floating-action-button" || role="button" label="Open actions menu" +# @post text="Take a photo" +# @reset .claude/skills/agent-device/flows/macros/return-to-inbox.ad +# @span ManualCameraInit +is exists "id=\"BaseSidebarScreen\"" +is exists "id=\"floating-action-button\" || role=\"button\" label=\"Open actions menu\"" +press "id=\"floating-action-button\" || role=\"button\" label=\"Open actions menu\"" +wait "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" 10000 +press "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" +wait "id=\"IOURequestStartPage\"" 10000 +press "role=\"tab\" label=\"Scan\" || label=\"Scan\" hittable=true" +wait "text=\"Take a photo\"" 15000 diff --git a/.claude/skills/measure-telemetry-span/flows/send-message.ad b/.claude/skills/measure-telemetry-span/flows/send-message.ad new file mode 100644 index 000000000000..c1cd2444c7f2 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/send-message.ad @@ -0,0 +1,16 @@ +# @desc From the Inbox tab, open a chat matching TARGET_CHAT in the LHN, type a message and send it. Triggers the ManualSendMessageVisible Sentry span (Send press -> optimistic message fragment rendered). Assumes the user is signed in with at least one chat in the Inbox. +# @pre id="BaseSidebarScreen" +# @pre id="searchButton" || role="button" label="Search" +# @post text="${MESSAGE}" +# @reset .claude/skills/agent-device/flows/macros/return-to-inbox.ad +# @span ManualSendMessageVisible +# @param MESSAGE Unique message text to send and verify in the opened chat. +# @param TARGET_CHAT Exact accessible label of the report or chat row to open from the Inbox LHN. + +is exists "id=\"BaseSidebarScreen\"" +is exists "id=\"searchButton\" || role=\"button\" label=\"Search\"" +press "role=\"button\" label=\"${TARGET_CHAT}\" || label=\"${TARGET_CHAT}\" hittable=true" +wait "label=\"Write something...\" editable=true" 10000 +fill "label=\"Write something...\" editable=true" "${MESSAGE}" +press "role=\"button\" label=\"Send\" || label=\"Send\" hittable=true" +wait "text=\"${MESSAGE}\"" 10000 diff --git a/.claude/skills/measure-telemetry-span/flows/submit-expense.ad b/.claude/skills/measure-telemetry-span/flows/submit-expense.ad new file mode 100644 index 000000000000..6dc068fff34b --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/submit-expense.ad @@ -0,0 +1,12 @@ +# @desc Tap the submit button on the IOU confirmation page (IOURequestStepConfirmation). Triggers ManualCreateExpenseSubmit, ManualSubmitToDestinationVisible, and ManualCreateExpenseServerResponse. If the device has location permission granted, ManualGeolocationWait also fires as a child of Submit. Requires being on the confirmation page already. +# @pre id="IOURequestStepConfirmation" +# @post id="SearchPageNarrow" || id="Search" +# @post role="tab" label="Expenses" || role="button" label="Expenses" || label="Expenses" hittable=true +# @span ManualCreateExpenseSubmit +# @span ManualSubmitToDestinationVisible +# @span ManualCreateExpenseServerResponse +# @span ManualGeolocationWait +is exists "id=\"IOURequestStepConfirmation\"" +press "role=\"button\" label=\"Create expense\" || label=\"Create expense\" hittable=true" +wait "id=\"SearchPageNarrow\" || id=\"Search\"" 15000 +is exists "role=\"tab\" label=\"Expenses\" || role=\"button\" label=\"Expenses\" || label=\"Expenses\" hittable=true" diff --git a/.claude/skills/measure-telemetry-span/flows/switch-home-to-inbox.ad b/.claude/skills/measure-telemetry-span/flows/switch-home-to-inbox.ad new file mode 100644 index 000000000000..af3f2472aa74 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/switch-home-to-inbox.ad @@ -0,0 +1,10 @@ +# @desc Switch the bottom navigation tab from Home to Inbox. Triggers the ManualNavigateToInboxTab Sentry span end-to-end (BottomTab click -> Inbox layout completion). Assumes the user is signed in and the bottom nav bar is visible. +# @pre id="HomePage" +# @pre role="tab" label="Inbox" || role="tab" label="Inbox. Your review is required" || label="Inbox" hittable=true || label="Inbox. Your review is required" hittable=true +# @post id="BaseSidebarScreen" +# @reset .claude/skills/agent-device/flows/macros/switch-to-home.ad +# @span ManualNavigateToInboxTab +is exists "id=\"HomePage\"" +is exists "role=\"tab\" label=\"Inbox\" || role=\"tab\" label=\"Inbox. Your review is required\" || label=\"Inbox\" hittable=true || label=\"Inbox. Your review is required\" hittable=true" +press "role=\"tab\" label=\"Inbox\" || role=\"tab\" label=\"Inbox. Your review is required\" || label=\"Inbox\" hittable=true || label=\"Inbox. Your review is required\" hittable=true" +wait "id=\"BaseSidebarScreen\"" 10000 diff --git a/.claude/skills/measure-telemetry-span/flows/switch-home-to-reports.ad b/.claude/skills/measure-telemetry-span/flows/switch-home-to-reports.ad new file mode 100644 index 000000000000..9ecec6a5d525 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/flows/switch-home-to-reports.ad @@ -0,0 +1,10 @@ +# @desc Switch the bottom navigation tab from Home to Spend (legacy name: Reports). Triggers the ManualNavigateToReports Sentry span end-to-end (BottomTab click -> Spend screen layout completion). Assumes the user is signed in and the bottom nav bar is visible. +# @pre id="HomePage" +# @pre role="tab" label="Spend" || label="Spend" hittable=true +# @post id="SearchPageNarrow" || id="Search" +# @reset .claude/skills/agent-device/flows/macros/switch-to-home.ad +# @span ManualNavigateToReports +is exists "id=\"HomePage\"" +is exists "role=\"tab\" label=\"Spend\" || label=\"Spend\" hittable=true" +press "role=\"tab\" label=\"Spend\" || label=\"Spend\" hittable=true" +wait "id=\"SearchPageNarrow\" || id=\"Search\"" 10000 diff --git a/.claude/skills/measure-telemetry-span/measure.sh b/.claude/skills/measure-telemetry-span/measure.sh index fc89326bf334..42f5934bc94d 100755 --- a/.claude/skills/measure-telemetry-span/measure.sh +++ b/.claude/skills/measure-telemetry-span/measure.sh @@ -19,6 +19,18 @@ APP_ID="${APP_ID:-com.expensify.chat.dev}" LOG_PID="" FLOW_ENV_ARGS=() RESET_FLOW="" +# The repository replay wrapper enforces this deadline outside the Agent Device daemon. +REPLAY_TIMEOUT_MS="${REPLAY_TIMEOUT_MS:-120000}" +export AGENT_DEVICE_STATE_DIR="${AGENT_DEVICE_STATE_DIR:-$HOME/.agent-device-expensify-headless}" +# A separate state dir does not isolate the device-claim registry, which defaults to +# ~/.agent-device/device-claims. Without this the runner cannot acquire a device that any +# other agent-device session already claims, and fails with DEVICE_IN_USE. +export AGENT_DEVICE_CLAIMS_DIR="${AGENT_DEVICE_CLAIMS_DIR:-$AGENT_DEVICE_STATE_DIR/device-claims}" + +if ! [[ "$REPLAY_TIMEOUT_MS" =~ ^[0-9]+$ ]] || [[ "$REPLAY_TIMEOUT_MS" -lt 1 ]]; then + echo "REPLAY_TIMEOUT_MS must be a positive integer." >&2 + exit 1 +fi if [[ "$PLATFORM" != "ios" && "$PLATFORM" != "android" ]]; then echo "Platform must be 'ios' or 'android'." >&2 @@ -30,21 +42,30 @@ if ! [[ "$RUNS" =~ ^[0-9]+$ ]] || [[ "$RUNS" -lt 1 ]]; then exit 1 fi +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$(git rev-parse --show-toplevel)" -FLOWS_DIR="$REPO/.claude/skills/agent-device/flows" +FLOWS_DIR="$SKILL_DIR/flows" +REPLAY_RUNNER="$SKILL_DIR/scripts/replay-with-deadline.mjs" FLOW="" +MATCHING_FLOWS=() while IFS= read -r -d '' candidate; do - if grep -q "^# @tag[[:space:]]\+sentry-${SPAN}\$" "$candidate" 2>/dev/null; then - FLOW="$candidate" - break + if grep -q "^# @span[[:space:]]\+${SPAN}\$" "$candidate" 2>/dev/null; then + MATCHING_FLOWS+=("$candidate") fi done < <(find "$FLOWS_DIR" -name '*.ad' -type f -print0 2>/dev/null) -if [[ -z "$FLOW" ]]; then - echo "No flow declares '@tag sentry-$SPAN'. Available:" >&2 - find "$FLOWS_DIR" -name '*.ad' -type f -exec grep -h '^# @tag[[:space:]]\+sentry-' {} + 2>/dev/null | sed 's/.*sentry-//' | sort -u >&2 +if [[ "${#MATCHING_FLOWS[@]}" -eq 0 ]]; then + echo "No flow declares '@span $SPAN'. Available:" >&2 + find "$FLOWS_DIR" -name '*.ad' -type f -exec grep -h '^# @span[[:space:]]\+' {} + 2>/dev/null | awk '{print $3}' | sort -u >&2 exit 1 fi +# Exactly one flow owns each span, so measurement never picks by filesystem order. +if [[ "${#MATCHING_FLOWS[@]}" -gt 1 ]]; then + echo "Multiple flows declare '@span $SPAN'; exactly one flow must own each span:" >&2 + printf ' %s\n' "${MATCHING_FLOWS[@]}" >&2 + exit 1 +fi +FLOW="${MATCHING_FLOWS[0]}" RESET_DECL=$(grep -E '^# @reset[[:space:]]+' "$FLOW" | sed -E 's/^# @reset[[:space:]]+//' | head -1 || true) if [[ -n "$RESET_DECL" ]]; then @@ -60,6 +81,15 @@ if [[ -n "$RESET_DECL" ]]; then fi fi +FLOW_PRE=() +FLOW_POST=() +while IFS= read -r condition; do + FLOW_PRE+=("$condition") +done < <(sed -n 's/^# @pre[[:space:]]\{1,\}//p' "$FLOW") +while IFS= read -r condition; do + FLOW_POST+=("$condition") +done < <(sed -n 's/^# @post[[:space:]]\{1,\}//p' "$FLOW") + TMP_DIR="$(mktemp -d)" DURATIONS_FILE="$TMP_DIR/durations.txt" @@ -101,10 +131,106 @@ start_log() { echo $! } +run_replay() { + local flow="$1" + shift + # A LogBox can surface at any point in the loop and it covers the controls the next replay + # presses, so clear it before every replay rather than only after a relaunch. + dismiss_react_native_overlays + node "$REPLAY_RUNNER" "$flow" --timeout "$REPLAY_TIMEOUT_MS" "$@" +} + +# Keyed on the LogBox error screen's own controls, which is the only surface carrying all +# three at once. The snapshot's overlay hint is not usable here: it also fires for the +# collapsed warning badge, which cannot be dismissed and does not block presses, and it stays +# absent on the full-screen error. A press that is genuinely covered fails at the replay +# instead, where the divergence report names the target. +is_logbox_error_visible() { + local snapshot="$1" + [[ "$snapshot" == *'"Dismiss"'* && "$snapshot" == *'"Minimize"'* && "$snapshot" == *'"Copy"'* ]] +} + +dismiss_react_native_overlays() { + local snapshot + for _ in $(seq 1 5); do + snapshot="$(agent-device snapshot -i 2>&1 || true)" + if ! is_logbox_error_visible "$snapshot"; then + return + fi + + # `react-native dismiss-overlay` reports "verified gone" while the error screen is still + # up, so press LogBox's own control and re-read the snapshot to confirm. + agent-device press 'label="Dismiss"' >/dev/null 2>&1 || true + sleep 1 + done + + echo "React Native LogBox error screen still present after 5 dismissal attempts." >&2 + return 1 +} + +# The flow body owns @pre/@post enforcement, but nothing makes an author write those assertions. +# Re-checking the headers here catches a flow whose metadata and body have drifted apart. +assert_flow_conditions() { + local kind="$1" + shift + + local selector + for selector in "$@"; do + # shellcheck disable=SC2016 # matching a literal ${ left by an unresolved @param + if [[ "$selector" == *'${'* ]]; then + echo "Skipping $kind check with unresolved interpolation: $selector" >&2 + continue + fi + + if [[ "$kind" == "@post" ]]; then + if agent-device wait "$selector" 10000 >/dev/null 2>&1; then + continue + fi + elif agent-device is exists "$selector" >/dev/null 2>&1; then + continue + fi + + echo "Flow $kind not satisfied: $selector" >&2 + return 1 + done +} + +count_span_lines() { + grep -c "\\[Sentry\\]\\[$SPAN\\] Ending span" "$1" 2>/dev/null || true +} + +# A replay can pass every step and still emit no span - for example when the tab button +# short-circuits because the app already sits on the destination. Fail on the first such +# replay instead of spending every run and reporting "No captured runs" at the end. +require_new_span_line() { + local raw="$1" + local before="$2" + local label="$3" + local after + + for _ in $(seq 1 10); do + after="$(count_span_lines "$raw")" + if [[ "$after" -gt "$before" ]]; then + return + fi + sleep 1 + done + + echo "$label emitted no '[Sentry][$SPAN] Ending span' line. Current screen:" >&2 + agent-device snapshot -i >&2 || true + return 1 +} + reset_if_needed() { if [[ -n "$RESET_FLOW" ]]; then echo "Resetting with: $RESET_FLOW" >&2 - agent-device replay "$RESET_FLOW" >&2 + # A LogBox raised mid-navigation drops the transition it interrupted, and that is + # intermittent, so retry the reset once. `run_replay` clears the overlay first. A second + # failure is reported rather than measuring from the wrong screen. + if ! run_replay "$RESET_FLOW" >&2; then + echo "Reset failed. Clearing overlays and retrying once." >&2 + run_replay "$RESET_FLOW" >&2 + fi return fi @@ -115,6 +241,7 @@ reset_if_needed() { if [[ "$PLATFORM" == "android" ]]; then wait_until_android_ui_ready fi + dismiss_react_native_overlays } # After --relaunch on Android, UIAutomator often returns Snapshot: 0 nodes briefly; warmup replay then fails @pre. @@ -139,20 +266,40 @@ measure_current_branch() { agent-device boot --platform "$PLATFORM" >&2 fi - agent-device open "$APP_ID" --platform "$PLATFORM" --relaunch >&2 - sleep 5 - if [[ "$PLATFORM" == "android" ]]; then - wait_until_android_ui_ready + if [[ -n "$RESET_FLOW" ]]; then + # A declared @reset owns the start state, so attach to the running app instead of + # relaunching. A relaunch only adds the first post-launch navigation, which is where the + # app is most likely to raise a LogBox that aborts the very transition being measured. + agent-device open "$APP_ID" --platform "$PLATFORM" >&2 + dismiss_react_native_overlays + reset_if_needed + else + agent-device open "$APP_ID" --platform "$PLATFORM" --relaunch >&2 + sleep 5 + if [[ "$PLATFORM" == "android" ]]; then + wait_until_android_ui_ready + fi + dismiss_react_native_overlays fi LOG_PID=$(start_log "$raw") - agent-device replay "$FLOW" ${FLOW_ENV_ARGS[@]+"${FLOW_ENV_ARGS[@]}"} >&2 # warmup + + local spans_before + spans_before="$(count_span_lines "$raw")" + assert_flow_conditions "@pre" ${FLOW_PRE[@]+"${FLOW_PRE[@]}"} + run_replay "$FLOW" ${FLOW_ENV_ARGS[@]+"${FLOW_ENV_ARGS[@]}"} >&2 # warmup + assert_flow_conditions "@post" ${FLOW_POST[@]+"${FLOW_POST[@]}"} + require_new_span_line "$raw" "$spans_before" "Warmup" reset_if_needed sleep 1 for i in $(seq 1 "$RUNS"); do echo "Run $i/$RUNS" >&2 - agent-device replay "$FLOW" ${FLOW_ENV_ARGS[@]+"${FLOW_ENV_ARGS[@]}"} >&2 + spans_before="$(count_span_lines "$raw")" + assert_flow_conditions "@pre" ${FLOW_PRE[@]+"${FLOW_PRE[@]}"} + run_replay "$FLOW" ${FLOW_ENV_ARGS[@]+"${FLOW_ENV_ARGS[@]}"} >&2 + assert_flow_conditions "@post" ${FLOW_POST[@]+"${FLOW_POST[@]}"} + require_new_span_line "$raw" "$spans_before" "Run $i/$RUNS" reset_if_needed sleep 1 done diff --git a/.claude/skills/measure-telemetry-span/scripts/replay-with-deadline.mjs b/.claude/skills/measure-telemetry-span/scripts/replay-with-deadline.mjs new file mode 100644 index 000000000000..5e049384cc52 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/scripts/replay-with-deadline.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import {spawn} from 'node:child_process'; + +const replayArgs = process.argv.slice(2); +if (replayArgs.length === 0) { + process.stderr.write('Usage: replay-with-deadline.mjs --timeout [agent-device replay options]\n'); + process.exit(2); +} + +function readFlagValue(args, flag) { + const index = args.indexOf(flag); + if (index >= 0) { + return args.at(index + 1); + } + + const prefix = `${flag}=`; + return args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length); +} + +const timeoutValue = readFlagValue(replayArgs, '--timeout') ?? process.env.REPLAY_TIMEOUT_MS ?? '120000'; +const timeoutMs = Number(timeoutValue); +if (!Number.isInteger(timeoutMs) || timeoutMs < 1) { + process.stderr.write(`Replay timeout must be a positive integer, received: ${timeoutValue}\n`); + process.exit(2); +} + +const stateDirectory = process.env.AGENT_DEVICE_STATE_DIR; +if (!stateDirectory) { + process.stderr.write('AGENT_DEVICE_STATE_DIR is required so timeout cleanup cannot stop an unrelated daemon.\n'); + process.exit(2); +} + +const detached = process.platform !== 'win32'; +const replay = spawn('agent-device', ['replay', ...replayArgs], { + detached, + env: process.env, + stdio: 'inherit', +}); + +let timedOut = false; +let terminationExitCode; +let forceKillTimer; + +function signalReplay(signal) { + if (!replay.pid) { + return; + } + + try { + if (detached) { + process.kill(-replay.pid, signal); + } else { + replay.kill(signal); + } + } catch (error) { + if (error?.code !== 'ESRCH') { + throw error; + } + } +} + +function startCleanup() { + const cleanup = spawn('agent-device', ['daemon', 'stop', '--state-dir', stateDirectory, '--clean'], { + env: process.env, + stdio: 'inherit', + }); + const cleanupTimer = setTimeout(() => { + process.stderr.write('Daemon cleanup exceeded 2000ms and was terminated.\n'); + cleanup.kill('SIGKILL'); + }, 2000); + + cleanup.once('error', (error) => { + clearTimeout(cleanupTimer); + process.stderr.write(`Unable to start daemon cleanup: ${error.message}\n`); + }); + cleanup.once('exit', () => { + clearTimeout(cleanupTimer); + }); +} + +function handleTermination(signal, exitCode) { + if (timedOut || terminationExitCode) { + return; + } + + terminationExitCode = exitCode; + process.stderr.write(`Received ${signal}. Terminating replay and cleaning the isolated daemon.\n`); + signalReplay(signal); + forceKillTimer = setTimeout(() => { + signalReplay('SIGKILL'); + }, 1000); +} + +process.on('SIGINT', () => { + handleTermination('SIGINT', 130); +}); +process.on('SIGTERM', () => { + handleTermination('SIGTERM', 143); +}); + +const deadlineTimer = setTimeout(() => { + timedOut = true; + process.stderr.write(`Replay exceeded ${timeoutMs}ms. Terminating the client and cleaning the isolated daemon.\n`); + signalReplay('SIGTERM'); + forceKillTimer = setTimeout(() => { + signalReplay('SIGKILL'); + }, 1000); +}, timeoutMs); + +replay.once('error', (error) => { + clearTimeout(deadlineTimer); + clearTimeout(forceKillTimer); + process.stderr.write(`Unable to start agent-device replay: ${error.message}\n`); + process.exitCode = 127; +}); + +replay.once('exit', (code, signal) => { + clearTimeout(deadlineTimer); + clearTimeout(forceKillTimer); + + if (timedOut) { + startCleanup(); + process.exitCode = 124; + return; + } + + if (terminationExitCode) { + startCleanup(); + process.exitCode = terminationExitCode; + return; + } + + if (signal) { + process.stderr.write(`agent-device replay exited from signal ${signal}.\n`); + process.exitCode = 1; + return; + } + + process.exitCode = code ?? 1; +}); diff --git a/.claude/skills/measure-telemetry-span/scripts/replay-with-deadline.test.mjs b/.claude/skills/measure-telemetry-span/scripts/replay-with-deadline.test.mjs new file mode 100644 index 000000000000..854bad529b95 --- /dev/null +++ b/.claude/skills/measure-telemetry-span/scripts/replay-with-deadline.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import {fileURLToPath} from 'node:url'; + +const runnerPath = fileURLToPath(new URL('./replay-with-deadline.mjs', import.meta.url)); + +function createFakeAgentDevice() { + const directory = mkdtempSync(path.join(tmpdir(), 'agent-device-deadline-')); + const executable = path.join(directory, 'agent-device'); + writeFileSync( + executable, + `#!/bin/sh +if [ "$1" = "replay" ]; then + if [ "$FAKE_AGENT_DEVICE_MODE" = "hang" ]; then + sleep 2 + fi + if [ "$FAKE_AGENT_DEVICE_MODE" = "ignore-signals" ]; then + trap '' INT TERM + sleep 2 + fi + if [ "$FAKE_AGENT_DEVICE_MODE" = "fail" ]; then + exit 7 + fi + exit 0 +fi +if [ "$1" = "daemon" ] && [ "$2" = "stop" ]; then + printf 'daemon-stop\\n' >> "$FAKE_CLEANUP_LOG" + exit 0 +fi +exit 2 +`, + ); + chmodSync(executable, 0o755); + return directory; +} + +function runWithFakeAgentDevice(mode, timeoutMs = 5000) { + const directory = createFakeAgentDevice(); + const cleanupLog = path.join(directory, 'cleanup.log'); + const startedAt = performance.now(); + const result = spawnSync(process.execPath, [runnerPath, 'flow.ad', '--session', 'deadline-test', '--timeout', String(timeoutMs)], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + AGENT_DEVICE_STATE_DIR: directory, + FAKE_AGENT_DEVICE_MODE: mode, + FAKE_CLEANUP_LOG: cleanupLog, + }, + }); + + return { + cleanupLog, + directory, + elapsedMs: performance.now() - startedAt, + result, + }; +} + +test('returns the replay exit code', () => { + const success = runWithFakeAgentDevice('success'); + const failure = runWithFakeAgentDevice('fail'); + + try { + assert.equal(success.result.status, 0, success.result.stderr); + assert.equal(failure.result.status, 7, failure.result.stderr); + } finally { + rmSync(success.directory, {recursive: true, force: true}); + rmSync(failure.directory, {recursive: true, force: true}); + } +}); + +test('terminates replay at the deadline and starts session cleanup', async () => { + const run = runWithFakeAgentDevice('hang', 250); + + try { + assert.equal(run.result.status, 124, run.result.stderr); + assert.ok(run.elapsedMs < 2000, `deadline took ${run.elapsedMs.toFixed(0)}ms`); + assert.match(run.result.stderr, /Replay exceeded 250ms/); + + for (let attempt = 0; attempt < 20 && !existsSync(run.cleanupLog); attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + + assert.equal(readFileSync(run.cleanupLog, 'utf8'), 'daemon-stop\n'); + } finally { + rmSync(run.directory, {recursive: true, force: true}); + } +}); + +test('handles repeated SIGTERM and cleans the isolated daemon', async () => { + const directory = createFakeAgentDevice(); + const cleanupLog = path.join(directory, 'cleanup.log'); + const child = spawn(process.execPath, [runnerPath, 'flow.ad', '--session', 'signal-test', '--timeout', '5000'], { + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + AGENT_DEVICE_STATE_DIR: directory, + FAKE_AGENT_DEVICE_MODE: 'ignore-signals', + FAKE_CLEANUP_LOG: cleanupLog, + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + child.kill('SIGTERM'); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + child.kill('SIGTERM'); + const exit = await new Promise((resolve) => { + child.once('close', (code, signal) => { + resolve({code, signal}); + }); + }); + + try { + assert.deepEqual(exit, {code: 143, signal: null}, stderr); + assert.equal(readFileSync(cleanupLog, 'utf8'), 'daemon-stop\n'); + } finally { + rmSync(directory, {recursive: true, force: true}); + } +}); diff --git a/.env.example b/.env.example index 78cc6d5b192c..c66b0e8d6635 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ SKIP_ONBOARDING=false USE_WDYR=false USE_REDUX_DEVTOOLS=false CAPTURE_METRICS=false +# Comma-separated Sentry span names to emit as structured release benchmark logs. +EXPO_PUBLIC_BENCHMARK_SENTRY_SPANS= ONYX_METRICS=false USE_THIRD_PARTY_SCRIPTS=false IS_EXPENSIFY_EMPLOYEE=false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 68a130a6342e..296c2a268dcf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,3 +13,6 @@ contributingGuides/philosophies/ @tgolen # Help docs get a review from Steph Elliott docs/articles/ @stephanieelliott + +# Claude Code skills +/.claude/skills/ @Julesssss @Expensify/pullerbear diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 7618c481b7c2..f651fab95fa4 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -9,7 +9,7 @@ inputs: default: 'false' SEED_STICKY_DISKS: description: | - 'true' only in the seedStickyDisks workflow, which installs dependencies and nothing else. + 'true' only in the seedCache workflow, which installs dependencies and nothing else. Everywhere else the disks are mounted read-through so no job can persist build artifacts or tool caches into the snapshot. required: false diff --git a/.github/actions/javascript/proposalPoliceComment/action.yml b/.github/actions/javascript/proposalPoliceComment/action.yml index e675af5598f7..d16b55201e8b 100644 --- a/.github/actions/javascript/proposalPoliceComment/action.yml +++ b/.github/actions/javascript/proposalPoliceComment/action.yml @@ -7,6 +7,9 @@ inputs: PROPOSAL_POLICE_API_KEY: description: 'OpenAI API key for accessing AI services' required: true + IS_TRUSTED_COMMENTER: + description: 'Whether the commenter is a verified approved contributor or partner' + required: true runs: using: 'node24' main: './index.js' diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 8310177805a9..0d6eafcdd4f6 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -52528,10 +52528,11 @@ var isProposal_default = isProposal; function escapeForXMLWrapper(text) { return text.replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } -function buildCommentIntentInput(commentBody) { - return ` +function buildCommentIntentInput(commentBody, isTrustedCommenter = false) { + const authorContext = isTrustedCommenter ? "trusted: the commenter is an approved contributor or has identified themselves as working for an approved partner" : "untrusted: no approved contributor or partner affiliation was verified"; + return [` ${escapeForXMLWrapper(commentBody)} -`; +`, `${authorContext}`].join("\n"); } function buildEditCheckInput(previousBody, editedBody) { return ["", ` @@ -52591,6 +52592,18 @@ var commentIntentExamples_default = import_expensify_common2.Str.dedent(` ___ ${CONST_default.INTENT.SPAM} - a claim on the job with no technical content. + ___ + trusted: the commenter is an approved contributor or has identified themselves as working for an approved partner + I can take this. + ___ + ${CONST_default.INTENT.NOT_AN_ATTEMPT} - the same content-free self-offer is acceptable from a trusted contributor. + + ___ + untrusted: no approved contributor or partner affiliation was verified + I can take this. + ___ + ${CONST_default.INTENT.SPAM} - a content-free self-offer from an untrusted commenter is spam. + ___ +1, I can do this. I have 5 years of React Native experience and have fixed similar bugs before. ___ @@ -52626,6 +52639,11 @@ var commentIntentExamples_default = import_expensify_common2.Str.dedent(` ___ ${CONST_default.INTENT.NOT_AN_ATTEMPT} - commenting on someone else's proposal. + ___ + [Proposal updated](https://github.com/Expensify/App/issues/12345#issuecomment-67890) - corrected the root cause: the client reads stale workspace data. The fix is to refresh the policy after the ownership change. + ___ + ${CONST_default.INTENT.NOT_AN_ATTEMPT} - a pointer to an existing proposal update, not a new proposal. Technical details do not change that intent. + ___ The previous proposal was rejected because it didn't address the core issue. Here's my thoughts on what we should do instead... ___ @@ -52776,6 +52794,7 @@ function buildCommentIntentInstructions() { ` ${ROLE} `, + "For a content-free self-offer to take the issue, return NOT_AN_ATTEMPT only when the input says the commenter is trusted. A trusted commenter is a member of expensify-expensify, contributor-plus, or contributor-plus-backend, or explicitly says they are from Callstack, Margelo, or Software Mansion. Treat the same self-offer from an untrusted commenter as SPAM. Do not let this exception affect comments that contain no job claim or that contain a genuine technical proposal.", ` ${templateDefinition_default} `, @@ -52826,6 +52845,11 @@ ${duplicateDetection_default} var DUPLICATE_CHECK_WITHDRAW_MESSAGE = "#### \u{1F6AB} Duplicated proposal withdrawn by \u{1F916} ProposalPolice."; var SUBSTANTIVE_EDIT_MESSAGE_PREFIX = "\u{1F6A8} Edited by **proposal-police**:"; var SUBSTANTIVE_EDIT_MESSAGE_REGEX = /^🚨 Edited by \*\*proposal-police\*\*:[^\n]*\n+/; +function stripSubstantiveEditBanner(body) { + const trimmedStart = body.trimStart(); + const stripped = trimmedStart.replace(SUBSTANTIVE_EDIT_MESSAGE_REGEX, ""); + return stripped === trimmedStart ? body : stripped; +} function buildTemplateReminderMessage(proposalAuthor) { return `\u26A0\uFE0F @${proposalAuthor} Thanks for your proposal. Please update it to follow the [proposal template](https://github.com/Expensify/App/blob/main/contributingGuides/PROPOSAL_TEMPLATE.md?plain=1), as proposals are only reviewed if they follow that format (note the mandatory sections).`; } @@ -66230,6 +66254,7 @@ async function run() { return; } const apiKey = getInput("PROPOSAL_POLICE_API_KEY", { required: true }); + const isTrustedCommenter = getInput("IS_TRUSTED_COMMENTER") === "true"; const openAI = new OpenAIUtils_default(apiKey); const issueNumber = payload.issue?.number ?? -1; const commentID = payload.comment?.id ?? -1; @@ -66241,7 +66266,7 @@ async function run() { console.log("Comment does not follow the proposal template. Classifying what it is trying to do..."); const intentResponse = await openAI.promptResponses({ instructions: buildCommentIntentInstructions(), - input: buildCommentIntentInput(newProposalBody), + input: buildCommentIntentInput(newProposalBody, isTrustedCommenter), model: PROPOSAL_POLICE_MODEL, promptCacheKey: "proposal-police-comment-intent", textFormat: COMMENT_INTENT_RESPONSE_FORMAT @@ -66333,14 +66358,19 @@ async function run() { } return; } - if (isCommentEditedEvent(payload) && payload.comment.body.trim().startsWith(SUBSTANTIVE_EDIT_MESSAGE_PREFIX)) { - console.log("Comment was already edited by proposal-police once, so only refreshing its recorded copy.\n", payload.comment.body); - await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment.user.login, payload.comment.body.trim().replace(SUBSTANTIVE_EDIT_MESSAGE_REGEX, "")); + const previousProposalBody = stripSubstantiveEditBanner(payload.changes.body?.from ?? ""); + const editedProposalBody = stripSubstantiveEditBanner(payload.comment?.body ?? ""); + const isAlreadyBannered = editedProposalBody !== (payload.comment?.body ?? ""); + if (previousProposalBody === editedProposalBody) { + console.log("Proposal text is unchanged after stripping any edit banner, skipping the edit check."); + if (isAlreadyBannered) { + await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment.user.login, editedProposalBody); + } return; } const response = await openAI.promptResponses({ instructions: buildEditCheckInstructions(), - input: buildEditCheckInput(payload.changes.body?.from, payload.comment?.body), + input: buildEditCheckInput(previousProposalBody, editedProposalBody), model: PROPOSAL_POLICE_MODEL, promptCacheKey: "proposal-police-edit-check", textFormat: EDIT_CHECK_RESPONSE_FORMAT @@ -66352,6 +66382,9 @@ async function run() { const action = parsedResponse?.action ?? CONST_default.NO_ACTION; if (action === CONST_default.NO_ACTION) { console.log("Detected NO_ACTION for comment, returning early."); + if (isAlreadyBannered) { + await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment.user.login, editedProposalBody); + } return; } if (action === CONST_default.ACTION_EDIT) { @@ -66362,9 +66395,9 @@ async function run() { comment_id: commentID, body: `${buildSubstantiveEditMessage(formattedDate)} -${payload.comment?.body}` +${editedProposalBody}` }); - await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment?.user.login ?? "", payload.comment?.body ?? ""); + await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment?.user.login ?? "", editedProposalBody); } } if (import.meta.main) { diff --git a/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts b/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts index 780f405590d3..1e4d80ab582a 100644 --- a/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts +++ b/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts @@ -11,8 +11,7 @@ import { buildSubstantiveEditMessage, buildTemplateReminderMessage, DUPLICATE_CHECK_WITHDRAW_MESSAGE, - SUBSTANTIVE_EDIT_MESSAGE_PREFIX, - SUBSTANTIVE_EDIT_MESSAGE_REGEX, + stripSubstantiveEditBanner, } from '@prompts/proposalPolice/messages'; import { COMMENT_INTENT_RESPONSE_FORMAT, @@ -151,6 +150,7 @@ async function run() { } const apiKey = getInput('PROPOSAL_POLICE_API_KEY', {required: true}); + const isTrustedCommenter = getInput('IS_TRUSTED_COMMENTER') === 'true'; const openAI = new OpenAIUtils(apiKey); const issueNumber = payload.issue?.number ?? -1; @@ -168,7 +168,7 @@ async function run() { console.log('Comment does not follow the proposal template. Classifying what it is trying to do...'); const intentResponse = await openAI.promptResponses({ instructions: buildCommentIntentInstructions(), - input: buildCommentIntentInput(newProposalBody), + input: buildCommentIntentInput(newProposalBody, isTrustedCommenter), model: PROPOSAL_POLICE_MODEL, promptCacheKey: 'proposal-police-comment-intent', textFormat: COMMENT_INTENT_RESPONSE_FORMAT, @@ -309,21 +309,27 @@ async function run() { return; } - // A comment we already bannered must never be bannered again, which is the only thing the edit check - // decides, so skip it. The proposal text can still have changed though, and without this the - // Conversation would keep serving every future duplicate check the copy stored before this edit. - // `startsWith` rather than `includes`, matching the ^-anchored regex used to strip it below: a comment - // that merely quotes the banner further down has not been flagged, and stripping would leave it in place. - if (isCommentEditedEvent(payload) && payload.comment.body.trim().startsWith(SUBSTANTIVE_EDIT_MESSAGE_PREFIX)) { - console.log('Comment was already edited by proposal-police once, so only refreshing its recorded copy.\n', payload.comment.body); - // Store the proposal itself, not the banner a previous run prepended to it - await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment.user.login, payload.comment.body.trim().replace(SUBSTANTIVE_EDIT_MESSAGE_REGEX, '')); + // Strip any banner a previous run prepended so the edit check compares proposal text to + // proposal text. Leaving it on either side would make a timestamp-only difference look + // substantial, and prepending another banner on ACTION_EDIT would stack them. + const previousProposalBody = stripSubstantiveEditBanner(payload.changes.body?.from ?? ''); + const editedProposalBody = stripSubstantiveEditBanner(payload.comment?.body ?? ''); + const isAlreadyBannered = editedProposalBody !== (payload.comment?.body ?? ''); + + // Our own banner prepend comes back as another edited event. After stripping, the proposal + // is unchanged, so there is nothing to classify — and calling the model would just spend a + // request on a difference we introduced. + if (previousProposalBody === editedProposalBody) { + console.log('Proposal text is unchanged after stripping any edit banner, skipping the edit check.'); + if (isAlreadyBannered) { + await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment.user.login, editedProposalBody); + } return; } const response = await openAI.promptResponses({ instructions: buildEditCheckInstructions(), - input: buildEditCheckInput(payload.changes.body?.from, payload.comment?.body), + input: buildEditCheckInput(previousProposalBody, editedProposalBody), model: PROPOSAL_POLICE_MODEL, promptCacheKey: 'proposal-police-edit-check', textFormat: EDIT_CHECK_RESPONSE_FORMAT, @@ -339,6 +345,11 @@ async function run() { const action = parsedResponse?.action ?? CONST.NO_ACTION; if (action === CONST.NO_ACTION) { console.log('Detected NO_ACTION for comment, returning early.'); + // An already-bannered comment can still have changed in a non-substantive way, and + // without this the Conversation would keep serving the copy stored before this edit. + if (isAlreadyBannered) { + await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment.user.login, editedProposalBody); + } return; } @@ -348,12 +359,12 @@ async function run() { ...context.repo, /* eslint-disable @typescript-eslint/naming-convention */ comment_id: commentID, - body: `${buildSubstantiveEditMessage(formattedDate)}\n\n${payload.comment?.body}`, + body: `${buildSubstantiveEditMessage(formattedDate)}\n\n${editedProposalBody}`, }); // The Conversation still holds this proposal as it read before the edit, so refresh it. Only - // substantial edits land here; minor rewording is deliberately left as-is. - await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment?.user.login ?? '', payload.comment?.body ?? ''); + // substantial edits land here; minor rewording of a never-bannered comment is deliberately left as-is. + await refreshStoredProposal(openAI, issueNumber, commentID, payload.comment?.user.login ?? '', editedProposalBody); } } diff --git a/.github/workflows/androidBump.yml b/.github/workflows/androidBump.yml index 73a7911925ee..39aedc819b4b 100644 --- a/.github/workflows/androidBump.yml +++ b/.github/workflows/androidBump.yml @@ -10,7 +10,7 @@ jobs: android_bump: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml index 46e376c2c7d1..2603542b47f8 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -19,7 +19,7 @@ jobs: && github.actor != 'MelvinBot' steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 83a8da1a47ee..f1deafa80af9 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -94,6 +94,7 @@ jobs: mobile-expensify-ref: ${{ inputs.MOBILE_EXPENSIFY_REF }} pull-request-number: ${{ inputs.APP_PR_NUMBER }} force-native-build: ${{ inputs.FORCE_NATIVE_BUILD }} + download-dsyms: true secrets: inherit buildWeb: @@ -140,7 +141,7 @@ jobs: needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS] steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.APP_REF }} @@ -180,7 +181,7 @@ jobs: needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS] steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.APP_REF }} diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index a2febcdec14c..684cdb64a5d4 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -63,7 +63,7 @@ jobs: PROGUARD_MAPPING_FILENAME: mapping.txt steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: submodules: true ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index ddf63a534f62..7cdb09c2d8a1 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -27,6 +27,10 @@ on: description: Force a full native build, bypassing Rock remote cache type: string default: 'false' + download-dsyms: + description: Download the prebuilt react-native dSYMs so Sentry can symbolicate its native frames. Off by default - it adds two large downloads per build. + type: boolean + default: false outputs: IOS_VERSION: @@ -51,6 +55,7 @@ jobs: runs-on: blacksmith-12vcpu-macos-latest env: PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} + RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS: ${{ inputs.download-dsyms && '1' || '0' }} outputs: IOS_VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} ROCK_ARTIFACT_URL: ${{ steps.set-artifact-url.outputs.ARTIFACT_URL }} @@ -112,6 +117,26 @@ jobs: - name: Install New Expensify Gems run: bundle install + - name: Resolve react-native artifact version + id: resolve-rn-artifacts + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + RESULT=$(./node_modules/.bin/bun scripts/artifacts-utils/resolve-artifacts.ts --platform=ios --hybrid=true --new-dot-root="$GITHUB_WORKSPACE") + VERSION=$(echo "$RESULT" | jq -r '.version // "source"') + if [ -z "$VERSION" ]; then + echo "::error::Could not resolve react-native artifact version from resolve-artifacts.ts output: $RESULT" + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache react-native artifacts + # v5.0.1 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb + with: + path: ~/Library/Caches/Expensify/react-native-artifacts + key: ${{ runner.os }}-rn-artifacts-react-hybrid-${{ steps.resolve-rn-artifacts.outputs.VERSION }}-dsyms-${{ inputs.download-dsyms }} + - name: Cache Pod dependencies # v5.0.1 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb diff --git a/.github/workflows/buildVictoryChartRenderer.yml b/.github/workflows/buildVictoryChartRenderer.yml index 0fd0c0c99292..868e8988d17b 100644 --- a/.github/workflows/buildVictoryChartRenderer.yml +++ b/.github/workflows/buildVictoryChartRenderer.yml @@ -25,7 +25,7 @@ jobs: BINARY_FILENAME: victory-chart-renderer-linux-x64 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildWeb.yml b/.github/workflows/buildWeb.yml index fc1a9ec917ad..4506e2f6bec0 100644 --- a/.github/workflows/buildWeb.yml +++ b/.github/workflows/buildWeb.yml @@ -39,7 +39,7 @@ jobs: PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index 22e4047f025b..d32246c0c73d 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -19,6 +19,10 @@ on: - 'package.json' - 'package-lock.json' - '.github/workflows/bunTests.yml' + # tests/tooling asserts the Jest transform cache key stays in sync across these three workflows. + - '.github/workflows/reassurePerformanceTests.yml' + - '.github/workflows/seedJestPerfCache.yml' + - '.github/workflows/seedCache.yml' concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-bun-tests @@ -30,7 +34,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/checkSVGCompression.yml b/.github/workflows/checkSVGCompression.yml index e48a16098108..baa9526fcc94 100644 --- a/.github/workflows/checkSVGCompression.yml +++ b/.github/workflows/checkSVGCompression.yml @@ -21,7 +21,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/checkValidateCodeTerminology.yml b/.github/workflows/checkValidateCodeTerminology.yml index c22cbe725ea4..5835bb2f06da 100644 --- a/.github/workflows/checkValidateCodeTerminology.yml +++ b/.github/workflows/checkValidateCodeTerminology.yml @@ -11,7 +11,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check validateCode terminology run: ./scripts/checkValidateCodeTerminology.sh diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index ba7abe2f7e83..098a2696e5af 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -101,7 +101,7 @@ jobs: run: echo "CONFLICT_BRANCH_NAME=cherry-pick-${{ inputs.TARGET }}-${{ steps.getPRInfo.outputs.PR_NUMBER }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Checkout target branch - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.TARGET }} token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 36ec4993d793..9a822b6158a4 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -15,7 +15,7 @@ jobs: IS_AUTHORIZED: ${{ steps.gate.outputs.IS_AUTHORIZED }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 9a9142650fa0..98324697a22a 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -22,7 +22,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate @@ -45,7 +45,7 @@ jobs: - name: Checkout repository if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: fetch-depth: 1 @@ -68,7 +68,9 @@ jobs: - name: Setup Claude review toolkit if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' id: toolkit - uses: Expensify/GitHub-Actions/.github/actions/claude-review-toolkit@2b751e9a86d8690f53443cce0ce886079eccea76 + uses: Expensify/GitHub-Actions/.github/actions/claude-review-toolkit@54bfb8923d4f94c3cb209bfbad362fca9f0816f5 + with: + rules_directory: .claude/skills/app-coding-standards/rules - name: Mark review as in progress if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml index ca15a700f21c..96f833c516f1 100644 --- a/.github/workflows/createDeployChecklist.yml +++ b/.github/workflows/createDeployChecklist.yml @@ -14,7 +14,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.REF || github.sha }} diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml index c9ea77b3d862..67a9591f0176 100644 --- a/.github/workflows/cspell.yml +++ b/.github/workflows/cspell.yml @@ -9,7 +9,7 @@ jobs: spellcheck: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7780bdcf1534..a1e6219d0b9e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ jobs: IOS_VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.ENVIRONMENT || github.sha }} token: ${{ secrets.OS_BOTIFY_TOKEN }} @@ -215,7 +215,7 @@ jobs: if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -269,7 +269,7 @@ jobs: if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -434,6 +434,7 @@ jobs: with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} variant: Release + download-dsyms: true secrets: inherit iosUploadTestflight: @@ -705,7 +706,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -767,7 +768,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -811,7 +812,7 @@ jobs: ] steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Post Slack message on failure uses: ./.github/actions/composite/announceFailedWorkflowInSlack @@ -948,7 +949,7 @@ jobs: needs: [prep, checkDeploymentSuccess] steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode @@ -1123,7 +1124,7 @@ jobs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Upload to Sentry for size analysis id: sentry-upload @@ -1152,7 +1153,7 @@ jobs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Upload to Sentry for size analysis id: sentry-upload diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index c8fce6d55def..3de8b9c5f68a 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -16,7 +16,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Give the issue/PR the Hourly, Engineering labels run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 9e0c635d2ec3..8979988505b0 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -33,7 +33,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: fetch-depth: 0 diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml index 09d27d11d1aa..0ebfc617a293 100644 --- a/.github/workflows/failureNotifier.yml +++ b/.github/workflows/failureNotifier.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'failure' }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Process Failed Jobs uses: ./.github/actions/javascript/failureNotifier diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index 5e7f070cafc5..b7880d77c08b 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -13,7 +13,7 @@ jobs: isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) && (contains(github.event.issue.labels.*.name, 'ForceProductionDeploy') || fromJSON(steps.verifyStagingBuilds.outputs.ALL_NATIVE_BUILDS_SUCCEEDED || 'false')) }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/formatCodeCovComment.yml b/.github/workflows/formatCodeCovComment.yml index de56663bdee5..ee379d83a216 100644 --- a/.github/workflows/formatCodeCovComment.yml +++ b/.github/workflows/formatCodeCovComment.yml @@ -13,7 +13,8 @@ jobs: if: github.event.issue.pull_request && github.event.comment.user.login == 'codecov[bot]' steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + - name: Format CodeCov Comment uses: ./.github/actions/javascript/formatCodeCovComment with: diff --git a/.github/workflows/generateTranslations.yml b/.github/workflows/generateTranslations.yml index 1e88ee73c26c..b3109fc9f83e 100644 --- a/.github/workflows/generateTranslations.yml +++ b/.github/workflows/generateTranslations.yml @@ -42,7 +42,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ steps.pr-data.outputs.HEAD_SHA }} diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 91a27c6be62c..92fe004e6cb9 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -15,7 +15,7 @@ on: - 'package.json' - 'package-lock.json' - 'patches/**' - - 'tsconfig.json' + - '**/tsconfig*.json' concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-knip @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout PR - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bc8bd10c78a9..72cbf457423c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,6 +16,7 @@ on: 'config/.editorconfig', 'config/eslint/**', 'scripts/lint.ts', + 'scripts/lint/**', 'scripts/lintChanged.sh', '.watchmanconfig', '.imgbotconfig', @@ -35,7 +36,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: # Only use the elevated OSBotify token on the post-merge `workflow_call` run # (so the auto-commit step below can push to the protected `main` branch). diff --git a/.github/workflows/lockDeploys.yml b/.github/workflows/lockDeploys.yml index 18a50423bf71..884b4e51f157 100644 --- a/.github/workflows/lockDeploys.yml +++ b/.github/workflows/lockDeploys.yml @@ -10,7 +10,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Wait for staging deploys to finish uses: ./.github/actions/javascript/awaitStagingDeploys diff --git a/.github/workflows/onyxConnectWithoutViewReviewers.yml b/.github/workflows/onyxConnectWithoutViewReviewers.yml index b3a721acc798..e87f514d0b12 100644 --- a/.github/workflows/onyxConnectWithoutViewReviewers.yml +++ b/.github/workflows/onyxConnectWithoutViewReviewers.yml @@ -31,13 +31,15 @@ jobs: const pr = context.payload.pull_request; const {owner, repo} = context.repo; - // Trigger only on a net-new call. We compare added vs. removed call lines per file (a call - // matches `Onyx.connectWithoutView(`), so editing a call in place nets to zero, removed - // comments/strings don't count, and a removal in one file can't mask a new call in another. + // Trigger only on a net-new production call. Test files and Jest mocks are excluded, and we + // compare added vs. removed call lines per file (a call matches `Onyx.connectWithoutView(`), + // so editing a call in place nets to zero, removed comments/strings don't count, and a + // removal in one file can't mask a new call in another. const CALL = /Onyx\.connectWithoutView\s*\(/; + const TEST_DIRECTORY = /(^|\/)(?:tests|__mocks__)\//; const files = await github.paginate(github.rest.pulls.listFiles, {owner, repo, pull_number: pr.number}); const addsNewCall = files.some((file) => { - if (!file.patch) { + if (!file.patch || TEST_DIRECTORY.test(file.filename)) { return false; } let added = 0; diff --git a/.github/workflows/oxfmt.yml b/.github/workflows/oxfmt.yml index 1e57c2f9a693..93d5e4fbf335 100644 --- a/.github/workflows/oxfmt.yml +++ b/.github/workflows/oxfmt.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/postDeployComments.yml b/.github/workflows/postDeployComments.yml index e2134ee315ff..6778eaedf38f 100644 --- a/.github/workflows/postDeployComments.yml +++ b/.github/workflows/postDeployComments.yml @@ -89,7 +89,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 80121fa9cdb3..0c09e86b3098 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -30,7 +30,7 @@ jobs: if: ${{ always() }} steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Exit failed workflow if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' || needs.bunTests.result == 'failure' }} @@ -46,7 +46,7 @@ jobs: SHOULD_DEPLOY: ${{ fromJSON(steps.shouldDeploy.outputs.SHOULD_DEPLOY) }} steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Get merged pull request id: getMergedPullRequest diff --git a/.github/workflows/proposalPolice.yml b/.github/workflows/proposalPolice.yml index cd7357a0cdd9..8992fc4fface 100644 --- a/.github/workflows/proposalPolice.yml +++ b/.github/workflows/proposalPolice.yml @@ -15,36 +15,41 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: '!contains(fromJSON(''["OSBotify", "imgbot[bot]", "melvin-bot[bot]", "codecov[bot]"]''), github.actor) && !contains(github.event.issue.assignees.*.login, github.event.comment.user.login)' steps: - # Expensify employees commenting on Help Wanted issues are giving direction, not bidding for the job, - # so none of these checks should ever apply to them. author_association is not enough: it only reports - # MEMBER for publicly visible org members, so a concealed employee would look like an outside - # contributor. Reading org team membership needs a read:org token, hence OS_BOTIFY_TOKEN. - - name: Check whether the commenter is an Expensify employee - id: isExpensifyEmployee + # A content-free job claim is acceptable from approved contributors and partner engineers, but is spam + # from everyone else. author_association is not enough for concealed members, so verify team membership + # with the read:org token. Partner affiliation is accepted only when the commenter explicitly states it. + - name: Check whether the commenter is an approved contributor or partner + id: commenterTrust env: - # Passed through the environment rather than interpolated into the script, so a crafted login can't inject shell + # Passed through the environment rather than interpolated into the script, so crafted values can't inject shell. COMMENTER: ${{ github.event.comment.user.login }} + COMMENT_BODY: ${{ github.event.comment.body }} GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} run: | - if OUTPUT=$(gh api "/orgs/Expensify/teams/expensify-expensify/memberships/${COMMENTER}" --silent 2>&1); then - echo 'RESULT=employee' >> "$GITHUB_OUTPUT" - elif echo "$OUTPUT" | grep -q '(HTTP 404)'; then - echo 'RESULT=not-employee' >> "$GITHUB_OUTPUT" - else - # gh exits non-zero for any error, not just 404, so a token or API problem lands here. Skipping is - # the safe direction: classifying an employee's comment could get it minimized as spam. - echo "::warning::Could not verify Expensify membership for ${COMMENTER}, skipping ProposalPolice: ${OUTPUT}" - echo 'RESULT=unknown' >> "$GITHUB_OUTPUT" + TRUSTED=false + for TEAM in expensify-expensify contributor-plus contributor-plus-backend; do + if OUTPUT=$(gh api "/orgs/Expensify/teams/${TEAM}/memberships/${COMMENTER}" --silent 2>&1); then + TRUSTED=true + break + elif [[ "$OUTPUT" != *'(HTTP 404)'* ]]; then + echo "::warning::Could not verify ${COMMENTER}'s ${TEAM} membership: ${OUTPUT}" + fi + done + + shopt -s nocasematch + if [[ "$COMMENT_BODY" =~ (^|[[:space:]])from[[:space:]]+(Callstack|Margelo|Software[[:space:]]?Mansion)([^[:alnum:]_]|$) ]]; then + TRUSTED=true fi - - if: steps.isExpensifyEmployee.outputs.RESULT == 'not-employee' - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + echo "TRUSTED=${TRUSTED}" >> "$GITHUB_OUTPUT" + + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 # Classifies new comments that don't follow the proposal template, detects duplicate proposals, # and grades edits to existing proposals. Action type logic can be found in the script files. - name: Run ProposalPolice™ Comment Check Script - if: steps.isExpensifyEmployee.outputs.RESULT == 'not-employee' uses: ./.github/actions/javascript/proposalPoliceComment with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PROPOSAL_POLICE_API_KEY: ${{ secrets.PROPOSAL_POLICE_API_KEY }} + IS_TRUSTED_COMMENTER: ${{ steps.commenterTrust.outputs.TRUSTED }} diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml index affce45436ba..8048abe07e4d 100644 --- a/.github/workflows/publishReactNativeAndroidArtifacts.yml +++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml @@ -40,7 +40,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout Code - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.app_ref }} submodules: ${{ matrix.is_hybrid }} diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml index ed90de576152..50edbcdb48d7 100644 --- a/.github/workflows/publishReactNativeArtifacts.yml +++ b/.github/workflows/publishReactNativeArtifacts.yml @@ -51,8 +51,8 @@ jobs: build_targets: ${{ steps.getArtifactBuildTargets.outputs.BUILD_TARGETS }} steps: - name: Checkout - # v1 - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f + # v1.7.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 with: submodules: true ref: ${{ github.event.before || 'main' }} diff --git a/.github/workflows/publishReactNativeiOSArtifacts.yml b/.github/workflows/publishReactNativeiOSArtifacts.yml index 10f2ab0398ce..56aebc1d2322 100644 --- a/.github/workflows/publishReactNativeiOSArtifacts.yml +++ b/.github/workflows/publishReactNativeiOSArtifacts.yml @@ -64,7 +64,7 @@ jobs: - name: Resolve Hermes version id: hermes - run: echo "VERSION=$(sed 's/^hermes-v\{0,1\}//' node_modules/react-native/sdks/.hermesversion)" >> "$GITHUB_OUTPUT" + run: echo "VERSION=$(sed 's/^hermes-v\{0,1\}//' node_modules/react-native/sdks/.hermesv1version)" >> "$GITHUB_OUTPUT" - name: Fetch prebuild files from upstream RN uses: ./.github/actions/composite/fetchReactNativePrebuild @@ -190,7 +190,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout Code - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.app_ref }} submodules: ${{ matrix.is_hybrid }} diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml index 677ca1ef8094..46b1db684387 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index e0574eff63e5..422ffc782c75 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -6,13 +6,19 @@ on: branches-ignore: [staging, production] paths-ignore: [docs/**, help/**, .github/**, contributingGuides/**, tests/**, '**.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. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: baseline-perf-tests: if: ${{ github.actor != 'OSBotify' }} runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout baseline branch - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Checkout baseline branch shell: bash @@ -26,10 +32,41 @@ jobs: - name: Setup NodeJS uses: ./.github/actions/composite/setupNode + # Skips a cold Babel pass over the whole source tree; seedJestPerfCache.yml writes the entry + # on main and explains the key. Restore-only, so nothing an untrusted PR runs can end up in an + # entry a later run executes, and no restore-keys: a prefix fallback could serve output built + # by an older babel-plugin-react-compiler and move the render counts gated at + # COUNT_DEVIATION: 0. Keep the key identical to the other three. + - name: Restore Jest transform cache + id: restoreJestCache + # v5.0.1 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb + with: + path: .jest-cache + key: ${{ format('{0}-{1}-jest-perf-{2}', runner.os, runner.arch, hashFiles('normalized-package-lock.json', 'patches/**', 'babel.config.js', 'config/babel/**', 'jest.config.js', '.nvmrc')) }} + + # A miss only makes this job slow, so this warning is the only symptom of a cache that + # silently died: `Report Jest cache size` below reads the directory after the perf run, by + # which point Jest has written a full transform set either way. + - name: Warn on a Jest transform cache miss + if: steps.restoreJestCache.outputs.cache-hit != 'true' + run: echo "::warning::Jest perf transform cache miss - this job re-transforms the whole source tree. Expected only when the toolchain key rotated (lockfile, patches, Babel or Jest config, Node version); otherwise seedJestPerfCache.yml has stopped writing the entry." + - name: Run baseline performance tests shell: bash run: NODE_OPTIONS=--experimental-vm-modules npx reassure --baseline + # Compare against the seed job's notice on a recent push to main: near zero means the seed + # stopped running, roughly double means a second transform set is being written. + - name: Report Jest cache size + if: always() + run: | + if [[ -d .jest-cache ]]; then + echo "::notice::Jest perf cache: $(du -sm .jest-cache | cut -f1)MB, $(find .jest-cache -type f | wc -l) files" + else + echo "::notice::Jest perf cache: absent" + fi + - name: Upload baseline performance results # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f @@ -43,11 +80,31 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode + # Skips a cold Babel pass over the whole source tree; seedJestPerfCache.yml writes the entry + # on main and explains the key. Restore-only, so nothing an untrusted PR runs can end up in an + # entry a later run executes, and no restore-keys: a prefix fallback could serve output built + # by an older babel-plugin-react-compiler and move the render counts gated at + # COUNT_DEVIATION: 0. Keep the key identical to the other three. + - name: Restore Jest transform cache + id: restoreJestCache + # v5.0.1 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb + with: + path: .jest-cache + key: ${{ format('{0}-{1}-jest-perf-{2}', runner.os, runner.arch, hashFiles('normalized-package-lock.json', 'patches/**', 'babel.config.js', 'config/babel/**', 'jest.config.js', '.nvmrc')) }} + + # A miss only makes this job slow, so this warning is the only symptom of a cache that + # silently died: `Report Jest cache size` below reads the directory after the perf run, by + # which point Jest has written a full transform set either way. + - name: Warn on a Jest transform cache miss + if: steps.restoreJestCache.outputs.cache-hit != 'true' + run: echo "::warning::Jest perf transform cache miss - this job re-transforms the whole source tree. Expected only when the toolchain key rotated (lockfile, patches, Babel or Jest config, Node version); otherwise seedJestPerfCache.yml has stopped writing the entry." + - name: Run branch performance tests shell: bash env: @@ -55,6 +112,17 @@ jobs: COMMIT_HASH: ${{ github.event.pull_request.head.sha }} run: NODE_OPTIONS=--experimental-vm-modules npx reassure --branch="$BRANCH" --commit-hash="$COMMIT_HASH" --no-compare + # Compare against the seed job's notice on a recent push to main: near zero means the seed + # stopped running, roughly double means a second transform set is being written. + - name: Report Jest cache size + if: always() + run: | + if [[ -d .jest-cache ]]; then + echo "::notice::Jest perf cache: $(du -sm .jest-cache | cut -f1)MB, $(find .jest-cache -type f | wc -l) files" + else + echo "::notice::Jest perf cache: absent" + fi + - name: Upload branch performance results # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f @@ -69,7 +137,7 @@ jobs: needs: [baseline-perf-tests, branch-perf-tests] steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 528b109acdf2..6bf7b31b06fa 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -57,7 +57,7 @@ jobs: is_hybrid_build: true steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.resolveRefs.outputs.APP_REF }} submodules: ${{ matrix.is_hybrid_build || false }} diff --git a/.github/workflows/reviewerChecklist.yml b/.github/workflows/reviewerChecklist.yml index cee88fe39a67..e85b8bfe5b49 100644 --- a/.github/workflows/reviewerChecklist.yml +++ b/.github/workflows/reviewerChecklist.yml @@ -9,7 +9,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Filter paths uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedCache.yml similarity index 72% rename from .github/workflows/seedStickyDisks.yml rename to .github/workflows/seedCache.yml index 39246bb81e94..ae6a9472c29e 100644 --- a/.github/workflows/seedStickyDisks.yml +++ b/.github/workflows/seedCache.yml @@ -1,4 +1,4 @@ -name: Seed sticky disks +name: Seed caches on: push: @@ -15,7 +15,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node (seed) uses: ./.github/actions/composite/setupNode @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: submodules: true token: ${{ secrets.OS_BOTIFY_TOKEN }} @@ -37,3 +37,7 @@ jobs: with: IS_HYBRID_BUILD: 'true' SEED_STICKY_DISKS: 'true' + + seedJestPerfCache: + name: Seed Jest perf cache + uses: ./.github/workflows/seedJestPerfCache.yml diff --git a/.github/workflows/seedJestPerfCache.yml b/.github/workflows/seedJestPerfCache.yml new file mode 100644 index 000000000000..02f62bc64000 --- /dev/null +++ b/.github/workflows/seedJestPerfCache.yml @@ -0,0 +1,83 @@ +name: Seed Jest perf cache + +# Writes the Jest transform cache that reassurePerformanceTests.yml restores. +# +# It has to be written from main. An entry is scoped to the ref that wrote it, so one written by +# the pull_request-only perf workflow would be visible to that PR alone, and writing from main is +# what keeps the shared entry out of reach of fork PRs. +# +# Called by seedCache.yml on every push to main, with no paths filter and no schedule: a run that +# finds the entry present skips the populate, so probing every push is what rebuilds an evicted +# entry. No concurrency block, because a called workflow runs inside the caller's run and +# seedCache.yml's group already gates this job with cancel-in-progress false. + +on: + workflow_call: + workflow_dispatch: + +jobs: + seedJestPerfCache: + name: Seed the Jest transform cache + # Must match the runner class of the perf measure jobs. + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + + # Writes normalized-package-lock.json, which the cache key hashes. + - name: Setup NodeJS + uses: ./.github/actions/composite/setupNode + + # Covers what invalidates every entry at once: the lockfile and patches/** for the toolchain, + # babel.config.js and config/babel/** for the Babel options, jest.config.js for the config + # babel-jest hashes, and .nvmrc for process.version. It hashes normalized-package-lock.json, + # the app-version-stripped lockfile setupNode writes, so deploy version bumps do not rotate + # the key for no change in transform output. + # + # Not covered: `.env`, DEBUG_BABEL_TRACE and CAPTURE_METRICS, which babel.config.js reads at + # require time to add plugins that babel-jest does not hash into an entry's name. No job here + # creates a `.env`, so all of them inline the same EXPO_PUBLIC_* defaults; a step that gave + # one job its own would make restored output wrong rather than stale and would have to be + # hashed in here. + # + # No restore-keys, here or in the perf workflow: a prefix fallback would carry over output + # built by the previous toolchain, which is what the lockfile hash exists to prevent. + - name: Look up the Jest transform cache + id: lookup + # v5.0.1 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb + with: + path: .jest-cache + key: ${{ format('{0}-{1}-jest-perf-{2}', runner.os, runner.arch, hashFiles('normalized-package-lock.json', 'patches/**', 'babel.config.js', 'config/babel/**', 'jest.config.js', '.nvmrc')) }} + lookup-only: true + + # Must be the invocation the perf jobs run: Jest hashes the serialized project config into + # every entry's key, so a different testMatch or testRegex populates a key space the perf jobs + # never read. --baseline vs --branch never reaches Jest (reassure-cli commands/measure.js). + # The measurements are discarded; only the transform output is wanted. + # + # Deliberately allowed to fail the job, since only a genuine Jest failure on main can fail it. + # The save below is skipped on a failure because a plain `if:` implies success(), so no + # partial entry is written. Do not add continue-on-error without emitting a ::warning:: too - + # that would turn this job green with no symptom at all. + - name: Populate the Jest transform cache + if: steps.lookup.outputs.cache-hit != 'true' + shell: bash + run: NODE_OPTIONS=--experimental-vm-modules npx reassure --baseline + + - name: Save the Jest transform cache + if: steps.lookup.outputs.cache-hit != 'true' + # v5.0.1 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb + with: + path: .jest-cache + key: ${{ format('{0}-{1}-jest-perf-{2}', runner.os, runner.arch, hashFiles('normalized-package-lock.json', 'patches/**', 'babel.config.js', 'config/babel/**', 'jest.config.js', '.nvmrc')) }} + + - name: Report Jest cache size + if: always() + run: | + if [[ -d .jest-cache ]]; then + echo "::notice::Jest perf cache: $(du -sm .jest-cache | cut -f1)MB, $(find .jest-cache -type f | wc -l) files (cache-hit=${{ steps.lookup.outputs.cache-hit || 'false' }})" + else + echo "::notice::Jest perf cache: not populated this run (cache-hit=${{ steps.lookup.outputs.cache-hit || 'false' }})" + fi diff --git a/.github/workflows/shellCheck.yml b/.github/workflows/shellCheck.yml index 7a23b5b007f9..871e5ab42124 100644 --- a/.github/workflows/shellCheck.yml +++ b/.github/workflows/shellCheck.yml @@ -13,7 +13,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Lint shell scripts with ShellCheck run: npm run shellcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a05f12967e03..163a97c69349 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: name: test (job ${{ fromJSON(matrix.chunk) }}) steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode @@ -87,7 +87,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 name: Storybook tests steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index 5703f43b28ac..b7f3d20e3d0b 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -20,7 +20,7 @@ jobs: BUILD_MOBILE: ${{ steps.detectOSBotifyPush.outputs.BUILD_MOBILE || 'true' }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Validate that user is an Expensify employee uses: ./.github/actions/composite/validateActor diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index cee1d7df04c3..dce010bd5bc2 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/unused-styles.yml b/.github/workflows/unused-styles.yml index e5f2e7c9330d..03afa2affa95 100644 --- a/.github/workflows/unused-styles.yml +++ b/.github/workflows/unused-styles.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml index 4b93d4a7cdbe..8026529e7abe 100644 --- a/.github/workflows/updateHelpDotRedirects.yml +++ b/.github/workflows/updateHelpDotRedirects.yml @@ -22,7 +22,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Create help dot redirect env: diff --git a/.github/workflows/updateProtectedBranch.yml b/.github/workflows/updateProtectedBranch.yml index 47a188ebd6f2..869ef5152d2a 100644 --- a/.github/workflows/updateProtectedBranch.yml +++ b/.github/workflows/updateProtectedBranch.yml @@ -28,7 +28,7 @@ jobs: fi - name: Checkout source branch - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ steps.getSourceBranch.outputs.SOURCE_BRANCH }} token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/validateBuildRequest.yml b/.github/workflows/validateBuildRequest.yml index 6363acdfc6c8..d212bf02f818 100644 --- a/.github/workflows/validateBuildRequest.yml +++ b/.github/workflows/validateBuildRequest.yml @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Validate that user is an Expensify employee if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/validateContributorPR.yml b/.github/workflows/validateContributorPR.yml index 1e5329857b4d..7b1866355a3d 100644 --- a/.github/workflows/validateContributorPR.yml +++ b/.github/workflows/validateContributorPR.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate diff --git a/.github/workflows/validateDocsRoutes.yml b/.github/workflows/validateDocsRoutes.yml index 7055ef61a831..0e9ba202003d 100644 --- a/.github/workflows/validateDocsRoutes.yml +++ b/.github/workflows/validateDocsRoutes.yml @@ -11,7 +11,7 @@ jobs: if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/validateGithubActions.yml b/.github/workflows/validateGithubActions.yml index e6aa2ea04d3c..4d4f9b5ef9fa 100644 --- a/.github/workflows/validateGithubActions.yml +++ b/.github/workflows/validateGithubActions.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths: ['.github/**', 'scripts/**', 'package.json', 'package-lock.json', 'tsconfig.json'] + paths: ['.github/**', 'scripts/**', 'package.json', 'package-lock.json', '**/tsconfig*.json'] jobs: verify: @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/validateMobileExpensifySubmodule.yml b/.github/workflows/validateMobileExpensifySubmodule.yml index fd5d1b76409f..bc97fa057724 100644 --- a/.github/workflows/validateMobileExpensifySubmodule.yml +++ b/.github/workflows/validateMobileExpensifySubmodule.yml @@ -9,14 +9,14 @@ on: jobs: validate-mobile-expensify-submodule: - if: github.actor != 'OSBotify' && !endsWith(github.actor, '[bot]') + if: github.actor != 'OSBotify' && !endsWith(github.actor, '[bot]') && !github.event.pull_request.head.repo.fork runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout App - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Checkout Mobile-Expensify - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: repository: Expensify/Mobile-Expensify path: .github/mobile-expensify-repo diff --git a/.github/workflows/validatePatches.yml b/.github/workflows/validatePatches.yml index 59be39399e35..20f23630742f 100644 --- a/.github/workflows/validatePatches.yml +++ b/.github/workflows/validatePatches.yml @@ -11,7 +11,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Fetch main branch run: git fetch origin --depth=1 main diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml index 39fb5f608114..7b94b91393c9 100644 --- a/.github/workflows/verifyHybridApp.yml +++ b/.github/workflows/verifyHybridApp.yml @@ -89,4 +89,5 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} variant: Release + download-dsyms: true secrets: inherit diff --git a/.github/workflows/verifySignedCommits.yml b/.github/workflows/verifySignedCommits.yml index 672c4c7442ed..6f6b8d10559d 100644 --- a/.github/workflows/verifySignedCommits.yml +++ b/.github/workflows/verifySignedCommits.yml @@ -9,7 +9,7 @@ jobs: verifySignedCommits: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Verify signed commits uses: ./.github/actions/javascript/verifySignedCommits diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index 9c98079af8dc..be82bc3787c2 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -10,7 +10,7 @@ jobs: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' }} steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Get merged pull request id: getMergedPullRequest diff --git a/.gitignore b/.gitignore index 8ed197f41bb6..38ff8b9e8550 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ storybook-static # E2E test reports tests/e2e/results/ .reassure +.benchmarks/ # Temporary files created by Metro to check the health of the file watcher .metro-health-check* diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 77126c623460..8730a9acda24 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,9 +1,10 @@ -import '@libs/Middleware/register'; import EnvironmentProvider from '@components/EnvironmentContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; import {SearchContextProvider} from '@components/Search/SearchContextProvider'; +import registerMiddlewares from '@libs/Middleware/register'; + import colors from '@styles/theme/colors'; import ComposeProviders from '@src/components/ComposeProviders'; @@ -23,6 +24,8 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'; import './fonts.css'; +registerMiddlewares(); + Onyx.init({ keys: ONYXKEYS, }); diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index 3ca4a408b9de..08f0eb458dd7 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -48,6 +48,10 @@ "/": "/partners/plaid/oauth_ios", "comment": "Plaid setup" }, + { + "/": "/oauth/native-callback", + "comment": "Native-only Cloudflare Access OAuth callback, no web route" + }, { "/": "/statements/*", "comment": "Wallet statements" diff --git a/CLAUDE.md b/CLAUDE.md index c136517d4260..6017d9e17e59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that ### Code Quality -- **ESLint**: Linter. Pre-existing violations are grandfathered via [`eslint-seatbelt`](https://github.com/justjake/eslint-seatbelt). +- **ESLint**: Linter. Pre-existing violations are grandfathered via the seatbelt ratchet in `scripts/lint/`. ### Post-Edit Checklist (IMPORTANT) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3ac8c5c19fc5..e0b1529687b7 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3ac8c5c19fc54a32b4841d6853f9ef4408a72f3f +Subproject commit e0b1529687b774c09201af57e7fb997efbe89d97 diff --git a/android/app/build.gradle b/android/app/build.gradle index 7368f94f1304..682616439399 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046900 - versionName "9.4.69-0" + versionCode 1009047702 + versionName "9.4.77-2" // 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/CertificatePinning.kt b/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt index 0cd1f23b38d5..4bb010021674 100644 --- a/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt +++ b/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt @@ -1,13 +1,19 @@ package com.expensify.chat +import android.net.http.X509TrustManagerExtensions import com.facebook.react.modules.network.OkHttpClientProvider import io.sentry.Sentry import io.sentry.SentryLevel import okhttp3.CertificatePinner import okhttp3.Interceptor import okhttp3.Response +import java.security.KeyStore +import java.security.cert.Certificate +import java.security.cert.X509Certificate import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager /** * Certificate pinning for React Native's shared OkHttp client (Iteration 1 - NewDot). @@ -46,20 +52,43 @@ object CertificatePinning { private const val CERTIFICATE_PINNING_MODE_TAG = "certificate_pinning_mode" private const val CERTIFICATE_PINNING_CHANNEL_TAG = "certificate_pinning_channel" + /** + * Cloudflare can issue the expensify.com edge certificate from any of the CAs it uses - + * Let's Encrypt, Google Trust Services or SSL.com - and can rotate between them without notice + * (the unannounced 2026-07-07 Let's Encrypt -> Google Trust Services rotation is what broke us). + * To survive leaf rotation, intermediate rotation AND a CA switch without an emergency release, the + * Cloudflare-fronted hosts (Groups A & B) pin the SPKI of the ROOT of each of those three CAs, plus + * the live issuing intermediate for each. Any of these appearing in the served chain satisfies the + * pin. Regenerate with scripts/generateCertificatePins.sh (which also prints these CA pins). + */ + private val CLOUDFLARE_EXPENSIFY_PINS: List = listOf( + // Let's Encrypt - live CA for www/secure/staging (reverted to Let's Encrypt after the 2026-07-07 GTS rotation) + "sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", // ISRG Root X1 + "sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", // ISRG Root X2 + "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", // Let's Encrypt YE1 intermediate (live ECDSA) + // Google Trust Services - backup CA + "sha256/hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", // GTS Root R1 + "sha256/Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", // GTS Root R2 + "sha256/QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", // GTS Root R3 + "sha256/mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", // GTS Root R4 + "sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", // Google Trust Services WE1 intermediate (live ECDSA) + // SSL.com - backup CA + "sha256/G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", // SSL.com TLS ECC Root CA 2022 + "sha256/K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=", // SSL.com TLS RSA Root CA 2022 + ) + /** * Canonical pin data: domain → list of "sha256/" pin strings. - * The first hash is the leaf SPKI, the second is the issuing intermediate CA SPKI. * Keep in sync with config/certificatePinning/pins.json. */ private val PINNED_DOMAINS: Map> = mapOf( - // Group A: leaf CN=expensify.com + Let's Encrypt YE1 intermediate - "www.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), - "secure.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), - "staging.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), - "staging-secure.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), - // Group B: leaf CN=expensify.com + Google Trust Services WE1 intermediate - "new.expensify.com" to listOf("sha256/G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), - "staging.new.expensify.com" to listOf("sha256/G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), + // Groups A & B: Cloudflare-fronted expensify.com hosts - multi-CA root + intermediate pinning (see above) + "www.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS, + "secure.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS, + "staging.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS, + "staging-secure.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS, + "new.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS, + "staging.new.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS, // Group C: integrations leaf + Let's Encrypt R13 intermediate "integrations.expensify.com" to listOf("sha256/7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "sha256/AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="), // Group D: travel leaf + Google Trust Services WE1 intermediate @@ -77,6 +106,62 @@ object CertificatePinning { return builder.build() } + /** + * System trust manager used to rebuild the validated chain up to its trust anchor. Lazily + * initialized; null if the platform trust manager is unavailable. + */ + private val trustManagerExtensions: X509TrustManagerExtensions? by lazy { + try { + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(null as KeyStore?) + val tm = tmf.trustManagers + .filterIsInstance() + .firstOrNull() ?: return@lazy null + X509TrustManagerExtensions(tm) + } catch (_: Exception) { + null + } + } + + /** + * Returns the fully validated chain (leaf → intermediates → trust-anchor ROOT) for the raw + * certificates a server presented during the handshake. + * + * A TLS server sends only its leaf and intermediates — never the root — so calling + * [CertificatePinner.check] directly on the raw peer list can only ever match a pinned leaf or + * intermediate, never a pinned ROOT. Because our durable, rotation-proof pins are the CA ROOTs + * (a leaf/intermediate rotation must not require an app update), those pins would silently fail + * to match on the raw peer chain and produce false pin-mismatch reports whenever a CA issues from + * an intermediate we don't happen to pin. Rebuilding the chain via the system trust manager + * appends the anchor, so the root pins are actually evaluated — matching how the platform + * `` and OkHttp's own enforce-mode check behave. Mirrors + * [WebViewCertificateMonitor]'s chain reconstruction. Falls back to the raw peer certificates if + * reconstruction is unavailable or fails, so behaviour is never worse than before. + */ + private fun anchoredChain(peerCertificates: List, host: String): List { + val extensions = trustManagerExtensions ?: return peerCertificates + val x509Chain = peerCertificates.filterIsInstance() + val leaf = x509Chain.firstOrNull() ?: return peerCertificates + + val authTypes = if (leaf.publicKey.algorithm == "EC") { + arrayOf("ECDHE_ECDSA", "ECDSA") + } else { + arrayOf("RSA", "ECDHE_RSA") + } + + for (authType in authTypes) { + try { + val fullChain = extensions.checkServerTrusted(x509Chain.toTypedArray(), authType, host) + if (fullChain.isNotEmpty()) { + return fullChain + } + } catch (_: Exception) { + // Try the next authType; fall back to the raw peer chain if all fail. + } + } + return peerCertificates + } + /** * Install the pinned OkHttp client factory. Must be called before any networking (i.e. early in * [MainApplication.onCreate]). Pinning is disabled in debug builds so local dev keeps working. @@ -107,7 +192,9 @@ object CertificatePinning { * Installs a wrapping [javax.net.ssl.HostnameVerifier] on [HttpsURLConnection] that validates * certificate pins after the platform hostname verifier succeeds. This covers native code and * third-party libraries that use [java.net.URL] / [HttpsURLConnection] instead of OkHttp. - * Mismatches are reported to Sentry without failing the connection (monitor mode only). + * The served chain is passed through [anchoredChain] first so root pins are evaluated, not just + * the leaf/intermediate the server sent. Mismatches are reported to Sentry without failing the + * connection (monitor mode only). */ private fun installHttpsURLConnectionMonitor(certificatePinner: CertificatePinner) { val originalVerifier = HttpsURLConnection.getDefaultHostnameVerifier() @@ -115,7 +202,7 @@ object CertificatePinning { val result = originalVerifier.verify(hostname, session) if (result && PINNED_DOMAINS.containsKey(hostname)) { try { - certificatePinner.check(hostname, session.peerCertificates.toList()) + certificatePinner.check(hostname, anchoredChain(session.peerCertificates.toList(), hostname)) } catch (error: SSLPeerUnverifiedException) { reportPinningFailure( hostname = hostname, @@ -185,7 +272,9 @@ object CertificatePinning { /** * Validates certificate pins after the TLS handshake completes without blocking the request. - * Used during the monitor-only rollout phase. + * Used during the monitor-only rollout phase. The served chain is passed through [anchoredChain] + * first so the trust-anchor ROOT is included and our root pins are actually evaluated (see + * [anchoredChain]); otherwise only a pinned leaf/intermediate could ever match here. */ private class CertificatePinningMonitorInterceptor( private val certificatePinner: CertificatePinner, @@ -193,14 +282,17 @@ object CertificatePinning { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val response = chain.proceed(request) + val host = request.url.host val handshake = chain.connection()?.handshake() - if (handshake != null) { + // Only the pinned expensify hosts are validated (and only they pay the chain-rebuild + // cost in anchoredChain); all other traffic passes through untouched. + if (handshake != null && PINNED_DOMAINS.containsKey(host)) { try { - certificatePinner.check(request.url.host, handshake.peerCertificates) + certificatePinner.check(host, anchoredChain(handshake.peerCertificates, host)) } catch (error: SSLPeerUnverifiedException) { reportPinningFailure( - hostname = request.url.host, + hostname = host, url = request.url, channel = "OkHttp", message = error.message ?: "Certificate pinning validation failed", diff --git a/android/app/src/main/res/xml/network_security_config_enforce.xml b/android/app/src/main/res/xml/network_security_config_enforce.xml index b3f9b9c38a19..d30add1a4427 100644 --- a/android/app/src/main/res/xml/network_security_config_enforce.xml +++ b/android/app/src/main/res/xml/network_security_config_enforce.xml @@ -18,25 +18,33 @@ pin-set silently disables pinning; rotation is managed through app updates instead. --> - + www.expensify.com secure.expensify.com staging.expensify.com staging-secure.expensify.com - - cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc= - brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4= - - - - - new.expensify.com staging.new.expensify.com - G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4= - kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4= + + C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M= + diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI= + brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4= + + hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc= + Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78= + QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g= + mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c= + kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4= + + G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE= + K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ= diff --git a/assets/images/concierge.svg b/assets/images/concierge.svg index d3b0861e759a..3b1cdc16a44a 100644 --- a/assets/images/concierge.svg +++ b/assets/images/concierge.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/document-check.svg b/assets/images/document-check.svg new file mode 100644 index 000000000000..1364765e7e70 --- /dev/null +++ b/assets/images/document-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/campfire-icon-square.svg b/assets/images/integrationicons/campfire-icon-square.svg new file mode 100644 index 000000000000..a9cb5ec6b5bd --- /dev/null +++ b/assets/images/integrationicons/campfire-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/chatgpt-icon-square.svg b/assets/images/integrationicons/chatgpt-icon-square.svg new file mode 100644 index 000000000000..d63a1699daa6 --- /dev/null +++ b/assets/images/integrationicons/chatgpt-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/claude-icon-square.svg b/assets/images/integrationicons/claude-icon-square.svg new file mode 100644 index 000000000000..72c0b260e412 --- /dev/null +++ b/assets/images/integrationicons/claude-icon-square.svg @@ -0,0 +1 @@ + diff --git a/assets/images/integrationicons/cursor-icon-square.svg b/assets/images/integrationicons/cursor-icon-square.svg new file mode 100644 index 000000000000..0d94a03019eb --- /dev/null +++ b/assets/images/integrationicons/cursor-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/pie-chart.svg b/assets/images/pie-chart.svg new file mode 100644 index 000000000000..3d9472ce0209 --- /dev/null +++ b/assets/images/pie-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__ai-automation.svg b/assets/images/simple-illustrations/simple-illustration__ai-automation.svg new file mode 100644 index 000000000000..df0bd835c32f --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__ai-automation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__carddenied.svg b/assets/images/simple-illustrations/simple-illustration__carddenied.svg new file mode 100644 index 000000000000..88d5f41d38a3 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__carddenied.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__cardintowallet.svg b/assets/images/simple-illustrations/simple-illustration__cardintowallet.svg new file mode 100644 index 000000000000..3562607776e8 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__cardintowallet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/babel/oxcJestTransformer.js b/config/babel/oxcJestTransformer.js new file mode 100644 index 000000000000..33a1f167c35f --- /dev/null +++ b/config/babel/oxcJestTransformer.js @@ -0,0 +1,93 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const esbuild = require('esbuild'); +const {transformSync} = require('oxc-transform-react'); + +const babelJest = require('babel-jest'); +const OXC_TRANSFORM_REACT_VERSION = require('oxc-transform-react/package.json').version; +const BaseReactCompilerConfig = require('./reactCompilerConfig'); + +const babelTransformer = babelJest.createTransformer(); + +const NODE_MODULES_RE = /[/\\]node_modules[/\\]/; +const TESTS_RE = /[/\\]tests[/\\]/; +const JEST_SETUP_RE = /[/\\]jest[/\\]/; +const MOCKS_RE = /[/\\]__mocks__[/\\]/; + +const TRANSFORMER_SOURCE = fs.readFileSync(__filename); +const REACT_COMPILER_CONFIG_KEY = JSON.stringify(BaseReactCompilerConfig); + +const REACT_COMPILER_OPTIONS = { + ...BaseReactCompilerConfig, + panicThreshold: 'none', + eslintSuppressionRules: [], +}; + +function getLang(filename) { + const ext = path.extname(filename).slice(1); + if (ext === 'tsx') { + return 'tsx'; + } + if (ext === 'ts') { + return 'ts'; + } + return 'jsx'; +} + +function shouldUseOxc(filename) { + return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename); +} + +function processWithOxc(sourceText, sourcePath) { + const oxcResult = transformSync(sourcePath, sourceText, { + lang: getLang(sourcePath), + sourcemap: true, + jsx: {runtime: 'automatic', development: true}, + reactCompiler: REACT_COMPILER_OPTIONS, + }); + + if (oxcResult.fatal || !oxcResult.code) { + return null; + } + + const cjs = esbuild.transformSync(oxcResult.code, { + loader: 'js', + format: 'cjs', + supported: {'dynamic-import': false}, + sourcefile: sourcePath, + sourcemap: true, + }); + + return {code: cjs.code, map: cjs.map}; +} + +module.exports = { + canInstrument: false, + getCacheKey(sourceText, sourcePath, transformOptions) { + if (!shouldUseOxc(sourcePath)) { + return babelTransformer.getCacheKey(sourceText, sourcePath, transformOptions); + } + + return crypto + .createHash('sha1') + .update(sourceText) + .update('\0', 'utf8') + .update(sourcePath) + .update(TRANSFORMER_SOURCE) + .update(REACT_COMPILER_CONFIG_KEY) + .update(esbuild.version) + .update(OXC_TRANSFORM_REACT_VERSION) + .digest('hex'); + }, + process(sourceText, sourcePath, transformOptions) { + if (shouldUseOxc(sourcePath)) { + const result = processWithOxc(sourceText, sourcePath); + if (result) { + return result; + } + } + + return babelTransformer.process(sourceText, sourcePath, transformOptions); + }, +}; diff --git a/config/certificatePinning/README.md b/config/certificatePinning/README.md index ab290ae44ead..aba1881a7ed8 100644 --- a/config/certificatePinning/README.md +++ b/config/certificatePinning/README.md @@ -103,11 +103,53 @@ When pins change, update **all** of them. Each domain pins: Both production and staging hosts are pinned in every release build, because beta/TestFlight builds resolve their runtime environment to STAGING and hit `staging.*` APIs while still being non-debug. +## Cloudflare-fronted hosts: multi-CA root + intermediate pinning + +The `expensify.com` edge certificates for `www`, `secure`, `staging`, `staging-secure`, `new` and +`staging.new` are issued by **Cloudflare**, which can pick — and rotate between — any of the CAs it +uses (**Let's Encrypt**, **Google Trust Services**, **SSL.com**) without notice. An unannounced +Let's Encrypt → Google Trust Services rotation on 2026-07-07 is what broke pinning; `www` has since +reverted to Let's Encrypt. Cloudflare explicitly documents that you should **not** pin a single CA's +chain ([SSL/TLS docs](https://developers.cloudflare.com/ssl/reference/certificate-pinning/)). + +To keep the app working across leaf rotation, intermediate rotation, **and** a switch between those +three CAs — without shipping an emergency release each time — these six hosts (Groups A & B) share +one pin set that pins the **SPKI of the ROOT** of all three CAs plus each CA's **live issuing +intermediate**. Pinning the roots is what survives an intermediate rotation (the failure mode that +hit us). Any one of these appearing in the served chain satisfies the pin: + +| CA | Pin (base64 SHA-256 of SPKI) | Certificate | +|----|------------------------------|-------------| +| Let's Encrypt | `C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=` | ISRG Root X1 (RSA 4096) | +| Let's Encrypt | `diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=` | ISRG Root X2 (ECDSA P-384) | +| Let's Encrypt | `brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=` | Let's Encrypt YE1 (live ECDSA intermediate) | +| Google Trust Services | `hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=` | GTS Root R1 (RSA 4096) | +| Google Trust Services | `Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=` | GTS Root R2 (RSA 4096) | +| Google Trust Services | `QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=` | GTS Root R3 (ECDSA P-384) | +| Google Trust Services | `mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=` | GTS Root R4 (ECDSA P-384) | +| Google Trust Services | `kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=` | GTS WE1 (live ECDSA intermediate) | +| SSL.com | `G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=` | SSL.com TLS ECC Root CA 2022 (ECDSA P-384) | +| SSL.com | `K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=` | SSL.com TLS RSA Root CA 2022 (RSA 4096) | + +These root/intermediate pins are broad by design (they trust each CA's whole hierarchy), which is the +tightest safe posture for a host whose CA is controlled by Cloudflare. The other groups +(`integrations`, `travel`, CloudFront) are single-CA and keep the tighter leaf + issuing-intermediate +pinning. Trade-off accepted per the incident: resilience over a narrower trust set for the Cloudflare +hosts. + +**Before flipping these hosts to enforce mode**, re-run `scripts/generateCertificatePins.sh +--ca-pins` on a networked machine to confirm each root/intermediate SPKI still matches (roots are +stable for years, but confirm) and that the live `www`/`new` chains still terminate in one of the +pinned CAs. + ## Regenerating pins ```bash ./scripts/generateCertificatePins.sh # prints leaf + intermediate hashes per domain ./scripts/generateCertificatePins.sh --android # also prints the network_security_config +./scripts/generateCertificatePins.sh --ca-pins # prints the multi-CA root+intermediate pins for the + # Cloudflare-fronted expensify.com hosts (Groups A & B), + # computed from each CA's official published certificate ``` ## Rotation runbook diff --git a/config/certificatePinning/pins.json b/config/certificatePinning/pins.json index dce0f95bfcdb..4dde90825c5d 100644 --- a/config/certificatePinning/pins.json +++ b/config/certificatePinning/pins.json @@ -1,19 +1,77 @@ { - "_comment": "Canonical source of truth for SSL certificate pins (Iteration 1 - NewDot). Regenerate with scripts/generateCertificatePins.sh. Each domain pins the leaf SPKI hash (primary) and its issuing intermediate CA SPKI hash (durable backup that survives leaf rotation). Hashes are base64-encoded SHA-256 of the Subject Public Key Info. Native code (Android network_security_config_enforce.xml, Android OkHttpClientFactory, Android NitroFetch Cronet, iOS TrustKit) MUST be kept in sync with this file - the generation script prints the values for each target.", - "_lastGenerated": "2026-06-03", + "_comment": "Canonical source of truth for SSL certificate pins (Iteration 1 - NewDot). Regenerate with scripts/generateCertificatePins.sh. Cloudflare-fronted expensify.com hosts (Groups A & B: www/secure/staging/staging-secure/new/staging.new) pin the SPKI of the ROOT of every CA Cloudflare may use for them - Let's Encrypt (ISRG Root X1/X2), Google Trust Services (GTS Root R1-R4) and SSL.com (TLS ECC/RSA Root CA 2022) - plus each CA's live issuing intermediate, so the app survives leaf rotation, intermediate rotation, and a switch between those CAs without an emergency release. Other hosts pin the leaf SPKI (primary) and its issuing intermediate CA SPKI (durable backup). Hashes are base64-encoded SHA-256 of the Subject Public Key Info. Native code (Android network_security_config_enforce.xml, Android CertificatePinning.kt, Android NitroFetch Cronet via patches/react-native-nitro-fetch+1.5.4.patch, iOS CertificatePinning.swift) and the Mobile-Expensify HybridApp files MUST be kept in sync with this file.", + "_lastGenerated": "2026-08-03", "enforcePinning": false, + "_cloudflareExpensifyNote": "Groups A & B share this pin set. Let's Encrypt is the live CA for www/secure/staging (reverted from Google Trust Services after the unannounced 2026-07-07 rotation). Google Trust Services and SSL.com are pinned as backups so a future Cloudflare CA change does not require an emergency release. Root pins survive intermediate rotation (an intermediate rotation is what broke us on 2026-07-07).", + "_cloudflareExpensifyHashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "_cloudflareExpensifyHashNotes": { + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=": "ISRG Root X1 (Let's Encrypt root, RSA 4096)", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=": "ISRG Root X2 (Let's Encrypt root, ECDSA P-384)", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=": "Let's Encrypt YE1 (live ECDSA issuing intermediate)", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=": "GTS Root R1 (Google Trust Services root, RSA 4096)", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=": "GTS Root R2 (Google Trust Services root, RSA 4096)", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=": "GTS Root R3 (Google Trust Services root, ECDSA P-384)", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=": "GTS Root R4 (Google Trust Services root, ECDSA P-384)", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=": "Google Trust Services WE1 (live ECDSA issuing intermediate)", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=": "SSL.com TLS ECC Root CA 2022 (ECDSA P-384)", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=": "SSL.com TLS RSA Root CA 2022 (RSA 4096)" + }, "domains": { "www.expensify.com": { - "hashes": ["cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="], - "notes": "leaf CN=expensify.com + Let's Encrypt YE1 intermediate" + "hashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "notes": "Cloudflare-fronted (Group A). Live CA: Let's Encrypt. Multi-CA root+intermediate pinning - see _cloudflareExpensifyNote." }, "secure.expensify.com": { - "hashes": ["cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="], - "notes": "leaf CN=expensify.com + Let's Encrypt YE1 intermediate" + "hashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "notes": "Cloudflare-fronted (Group A). Live CA: Let's Encrypt. Multi-CA root+intermediate pinning - see _cloudflareExpensifyNote." }, "new.expensify.com": { - "hashes": ["G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="], - "notes": "leaf CN=expensify.com + Google Trust Services WE1 intermediate" + "hashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "notes": "Cloudflare-fronted (Group B). Live CA: Google Trust Services. Multi-CA root+intermediate pinning - see _cloudflareExpensifyNote." }, "integrations.expensify.com": { "hashes": ["7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="], @@ -28,16 +86,49 @@ "notes": "leaf *.cloudfront.net + Amazon RSA 2048 M01 intermediate" }, "staging.expensify.com": { - "hashes": ["cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="], - "notes": "leaf CN=expensify.com + Let's Encrypt YE1 intermediate" + "hashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "notes": "Cloudflare-fronted (Group A). Live CA: Let's Encrypt. Multi-CA root+intermediate pinning - see _cloudflareExpensifyNote." }, "staging-secure.expensify.com": { - "hashes": ["cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="], - "notes": "leaf CN=expensify.com + Let's Encrypt YE1 intermediate" + "hashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "notes": "Cloudflare-fronted (Group A). Live CA: Let's Encrypt. Multi-CA root+intermediate pinning - see _cloudflareExpensifyNote." }, "staging.new.expensify.com": { - "hashes": ["G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="], - "notes": "leaf CN=expensify.com + Google Trust Services WE1 intermediate" + "hashes": [ + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ], + "notes": "Cloudflare-fronted (Group B). Live CA: Google Trust Services. Multi-CA root+intermediate pinning - see _cloudflareExpensifyNote." }, "staging.travel.expensify.com": { "hashes": ["Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="], diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index fff24b0167f7..e7c48b76e50d 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -13,7 +13,6 @@ import reactNativeA11Y from 'eslint-plugin-react-native-a11y'; import rulesdir from 'eslint-plugin-rulesdir'; import testingLibrary from 'eslint-plugin-testing-library'; import youDontNeedLodashUnderscore from 'eslint-plugin-you-dont-need-lodash-underscore'; -import seatbelt from 'eslint-seatbelt'; import {defineConfig, globalIgnores} from 'eslint/config'; import globals from 'globals'; import {createRequire} from 'node:module'; @@ -22,7 +21,6 @@ import {fileURLToPath} from 'node:url'; import tseslint from 'typescript-eslint'; import reportNameUtilsPlugin from './plugins/eslint-plugin-report-name-utils.mjs'; -import expensifyProcessor from './processors/eslint-processor-expensify.mjs'; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); @@ -45,16 +43,6 @@ const localRulesDir = path.resolve(projectRoot, 'eslint-plugin-local-rules'); rulesdir.RULES_DIR = [expensifyRulesDir, localRulesDir]; const restrictedImportPaths = [ - { - name: '@components/Button', - importNames: ['default'], - message: 'The legacy Button is deprecated. Please use the composed Button from `@components/ButtonComposed` instead. Importing the `ButtonProps` type from here is still allowed.', - }, - { - name: '@src/components/Button', - importNames: ['default'], - message: 'The legacy Button is deprecated. Please use the composed Button from `@components/ButtonComposed` instead. Importing the `ButtonProps` type from here is still allowed.', - }, { name: 'react-native', importNames: [ @@ -199,7 +187,7 @@ const restrictedReportNameImportPatterns = [ ]; // `isPaidGroupPolicy` is BILLING/paid-only (Collect/Control). Existing usages are grandfathered via -// eslint-seatbelt; this only flags NEW imports so they make a conscious choice: for workspace feature +// the seatbelt baseline; this only flags NEW imports so they make a conscious choice: for workspace feature // gating (violations, report fields, workspace chat, report creation, expense-workspace usability) use // `isGroupPolicy` / `isReportInGroupPolicy` instead, otherwise free group plans like Submit (submit2026) // are wrongly excluded and access bugs return. @@ -240,35 +228,13 @@ const config = defineConfig([ }, }, }, - fileProgress.configs['recommended-ci'], - - // Suppress lint rules that are unnecessary for files successfully compiled by React Compiler. - // The processor runs React Compiler on each file and filters out redundant lint messages. - { - files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], - processor: expensifyProcessor, - }, - - // eslint-seatbelt config. The processor is stitched into `expensifyProcessor` - // above, so we only wire up the plugin, settings, and `configure` rule here. { + ...fileProgress.configs['recommended-ci'], settings: { - seatbelt: { - seatbeltFile: path.join(dirname, 'eslint.seatbelt.tsv'), - threadsafe: true, - // Never persist TSV updates unless we're in CI. In CI, the ephemeral - // write is harmless on PR runs and essential on `push: main`, where - // OSBotify commits the tightened baseline back to main - // (see .github/workflows/lint.yml). SEATBELT_INCREASE overrides this. - readOnly: !process.env.CI, + progress: { + hide: process.env.CI === 'true' || process.env.LINT_PIPELINE === '1', }, }, - plugins: { - 'eslint-seatbelt': seatbelt, - }, - rules: { - 'eslint-seatbelt/configure': 'error', - }, }, { @@ -304,7 +270,9 @@ const config = defineConfig([ languageOptions: { parserOptions: { - project: path.resolve(projectRoot, 'tsconfig.json'), + // The app project, not the root solution: the solution owns no files, so typed linting + // has nothing to resolve against there. + project: path.resolve(projectRoot, 'tsconfig.app.json'), projectService: false, }, @@ -486,14 +454,16 @@ const config = defineConfig([ }, }, - // Enforces every Onyx type and its properties to have a comment explaining its purpose. + // Enforces every Onyx type to have a comment explaining its purpose. Per-property + // documentation is enforced by the AI reviewer (CONSISTENCY-10) instead, + // since a property with nothing non-obvious to say needs no comment. { files: ['src/types/onyx/**/*.ts'], rules: { 'jsdoc/require-jsdoc': [ 'error', { - contexts: ['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSPropertySignature'], + contexts: ['TSInterfaceDeclaration', 'TSTypeAliasDeclaration'], }, ], }, @@ -690,6 +660,15 @@ const config = defineConfig([ }, }, + // The styles layer composes tokens out of `variables`, so it reads them by name. Raw numeric literals stay banned. + { + files: ['src/styles/**'], + ignores: ['src/styles/typography.ts', 'src/styles/variables.ts'], + rules: { + 'rulesdir/no-raw-typography': ['error', {allowVariablesReferences: true}], + }, + }, + // Restrict `computeReportName` imports everywhere except the one file that // legitimately consumes it. This block overrides the main `no-restricted-imports` // for ts/tsx files, so we re-apply the main `restrictedImportPaths`/`restrictedImportPatterns` @@ -708,6 +687,28 @@ const config = defineConfig([ }, }, + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react-native-onyx/dist/OnyxUtils', + message: + 'OnyxUtils is not a sanctioned way to read Onyx data. Use useOnyx() from @hooks/useOnyx in render paths, or a short-lived Onyx.connectWithoutView() for non-render logic. Type-only imports are still allowed.', + allowTypeImports: true, + }, + ], + }, + ], + }, + }, + { files: ['src/**/*'], ignores: ['src/languages/**', 'src/CONST/index.ts', 'src/NAICS.ts'], @@ -735,7 +736,10 @@ const config = defineConfig([ }, { - files: ['scripts/**/*.ts', 'tests/tooling/**/*.ts', 'server/{libs,plugins,stubs}/**/*.{ts,tsx}', 'evals/**/*.ts'], + // `prompts` is not Bun code, but the Bun program is the one that owns it: `scripts`, + // `evals` and `tests/tooling` are its callers. (The Node program lists it too, for the + // Proposal Police GitHub Action.) + files: ['scripts/**/*.ts', 'tests/tooling/**/*.ts', 'server/{libs,plugins,stubs}/**/*.{ts,tsx}', 'evals/**/*.ts', 'prompts/**/*.ts'], languageOptions: { parserOptions: { project: path.resolve(projectRoot, 'tsconfig.bun.json'), diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 49b7b2e2a5a1..74856344b622 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1,6 +1,17 @@ # eslint-seatbelt temporarily allowed errors # docs: https://github.com/justjake/eslint-seatbelt#readme +"../../__mocks__/@pusher/pusher-websocket-react-native/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../__mocks__/@react-native-camera-roll/camera-roll.ts" "@typescript-eslint/no-deprecated/CameraRoll.save" 1 +"../../__mocks__/@react-native-community/netinfo.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../__mocks__/@react-navigation/native/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../__mocks__/@ua/react-native-airship.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../__mocks__/expo-video.tsx" "react-hooks/refs" 2 +"../../__mocks__/openai.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../__mocks__/react-freeze.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../__mocks__/react-native-onyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../__mocks__/react-native-safe-area-context.tsx" "react-hooks/refs" 4 +"../../__mocks__/react-native.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/authorChecklist/categories/newComponentCategory.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.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 @@ -19,17 +30,6 @@ "../../.github/libs/GithubUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../.github/libs/sanitizeJSONStringValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/scripts/createDocsRoutes.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/@pusher/pusher-websocket-react-native/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/@react-native-camera-roll/camera-roll.ts" "@typescript-eslint/no-deprecated/CameraRoll.save" 1 -"../../__mocks__/@react-native-community/netinfo.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/@react-navigation/native/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/@ua/react-native-airship.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 -"../../__mocks__/expo-video.tsx" "react-hooks/refs" 2 -"../../__mocks__/openai.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/react-freeze.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/react-native-onyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../__mocks__/react-native-safe-area-context.tsx" "react-hooks/refs" 4 -"../../__mocks__/react-native.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../assets/emojis/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../jest/setup.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../jest/setupAfterEnv.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -44,22 +44,10 @@ "../../scripts/sentry/release-adoption-older-than.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../scripts/utils/Git.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../scripts/utils/OpenAIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../scripts/utils/TSCompilerUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../scripts/utils/Translator/TranslationPromptUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../scripts/utils/TSCompilerUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../server/stubs/react-native-reanimated.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../server/victory-chart-renderer/src/initSkiaForCli.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/CONST/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/DeepLinkHandler.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/DeepLinkHandler.tsx" "no-restricted-syntax" 1 -"../../src/Expensify.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/Expensify.tsx" "no-restricted-syntax" 1 -"../../src/Expensify.tsx" "react-hooks/refs" 1 -"../../src/Expensify.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/GlobalModals.tsx" "no-restricted-syntax" 2 -"../../src/ONYXKEYS.ts" "no-restricted-syntax" 2 -"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 54 -"../../src/ROUTES.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/TIMEZONES.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AccountingConnectionConfirmationModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ActionSheetAwareScrollView/useActionSheetAwareScrollViewRef.ts" "react-hooks/immutability" 1 "../../src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.ts" "@typescript-eslint/no-deprecated/useScrollViewOffset" 1 @@ -82,8 +70,8 @@ "../../src/components/AttachmentPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 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/Pager/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Attachments/AttachmentCarousel/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts" "no-restricted-syntax" 1 "../../src/components/Attachments/AttachmentView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "no-restricted-syntax" 1 @@ -96,9 +84,6 @@ "../../src/components/AvatarSelector.tsx" "no-restricted-syntax" 2 "../../src/components/AvatarWithImagePicker.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/BookTravelButton.tsx" "no-restricted-imports" 1 -"../../src/components/Button/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/Button/validateSubmitShortcut/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/ButtonComposed/Button.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../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/BarChart/BarChartContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -114,14 +99,15 @@ "../../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/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ConnectToNetSuiteFlow/index.tsx" "react-hooks/refs" 5 "../../src/components/ConnectToNetSuiteFlow/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ConnectToXeroFlow/index.native.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ConnectToXeroFlow/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/CountryPicker/CountrySelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/CountryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/createOnyxContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/DatePicker/CalendarPicker/YearPickerModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/DatePicker/DatePickerModal.tsx" "react-hooks/set-state-in-effect" 1 @@ -135,7 +121,6 @@ "../../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/EReceiptWithSizeCalculation.tsx" "@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 @@ -143,9 +128,8 @@ "../../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/EmojiSuggestions.tsx" "no-restricted-syntax" 1 "../../src/components/EmptySelectionListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/EnvironmentBadge.tsx" "no-restricted-syntax" 1 +"../../src/components/EReceiptWithSizeCalculation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/FeedbackSurvey.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/FilePicker/index.native.tsx" "react-hooks/refs" 1 "../../src/components/FilePicker/index.tsx" "react-hooks/refs" 1 @@ -161,6 +145,12 @@ "../../src/components/Form/InputWrapper.tsx" "react-hooks/refs" 1 "../../src/components/GPSInProgressModal/index.native.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/GPSTripStateChecker/index.native.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/components/HeaderWithBackButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/Hoverable/ActiveHoverable.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Hoverable/ActiveHoverable.tsx" "react-hooks/refs" 3 +"../../src/components/Hoverable/ActiveHoverable.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/Hoverable/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Hoverable/index.tsx" "react-hooks/refs" 1 "../../src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/components/HTMLEngineProvider/HTMLRenderers/AccountManagerLinkRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -176,12 +166,6 @@ "../../src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getSkiaLineMetrics.ts" "rulesdir/no-raw-typography" 1 "../../src/components/HTMLEngineProvider/index.tsx" "no-restricted-syntax" 1 -"../../src/components/HeaderWithBackButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/Hoverable/ActiveHoverable.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Hoverable/ActiveHoverable.tsx" "react-hooks/refs" 3 -"../../src/components/Hoverable/ActiveHoverable.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/Hoverable/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Hoverable/index.tsx" "react-hooks/refs" 1 "../../src/components/Icon/BankIcons/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Icon/BankIconsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 45 "../../src/components/Icon/ExpensifyIconLoader.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -221,8 +205,8 @@ "../../src/components/MoneyRequestConfirmationList/hooks/useTransactionReportForConfirmation.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationList/sections/AmountField.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationList/sections/selectors.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationListFooter/sections/DistanceMapSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -233,6 +217,12 @@ "../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../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/config/scenarios/ChangePIN.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/MultifactorAuthentication/Context/stateReducer.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/MultifactorAuthentication/Context/usePromptContent.ts" "react-hooks/refs" 10 +"../../src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx" "no-restricted-syntax" 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 @@ -240,22 +230,16 @@ "../../src/components/MultiGestureCanvas/usePanGesture.ts" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "react-hooks/set-state-in-effect" 1 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "no-restricted-syntax" 1 -"../../src/components/MultifactorAuthentication/Context/stateReducer.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/MultifactorAuthentication/Context/usePromptContent.ts" "react-hooks/refs" 10 -"../../src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx" "no-restricted-syntax" 1 -"../../src/components/MultifactorAuthentication/components/Modals/createCancelConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Navigation/QuickCreationActionsBar/index.tsx" "no-restricted-imports" 1 "../../src/components/NumberWithSymbolForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/NumberWithSymbolForm.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/OfflineWithFeedback.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/OptionRow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/OptionRow.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/ParentNavigationSubtitle.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/ParentNavigationSubtitle.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Picker/BasePicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Picker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PopoverMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -268,8 +252,6 @@ "../../src/components/PressableWithSecondaryInteraction/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/PressableWithSecondaryInteraction/index.native.tsx" "no-restricted-syntax" 1 "../../src/components/PressableWithSecondaryInteraction/index.tsx" "no-restricted-syntax" 1 -"../../src/components/RNMarkdownTextInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/RNMaskedTextInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../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 @@ -280,22 +262,31 @@ "../../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/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/TaskPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReportActionItem/TaskView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx" "@typescript-eslint/no-deprecated/fontSize" 2 -"../../src/components/ReportActionItem/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/RNMarkdownTextInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/RNMaskedTextInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/RoomHeaderAvatars.tsx" "no-restricted-syntax" 2 "../../src/components/Rule/RuleBooleanBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Rule/TextBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SAMLLoadingIndicator.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/components/ScreenShareRequestModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ScreenWrapper/ScreenWrapperContainer.tsx" "react-hooks/refs" 2 +"../../src/components/Search/chartGroupByConfig.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 "../../src/components/Search/FilterComponents/DateFilterBase.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Search/FilterComponents/DatePresetFilterBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/FilterComponents/ReportField/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterComponents/SingleSelect.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterComponents/TypeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterDropdowns/SortByPopup.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/hooks/useOptimisticSearchTracking.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/components/Search/hooks/useStableOptimisticSortedData.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../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/SearchAutocompleteList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchBulkActionsButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -308,38 +299,32 @@ "../../src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TaskListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 18 +"../../src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/SearchPageHeader/SearchFilterBar.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Search/SearchQueryProvider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/Search/SearchRouter/SearchRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Search/SearchRouter/SearchRouterUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/buildSubstitutionsMap.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/getQueryWithSubstitutions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../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/SearchRouterUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchSingleSelectionPicker.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/Search/chartGroupByConfig.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 -"../../src/components/Search/hooks/useOptimisticSearchTracking.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/components/Search/hooks/useStableOptimisticSortedData.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Search/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../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/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/BaseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/SpendRuleListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.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/utils/getListboxRole/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/AnimatedSettlementButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 10 @@ -348,11 +333,10 @@ "../../src/components/SidePanel/SidePanelReport/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SignInButtons/IconButton.tsx" "no-restricted-syntax" 1 "../../src/components/SkeletonViewContentLoader/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/StatePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../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/AgreementsFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/components/SubStepForms/CountryFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SubStepForms/DateOfBirthStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SubStepForms/DocusignFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -361,12 +345,12 @@ "../../src/components/SubStepForms/RegistrationNumberStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SwipeableView/index.native.tsx" "react-hooks/refs" 2 "../../src/components/SymbolButton.tsx" "no-restricted-syntax" 1 -"../../src/components/TabSelector/getBackground.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Table/middlewares/filtering.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Table/Table.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Table/TableContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/components/Table/middlewares/filtering.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tables/WorkspaceCategoryRulesTable/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/TabSelector/getBackground.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TagPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TaxPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Text.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 @@ -379,10 +363,9 @@ "../../src/components/TextInput/BaseTextInput/implementations.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/TextInput/BaseTextInput/isTextInputFocused.ts" "@typescript-eslint/no-deprecated/React.MutableRefObject" 1 "../../src/components/TextInput/BaseTextInput/isTextInputFocused.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/TextInput/TextInputMeasurement/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TextInput/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/TextInput/index.tsx" "no-restricted-syntax" 1 -"../../src/components/TextInputWithSymbol/index.web.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/components/TextInput/TextInputMeasurement/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TextLinkBlock.tsx" "no-restricted-syntax" 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 @@ -399,6 +382,7 @@ "../../src/components/Tooltip/GenericTooltip.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "react-hooks/refs" 7 +"../../src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx" "rulesdir/no-raw-typography" 2 "../../src/components/TransactionItemRow/DataCells/MerchantCell.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/UpdateAppModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 @@ -414,20 +398,27 @@ "../../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 -"../../src/components/ZeroWidthView/index.tsx" "no-restricted-syntax" 2 -"../../src/components/createOnyxContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/withCurrentUserPersonalDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withNavigationFallback.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/withNavigationTransitionEnd.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withToggleVisibilityView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withViewportOffsetTop.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/ZeroWidthView/index.tsx" "no-restricted-syntax" 2 +"../../src/CONST/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/DeepLinkHandler.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/DeepLinkHandler.tsx" "no-restricted-syntax" 1 +"../../src/Expensify.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/Expensify.tsx" "no-restricted-syntax" 1 +"../../src/Expensify.tsx" "react-hooks/refs" 1 +"../../src/Expensify.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/GlobalModals.tsx" "no-restricted-syntax" 2 "../../src/hooks/useAccountIndicatorChecks.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useAdvancedSearchFilters.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useAnimatedHighlightStyle/index.ts" "react-hooks/set-state-in-effect" 2 "../../src/hooks/useAssignCard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/hooks/useAutocompleteSuggestions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useAutoCreateTrackWorkspace.ts" "no-restricted-imports" 1 "../../src/hooks/useAutoUpdateTimezone.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/hooks/useAutocompleteSuggestions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useBulkDuplicateAction.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useBulkPayOptions.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useCachedImageSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -439,15 +430,16 @@ "../../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/useDebounceNonReactive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDebouncedState.ts" "react-hooks/refs" 2 "../../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" 3 "../../src/hooks/useGeographicalStateAndCountryFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../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 "../../src/hooks/useInitialSelection.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -458,7 +450,7 @@ "../../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" 2 +"../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3 "../../src/hooks/useNativeCamera.ts" "react-hooks/refs" 1 "../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2 "../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 @@ -473,7 +465,6 @@ "../../src/hooks/usePolicyData/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/usePreferredCurrency.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/usePrevious.ts" "react-hooks/refs" 1 -"../../src/hooks/useReceiptScanDrop.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useReportScrollManager/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useResetBankAccountModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useResponsiveLayout/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -497,272 +488,102 @@ "../../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/API/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/API/makeRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Accessibility/moveAccessibilityFocus/types.ts" "@typescript-eslint/no-deprecated/ElementRef" 1 "../../src/libs/AccountingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/AttendeeUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/Avatars/InitialAvatars.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/BankAccountUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/CardFeedUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/libs/CardUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 -"../../src/libs/CategoryOptionListUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Clipboard/index.ts" "@typescript-eslint/no-deprecated/document.execCommand" 1 -"../../src/libs/Clipboard/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Clipboard/index.ts" "no-restricted-syntax" 1 -"../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-deprecated/TextInput.State.currentlyFocusedField" 1 -"../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ComposerUtils/index.ts" "no-restricted-syntax" 1 -"../../src/libs/CountryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/CurrencyUtils.ts" "rulesdir/no-onyx-connect" 1 -"../../src/libs/DateUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/DebugUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 -"../../src/libs/DistanceRequestUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/DomUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/EmojiTrie.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/EmojiUtils.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Environment/betaChecker/index.android.ts" "no-restricted-syntax" 1 -"../../src/libs/Environment/getEnvironment/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ErrorUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 -"../../src/libs/ErrorUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/ExpenseRuleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ExportOnyxState/common.ts" "@typescript-eslint/no-unsafe-type-assertion" 14 -"../../src/libs/ExportOnyxState/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Fullstory/common.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../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/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/KeyboardShortcut/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/KeyboardShortcut/isEnterWhileComposition.ts" "@typescript-eslint/no-deprecated/event.keyCode" 1 -"../../src/libs/KeyboardShortcut/isEnterWhileComposition.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/KeyboardShortcut/isEnterWhileComposition.ts" "no-restricted-syntax" 1 -"../../src/libs/LocalePhoneNumber.ts" "rulesdir/no-onyx-connect" 1 -"../../src/libs/Localize/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 -"../../src/libs/Localize/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Log.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/LoginUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/MergeTransactionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../src/libs/Middleware/HandleUnusedOptimisticID.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/Middleware/HandleUnusedOptimisticID.ts" "no-restricted-syntax" 1 -"../../src/libs/Middleware/Logging.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Middleware/Pagination.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../src/libs/Middleware/SaveResponseInOnyx.ts" "no-restricted-syntax" 1 -"../../src/libs/ModifiedExpenseMessage.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 -"../../src/libs/ModifiedExpenseMessage.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 -"../../src/libs/MoneyRequestReportUtils.ts" "no-restricted-imports" 1 -"../../src/libs/Navigation/AppNavigator/AuthScreens.tsx" "no-restricted-syntax" 1 -"../../src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx" "no-restricted-syntax" 6 -"../../src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx" "react-hooks/refs" 1 -"../../src/libs/Navigation/AppNavigator/DelegatorConnectGate.tsx" "no-restricted-syntax" 1 -"../../src/libs/Navigation/AppNavigator/FreezeWrapper/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/NewChatHandler.tsx" "no-restricted-syntax" 2 -"../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/SearchHandler.tsx" "no-restricted-syntax" 1 -"../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/ShortcutsOverviewHandler.tsx" "no-restricted-syntax" 1 -"../../src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx" "no-restricted-syntax" 2 -"../../src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx" "no-restricted-imports" 1 -"../../src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx" "no-restricted-syntax" 1 -"../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../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/createRootStackNavigator/GetStateForActionHandlers.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 -"../../src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/restoreTabNavigatorRoutes.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/createWorkspaceNavigator/WorkspaceRouter.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/DebugTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../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/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 -"../../src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/guards/OnboardingGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Navigation/helpers/createNormalizedConfigs.ts" "@typescript-eslint/no-deprecated/escape" 1 -"../../src/libs/Navigation/helpers/createNormalizedConfigs.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../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/dynamicRoutesUtils/isDynamicRouteScreen.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/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 -"../../src/libs/Navigation/helpers/getStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/getTopmostReportParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/goBackFromWorkspaceSettingPages.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/isNavigatorName.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/isRHPOnSearchMoneyRequestReportPage.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/isTabRouteAtRoot.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/lastVisitedTabPathUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/linkTo/getMinimalAction.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/linkTo/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 16 -"../../src/libs/Navigation/helpers/navigateToWorkspacesPage.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/popReportsSplitNavigatorToReport.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/replaceCompanyCardsRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/swapBackgroundTabForRHPTarget.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/Navigation/helpers/tabNavigatorUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Navigation/helpers/useIsSidebarRouteActive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/linkingConfig/RELATIONS/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/Navigation/linkingConfig/config.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Network/LoadTestState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Network/SequentialQueue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Network/enhanceParameters.ts" "no-restricted-syntax" 1 -"../../src/libs/Network/index.ts" "no-restricted-syntax" 1 -"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 -"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 -"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-imports" 1 -"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-syntax" 2 -"../../src/libs/Notification/PushNotification/parsePushNotificationPayload.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../src/libs/Notification/PushNotification/shouldShowPushNotification.ts" "no-restricted-syntax" 2 -"../../src/libs/Notification/PushNotification/subscribeToPushNotifications.ts" "no-restricted-syntax" 1 -"../../src/libs/ObjectUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/deprecatedAllReportActions" 2 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/deprecatedAllSortedReportActions[reportID]" 1 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/deprecatedCachedOneTransactionThreadReportIDs[reportID]" 2 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/deprecatedLastReportActions[reportID]" 3 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 7 -"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/OptionsListUtils/index.ts" "rulesdir/no-onyx-connect" 3 -"../../src/libs/OptionsListUtils/searchMatchUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 -"../../src/libs/PageHTMLCapture/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2 -"../../src/libs/ParsingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/PaymentUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 -"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/PersonalDetailsUtils.ts" "rulesdir/no-onyx-connect" 2 -"../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/PolicyUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 -"../../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-deprecated/reportAction?.originalMessage" 2 -"../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 109 -"../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 2 -"../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 -"../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 -"../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/ReportPrimaryActionUtils.ts" "no-restricted-imports" 1 -"../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 33 -"../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 16 -"../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 -"../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 48 -"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 2 -"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 71 -"../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1 -"../../src/libs/SelectionScraper/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 -"../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Sound/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/Sound/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/SpendRulesUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/StatusBar/index.android.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/StatusBar/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/StatusBar/types.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/StringUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/TransactionPreviewUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 3 -"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/libs/UnreadIndicatorUpdater/index.ts" "no-restricted-imports" 1 -"../../src/libs/UnreadIndicatorUpdater/index.ts" "no-restricted-syntax" 1 -"../../src/libs/Url.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/UserAvatarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/ValidationUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Violations/ViolationsUtils.ts" "no-restricted-syntax" 1 -"../../src/libs/Wallet/index.ios.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/WorkspaceReportFieldUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/WorkspacesSettingsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/actions/__mocks__/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/actions/__mocks__/App.ts" "no-restricted-syntax" 2 +"../../src/libs/actions/__mocks__/OnyxUpdates.ts" "no-restricted-syntax" 1 "../../src/libs/actions/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/App.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/applyOnyxUpdatesReliably.ts" "no-restricted-syntax" 1 "../../src/libs/actions/BankAccounts.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/BankAccounts.ts" "no-restricted-syntax" 2 "../../src/libs/actions/CanvasSize.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Card.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/Card.ts" "no-restricted-syntax" 2 "../../src/libs/actions/Chronos.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/clearOnyxAndSeedFullReconnect.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/ClearReportActionErrors.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../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/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 +"../../src/libs/actions/connections/SageIntacct.ts" "no-restricted-syntax" 2 +"../../src/libs/actions/connections/Xero.ts" "no-restricted-syntax" 2 "../../src/libs/actions/Delegate.ts" "no-restricted-syntax" 4 "../../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 +"../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "no-restricted-imports" 1 +"../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "no-restricted-syntax" 2 "../../src/libs/actions/Help.ts" "no-restricted-syntax" 1 "../../src/libs/actions/HybridApp/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/actions/ImportTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/actions/ImportTransactions.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/InputFocus/index.web.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/BulkEdit.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/BulkEdit.ts" "no-restricted-syntax" 2 +"../../src/libs/actions/IOU/DeleteMoneyRequest.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 "../../src/libs/actions/IOU/DeleteMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/DeleteMoneyRequest.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/Duplicate.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../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" 11 "../../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-deprecated/getReportTransactions" 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-unsafe-type-assertion" 1 +"../../src/libs/actions/IOU/ReportWorkflow.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 5 "../../src/libs/actions/IOU/ReportWorkflow.ts" "no-restricted-imports" 1 "../../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 "../../src/libs/actions/IOU/SplitExpenseItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/SplitTransactionUpdate.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 +"../../src/libs/actions/IOU/TrackExpense.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 "../../src/libs/actions/IOU/TrackExpense.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/TrackExpense.ts" "no-restricted-syntax" 2 +"../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 "../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 11 -"../../src/libs/actions/ImportTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/actions/ImportTransactions.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/InputFocus/index.web.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Link.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/actions/Link.ts" "no-restricted-syntax" 3 "../../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" 2 "../../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 "../../src/libs/actions/OdometerTransactionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/OnyxDerived/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/OnyxUpdateManager/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts" "no-restricted-syntax" 1 "../../src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts" "no-restricted-syntax" 1 "../../src/libs/actions/OnyxUpdateManager/utils/applyUpdates.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts" "no-restricted-syntax" 1 "../../src/libs/actions/OnyxUpdates.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/PaymentMethods.ts" "no-restricted-syntax" 2 "../../src/libs/actions/PersonalCards.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -777,6 +598,7 @@ "../../src/libs/actions/Policy/Member.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/actions/Policy/PerDiem.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Policy/Plan.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/Policy/Policy.ts" "@typescript-eslint/no-deprecated/ReportUtils.getReportTransactions" 1 "../../src/libs/actions/Policy/Policy.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 "../../src/libs/actions/Policy/Policy.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/libs/actions/Policy/Policy.ts" "no-restricted-imports" 2 @@ -792,15 +614,18 @@ "../../src/libs/actions/QueuedOnyxUpdates.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts" "no-restricted-syntax" 1 "../../src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/Report/MarkAllMessageAsRead.tsx" "no-restricted-syntax" 1 +"../../src/libs/actions/replaceOptimisticReportWithActualReport.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 3 "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-deprecated/reportAction.originalMessage" 1 "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 19 "../../src/libs/actions/Report/index.ts" "no-restricted-syntax" 7 "../../src/libs/actions/Report/index.ts" "rulesdir/no-onyx-connect" 3 +"../../src/libs/actions/Report/MarkAllMessageAsRead.tsx" "no-restricted-syntax" 1 "../../src/libs/actions/ReportLayout.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/ReportLayout.ts" "no-restricted-syntax" 1 "../../src/libs/actions/RequestConflictUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/ScheduleCall.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/Search.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/Search.ts" "@typescript-eslint/no-unsafe-type-assertion" 16 "../../src/libs/actions/Session/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/Session/index.ts" "no-restricted-syntax" 9 @@ -817,39 +642,52 @@ "../../src/libs/actions/Task.ts" "no-restricted-syntax" 5 "../../src/libs/actions/TaxRate.ts" "no-restricted-syntax" 1 "../../src/libs/actions/TeachersUnite.ts" "no-restricted-syntax" 1 +"../../src/libs/actions/Transaction.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/Transaction.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/Transaction.ts" "no-restricted-syntax" 2 "../../src/libs/actions/TransactionEdit.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/Travel.ts" "no-restricted-syntax" 1 "../../src/libs/actions/TravelBilling.ts" "no-restricted-syntax" 3 "../../src/libs/actions/UnreportedExpenses.tsx" "no-restricted-syntax" 1 -"../../src/libs/actions/User.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/libs/actions/User.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/User.ts" "no-restricted-imports" 1 -"../../src/libs/actions/User.ts" "no-restricted-syntax" 8 +"../../src/libs/actions/User.ts" "no-restricted-syntax" 7 "../../src/libs/actions/VacationDelegate.ts" "no-restricted-syntax" 2 "../../src/libs/actions/Wallet.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/actions/Wallet.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/Welcome/OnboardingFlow.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/Welcome/index.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/__mocks__/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/actions/__mocks__/App.ts" "no-restricted-syntax" 2 -"../../src/libs/actions/__mocks__/OnyxUpdates.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/applyOnyxUpdatesReliably.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/clearOnyxAndSeedFullReconnect.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../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 -"../../src/libs/actions/connections/SageIntacct.ts" "no-restricted-syntax" 2 -"../../src/libs/actions/connections/Xero.ts" "no-restricted-syntax" 2 -"../../src/libs/actions/connections/index.ts" "no-restricted-imports" 1 -"../../src/libs/actions/connections/index.ts" "no-restricted-syntax" 3 -"../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "no-restricted-imports" 1 -"../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "no-restricted-syntax" 2 -"../../src/libs/actions/replaceOptimisticReportWithActualReport.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/actions/Welcome/OnboardingFlow.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/API/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../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/Avatars/InitialAvatars.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../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" 13 +"../../src/libs/CategoryOptionListUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/clearSelectedTextIfComposerBlurred/clearSelectedTextIfComposerBlurred.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Clipboard/index.ts" "@typescript-eslint/no-deprecated/document.execCommand" 1 +"../../src/libs/Clipboard/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Clipboard/index.ts" "no-restricted-syntax" 1 +"../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-deprecated/TextInput.State.currentlyFocusedField" 1 +"../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/ComposerUtils/index.ts" "no-restricted-syntax" 1 "../../src/libs/compoundParamsKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/CountryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/CurrencyUtils.ts" "rulesdir/no-onyx-connect" 1 +"../../src/libs/DateUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/DebugUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 +"../../src/libs/DistanceRequestUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/DomUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/EmojiTrie.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/EmojiUtils.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Environment/getEnvironment/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/ErrorUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 +"../../src/libs/ErrorUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/ExpenseRuleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/ExportOnyxState/common.ts" "@typescript-eslint/no-unsafe-type-assertion" 14 +"../../src/libs/ExportOnyxState/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/fileDownload/DownloadUtils.ts" "no-restricted-syntax" 2 "../../src/libs/fileDownload/FileUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/fileDownload/getImageManipulator/index.native.ts" "@typescript-eslint/no-deprecated/manipulateAsync" 1 @@ -861,20 +699,185 @@ "../../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 +"../../src/libs/Formula.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 +"../../src/libs/Fullstory/common.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/getPlatform/index.ts" "no-restricted-syntax" 1 "../../src/libs/getReturnValue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../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 +"../../src/libs/KeyboardShortcut/isEnterWhileComposition.ts" "@typescript-eslint/no-deprecated/event.keyCode" 1 +"../../src/libs/KeyboardShortcut/isEnterWhileComposition.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/KeyboardShortcut/isEnterWhileComposition.ts" "no-restricted-syntax" 1 +"../../src/libs/LocalePhoneNumber.ts" "rulesdir/no-onyx-connect" 1 +"../../src/libs/Localize/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 +"../../src/libs/Localize/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/Log.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/LoginUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/mapChildrenFlat.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/memoize/cache/ArrayCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/memoize/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/libs/memoize/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"../../src/libs/MergeTransactionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/libs/Middleware/HandleUnusedOptimisticID.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/Middleware/HandleUnusedOptimisticID.ts" "no-restricted-syntax" 1 +"../../src/libs/Middleware/Logging.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Middleware/Pagination.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/libs/Middleware/SaveResponseInOnyx.ts" "no-restricted-syntax" 1 "../../src/libs/models/BankAccount.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/ModifiedExpenseMessage.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 +"../../src/libs/ModifiedExpenseMessage.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/MoneyRequestReportUtils.ts" "no-restricted-imports" 1 +"../../src/libs/Navigation/AppNavigator/AuthScreens.tsx" "no-restricted-syntax" 1 +"../../src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx" "no-restricted-syntax" 6 +"../../src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx" "react-hooks/refs" 1 +"../../src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 +"../../src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/restoreTabNavigatorRoutes.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/AppNavigator/createWorkspaceNavigator/WorkspaceRouter.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/AppNavigator/FreezeWrapper/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/NewChatHandler.tsx" "no-restricted-syntax" 2 +"../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/SearchHandler.tsx" "no-restricted-syntax" 1 +"../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/ShortcutsOverviewHandler.tsx" "no-restricted-syntax" 1 +"../../src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx" "no-restricted-syntax" 2 +"../../src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx" "no-restricted-imports" 1 +"../../src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx" "no-restricted-syntax" 1 +"../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../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 +"../../src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/DebugTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/guards/OnboardingGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/Navigation/helpers/createNormalizedConfigs.ts" "@typescript-eslint/no-deprecated/escape" 1 +"../../src/libs/Navigation/helpers/createNormalizedConfigs.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../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/dynamicRoutesUtils/isDynamicRouteScreen.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/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 +"../../src/libs/Navigation/helpers/getStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/getTopmostReportParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/goBackFromWorkspaceSettingPages.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/isNavigatorName.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/isRHPOnSearchMoneyRequestReportPage.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/isTabRouteAtRoot.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/lastVisitedTabPathUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/linkTo/getMinimalAction.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/linkTo/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 15 +"../../src/libs/Navigation/helpers/navigateToWorkspacesPage.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/popReportsSplitNavigatorToReport.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/replaceCompanyCardsRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/swapBackgroundTabForRHPTarget.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/Navigation/helpers/tabNavigatorUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/Navigation/helpers/useIsSidebarRouteActive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../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/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Network/enhanceParameters.ts" "no-restricted-syntax" 1 +"../../src/libs/Network/index.ts" "no-restricted-syntax" 1 +"../../src/libs/Network/LoadTestState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Network/SequentialQueue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 +"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 +"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-imports" 1 +"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-syntax" 2 +"../../src/libs/Notification/PushNotification/parsePushNotificationPayload.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/libs/Notification/PushNotification/shouldShowPushNotification.ts" "no-restricted-syntax" 2 +"../../src/libs/Notification/PushNotification/subscribeToPushNotifications.ts" "no-restricted-syntax" 1 +"../../src/libs/ObjectUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 +"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 7 +"../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/OptionsListUtils/index.ts" "rulesdir/no-onyx-connect" 2 +"../../src/libs/OptionsListUtils/searchMatchUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 +"../../src/libs/PageHTMLCapture/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2 +"../../src/libs/ParsingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/PaymentUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 +"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/PersonalDetailsUtils.ts" "rulesdir/no-onyx-connect" 2 +"../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/PolicyUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../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?.originalMessage" 2 +"../../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 +"../../src/libs/ReportAlternateTextUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 +"../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 +"../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 +"../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/ReportPreviewActionUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 +"../../src/libs/ReportPrimaryActionUtils.ts" "no-restricted-imports" 1 +"../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 17 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 32 +"../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 15 "../../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-deprecated/deprecatedGetReportName" 1 +"../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 48 +"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 2 +"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 71 +"../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1 +"../../src/libs/SelectionScraper/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 2 +"../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Sound/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/Sound/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/SpendRulesUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/StatusBar/index.android.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/StatusBar/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/StatusBar/types.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/StringUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/telemetry/forwardLogsToSentry.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/TransactionPreviewUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 +"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 3 +"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"../../src/libs/UnreadIndicatorUpdater/index.ts" "no-restricted-imports" 1 +"../../src/libs/UnreadIndicatorUpdater/index.ts" "no-restricted-syntax" 1 "../../src/libs/updateMultilineInputRange/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Url.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/UserAvatarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/ValidationUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/Violations/ViolationsUtils.ts" "no-restricted-syntax" 1 +"../../src/libs/Wallet/index.ios.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/WorkspaceReportFieldUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/WorkspacesSettingsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/ONYXKEYS.ts" "no-restricted-syntax" 2 "../../src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/AddressPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -892,95 +895,24 @@ "../../src/pages/DynamicReportChangeApproverPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/DynamicReportDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/DynamicReportDetailsPage.tsx" "react-hooks/preserve-manual-memoization" 4 -"../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/DynamicRoomMemberDetailsPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/DynamicRoomMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicRoomMembersPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/EnablePayments/shared/IdologyQuestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/EnablePayments/Wallet/PersonalInfo/substeps/DateOfBirthStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/EnablePayments/Wallet/utils/getSubstepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/EnablePayments/shared/IdologyQuestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "react-hooks/refs" 4 -"../../src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx" "@typescript-eslint/no-deprecated/fontSize" 2 -"../../src/pages/MultifactorAuthentication/RevokePage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ProfilePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/AddressFormFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/BankAccountDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Nationality.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/ContactInformation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/utils/getBankInfoStepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 11 -"../../src/pages/ReimbursementAccount/NonUSD/utils/getInitialSubStepForBankInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/utils/getInitialSubStepForBusinessInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/NonUSD/utils/getInputKeysForBankInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/refs" 3 -"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/set-state-in-effect" 4 -"../../src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/ReimbursementAccount/utils/getSubStepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/pages/ReportDescriptionPage.tsx" "no-restricted-imports" 1 -"../../src/pages/ReportDescriptionPage.tsx" "no-restricted-syntax" 1 -"../../src/pages/ScheduleCall/ScheduleCallPage.tsx" "react-hooks/preserve-manual-memoization" 1 -"../../src/pages/Search/EmptySearchView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Search/SavedSearchRenamePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Search/SearchChangeApproverPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/Search/SearchMoneyRequestReportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/Search/SearchPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/Search/SearchSavePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/Search/SearchTransactionsChangeReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/Share/ShareRootPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Share/ShareTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ShareCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/TeachersUnite/SaveTheWorldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 -"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "no-restricted-imports" 1 -"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "react-hooks/refs" 12 -"../../src/pages/TransactionDuplicate/ReviewFields.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 -"../../src/pages/TransactionMerge/DynamicConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionMerge/MergeTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionMerge/MergeTransactionsListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 1 -"../../src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/UnreportedExpenseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/domain/Groups/PreferredWorkspaceToggle.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/pages/domain/Saml/SamlLoginSectionContent.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/pages/home/ForYouSection/ConciergePromptBox.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "no-restricted-imports" 1 "../../src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/home/YourSpendSection/useYourSpendData.ts" "no-restricted-imports" 1 -"../../src/pages/inbox/ReactionListWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/ReportHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/inbox/ReportNavigateAwayHandler.tsx" "react-hooks/exhaustive-deps" 1 "../../src/pages/inbox/cleanStaleReportActionBackToParam.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/hooks/useReportWasDeleted.ts" "react-hooks/set-state-in-effect" 1 +"../../src/pages/inbox/ReactionListWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/actionContents/ActionContentRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/inbox/report/actionContents/JoinRequestContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/comment/TextCommentFragment.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx" "react-hooks/refs" 1 "../../src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1002,33 +934,27 @@ "../../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" "no-restricted-syntax" 1 -"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/refs" 5 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/inbox/report/SearchActionHeader.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 -"../../src/pages/inbox/report/TripSummary.tsx" "rulesdir/no-default-id-values" 1 -"../../src/pages/inbox/report/WhisperBanner.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/actionContents/ActionContentRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/inbox/report/actionContents/JoinRequestContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/comment/TextCommentFragment.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/pages/inbox/report/shouldUseEmojiPickerSelection/index.web.ts" "no-restricted-syntax" 1 +"../../src/pages/inbox/report/TripSummary.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/inbox/report/useDebouncedSaveDraft.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/WhisperBanner.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportOrNotFound.tsx" "react-hooks/refs" 3 +"../../src/pages/inbox/ReportHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/inbox/ReportNavigateAwayHandler.tsx" "react-hooks/exhaustive-deps" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/InvoiceMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/NewWorkspaceMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/TravelMenuItem.tsx" "no-restricted-imports" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/inbox/sidebar/SidebarLinksData.tsx" "react-hooks/refs" 1 "../../src/pages/iou/DynamicSplitExpensePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/DynamicSplitExpensePage.tsx" "react-hooks/set-state-in-effect" 3 "../../src/pages/iou/MoneyRequestAmountForm.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/iou/SplitList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/ParticipantSearchResults.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/pages/iou/request/step/DistanceManualTabContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/DynamicIOURequestEditReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1036,10 +962,8 @@ "../../src/pages/iou/request/step/DynamicIOURequestStepReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 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 @@ -1049,23 +973,74 @@ "../../src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3 "../../src/pages/iou/request/step/IOURequestStepDistanceMap.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "react-hooks/set-state-in-effect" 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 +"../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/StepScreenWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../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/iou/SplitList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../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 "../../src/pages/media/AttachmentModalScreen/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 16 +"../../src/pages/media/AttachmentModalScreen/routes/hooks/useReportAttachmentModalType.ts" "react-hooks/set-state-in-effect" 1 "../../src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx" "react-hooks/set-state-in-effect" 2 -"../../src/pages/media/AttachmentModalScreen/routes/hooks/useReportAttachmentModalType.ts" "react-hooks/set-state-in-effect" 1 +"../../src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "react-hooks/refs" 4 +"../../src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx" "@typescript-eslint/no-deprecated/fontSize" 2 +"../../src/pages/MultifactorAuthentication/RevokePage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ProfilePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/AddressFormFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 +"../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/BankAccountDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 +"../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Nationality.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/ContactInformation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/utils/getBankInfoStepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 11 +"../../src/pages/ReimbursementAccount/NonUSD/utils/getInitialSubStepForBankInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/utils/getInitialSubStepForBusinessInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/NonUSD/utils/getInputKeysForBankInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/refs" 3 +"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/set-state-in-effect" 4 +"../../src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/utils/getSubStepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/pages/ReportDescriptionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/ReportDescriptionPage.tsx" "no-restricted-syntax" 1 +"../../src/pages/ScheduleCall/ScheduleCallPage.tsx" "react-hooks/preserve-manual-memoization" 1 +"../../src/pages/Search/EmptySearchView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Search/SavedSearchRenamePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Search/SearchChangeApproverPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/Search/SearchMoneyRequestReportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/Search/SearchPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/Search/SearchTransactionsChangeReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 "../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1084,8 +1059,6 @@ "../../src/pages/settings/Profile/PronounsPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Profile/TimezoneInitialPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Profile/TimezoneSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/settings/Report/ReportDetailsColumnsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/settings/Rules/ExpenseRulesPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Security/AddDelegate/ConfirmDelegateValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1114,30 +1087,38 @@ "../../src/pages/settings/Wallet/WalletPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/WalletPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/WalletPage/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/Share/ShareRootPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Share/ShareTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ShareCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/signin/SAMLSignInPage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/signin/SMSDeliveryFailurePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/signin/SignInPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/signin/SignInPageLayout/BackgroundImage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/signin/SignInPageLayout/FooterRow/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/signin/SignInPageLayout/SignInHeroCopy.tsx" "rulesdir/no-raw-typography" 2 +"../../src/pages/signin/SignInPageLayout/SignInPageContent.tsx" "rulesdir/no-raw-typography" 2 +"../../src/pages/signin/SMSDeliveryFailurePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx" "react-hooks/set-state-in-effect" 4 "../../src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/TeachersUnite/SaveTheWorldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 +"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "react-hooks/refs" 12 +"../../src/pages/TransactionDuplicate/ReviewFields.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 +"../../src/pages/TransactionMerge/DynamicConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TransactionMerge/MergeTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TransactionMerge/MergeTransactionsListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 1 +"../../src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/UnreportedExpenseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/wallet/WalletStatementPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "react-hooks/set-state-in-effect" 3 -"../../src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/WorkspaceOverviewPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/WorkspaceOverviewSharePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/AccountingContext/default.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/ClaimOfferPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/accounting/certinia/import/CertiniaDimensionMappingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/workspace/accounting/ClaimOfferPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/dualentry/export/DualEntryExportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/advanced/DynamicSageIntacctAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/export/SageIntacctDatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1146,7 +1127,6 @@ "../../src/pages/workspace/accounting/intacct/import/SageIntacctAddUserDimensionPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/intacct/import/SageIntacctMappingsTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/advanced/NetSuiteExpenseReportApprovalLevelSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1155,8 +1135,10 @@ "../../src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteDateSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesDestinationSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesJournalPostingPreferenceSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingJournalPostingPreferenceSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "no-restricted-syntax" 2 +"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 12 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseSegmentTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1164,12 +1146,10 @@ "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomListMappingStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentMappingStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "no-restricted-syntax" 2 -"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportMappingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx" "rulesdir/no-default-id-values" 2 -"../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1187,6 +1167,8 @@ "../../src/pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage.tsx" "no-restricted-syntax" 3 "../../src/pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage.tsx" "rulesdir/no-default-id-values" 1 +"../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx" "rulesdir/no-default-id-values" 2 +"../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseEntitySelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1202,16 +1184,7 @@ "../../src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/rillet/export/RilletExportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/dualentry/export/DualEntryExportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/utils.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/xero/XeroImportPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/accounting/xero/XeroImportPage.tsx" "no-restricted-syntax" 1 -"../../src/pages/workspace/accounting/xero/XeroImportPage.tsx" "rulesdir/no-default-id-values" 1 -"../../src/pages/workspace/accounting/xero/XeroMapTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "no-restricted-syntax" 4 -"../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "rulesdir/no-default-id-values" 1 -"../../src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/xero/advanced/DynamicXeroAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1221,8 +1194,17 @@ "../../src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx" "no-restricted-syntax" 3 "../../src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx" "rulesdir/no-default-id-values" 1 +"../../src/pages/workspace/accounting/xero/XeroImportPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/xero/XeroImportPage.tsx" "no-restricted-syntax" 1 +"../../src/pages/workspace/accounting/xero/XeroImportPage.tsx" "rulesdir/no-default-id-values" 1 +"../../src/pages/workspace/accounting/xero/XeroMapTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "no-restricted-syntax" 4 +"../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "rulesdir/no-default-id-values" 1 +"../../src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/categories/CategorySettingsPage.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/companyCards/BankConnection/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1232,24 +1214,24 @@ "../../src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/preserve-manual-memoization" 2 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/hr/HRProviderCard.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/hr/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx" "react-hooks/refs" 2 "../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx" "react-hooks/refs" 2 +"../../src/pages/workspace/members/ImportedMembersConfirmationPage.tsx" "react-hooks/refs" 7 "../../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/ImportedMembersConfirmationPage.tsx" "react-hooks/refs" 7 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -1257,22 +1239,29 @@ "../../src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx" "react-hooks/set-state-in-effect" 2 "../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/reports/TypeSelector/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/reports/TypeSelector/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/taxes/WorkspaceTaxesPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3 "../../src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/upgrade/UpgradeIntro.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/withPolicy.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/withPolicyAndFullscreenLoading.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/withPolicyConnections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 2 -"../../src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "react-hooks/set-state-in-effect" 3 +"../../src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/WorkspaceOverviewPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/WorkspaceOverviewSharePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/polyfills/requestIdleCallback.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 52 +"../../src/ROUTES.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/selectors/AdvancedSearchFiltersForm.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/selectors/Domain.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/selectors/Policy.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1280,7 +1269,6 @@ "../../src/setup/moduleInitPolyfill.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/setup/telemetry/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/stories/AddressSearch.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/stories/Button.stories.tsx" "no-restricted-imports" 1 "../../src/stories/ButtonWithDropdownMenu.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/stories/DragAndDrop.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/stories/EReceipt.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1288,14 +1276,15 @@ "../../src/stories/ReportActionItemImages.stories.tsx" "no-restricted-syntax" 1 "../../src/stories/TextInput.stories.tsx" "react-hooks/set-state-in-effect" 1 "../../src/styles/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/styles/index.ts" "rulesdir/no-raw-typography" 29 +"../../src/styles/index.ts" "rulesdir/no-raw-typography" 27 "../../src/styles/theme/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/styles/utils/autoCompleteSuggestion/index.web.ts" "no-restricted-syntax" 1 "../../src/styles/utils/cursor/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 +"../../src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/styles/utils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/styles/utils/index.ts" "rulesdir/no-raw-typography" 4 +"../../src/TIMEZONES.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/types/form/SpendRuleForm.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/types/onyx/Account.ts" "no-restricted-syntax" 1 "../../src/types/onyx/AccountData.ts" "no-restricted-syntax" 1 @@ -1343,13 +1332,13 @@ "../../src/types/onyx/WalletOnfido.ts" "no-restricted-syntax" 1 "../../src/types/onyx/WalletTerms.ts" "no-restricted-syntax" 1 "../../src/types/onyx/WalletTransfer.ts" "no-restricted-syntax" 1 -"../../src/types/utils/CollectionDataSet.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/types/utils/EmptyObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/types/utils/ObjectUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/types/utils/asMutable.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/types/utils/callOrReturn.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/types/utils/CollectionDataSet.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/types/utils/EmptyObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/types/utils/getEmptyArray.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/types/utils/htmlDivElementRef.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/types/utils/ObjectUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/types/utils/textRef.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/types/utils/viewRef.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/utils/createDummySharedValue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1367,32 +1356,45 @@ "../../tests/actions/IOUTest/TrackExpenseTest.ts" "no-restricted-imports" 1 "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "no-restricted-imports" 1 "../../tests/actions/OdometerTransactionUtilsTest.ts" "no-restricted-imports" 1 +"../../tests/actions/OnyxUpdateManagerTest.ts" "@typescript-eslint/no-restricted-imports" 1 +"../../tests/actions/PolicyCategoryTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/actions/PolicyProfileTest.ts" "no-restricted-imports" 1 +"../../tests/actions/PolicyTagTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/actions/PolicyTest.ts" "no-restricted-imports" 1 "../../tests/actions/ReportFieldTest.ts" "no-restricted-imports" 1 "../../tests/actions/ReportPreviewActionUtilsTest.ts" "no-restricted-imports" 2 +"../../tests/actions/ReportTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/actions/ReportTest.ts" "no-restricted-imports" 1 "../../tests/actions/TaskTest.ts" "no-restricted-imports" 1 "../../tests/actions/TransactionTest.ts" "no-restricted-imports" 1 +"../../tests/ui/components/IOURequestStepConfirmationPageTest.tsx" "@typescript-eslint/no-restricted-imports" 1 +"../../tests/ui/ComposerTest.tsx" "rulesdir/no-raw-typography" 1 "../../tests/ui/MoneyRequestReportPreview.test.tsx" "no-restricted-imports" 1 +"../../tests/ui/UnreadIndicatorsTest.tsx" "@typescript-eslint/no-restricted-imports" 1 "../../tests/ui/WorkspaceMoreFeaturesPageTest.tsx" "no-restricted-imports" 1 -"../../tests/ui/components/Button.tsx" "no-restricted-imports" 1 +"../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-deprecated/randomReportAction.originalMessage" 3 +"../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-restricted-imports" 1 +"../../tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts" "rulesdir/no-raw-typography" 1 "../../tests/unit/FormulaTest.ts" "no-restricted-imports" 1 "../../tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts" "no-restricted-imports" 1 "../../tests/unit/IOUUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/ModifiedExpenseMessageTest.ts" "no-restricted-imports" 1 +"../../tests/unit/navigateAfterOnboardingTest.ts" "no-restricted-imports" 1 +"../../tests/unit/navigateToWorkspacesPageTest.ts" "no-restricted-imports" 1 +"../../tests/unit/OnyxDerivedSelfHealTest.ts" "@typescript-eslint/no-restricted-imports" 1 +"../../tests/unit/OnyxDerivedTest.tsx" "@typescript-eslint/no-restricted-imports" 1 +"../../tests/unit/pages/inbox/report/ReportActionEditMessageContext.test.tsx" "no-restricted-imports" 1 +"../../tests/unit/PersistedRequests.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/unit/QuickActionUtilsTest.ts" "no-restricted-imports" 2 "../../tests/unit/ReportPrimaryActionUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/ReportSecondaryActionUtilsTest.ts" "no-restricted-imports" 2 "../../tests/unit/ReportTitleUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/ReportUtilsTest.ts" "no-restricted-imports" 1 +"../../tests/unit/SearchAutocompleteListTest.tsx" "@typescript-eslint/no-restricted-imports" 1 "../../tests/unit/SidebarUtilsTest.ts" "no-restricted-imports" 1 -"../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-deprecated/randomReportAction.originalMessage" 3 -"../../tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts" "rulesdir/no-raw-typography" 1 -"../../tests/unit/navigateAfterOnboardingTest.ts" "no-restricted-imports" 1 -"../../tests/unit/navigateToWorkspacesPageTest.ts" "no-restricted-imports" 1 -"../../tests/unit/pages/inbox/report/ReportActionEditMessageContext.test.tsx" "no-restricted-imports" 1 +"../../tests/unit/TransactionTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/unit/useMarkAsReadTest.ts" "no-restricted-imports" 1 +"../../tests/unit/WorkspacesSettingsUtilsTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/utils/createMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../rsbuild/CustomVersionFilePlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../rsbuild/ModuleInitTimingPlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/config/eslint/processors/eslint-processor-expensify.mjs b/config/eslint/processors/eslint-processor-expensify.mjs deleted file mode 100644 index d4c128d40359..000000000000 --- a/config/eslint/processors/eslint-processor-expensify.mjs +++ /dev/null @@ -1,27 +0,0 @@ -/** - * ESLint only allows a single processor per file, so this one chains together - * all the custom processors we want to run. - */ -import seatbelt from 'eslint-seatbelt'; - -import reactCompilerCompatProcessor from './eslint-processor-react-compiler-compat.mjs'; -import stratifyNoDeprecatedProcessor from './eslint-processor-stratify-no-deprecated.mjs'; - -const seatbeltProcessor = seatbelt.processors.seatbelt; - -export default { - meta: {name: 'expensify-eslint-processor'}, - supportsAutofix: true, - - preprocess(text, filename) { - const [textResult] = reactCompilerCompatProcessor.preprocess(text, filename); - const [afterStratifyPreprocess] = stratifyNoDeprecatedProcessor.preprocess(textResult, filename); - return seatbeltProcessor.preprocess(afterStratifyPreprocess, filename); - }, - - postprocess(messagesPerBlock, filename) { - const afterCompilerFilter = reactCompilerCompatProcessor.postprocess(messagesPerBlock, filename); - const afterStratify = stratifyNoDeprecatedProcessor.postprocess([afterCompilerFilter], filename); - return seatbeltProcessor.postprocess([afterStratify], filename); - }, -}; diff --git a/config/eslint/processors/eslint-processor-react-compiler-compat.mjs b/config/eslint/processors/eslint-processor-react-compiler-compat.mjs deleted file mode 100644 index 878f23e928f1..000000000000 --- a/config/eslint/processors/eslint-processor-react-compiler-compat.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * ESLint processor that conditionally suppresses lint rules which are unnecessary - * for files that BOTH React Compilers memoize. - * - * React Compiler automatically memoizes components and hooks, making rules like - * `react/jsx-no-constructed-context-values` redundant for memoized files. But the app - * runs two different compilers -- Babel (babel-plugin-react-compiler) on native/Jest and - * OXC (oxc-transform-react) on web -- and they don't always agree. A file that only one compiler - * memoizes still ships without memoization on the other platform, so the manual memoization - * (and the lint rules that enforce it) is still needed there. - * - * This processor therefore: - * 1. Runs BOTH React Compilers on each file during the `preprocess` phase - * 2. Only if BOTH compilers memoize the file, filters out messages from rules that - * React Compiler makes unnecessary in `postprocess` - * 3. Otherwise (either compiler skips memoization, or a file fails to compile) preserves - * all lint messages as-is - */ -import _ from 'lodash'; - -import {didBothCompilersMemoizeFile} from '../../reactCompiler/checkBoth.mjs'; - -// Rules that are entirely unnecessary when React Compiler successfully compiles -// all functions in a file. Add more rules here as needed. -const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-context-values', 'rulesdir/no-inline-useOnyx-selector']); - -// react-hooks/exhaustive-deps warnings that suggest useCallback/useMemo are -// false positives in compiled files, since React Compiler auto-memoizes. -// We only suppress the "wrap in useCallback/useMemo" suggestions, NOT warnings -// about genuinely missing dependencies. -const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/; - -// Per-file compilation results, populated in preprocess, consumed in postprocess. -const compilationResults = new Map(); - -const processor = { - meta: { - name: 'react-compiler-compat', - version: '1.0.0', - }, - supportsAutofix: true, - - preprocess(text, filename) { - // Skip files that React Compiler wouldn't compile anyway - if (filename.includes('/tests/') || filename.includes('node_modules/')) { - compilationResults.set(filename, false); - } else { - compilationResults.set(filename, didBothCompilersMemoizeFile(text, filename)); - } - - // Pass the source through unchanged as a single code block - return [text]; - }, - - postprocess(messages, filename) { - const bothMemoized = compilationResults.get(filename); - compilationResults.delete(filename); - - if (bothMemoized) { - return _.filter(messages[0], (msg) => { - if (RULES_SUPPRESSED_BY_REACT_COMPILER.has(msg.ruleId)) { - return false; - } - if (msg.ruleId === 'react-hooks/exhaustive-deps' && EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN.test(msg.message)) { - return false; - } - return true; - }); - } - - return messages[0]; - }, -}; - -export default processor; diff --git a/config/eslint/processors/eslint-processor-stratify-no-deprecated.mjs b/config/eslint/processors/eslint-processor-stratify-no-deprecated.mjs deleted file mode 100644 index 84abf23d661b..000000000000 --- a/config/eslint/processors/eslint-processor-stratify-no-deprecated.mjs +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Rewrites `@typescript-eslint/no-deprecated` messages into per-API rule IDs - * (e.g. `@typescript-eslint/no-deprecated/StyleSheet.absoluteFillObject`) so - * eslint-seatbelt can ratchet each deprecated API independently. - */ -import {parse} from '@babel/parser'; - -const NO_DEPRECATED_RULE_ID = '@typescript-eslint/no-deprecated'; - -// AST keys to ignore while walking children: positions, comments, etc. -const NON_CHILD_KEYS = new Set(['loc', 'start', 'end', 'extra', 'leadingComments', 'trailingComments', 'innerComments']); - -// Node types whose children are part of a single dotted/qualified expression -// (e.g. `Foo.bar`, `Foo?.bar`, `Foo.bar` in TS type position). -const MEMBER_LIKE_TYPES = new Set(['MemberExpression', 'OptionalMemberExpression', 'TSQualifiedName']); - -const sourceByFilename = new Map(); - -const isAstNode = (value) => !!value && typeof value === 'object' && typeof value.type === 'string' && typeof value.start === 'number' && typeof value.end === 'number'; - -/** Iterate over a node's direct AST children, skipping non-child metadata. */ -function* astChildren(node) { - for (const [key, value] of Object.entries(node)) { - if (NON_CHILD_KEYS.has(key)) { - continue; - } - for (const child of Array.isArray(value) ? value : [value]) { - if (isAstNode(child)) { - yield child; - } - } - } -} - -/** Convert ESLint's 1-based (line, column) into a 0-based source offset, or -1 if line is out of range. */ -function lineColumnToOffset(source, line, column) { - let lineStart = 0; - for (let currentLine = 1; currentLine < line; currentLine++) { - const nextNewline = source.indexOf('\n', lineStart); - if (nextNewline < 0) { - return -1; - } - lineStart = nextNewline + 1; - } - return lineStart + column - 1; -} - -/** - * Walk down the AST following children whose range contains `offset`. - * Returns the ancestor path (root → deepest) or `null` if the offset is out of range. - */ -function findAstPathAtOffset(root, offset) { - if (offset < 0 || offset < root.start || offset > root.end) { - return null; - } - const path = [root]; - while (true) { - const current = path.at(-1); - let descended = false; - for (const child of astChildren(current)) { - if (offset >= child.start && offset <= child.end) { - path.push(child); - descended = true; - break; - } - } - if (!descended) { - return path; - } - } -} - -/** Walk a path upward through any wrapping member/qualified expression and return the topmost. */ -function topOfMemberChain(path) { - let topIndex = path.length - 1; - while (topIndex > 0 && MEMBER_LIKE_TYPES.has(path.at(topIndex - 1).type)) { - topIndex--; - } - return path.at(topIndex); -} - -function parseSourceOrNull(source) { - try { - return parse(source, {sourceType: 'module', plugins: ['typescript', 'jsx']}); - } catch { - return null; - } -} - -/** Slice the full deprecated expression (e.g. `StyleSheet.absoluteFillObject`) at the lint location, or null on miss. */ -function getDeprecatedExpressionFromSource(source, ast, message) { - const offset = lineColumnToOffset(source, message.line, message.column); - const path = findAstPathAtOffset(ast, offset); - if (!path) { - return null; - } - const top = topOfMemberChain(path); - return source.slice(top.start, top.end); -} - -/** Fallback: parse the symbol name out of the lint message text. */ -function getSymbolNameFromMessage(message) { - const match = /^`([^`]+)`/.exec(message.message); - return match ? match.at(1) : null; -} - -/** Trim; collapse whitespace and `/` to `_`. Preserves `.`, `#`, `$`, `@`. */ -function toRuleIdSuffix(apiName) { - return apiName.trim().replaceAll(/[\s/]+/g, '_'); -} - -/** - * @param {import('eslint').Linter.LintMessage[]} messages - * @param {string | null} source - * @returns {import('eslint').Linter.LintMessage[]} - */ -function stratifyMessages(messages, source) { - const hasNoDeprecatedMessages = messages.some((message) => message.ruleId === NO_DEPRECATED_RULE_ID); - const ast = source && hasNoDeprecatedMessages ? parseSourceOrNull(source) : null; - - return messages.map((message) => { - if (message.ruleId !== NO_DEPRECATED_RULE_ID) { - return message; - } - const apiName = (ast && getDeprecatedExpressionFromSource(source, ast, message)) || getSymbolNameFromMessage(message); - if (!apiName) { - return message; - } - return {...message, ruleId: `${NO_DEPRECATED_RULE_ID}/${toRuleIdSuffix(apiName)}`}; - }); -} - -const processor = { - meta: { - name: 'stratify-no-deprecated', - version: '1.0.0', - }, - supportsAutofix: true, - - preprocess(text, filename) { - sourceByFilename.set(filename, text); - return [text]; - }, - - postprocess(messagesPerBlock, filename) { - const source = sourceByFilename.get(filename) ?? null; - sourceByFilename.delete(filename); - return stratifyMessages(messagesPerBlock[0], source); - }, -}; - -export default processor; diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index 91a498bd8a14..4db2c942a6b8 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -175,6 +175,9 @@ const getSharedConfiguration = ({file = '.env', isDevServer = false}: Environmen // @sentry/react-native references the optional expo-updates module. We do not install it, // so web/Storybook bundles should treat it as unavailable instead of failing resolution. 'expo-updates': false, + // @sentry/react-native references Expo Router internally. We do not install it, so web/Storybook + // bundles should treat it as unavailable instead of failing resolution. + 'expo-router/build/global-state/router-store': false, // Use legacy build of pdfjs-dist to support older browsers 'pdfjs-dist$': path.resolve(dirname, '../../node_modules/pdfjs-dist/legacy/build/pdf.mjs'), '@assets': path.resolve(dirname, '../../assets'), @@ -570,6 +573,11 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer name: `${process.env.npm_package_name}@${process.env.npm_package_version}`, create: true, setCommits: {auto: true}, + // Don't inject SENTRY_RELEASE into every chunk: the SDK only reads it as a + // fallback, and setupSentry.ts passes `release` to Sentry.init explicitly. + // If set to true, the app version is embedded into every chunk, so each version + // bump changes the contenthash of every bundle and invalidates the entire cache. + inject: false, }, sourcemaps: { assets: './dist/**/*.{js,map}', diff --git a/contributingGuides/BENCHMARKING.md b/contributingGuides/BENCHMARKING.md new file mode 100644 index 000000000000..611f10818ab6 --- /dev/null +++ b/contributingGuides/BENCHMARKING.md @@ -0,0 +1,198 @@ + + +# Native app benchmarks + +The native release bundle can emit structured completion events for selected manual Sentry spans. Benchmark logging is disabled unless a developer explicitly configures span names in the git-ignored root `.env` file. + +## Configure benchmark spans + +Add a comma-separated list of exact Sentry span names to `.env` before building the release app: + +```dotenv +EXPO_PUBLIC_BENCHMARK_SENTRY_SPANS=ManualAppStartup,ManualAppStartupNetworkRequest +``` + +Rebuild the release app after changing the list. Enabled spans produce one flat, machine-readable line when they end successfully: + +```text +[EXPENSIFY_BENCHMARK] {"event":"span_end","span":"ManualAppStartup","durationMs":1234,"timestamp":1710000000000} +``` + +The app uses `console.warn` for this opt-in output because production bundles remove `console.log`, `console.info`, and `console.debug`. The Sentry console integration only forwards errors, so benchmark lines do not create Sentry log events or additional network traffic. A dedicated native logging bridge would avoid warning semantics, but would add native code solely for local tooling on both platforms. + +## Bootstrap side-by-side native apps + +Use the device bootstrap script before producing a local native build. When the platform is omitted, it bootstraps both Android and iOS. It patches the release build variant by default. Unless `--bundle-identifier` is provided, it derives a unique identifier from a GitHub username. The script resolves that username in this order: + +1. The value passed to `--github-username`. +2. The user returned by `gh api user` when the GitHub CLI is installed. The CLI checks `GH_TOKEN`, then `GITHUB_TOKEN`, then its stored active account. +3. The user returned by the GitHub API using `GH_TOKEN` when the GitHub CLI is not installed. + +If the GitHub CLI is installed but cannot resolve a user, run `gh auth login` or pass `--github-username` or `--bundle-identifier`. If the CLI is not installed, set `GH_TOKEN` or pass one of those identifier options. An identifier suffix is useful when the same developer needs separate apps for multiple branches or worktrees: + +```shell +nr bootstrap-device -- --identifier-suffix baseline +``` + +Pass a platform to bootstrap only that native project: + +```shell +nr bootstrap-device -- android --identifier-suffix baseline +nr bootstrap-device -- ios --identifier-suffix baseline +``` + +Pass a comma-separated list to `--build-variants` to patch `release`, `debug`, or `adhoc` builds. Select every variant that you intend to build after bootstrapping: + +```shell +nr bootstrap-device -- android --identifier-suffix baseline --build-variants release,debug,adhoc +nr bootstrap-device -- ios --identifier-suffix baseline --build-variants release,debug,adhoc +``` + +Pass `--bundle-identifier` to replace the generated base identifier, or `--github-username` to override only the username used by the default. Android converts hyphens in GitHub usernames and identifier suffixes to underscores because Android application ID segments are Java identifiers. The identifier suffix is also included in the launcher display name, for example `Expensify (baseline)` and `Expensify Debug (baseline)`. + +On Android, bootstrapping changes the shared base application ID, then patches only the selected variants' Firebase clients, shortcut resources, and app names. Selected release-derived variants use the checked-in debug keystore with minification disabled so they can be signed and assembled locally. The package-matched entries added to `google-services.json` reuse the registered Expensify Firebase resources. This avoids a Firebase dashboard change and keeps Firebase startup behavior present in benchmark builds. + +On iOS, bootstrapping changes signing settings and bundle identifiers only for the selected Xcode build configurations. It updates the entitlement files used by those configurations and the shared app display name. + +The synthetic package is not a newly registered Firebase Android app. Package/signing-restricted services, notably Google Sign-In, do not work with it, and Firebase data may be attributed to the registered Expensify app whose resources were reused. Do not distribute or upload this build. Disabling Google Services is not recommended for performance comparisons: it removes production startup work and can change application behavior, making the result less representative. + +Build and install a bootstrapped Android release APK with the Mobile-Expensify Gradle wrapper, then use the application ID printed by the bootstrap command: + +```shell +./Mobile-Expensify/Android/gradlew -p Mobile-Expensify/Android assembleRelease +adb -s emulator-5554 install -r Mobile-Expensify/Android/build/outputs/apk/release/Expensify-release.apk +``` + +## Run the benchmark + +The benchmark runs one unmeasured warm-up followed by 20 measured cold-process launches by default. It collects every span configured in `EXPO_PUBLIC_BENCHMARK_SENTRY_SPANS` and prints a separate statistics row for each span. Every run writes the raw samples and the same Average, P50, P75, P90, P95, P99, Min, and Max table as CSV files under the git-ignored `.benchmarks` directory. + +Android: + +```shell +npm run benchmark-app-startup -- android 20 --device emulator-5554 +``` + +iOS physical device: + +```shell +npm run benchmark-app-startup -- ios 20 --device "Developer's iPhone" +``` + +The positional arguments are the platform and measured run count. By default, each launch collects metrics for 30 seconds. Use a fixed collection window without `--wait-until-span` when measuring multiple spans, since they can finish at different times. Use `--wait-time` to change that window: + +```shell +npm run benchmark-app-startup -- ios 20 --wait-time 10 +``` + +Use `--wait-until-span` to finish a run early when a particular configured span ends. Spans that finish after that cutoff are not collected. The wait time remains the maximum time allowed for the stop span to end. For example, measure only startup and stop when it ends: + +```shell +npm run benchmark-app-startup -- ios 20 --span ManualAppStartup --wait-time 30 --wait-until-span ManualAppStartup +``` + +Pass `--span` to restrict the statistics and CSV output to one span. To measure the startup network request within a fixed collection window: + +```shell +npm run benchmark-app-startup -- ios 20 --span ManualAppStartupNetworkRequest --wait-time 30 +``` + +The wait-until span can differ from the measured span as long as both are included in `EXPO_PUBLIC_BENCHMARK_SENTRY_SPANS`. Choose it only when later spans may be omitted intentionally. `ManualAppStartupNetworkRequest` can finish after `ManualAppStartup`, so stopping at startup does not guarantee a network-request sample. + +Use `--app-id` for a nonstandard Android application ID or iOS bundle identifier. `--output` selects the raw sample CSV path, and `--results-output` selects the statistics table CSV path. Without `--results-output`, the script adds `-results.csv` to the raw sample filename. For example, `.benchmarks/ios-all-spans-process.csv` produces `.benchmarks/ios-all-spans-process-results.csv`. + +Runs where a configured metric does not end within the collection window are reported as `not observed` and are omitted from that metric's percentile calculations. The final console and CSV tables show the actual sample count in `runs`. The console also warns for each incomplete metric, for example `12/20 samples collected; 8 missing`, and identifies the binary in comparison mode. Statistics exclude missing samples and may be biased toward faster completions. Use a longer fixed collection window when samples are missing; increasing `--wait-time` while retaining `--wait-until-span` does not delay its early cutoff. + +```shell +npm run benchmark-app-startup -- ios 20 \ + --output .benchmarks/startup-samples.csv \ + --results-output .benchmarks/startup-results.csv +``` + +### Create a results table from existing samples + +Use the `results` command to recalculate and export the statistics table from one or more raw sample CSV files. Samples from all input files are combined by span, the table is printed to the terminal, and `.benchmarks/results.csv` is written by default. + +```shell +npm run benchmark-app-startup -- results \ + --input-files .benchmarks/startup-samples-a.csv,.benchmarks/startup-samples-b.csv +``` + +Use `--results-output` to choose a different destination: + +```shell +npm run benchmark-app-startup -- results \ + --input-files .benchmarks/startup-samples.csv \ + --results-output .benchmarks/startup-results.csv +``` + +### Compare two installed apps + +Pass `--app-id-a` and `--app-id-b` to enable alternating comparison mode. The two application IDs or bundle identifiers must identify different apps that are already installed on the device. The script warms up each app once, then measures app A followed by app B for every run, keeping the two apps interleaved on the same device without reinstalling either one. + +```shell +npm run benchmark-app-startup -- android 20 \ + --app-id-a com.example.expensify.baseline \ + --app-id-b com.example.expensify.candidate \ + --device emulator-5554 \ + --span ManualAppStartup \ + --wait-time 30 +``` + +Use `--output-a` and `--output-b` to choose the per-app sample CSV files. Use `--results-output-a` and `--results-output-b` to choose the corresponding statistics table files. By default, the sample files are written as `---a.csv` and `...-b.csv` under `.benchmarks`, and each statistics filename adds `-results.csv`. The terminal also prints a separate multi-span summary table for each app. Output options without the `-a` or `-b` suffix are reserved for single-app mode. + +Pass `--cold` together with `--app-path-a` and `--app-path-b` when you want each comparison app reinstalled before every warm-up and measured launch. The paths identify the corresponding Android APKs or signed iOS `.app` bundles. Cold comparison mode also clears app state using the platform-specific cold-start behavior; artifact paths are rejected in process mode. + +```shell +npm run benchmark-app-startup -- ios 20 --cold \ + --app-id-a com.example.app.a \ + --app-id-b com.example.app.b \ + --app-path-a /path/to/app-a.app \ + --app-path-b /path/to/app-b.app \ + --device "Developer's iPhone" \ + --wait-time 30 +``` + +The script runs with Bun and also exports `benchmarkAppStartups`, `benchmarkAppStartupsAlternating`, and `benchmarkStartups`. Other local tooling can install an artifact and invoke the same benchmark implementation. Statistics calculation, raw sample parsing, and CSV result writing live in `scripts/lib/benchmarkStatistics.ts`. The lower-level shared, Android, and iOS process tooling lives in `scripts/lib/nativeAppBenchmark.ts` and `scripts/lib/nativeAppBenchmark/`. This guide does not assume that an existing PGO script has adopted these exports. + +The percentile and summary-statistics functions in `scripts/lib/benchmarkStatistics.ts` remain side-effect free. Callers can use those calculations independently or opt into the same module's sample-file parsing and CSV export helpers without importing device commands or startup orchestration. + +Android benchmark logs are scoped to the PID launched for each application ID. This prevents log events from the other installed comparison binary from being counted in the current sample. + +The Android collector reads tagged warnings from that process through `adb logcat`. CoreDevice does not provide the benchmark script with an equivalent stream of iOS app logs, so the iOS logger also writes one marker file per span to the app cache. The collector copies those markers from the app container with `devicectl`. + +For example, another build or profiling script can install an artifact and call `benchmarkAppStartups` with its platform, device, bundle identifier, output paths, and span names. This keeps artifact building and installation in the caller while sharing startup measurement and platform-device behavior. + +```ts +import {benchmarkAppStartups} from '../benchmarkAppStartup'; + +await benchmarkAppStartups({ + platform: 'android', + rootDirectory, + deviceIdentifier, + appID: 'org.me.mobiexpensifyg', + mode: 'process', + spanNames: ['ManualAppStartup', 'ManualAppStartupNetworkRequest'], + runs, + waitTimeSeconds: 30, + outputPath, +}); +``` + +### True-cold mode + +By default, each run terminates and relaunches the existing app process without clearing persisted state. Pass `--cold` to clear state before every launch. + +On Android, true-cold mode clears package data and resets compiled package state with `cmd package compile --reset`: + +```shell +npm run benchmark-app-startup -- android --cold --device emulator-5554 +``` + +On iOS, clearing app data requires uninstalling and reinstalling the signed app, so `--app-path` is required: + +```shell +npm run benchmark-app-startup -- ios --cold --device "Developer's iPhone" --app-path /path/to/Expensify.app +``` + +True-cold runs start with no authenticated account or persisted application state. Cold-process runs are usually more suitable for benchmarking authenticated startup flows. diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 67f87f1bfe6c..e21e117ba53e 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -55,8 +55,6 @@ We hire and pay external contributors via [Upwork.com](https://www.upwork.com). **The number of PRs does not change the job price**. The job price is the price in the issue title. If the title states no price, the job price is the current standard amount. -**The number of PRs does not change the job price.** The job price is the price in the issue title. If the title states no price, the job price is the current standard amount. The number of PRs needed to close the issue does not change it. - Please add your Upwork profile link in your GitHub Bio to help ensure prompt payment. If you're using Slack or Expensify for discussions, please add your Upwork profile link **and** your GitHub username in your Slack Title and Expensify Status. Payment for your contributions will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the team member assigned to pay (Look for the melvin-bot "Triggered auto assignment to... (`Awaiting Payment`)" to see who this is). diff --git a/contributingGuides/LINTING.md b/contributingGuides/LINTING.md index d7ec02c7a65e..86be44432903 100644 --- a/contributingGuides/LINTING.md +++ b/contributingGuides/LINTING.md @@ -1,6 +1,6 @@ # Linting -The App is linted with [ESLint](https://eslint.org) and its configuration lives in [`config/eslint/`](../config/eslint/). The main source of truth is [`config/eslint/eslint.config.mjs`](../config/eslint/eslint.config.mjs) — every other file in that directory (plugins, processors, the seatbelt baseline) is wired up from there. +The App is linted with [ESLint](https://eslint.org). Rule configuration lives in [`config/eslint/`](../config/eslint/); the runner, seatbelt ratchet, and post-process pipeline live in [`scripts/lint/`](../scripts/lint/). ## TL;DR @@ -24,15 +24,19 @@ npm run lint-watch npm run eslint-report ``` -Prefer `npm run lint` (or `lint-changed` / `lint -- `) over raw `npx eslint` invocations. Those wrappers increase the memory allocation to prevent OOM errors, and also include caching and concurrency flags for faster linting. +Put flags before file or directory paths. The CLI stops parsing flags once it starts collecting variadic targets. -By default the wrapper passes `--quiet` to ESLint so only blocking errors are printed — seatbelt-grandfathered violations (which CI does not fail on) are suppressed from the output but still evaluated against the baseline. Pass `--show-warnings` when you want to see them too, e.g. when paying down baselined errors. +Prefer `npm run lint` (or `lint-changed` / `lint -- `) over raw `npx eslint` invocations. The wrapper increases the memory allocation to prevent OOM errors, applies the seatbelt ratchet and the other post-processors, and includes caching and concurrency flags for faster linting. -## eslint-seatbelt -We use [eslint-seatbelt](https://github.com/justjake/eslint-seatbelt) to manage known lint errors. +Editor integrations and bare `npx eslint` still run the rule set from `config/eslint/`, but they do **not** apply the seatbelt ratchet, the React Compiler filter, or no-deprecated stratification. Those run only in `scripts/lint/`. Grandfathered seatbelt rows may therefore show as errors in the editor even though `npm run lint` passes. + +By default the wrapper only prints blocking errors — seatbelt-grandfathered violations (which CI does not fail on) are suppressed from the output but still evaluated against the baseline. Pass `--show-warnings` when you want to see them too, e.g. when paying down baselined errors. + +## Seatbelt +Known lint errors are grandfathered by a seatbelt ratchet implemented in [`scripts/lint/processors/Seatbelt.ts`](../scripts/lint/processors/Seatbelt.ts). The TSV format and env-var names match the previous `eslint-seatbelt` plugin so existing workflows stay the same. 1. **Every rule is an error.** There are no warnings. -2. **Pre-existing errors are grandfathered in via [`eslint-seatbelt`](https://github.com/justjake/eslint-seatbelt).** A per-file / per-rule baseline lives at [`config/eslint/eslint.seatbelt.tsv`](../config/eslint/eslint.seatbelt.tsv). As long as a file's error count for a given rule is **≤** its recorded baseline, seatbelt reclassifies those errors as warnings and the run still passes. +2. **Pre-existing errors are grandfathered via the seatbelt ratchet.** A per-file / per-rule baseline lives at [`config/eslint/eslint.seatbelt.tsv`](../config/eslint/eslint.seatbelt.tsv). As long as a file's error count for a given rule is **≤** its recorded baseline, seatbelt reclassifies those errors as warnings and the run still passes. 3. **The baseline is a ratchet.** The count can only go down over time — never up — unless you (and a reviewer) explicitly allow an increase. 4. **The baseline auto-tightens on `main`.** When a PR merges, the lint job on `main` rewrites `eslint.seatbelt.tsv` to reflect the new (lower) counts and commits it back as OSBotify. You don't need to commit TSV updates yourself during normal development. 5. **You never hand-edit `eslint.seatbelt.tsv`.** Seatbelt rewrites it deterministically based on what it sees during a lint run. @@ -80,7 +84,7 @@ npm run eslint-report ### "I fixed an existing baselined error" -Just run `npm run lint` (or `npm run lint-changed`) locally. Seatbelt notices the count went down and passes. **It does not rewrite `config/eslint/eslint.seatbelt.tsv` locally** — the config sets `readOnly: !process.env.CI`, so the TSV is only rewritten in CI. After your PR merges, the lint job on `main` re-runs, writes the tightened TSV, and OSBotify commits it back to `main` for you. +Just run `npm run lint` (or `npm run lint-changed`) locally. Seatbelt notices the count went down and passes. **It does not rewrite `config/eslint/eslint.seatbelt.tsv` locally** — `readOnly` defaults to on unless `CI` is set, so the TSV is only rewritten in CI. After your PR merges, the lint job on `main` re-runs, writes the tightened TSV, and OSBotify commits it back to `main` for you. No TSV commit required on your end. @@ -97,7 +101,7 @@ The default assumption is that you fix it. If you genuinely need to land code th That will modify `config/eslint/eslint.seatbelt.tsv`. **Always commit the diff alongside your code**, and expect a reviewer to ask you why a fix wasn't feasible. Because the violation stays visible in the baseline, it can still be fixed later. -2. **Suppress the specific occurrence** with an `eslint-disable-next-line ` comment and a justification — preferred for **permanent** suppressions, where you genuinely believe the lint rule is wrong for that specific case and you don't expect the violation to ever be fixed. See [`CONSISTENCY-5`](../.claude/skills/coding-standards/rules/consistency-5-justify-eslint-disable.md). +2. **Suppress the specific occurrence** with an `eslint-disable-next-line ` comment and a justification — preferred for **permanent** suppressions, where you genuinely believe the lint rule is wrong for that specific case and you don't expect the violation to ever be fixed. See [`CONSISTENCY-5`](../.claude/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md). > **Which should I use?** Reach for `SEATBELT_INCREASE` when the error is merely inconvenient to fix right now. Reach for `eslint-disable-next-line` only when you are making a deliberate, permanent exception — i.e. you're certain the rule simply doesn't apply to this particular case. `eslint-disable` comments hide violations from the baseline entirely, so they should only be used when the suppression is intentional and indefinite. @@ -124,7 +128,7 @@ Commit the updated `config/eslint/eslint.seatbelt.tsv` alongside the rename. The ## CI behavior -The [`ESLint check`](../.github/workflows/lint.yml) workflow runs `npm run lint`. In CI, `readOnly` is off (so seatbelt can write) and `SEATBELT_FROZEN=0` is exported from [`scripts/lint.ts`](../scripts/lint.ts) so GitHub Actions' auto-set `CI=true` doesn't flip seatbelt into frozen mode. +The [`ESLint check`](../.github/workflows/lint.yml) workflow runs `npm run lint`. In CI, `readOnly` is off (so seatbelt can write) and `SEATBELT_FROZEN` defaults to off so GitHub Actions' auto-set `CI=true` doesn't flip seatbelt into frozen mode. - **PR runs:** counts go down → passes (TSV rewrite is ephemeral and thrown away with the runner). Counts go up without `SEATBELT_INCREASE` → fails with seatbelt's "exceeds allowed count" error. - **`push: main` runs:** same behavior, plus an extra step — if `config/eslint/eslint.seatbelt.tsv` changed, OSBotify commits the tightened baseline straight back to `main`. @@ -143,12 +147,12 @@ Set any of these env vars for a one-off local run when you need to bypass seatbe | `SEATBELT_DISABLE=1` | Skip all seatbelt processing for this run — raw ESLint output, baseline file ignored. | | `SEATBELT_VERBOSE=1` | Log every decrement/increment seatbelt performs. Handy when debugging the baseline. | -Full reference: [eslint-seatbelt README](https://github.com/justjake/eslint-seatbelt#configuration). +The env-var names and TSV format are unchanged from the previous `eslint-seatbelt` plugin. ## Related reading - [Ratcheting errors](https://www.notion.com/blog/how-we-evolved-our-code-notions-ratcheting-system-using-custom-eslint-rules). -- [`CONSISTENCY-5`: Justify ESLint rule disables](../.claude/skills/coding-standards/rules/consistency-5-justify-eslint-disable.md) — when `eslint-disable` is acceptable, and how to document it. -- [`CLEAN-REACT-PATTERNS-0`: React Compiler compliance](../.claude/skills/coding-standards/rules/clean-react-0-compiler.md) — why some hook-related rules are suppressed per-file. +- [`CONSISTENCY-5`: Justify ESLint rule disables](../.claude/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md) — when `eslint-disable` is acceptable, and how to document it. +- [`CLEAN-REACT-PATTERNS-0`: React Compiler compliance](../.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md) — why some hook-related rules are suppressed per-file. - [`STYLE.md`](./STYLE.md) — the coding style rules many of our ESLint rules enforce. - [`REACT_COMPILER.md`](./REACT_COMPILER.md) — separate compiler-compliance CI check that runs alongside lint. diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index db741e60d7f1..ffffbbc2c49d 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -11,6 +11,8 @@ The navigation in the app is built on top of the `react-navigation` library. To - [Going back](#going-back) - [Dismissing modals](#dismissing-modals) - [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report) + - [Pre-mounting a destination behind an RHP](#pre-mounting-a-destination-behind-an-rhp) + - [What pre-inserting actually costs](#what-pre-inserting-actually-costs) - [Summary](#summary) - [Adding new screens](#adding-new-screens) - [Multi-step flows with URL synchronization](#multi-step-flows-with-url-synchronization) @@ -104,6 +106,9 @@ interceptAnonymousUser(() => { > 3. Our method uses `PUSH` instead of `NAVIGATE` by default! > 4. We do not have a separate function `REPLACE`. To use this method, you need to pass the `forceReplace` option to `Navigation.navigate`. +> [!TIP] +> When navigating straight from a touch handler (a shutter press, a tap that jumps to another screen), use `deferNavigate` (`@libs/Navigation/deferNavigate`) instead of `Navigation.navigate` directly. A synchronous navigate can mount the next screen mid-touch, so the release lands on the new screen (e.g. its back button) and pops the flow back. `deferNavigate` runs it on the next microtask so the touch finishes first. + ### Going back To navigate back, we use the `Navigation.goBack` function. We can call this function without any parameters, but the most common case is to call it with `backToRoute`. It is worth remembering that it is possible to deep link to any page in the application. When we open a specific page using a link, the navigation state is rebuilt and we lose previously visited pages. In such a case, we can simply use the mentioned parameter to indicate which page should be opened when going back. @@ -248,6 +253,59 @@ Navigation.dismissModalWithReport({ > 1. On a narrow screen, we do not want to perform two operations: closing the modal and opening the report. This would cause two actions to be displayed on the screen, which could be confusing for users. Instead of two operations, we perform a replace on the modal, thanks to which there is a smooth transition to the report with simultaneous closing of the modal. > 2. On a wide screen, we need to be sure that the modal has been closed before we want to navigate to the report. For this purpose, `navigate` is passed as the `afterTransition` callback to `dismissModal`, so it only runs once the dismiss transition has completed (tracked via `TransitionTracker`). +### Pre-mounting a destination behind an RHP + +When dismissing an RHP reveals a **different** fullscreen destination (not the screen already behind it - see [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report) for that case), the destination needs to be mounted before the dismissal reveals it. Otherwise there's a visible gap on narrow layout, or a flash of the previous page on wide layout, while React mounts the destination tree. + +`usePreMountDestination` (`src/hooks/usePreMountDestination`) centralizes this lifecycle. It has two layout-specific strategies: + +- **Narrow layout (default, `narrowDestinationStrategy: CONST.NARROW_DESTINATION_STRATEGY.PRE_INSERT`):** on mount, waits for the RHP's open transition, then pre-inserts the destination route underneath the RHP at idle priority (`preInsertFullscreenUnderRHP`). By the time the user dismisses, the destination is already mounted, so dismissal just reveals it. +- **Narrow layout with `narrowDestinationStrategy: CONST.NARROW_DESTINATION_STRATEGY.REVEAL`, or wide layout (always), or narrow layout where the pre-insert hasn't finished yet:** `reveal()` calls `Navigation.revealRouteBeforeDismissingModal` instead - it swaps in the destination and dismisses in one step, at reveal time rather than eagerly. Correctness is the same either way; only the narrow pre-insert path has the mount-ahead-of-time perf win. + +```tsx +const destinationRoute = buildDestinationRoute(itemID); +const {reveal, cleanupPreMount} = usePreMountDestination(destinationRoute); + +const handleSubmit = () => { + saveDataRequiredByDestination(); // synchronous work happens before reveal() + reveal(); +}; + +const handleBackOut = () => { + cleanupPreMount(); // safe to call unconditionally - no-ops if nothing was pre-inserted + Navigation.goBack(); +}; +``` + +See `IOURequestStepConfirmation.tsx` for a reference implementation. + +- `reveal(afterTransition?)`: dismisses the RHP over the pre-inserted destination if the hook owns one, otherwise falls back to `revealRouteBeforeDismissingModal` (see above). +- `cleanupPreMount()`: removes the owned pre-insert, if any. Call it unconditionally on every back-out path (header back, hardware back) that closes the RHP without calling `reveal()` - it's a no-op when this instance never actually pre-inserted anything. +- `shouldPreservePreInsertedRouteOnUnmount`: pass when the component unmounts before `reveal()` runs but the pre-insert should survive (e.g. the caller dismisses separately after a submit). + +> [!NOTE] +> Only one component may own a pre-inserted route at a time. `reveal()` logs an alert if it runs while a *different* flow's pre-insert flag is still set - that's a sign the previous owner didn't clean up. + +> [!NOTE] +> When the destination resolves to one of the app's root tabs (Home, Inbox, Search, Settings, or Workspaces), pre-insert switches to that tab instead of pushing (`[Tab(A), RHP] -> [Tab(B), RHP]`), with the original tab saved for restore-on-cancel. For any other destination, it pushes a new route between the origin and the RHP instead (`[origin, RHP] -> [origin, destination, RHP]`). Which one happens is determined by the destination route, not by anything the caller configures. + +> [!NOTE] +> See [PERF-18](../.claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md) for the AI-review checklist covering this hook. + +#### What pre-inserting actually costs + +Pre-inserting is not a lightweight placeholder swap - it's a real second screen mounted in the stack, with its own Onyx connections, effects, and any API calls it fires on mount, running concurrently with the RHP that's still open. That mount happens whether or not the user ever reveals it: if they back out, `cleanupPreMount()` removes the *route*, but any data fetch the destination's mount already triggered already ran. For a screen with meaningful data-fetching behind it, that's wasted work on every back-out, not a free perf win. + +This is why the hook's own precondition matters and isn't just a checkbox: the destination has to be known at mount time, otherwise there's nothing correct to pre-insert. Dwell time is not a precondition - the pre-insert waits for the RHP's open transition and then schedules at idle priority, and if the user dismisses before that fires, the unmount cleanup cancels it and nothing was mounted. + +Native swipe-back can pop the RHP at the native layer before any JS cleanup runs, briefly flashing the pre-inserted destination. This is a known limitation without a deterministic fix yet. + +**Examples when NOT to use `usePreMountDestination`:** + +- **The destination is heavy and rarely reached from the dismiss path.** If most users dismiss rather than actually submitting, pre-inserting on every RHP open pays the concurrent-mount cost far more often than it pays off. Search logs to profile the production dismiss-to-reveal ratio before assuming pre-insert wins. +- **The dismiss isn't RHP-to-fullscreen with a destination known at mount time.** `dismissModalWithReport` and the strategies in `submitDismissStrategies.ts` handle shapes this hook doesn't model: RHP-to-RHP transitions (`dismissToPreviousRHP`), and destinations resolved at dismiss time (`dismissRHPToReport` branches on `report.transactionCount` when the user submits). Converging those on the hook would mean growing the hook to cover them, not migrating call sites. +- **You're tempted to use `shouldPreservePreInsertedRouteOnUnmount` as a default escape hatch.** It exists for one narrow case: a *different* caller finishes the dismiss after this component unmounts. Anywhere else it leaves a pre-inserted route with no owner left to clean it up, so the next flow's `reveal()` runs into an already-set pre-insert flag. + ### Summary - `Navigation.navigate` is used to navigate between screens. Remember that it calls the `linkTo` method implemented by us. It accepts the route as a parameter not a screen name. @@ -255,6 +313,7 @@ Navigation.dismissModalWithReport({ - If you want to go back to the screen regardless of its parameter values, pass `{compareParams: false}` to `Navigation.goBack`. - If you want to close the entire modal window, regardless of how many pages you have opened, use `Navigation.dismissModal` to do that. - If you want to open a report from RHP to prevent navigation back to this modal window, use `Navigation.dismissModalWithReport`. +- If dismissing an RHP reveals a different fullscreen destination, use `usePreMountDestination` to mount it ahead of time - but only when the destination is known at mount time; it's a real concurrent mount, not a free perf win, so profile before reaching for it on a heavy or rarely-reached destination. ## Adding new screens @@ -1677,9 +1736,11 @@ The purpose of the `adaptStateIfNecessary` function is to ensure that a given `S When is this function used: -- This function is called when the application starts. If we open the application on the central screen, this function will push the sidebar screen. +- This function is called when the application starts. If we open the application on the central screen, this function will push the sidebar screen unless the navigation explicitly opts out on a narrow layout. Wide layouts always keep the sidebar. - When we are on the sidebar on a small screen and we expand it to a wide layout, we have to push the central screen to fill the space on the screen. +The initial-route condition can also apply during in-app navigation while `TAB_NAVIGATOR` is the only route in the root stack. A caller can use `shouldSkipInitialSplitNavigatorSidebar` to prevent a directly opened split destination from adding the sidebar as an intermediate Back destination on a narrow layout. + > [!NOTE] > `adaptStateIfNecessary` is called in the `getInitialState` and `getRehydratedState` methods in `src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts`. diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md index 604bdeebbfa0..3c457f3b127f 100644 --- a/contributingGuides/OBSERVABILITY_METRICS.md +++ b/contributingGuides/OBSERVABILITY_METRICS.md @@ -152,15 +152,48 @@ This document lists all implemented telemetry metrics in the Expensify App. **Start**: Message submitted in composer, only when scrolled to bottom ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) **End**: - User sees: Their message appears in chat -- Technical: Message layout complete (`onLayout` event) in [`src/pages/inbox/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/TextCommentFragment.tsx) +- Technical: Message layout complete (`onLayout` event) in [`src/pages/inbox/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/TextCommentFragment.tsx), or in [`AttachmentCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/AttachmentCommentFragment.tsx) when the sent text parses to an attachment-only message (markdown video). Both use `useSendMessageSpanMarks`. **Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${reportActionID}` (optimistic report action ID) **Attributes**: `report_id`, `message_length`, `canceled_by_skeleton`, `send_message_source`, `report_action_count`, `money_request_preview_count` -**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessageVisible` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. +**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessageVisible` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. Its child phase spans (below) are cancelled first. - `canceled_by_skeleton` values (`CONST.TELEMETRY.CANCELED_BY_SKELETON`) based on skeleton condition **Cancellation (report unmount / navigate away)**: If the user leaves the report before their message renders, any pending `ManualSendMessageVisible` span is cancelled via `cancelSpansByPrefix()` to avoid orphaned spans. Cancelled this way the span gets `canceled: true` but **no** `canceled_by_skeleton` (a blanket cancel by span-id prefix, not scoped to one `report_id`). **Notes**: `send_message_source` = `_` (+ `_rhp` in the RHP, + `_from_report` when drilled in from a report) — slice the metric by send path. `report_action_count` / `money_request_preview_count` — how many renderable actions the chat's list holds and how many of them are `REPORT_PREVIEW` items — slice the metric by list weight (e.g. chats with many `MoneyRequestReportPreview` items). +### Send Message (Propagate) + +**Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE` +**Sentry Name**: `ManualSendMessagePropagate` +**Threshold**: none, read as a slice of `ManualSendMessageVisible` +**What's Measured**: Time from queueing the optimistic write to React committing the sent row: Onyx applying the merge, notifying subscribers, and React rendering and committing up to that row +**Start**: `API.write` returns in `addActions` ([`src/libs/actions/Report/index.ts`](https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts)) +**End**: The sent row's layout effect ([`src/libs/telemetry/useSendMessageSpanMarks.ts`](https://github.com/Expensify/App/blob/main/src/libs/telemetry/useSendMessageSpanMarks.ts)) +**Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_${reportActionID}`, child of the `ManualSendMessageVisible` span with the same `reportActionID` +**Notes**: Layout effects run inside the commit and innermost component first, so this ends mid-commit. The time before it starts is the submit itself, which has no span. + +### Send Message (Post Commit) + +**Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT` +**Sentry Name**: `ManualSendMessagePostCommit` +**Threshold**: none, read as a slice of `ManualSendMessageVisible` +**What's Measured**: Time from React committing the sent row to the platform laying it out: the rest of the commit, passive effects, derived recomputes and the re-renders their writes cause, then platform layout +**Start**: The sent row's layout effect ([`src/libs/telemetry/useSendMessageSpanMarks.ts`](https://github.com/Expensify/App/blob/main/src/libs/telemetry/useSendMessageSpanMarks.ts)) +**End**: `onLayout` on the sent row, the same event that ends the parent +**Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT}_${reportActionID}`, child of the `ManualSendMessageVisible` span with the same `reportActionID` +**Notes**: Excludes the sent row's own render, which happened in `Propagate`. Both phases live in [`src/libs/telemetry/sendMessageSpans.ts`](https://github.com/Expensify/App/blob/main/src/libs/telemetry/sendMessageSpans.ts): they no-op unless the parent is active, and are closed before it, since Sentry discards a child still running when the root span becomes a transaction. + +### Onyx Derived Compute + +**Constant**: `CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE` +**Sentry Name**: `OnyxDerivedCompute` +**Threshold**: none, read as a slice of its parent +**What's Measured**: One derived-value recompute, from the compute function to its `setDerivedValue` write. Excludes the re-render that write causes in subscribers +**Start**: Before the compute function runs ([`src/libs/actions/OnyxDerived/index.ts`](https://github.com/Expensify/App/blob/main/src/libs/actions/OnyxDerived/index.ts)) +**End**: After `setDerivedValue` writes the result +**Attributes**: `derivedKey` (which value recomputed), `triggeredKeys` (which dependencies fired it), `is_startup` +**Notes**: Recorded only when a parent span is open: app startup, or a send when exactly one `ManualSendMessageVisible` is active. The engine coalesces a dependency burst into one compute per key, so with two sends in flight the compute gets no parent rather than a guessed one. Which phase a compute lands in varies between sends, so read these for cost and cause, not for position. + ## Failure Rates ### 404 Pages diff --git a/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md index 84afc7daa8de..bdc9f0b59b4f 100644 --- a/contributingGuides/PERFORMANCE_METRICS.md +++ b/contributingGuides/PERFORMANCE_METRICS.md @@ -24,7 +24,7 @@ Project is using Firebase for tracking these metrics. However, not all of them a | `open_report_from_preview` | ✅ | Time taken to open a report from preview.

(previously `switch_report_from_preview`)

**Platforms:** All | Starts when the user presses the Report Preview. | Stops when the `ReportActionsList` finishes laying out. | | `open_report_thread` | ✅ | Time taken to open a thread in a report.

**Platforms:** All | Starts when user presses Report Action Item. | Stops when the `ReportActionsList` finishes laying out. | | `send_message` | ✅ | Time taken to send a message.

**Platforms:** All | Starts when the new message is sent. | Stops when the message is being rendered in the chat. | -| `pusher_ping_pong` | ✅ | The time it takes to receive a PONG event through Pusher.

**Platforms:** All | Starts every minute and repeats on the minute. | Stops when the event is received from the server. | +| `pusher_ping_pong` | ❌ | **Deprecated.** The time it took to receive a PONG event through Pusher. The HTTP `PusherPing` heartbeat this measured was removed - the Pusher SDK already runs its own websocket connection check.

**Platforms:** All | n/a | n/a | | `open_create_expense` | ❌ | Time taken to open "Create expense" screen.

**Platforms:** All | Starts when the `Create expense` is pressed. | Stops when the `IOURequestStartPage` finishes laying out. | | `open_create_expense_contact` | ❌ | Time taken to "Create expense" screen.

**Platforms:** All | Starts when the `Next` button on `Create expense` screen is pressed. | Stops when the `IOURequestStepParticipants` finishes laying out. | | `open_create_expense_approve` | ❌ | Time taken to "Create expense" screen.

**Platforms:** All | Starts when the `Contact` on `Choose recipient` screen is selected. | Stops when the `IOURequestStepConfirmation` finishes laying out. | diff --git a/contributingGuides/PROPOSAL_POLICE.md b/contributingGuides/PROPOSAL_POLICE.md new file mode 100644 index 000000000000..879ab0ce4a54 --- /dev/null +++ b/contributingGuides/PROPOSAL_POLICE.md @@ -0,0 +1,67 @@ +# ProposalPolice™ + +ProposalPolice™ is a GitHub Action that watches comments on `Help Wanted` issues. It keeps proposal threads readable by reminding people to use the proposal template, collapsing comments that ask for a job without proposing anything, and withdrawing proposals that duplicate an existing one. + +It only ever comments, collapses, or edits comments. It never assigns a job, and it never decides whether a proposal is any good — that is still the C+ and internal engineer's call. See [CONTRIBUTING.md](./CONTRIBUTING.md#propose-a-solution-for-the-job) for the proposal process itself. + +## When it runs + +It runs when a comment is created or edited, and does nothing unless all of these are true: + +- The issue is open and has the `Help Wanted` label. +- The comment is not from an internal bot. +- The comment author is not assigned to the issue. +- For an edit, the comment body actually changed. + +## What it checks + +A comment counts as a proposal when it contains the word "Proposal" plus both mandatory headings from the [proposal template](./PROPOSAL_TEMPLATE.md). + +| Comment | What happens | +|---------|--------------| +| **Follows the template** | Compared against every earlier proposal on the issue. A close match is withdrawn (see below). | +| **Does not follow the template, but makes a real technical attempt** | You get a reply pointing you at the template. Your comment is left alone. | +| **Asks for the job with no technical content** — "assign me", "I applied on Upwork" | The comment is collapsed as spam and you get a reply asking for a proposal. | +| **Anything else** — questions, retest results, feedback on someone else's proposal, takeover coordination | Nothing. Ordinary discussion is not touched. | + +Members of `expensify-expensify`, `contributor-plus`, or `contributor-plus-backend`, and anyone who says they are from agencies Expensify works with, can offer their services on a job without posting a proposal and won't be flagged as spam. + +## Duplicate proposals + +Every proposal on an issue is recorded, and each new one is scored against the earlier ones. Only the root cause and the proposed solution count — two proposals are similar when they propose the same technical change, no matter how differently they are written. Sharing a file name, a variable, or an error message is not enough. + +If a new proposal scores 90% or higher against someone else's earlier proposal, ProposalPolice replaces the comment body with a withdrawal notice and posts a reply linking to the original. **Your text is gone at that point**, so read the existing proposals before posting. + +Your own earlier proposals are never treated as duplicates, so you can freely revise your thinking in a new comment. + +You may also see a short bot comment saying it is tracking duplicate proposals for the issue. That comment stores an ID it needs between runs and can be ignored. + +## Edited proposals + +When you substantially change the root cause or the solution in a proposal, ProposalPolice prepends a banner with the time of the edit, so reviewers can see the proposal they read has changed. Fixing a typo or rewording a sentence does not trigger it. + +## If it gets something wrong + +The action is deliberately fail-safe: any error is swallowed and the run is marked successful, so a bad run leaves your comment untouched rather than notifying everyone on the issue. + +If it flags something incorrectly, say so on the issue and tag the C+ or the assigned engineer. Do not re-post the same proposal — it will be withdrawn again. + +## Where the code lives + +| Path | What it holds | +|------|---------------| +| [`.github/workflows/proposalPolice.yml`](../.github/workflows/proposalPolice.yml) | Trigger conditions and the trusted-commenter check | +| [`.github/actions/javascript/proposalPoliceComment`](../.github/actions/javascript/proposalPoliceComment) | All of the logic and the decision to act | +| [`prompts/proposalPolice`](../prompts/proposalPolice) | Instructions, response schemas, and every message it posts | +| [`scripts/utils/ProposalPolice`](../scripts/utils/ProposalPolice) | Bookkeeping for the recorded proposals used by duplicate detection | +| [`tests/tooling`](../tests/tooling) | Unit tests, which run in CI | +| [`evals/proposalPolice`](../evals/proposalPolice) | Offline evals for the three judgment calls. These cost real tokens and do **not** run in CI — run them by hand when changing a prompt, model, or threshold. See [evals/README.md](../evals/README.md). | + +The wording of every message contributors see lives in `prompts/proposalPolice/messages.ts`. The model picks which message applies; it never writes one. + +## Related documentation + +- [PROPOSAL_TEMPLATE.md](./PROPOSAL_TEMPLATE.md) — the template ProposalPolice enforces +- [CONTRIBUTING.md](./CONTRIBUTING.md) — full contributor workflow +- [HOW_TO_WORK_WITH_MELVINBOT.md](./HOW_TO_WORK_WITH_MELVINBOT.md) — the AI agent that posts and implements proposals +- [AI_ETIQUETTE.md](./AI_ETIQUETTE.md) — accountability for AI-assisted work diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index d0299c324c47..98b3177b3c14 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -1026,41 +1026,39 @@ So, if a new language feature isn't something we have agreed to support it's off ## React Coding Standards -For additional React best practices and patterns that are automatically enforced during code review, see the [Coding Standards](../.claude/skills/coding-standards/SKILL.md). +For additional React best practices and patterns that are automatically enforced during code review, see the [App Coding Standards](../.claude/skills/app-coding-standards/SKILL.md). ### Code Documentation -- Add descriptions to all component props using a block comment above the definition. No need to document the types, but add some context for each property so that other developers understand the intended use. +- Add a `/** ... */` block comment above a component prop or an `src/types/onyx/**` property only when there is a non-obvious fact to add: a unit, a default, a boundary condition, null/undefined semantics, ownership, an invariant, or how it differs from a sibling. A comment that only restates the name or type adds nothing; delete it instead of padding it. A prop or property with nothing non-obvious to say is already documented by its name. ```tsx -// Bad +// Bad: comments just restate the name type ComponentProps = { - currency: string; + /** The currency that the reward is in */ + currency?: string; + + /** The amount of reward */ amount: number; + + /** Whether the reward has been ignored */ isIgnored: boolean; }; -// Bad +// Bad: same issue with // comments type ComponentProps = { // The currency that the reward is in - currency: string; - - // The amount of reward - amount: number; - - // If the reward has been ignored or not - isIgnored: boolean; + currency?: string; }; -// Good +// Good: comments add facts the name and type can't - isIgnored is obvious so it has no comment type ComponentProps = { - /** The currency that the reward is in */ - currency: string; + /** ISO 4217 currency code, defaults to the workspace currency */ + currency?: string; - /** The amount of the reward */ + /** In cents */ amount: number; - /** If the reward has not been ignored yet */ isIgnored: boolean; }; ``` diff --git a/contributingGuides/philosophies/AI-REVIEWER.md b/contributingGuides/philosophies/AI-REVIEWER.md index f91a0f0c2eb8..91cb1df2fff6 100644 --- a/contributingGuides/philosophies/AI-REVIEWER.md +++ b/contributingGuides/philosophies/AI-REVIEWER.md @@ -40,7 +40,7 @@ When AI feedback is unclear or ambiguous, contributors will benefit from discuss When AI feedback is incorrect or not applicable, reach out to the AI reviewer maintainers in the #expensify-open-source Slack channel to help improve the system. This feedback helps refine the reviewers and prevents the same issues from recurring. ### Keep rule documentation in sync with AI reviewer prompts -When adding or modifying rules, the corresponding documentation should be updated. The coding standard files in `.claude/skills/coding-standards/` are the source of truth for code review rules. +When adding or modifying rules, the corresponding documentation should be updated. The coding standard files in `.claude/skills/app-coding-standards/` are the source of truth for code review rules. ## Reviewer Setup @@ -49,7 +49,7 @@ When adding or modifying rules, the corresponding documentation should be update **code-inline-reviewer (Smart Linter)** - Reviews source code PRs for specific, predefined violations - Creates inline comments on lines that violate rules -- See `.claude/skills/coding-standards/` for current rule definitions +- See `.claude/skills/app-coding-standards/` for current rule definitions **Holistic Reviewer** - Provides general code review without predefined rules diff --git a/cspell.json b/cspell.json index 8de691a67eff..f41de7c5f970 100644 --- a/cspell.json +++ b/cspell.json @@ -35,6 +35,7 @@ "Amal", "Amal's", "Amina", + "Anysphere", "Areport", "Authy", "Ashby", @@ -283,7 +284,6 @@ "Oncorp", "PINATM", "PINATM", - "PINGPONG", "PKCE", "POLICYCHANGELOG_ADD_EMPLOYEE", "POWERFORM", @@ -643,6 +643,7 @@ "exchrate", "exfy", "exitstatus", + "exops", "expensescount", "expensicorp", "expensifyhelp", @@ -737,6 +738,7 @@ "jsbundle", "jumpcloud", "justworks", + "justjake", "kallidus", "keka", "kenjo", @@ -762,6 +764,7 @@ "lastiPhoneLogin", "lastname", "lefthook", + "legendapp", "libc", "Libc", "libc's", @@ -795,6 +798,7 @@ "menuitemradio", "metainfo", "metatags", + "microtask", "microtime", "microtransactions", "midoffice", @@ -867,11 +871,13 @@ "outform", "outplant", "oxfmt", + "oxlint", "padangle", "parasharrajat", "passcodes", "passplus", "passwordless", + "packageinstaller", "pathspec", "paycor", "payrollcode", @@ -938,6 +944,7 @@ "remotesync", "removeHiddenElems", "requestee", + "reserialization", "resizeable", "resultsbox", "retryable", @@ -983,6 +990,7 @@ "signingkey", "signup", "Signup", + "sideloaded", "sideproject", "simctl", "showcerts", @@ -1044,6 +1052,7 @@ "tosorted", "touchables", "tranid", + "travelled", "trinet", "trivago", "trustcacerts", @@ -1055,8 +1064,10 @@ "unapproves", "unassigning", "unassignment", + "unbaselined", "unassigns", "uncategorized", + "unconfigured", "unflushed", "unheld", "unhold", @@ -1064,6 +1075,7 @@ "uninstallations", "unminified", "unmuting", + "unprovisioned", "unredacted", "unregisters", "unreimbursed", @@ -1225,6 +1237,6 @@ "tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap", "evals/proposalPolice/fixtures/**" ], - "ignoreRegExpList": ["@assets/.*"], + "ignoreRegExpList": ["@assets/.*", "(? [workspace name]**. +2. Select **Accounting**. +3. Under **Xero**, select **Export**. +4. Select **Default vendor**. +5. Choose a vendor from the list. + +The default vendor is used only when an expense doesn't already have a vendor assigned. If no default vendor is configured, expenses export to the vendor **Credit Card Misc**. + +--- + +# FAQ + +## Do I have to set a vendor for a non-reimbursable expense? + +No. The **Vendor** field is optional. + +Expensify automatically attempts to match a vendor using eligible contacts imported from Xero. If no match is found, the field can remain blank. + +## Which Xero contacts appear in the Vendor field? + +Expensify imports Xero contacts and excludes contacts that are marked as Customers unless they're also marked as Suppliers. + +A contact marked only as a Customer won't appear in the **Vendor** field. A contact marked as both a Customer and a Supplier can appear. + +## Who can see the Vendor field on an expense? + +Only workspace admins can view and edit the **Vendor** field. Members and submitters don't see it. + +## 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. + + diff --git a/docs/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards.md b/docs/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards.md index 69d90db581fb..7d964ee00533 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards.md @@ -71,7 +71,7 @@ A CSV upload is a manual method for importing credit card transactions into Expe 3. Submit the file details via the [submission form](https://expensify.typeform.com/to/cGlCAz). ## American Express -1. Complete the required [Amex forms](https://drive.google.com/file/d/1zqDA_MCk06jk_fWjzx2y0r4gOyAMqKJe/view?usp=sharing). +1. Complete the required [Amex forms](https://drive.google.com/file/d/1pIqIroMizJfflGryTshupLAXGa5nff4u). 2. Email the forms to American Express. 3. Submit the feed details provided by American Express to concierge@expensify.com. 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 7bf66ba2f025..aa9a4a85ca61 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md @@ -52,7 +52,7 @@ To accomplish this, include phrases like: ## How to write instructions that reject expense reports -**Goal:** Sends an Outstanding (submitted) expense report back to the submitter with a user-facing rejection comment explaining why. +**Goal:** Sends an entire Outstanding (submitted) expense report back to the submitter with a user-facing rejection comment explaining why. To accomplish this, include phrases like: - Reject reports that have violations @@ -64,6 +64,18 @@ To accomplish this, include phrases like: --- +## How to write instructions that reject individual expenses + +**Goal:** Rejects selected expenses from an Outstanding (submitted) report individually, giving the submitter a reason for each expense while compliant expenses continue through approval. + +To accomplish this, include phrases like: +- Reject each expense over $500 and approve the rest +- Reject individual expenses that are not reimbursable and were not paid with a company card +- Reject every duplicate expense with a reason specific to that expense +- Reject noncompliant expenses without rejecting the whole report + +--- + ## How to write instructions that un-submit expense reports **Goal:** Unsubmits a submitted expense report, returning it to Draft so the submitter can edit it. @@ -257,4 +269,4 @@ To accomplish this, include phrases like: --- -*This reference covers all 19 AI Agent capabilities.* +*This reference covers all 20 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 a72450dbe43b..08da0392b128 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md @@ -304,10 +304,10 @@ To react to this event, include phrases like: - When a manager or admin removes a receipt from a submitted expense - When a receipt is detached from an expense after the report has already been submitted -**SmartScan (receipt OCR) finished reading a receipt on a submitted expense, so the merchant, amount, date, and itemized line items are now available. This is the earliest point at which the contents of a receipt can be checked against a rule — including a receipt that was matched onto an expense imported from a credit card.** +**SmartScan (receipt OCR) finished reading a receipt, so the merchant, amount, date, and itemized line items are now available. This is the earliest point at which the contents of a receipt can be checked against a rule — including a receipt that was matched onto an expense imported from a credit card. It fires whether the report is still a draft or already submitted.** To react to this event, include phrases like: -- When a receipt finishes scanning on a submitted expense +- When a receipt finishes scanning on an expense - When SmartScan finishes reading an itemized receipt - When a scanned receipt is matched onto an existing credit card expense - When receipt details need to be checked against workspace rules once the scan completes @@ -741,6 +741,18 @@ To react to this event, include phrases like: - When a member's final approver (forward-to) is changed - When expense report forwarding is updated for an employee +**A workspace admin changed which person approves a specific workspace member's reports above a set amount, or removed that approver.** + +To react to this event, include phrases like: +- When a member's approver for reports over a set amount is changed +- When large expense reports from a specific employee will now go to a different approver + +**A workspace admin changed the amount above which a specific workspace member's reports need an extra approver.** + +To react to this event, include phrases like: +- When the approval amount for a specific member is changed +- When the amount that sends an employee's reports to an extra approver is updated + **A workspace admin created a new conditional approval rule (e.g., expenses over a certain amount go to a specific approver).** To react to this event, include phrases like: @@ -887,4 +899,4 @@ To react to this event, include phrases like: --- -*This reference covers all 121 events AI Agents can react to.* +*This reference covers all 123 events AI Agents can react to.* diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Add-or-Update-a-Payment-Card.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Add-or-Update-a-Payment-Card.md index bd881c6eb712..db3f7995c2f0 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Add-or-Update-a-Payment-Card.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Add-or-Update-a-Payment-Card.md @@ -23,7 +23,7 @@ Only workspace owners can add a payment card or update an existing one. If you'r ## How to add a payment card 1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Account** > **Subscription**. -2. In the **Payment** section, select **Add payment card**. +2. In the **Payment** section, select **Add card**. 3. Enter your payment card details. 4. Accept the terms and select **Add payment card**. diff --git a/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md b/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md index 90ac9e391bf1..f83853da83ba 100644 --- a/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md +++ b/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md @@ -23,6 +23,7 @@ You might see spend analysis messages that: - Call out large or unusual purchases - Surface trends across categories, merchants, or members - Identify concentrated areas of spend +- Surface policy violation patterns, such as which members repeatedly submit expenses that break workspace rules - Ask questions to help clarify or validate patterns These spend analysis insights are generated automatically using your workspace data. The format, frequency, and content of these messages may change over time as we continue improving how Concierge analyzes and presents information. diff --git a/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md b/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md index ec2a33cc2810..cf49fe1710da 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md +++ b/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md @@ -17,6 +17,7 @@ Workspace Admins see indicators in Expensify when a company card feed is broken: - A notification appears in the **Time Sensitive** section on **Home**. - Concierge sends a message to the #admins room - A red dot appears on the card feed with the message “Card feed connection is broken” +- An expense with an imported card receipt shows a pending message that the bank connection is broken or needs re-authentication These reminders appear while the connection has recently broken. If a company card feed has gone 90 days or more without a successful sync, the **Time Sensitive** notification on **Home** and the red dot on the workspace row stop showing. The “Card feed connection is broken” banner remains on the **Company cards** page for direct (bank-connected) feeds, so a Workspace Admin can still reconnect the feed at any time. @@ -61,6 +62,10 @@ If you see an error while reconnecting, use the guidance below: - **What it means:** Temporary downtime or maintenance from your bank or connection provider - **What to do:** Wait and try again later +**Error: Bank connection needs re-authentication** + - **What it means:** Your bank requires you to re-authenticate the connection before transactions can resume importing. Until you reconnect, affected expenses show a pending message that the bank connection needs re-authentication. + - **What to do:** Go to **Company cards**, select the affected card feed, and follow the prompts to reconnect and restore the connection. + --- ## What happens after you fix a company card connection diff --git a/docs/articles/new-expensify/connect-credit-cards/Import-Company-Card-Transactions-From-a-Spreadsheet.md b/docs/articles/new-expensify/connect-credit-cards/Import-Company-Card-Transactions-From-a-Spreadsheet.md index c946b469000d..6809c99d0f0b 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Import-Company-Card-Transactions-From-a-Spreadsheet.md +++ b/docs/articles/new-expensify/connect-credit-cards/Import-Company-Card-Transactions-From-a-Spreadsheet.md @@ -1,8 +1,8 @@ --- title: Import Company Card Transactions From a Spreadsheet description: Learn how Workspace Admins can upload company card transactions manually from a spreadsheet file. -keywords: [New Expensify, import company card, upload file, import spreadsheet, CSV, TXT, XLS, XLSX, card feed, company card feed, bring your own card, BYOC, csv import, import csv, upload csv, spreadsheet import, import transactions, csv file, excel import, xls import] -internalScope: Audience is Workspace Admins. Covers how to import, update, and delete company card CSV feeds. Does not cover personal card imports or Plaid connections. +keywords: [New Expensify, import company card, upload file, import spreadsheet, CSV, TXT, XLS, XLSX, card feed, company card feed, bring your own card, BYOC, csv import, import csv, upload csv, spreadsheet import, import transactions, csv file, excel import, xls import, unique ID, duplicate transactions, duplicate expenses, re-import csv, reupload csv] +internalScope: Audience is Workspace Admins. Covers how to import, update, and delete company card CSV feeds, including mapping Unique ID to avoid duplicate transactions on re-import. Does not cover personal card imports or Plaid connections. --- # Import Company Card Transactions From a Spreadsheet @@ -17,20 +17,21 @@ If your bank does support a direct connection, you can connect your account to a Only **Workspace Admins** can import transactions for company cards. -**Company Cards** must be enabled in the workspace before you can import transactions. If you don't see **Company Cards**, enable it under **More features > Company Cards**. +**Company cards** must be enabled in the workspace before you can import transactions. If you don't see **Company cards**, enable it under **More features > Company cards**. --- ## How to import company card transactions from a spreadsheet 1. In the navigation tabs (on the left on web, on the bottom on mobile), go to the **Workspaces** and select your workspace. -2. Click **Company Cards**. -3. Click on **Add card**. - - If you already have a company card feed set up, click the feed name to see **Add Card**. -5. Choose **Import transactions from file**. -6. Choose the CSV, TXT, XLS, or XLSX file you want to upload. -7. Enter a name for the card feed. -8. Set your field mappings, mapping either a **Card number** or a **Card name**, along with **Date**, **Merchant**, **Amount**, and **Currency**. +2. Click **Company cards**. +3. Click **Add cards**. + - If you already have a company card feed set up, click the feed name to see **Add cards**. +4. Choose **Import transactions from file**. +5. Choose the CSV, TXT, XLS, or XLSX file you want to upload. +6. Enter a name for the card feed. +7. Set your field mappings, mapping either a **Card number** or a **Card name**, along with **Date**, **Merchant**, **Amount**, and **Currency**. +8. Map **Unique ID** to a column that holds a unique reference for each transaction, if your file has one. 9. Assign cards to users based on the transactions in the file. 10. Click **Import**. @@ -40,6 +41,22 @@ You must map at least one card-identity column — a **Card number** or a **Card --- +## How to use Unique ID to prevent duplicate transactions + +**Unique ID** is an optional column mapping that tells Expensify how to recognize a transaction it has already imported. Map it to a column in your file that holds a unique reference for each transaction, such as the bank's own transaction ID or reference number. + +When you map **Unique ID** and later upload a file that repeats some of the same rows, Expensify skips the rows it has already imported and only adds the new ones. This lets you upload overlapping files — for example, a full month-to-date export each week — without creating duplicate expenses. + +If you don't map **Unique ID**, Expensify treats every row in the file as a new transaction. Re-uploading the same file creates a duplicate expense for each row. + +For **Unique ID** to work, the values in that column must be unique within the file and stay the same for the same transaction across uploads. If the column repeats the same value on different transactions, Expensify treats them as the same transaction and skips the later ones. + +Each **Unique ID** value must also be more than 5 characters long. A value of 5 characters or fewer is too short to reliably identify a transaction, so Expensify ignores it and imports the row again every time you re-upload the file. If your file numbers transactions with short values such as `1`, `2`, and `3`, map **Unique ID** to a longer reference column instead. + +You must map **Unique ID** yourself on every import. Unlike the other field mappings, it is never filled in for you. + +--- + ## What happens after you import company card transactions from a spreadsheet - Imported transactions appear in the assigned cardholder's account. @@ -50,21 +67,21 @@ You must map at least one card-identity column — a **Card number** or a **Card ## How to upload additional transactions to an existing card feed -1. Go to **Workspaces > Company Cards**. +1. Go to **Workspaces > Company cards**. 2. Select the name of the card feed you want to update. 3. Click **Settings**. 4. Choose **Import spreadsheet**. 5. Choose the CSV, TXT, XLS, or XLSX file you want to upload. -6. Review and confirm the field mappings. +6. Review and confirm the field mappings, and map **Unique ID** again if your file has a unique reference column. 7. Click **Import**. -**Note:** Previously mapped fields will auto-fill to save time. +**Note:** Previously mapped fields auto-fill to save time, with one exception: **Unique ID** is never restored, so map it again on every upload to keep Expensify from importing duplicate transactions. --- ## How to delete a company card CSV feed -1. Go to **Workspaces > Company Cards**. +1. Go to **Workspaces > Company cards**. 2. Select the name of the card feed you want to delete. 3. Click **Settings**. 4. Select **Remove card feed**. @@ -92,6 +109,8 @@ Your file must include a way to identify each card so transactions can be matche - Amount - Currency (optional but recommended) +**Unique ID** is optional, but map it whenever your file has a unique reference for each transaction so re-uploading the file doesn't create duplicates. + ## How does matching transactions by card name work? Instead of a **Card number**, you can map a **Card name** column, and Expensify groups each transaction under the card identified by that name. After you upload the file, those cards appear as entries you can assign to users — the name doesn't need to match a card you've already assigned. You only need one card-identity column, so map a **Card number** column instead if you'd rather identify cards by number. @@ -106,7 +125,23 @@ If you map a required field such as **Date**, **Merchant**, or **Amount** — or ## Can I change field mappings after importing transactions? -Yes. When importing new transactions, previous mappings will be suggested, but you can change them as needed. +Yes. When importing new transactions, previous mappings will be suggested, but you can change them as needed. **Unique ID** is the one mapping that is never suggested — set it again each time. + +## Why do I get duplicate expenses when I upload the same file again? + +Expensify only recognizes a repeat transaction when you map **Unique ID**. If you upload a file with no **Unique ID** mapping, every row imports as a new transaction, so overlapping rows become duplicate expenses. Map **Unique ID** to the column that holds each transaction's unique reference and re-import. + +## Why is Unique ID blank when I upload another file to the same feed? + +Expensify deliberately doesn't restore a saved **Unique ID** mapping. The saved mapping can't be told apart from an automatically generated one, so restoring it could point **Unique ID** at the wrong column of your new file and cause valid transactions to be skipped. Map it by hand on each upload. + +## What happens to rows with a blank Unique ID? + +Expensify imports them. A row with an empty **Unique ID** cell gets a generated ID instead, so it always imports and won't be matched against a later upload. + +## Is there a minimum length for Unique ID? + +Yes. A **Unique ID** value must be more than 5 characters long. Expensify ignores any value of 5 characters or fewer because it's too short to reliably identify a transaction, so those rows create duplicates on a re-upload even though **Unique ID** is mapped. Map **Unique ID** to a column with longer references, such as the bank's own transaction ID. ## Do imported company card transactions sync across web and mobile? diff --git a/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md index 84aa9a25856c..12c24152cb72 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md +++ b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md @@ -76,7 +76,7 @@ Expensify will notify you when the feed is connected. To set up an Amex Corporate Feed, you’ll need to complete and email their required forms. 1. Download the forms: - [Amex Feed Setup Forms (Google Drive)](https://drive.google.com/file/d/1zqDA_MCk06jk_fWjzx2y0r4gOyAMqKJe/view?usp=sharing) + [Amex Feed Setup Forms (Google Drive)](https://drive.google.com/file/d/1pIqIroMizJfflGryTshupLAXGa5nff4u) 2. Complete the form as follows: - **Page 1:** Fill in legal company details, requestor name/email, feed start date, and **Control Account Number** (not a credit card number). - **Page 2:** Leave blank. diff --git a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md new file mode 100644 index 000000000000..07821604e83a --- /dev/null +++ b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md @@ -0,0 +1,115 @@ +--- +title: Configure DualEntry +description: Learn how to configure DualEntry import, export, and advanced sync settings in Expensify. +keywords: [New Expensify, DualEntry configuration, DualEntry import, DualEntry export, DualEntry advanced sync, DualEntry vendor bills] +internalScope: Audience is Workspace Admins configuring an existing DualEntry connection. Covers import, export, advanced settings and auto-sync. Does not cover the initial connection or API-key setup. +order: 2 +--- + +# Configure DualEntry + +Configure how Expensify imports accounting data from DualEntry and exports expenses back to your accounting system. + +If you haven't connected DualEntry yet, learn how to [connect to DualEntry](/articles/new-expensify/connections/dualentry/Connect-to-DualEntry). + +Once configured, you can: + +- Import categories, tags, and tax rates from DualEntry. +- Export out-of-pocket and card expenses. +- Automatically sync reports, reimbursements, and settlements. + +--- + +## Where to find DualEntry configuration settings + +Each Workspace has its own DualEntry integration. To view or update the configuration settings: + +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 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 + +Import settings control which accounting data is imported from DualEntry into Expensify. + +On the DualEntry integration, choose **Import** to configure: + +- **Chart of accounts** – Imports active expense (including cost of goods sold), 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. +- **Classifications** – Choose which DualEntry classifications to import as tags. Each available classification can be enabled independently, and all classifications are off by default. +- **Tax rates** – Import active VAT or GST rates from DualEntry. This option only appears when tax rates are available in DualEntry, and is off by default. + + +--- + +## How to configure DualEntry export settings + +Export settings determine how Expensify sends data to DualEntry. + +On the DualEntry integration, choose **Export** and configure: + +- **Preferred exporter** – Assign the Workspace Admin to automatically receive reports to export. +- **Export reimbursable expenses as** – Reimbursable expenses export as vendor bills. +- **Vendor bill date** – Choose whether exports use the date of the last expense, the export date, or the submitted date. +- **Export out-of-pocket expenses as** – Out-of-pocket expenses export as vendor bills. +- **Export company card expenses as** – Company card and Expensify Card expenses export as direct expenses. +- **Default vendor for all company cards** – Select the vendor used when an export can't be matched to a vendor automatically. +- **Company card account** – Select the DualEntry account used for company card expenses. +- **Configure exporting to multiple accounts** – Configure different DualEntry accounts for individual card programs or cards. This setting is off by default. + +When **Configure exporting to multiple accounts** is enabled: + +- **Card program account** lets you override the workspace **Company card account** for individual card programs. +- **Per-card account** lets you override the card program account for an individual card. + +An individual card uses its per-card account when one is configured. Otherwise, it uses its card program account. If the card program doesn't have an account override, it uses the workspace **Company card account**. + +You can also change an individual company card's DualEntry export account from its card details. + +--- + +## How to configure DualEntry advanced settings + +Advanced settings control sync frequency, reimbursement, and settlement automation. + +On the DualEntry integration, choose **Advanced** to configure: + +- **Auto-sync** – Automatically sync Expensify and DualEntry every day and export eligible reports automatically. Auto-sync is enabled after the first successful sync unless an admin previously changed the setting. +- **Export method** – Choose when reports are exported automatically. This option is only available when auto-sync is enabled and defaults to **Accrual**. + - **Accrual** exports out-of-pocket expenses after final approval. + - **Cash** exports out-of-pocket expenses after they are paid. +- **Sync reimbursed reports** – Automatically create bill payments in DualEntry when exported out-of-pocket reports are reimbursed in Expensify. This is enabled by default. +- **Bill payment account** – Select the DualEntry bank account used for bill payments. Expensify automatically selects an eligible bank account when possible. +- **Sync Expensify Card settlements** – Automatically sync Expensify Card settlement payments to DualEntry. This setting appears when Expensify Cards are enabled for the workspace. +- **Expensify Card settlement account** – Select the active USD bank account used for Expensify Card settlements. +- **Sync Travel Invoicing settlements** – Automatically sync Travel Invoicing settlement payments to DualEntry. This setting appears when Travel Invoicing is enabled. +- **Travel Invoicing settlement account** – Select the account used for Travel Invoicing settlements. +- **Travel Invoicing payable account** – Select the payable account used for Travel Invoicing settlements. + +--- + +# FAQ + +## Which DualEntry accounts import as categories? + +Only active expense (including cost of goods sold), current asset, and fixed asset accounts from your DualEntry chart of accounts import as categories. + +The initial chart of accounts arrives enabled. After that, **Enable newly imported accounts** determines whether newly imported accounts arrive as enabled or disabled categories. + +## Why did my report fail to export to DualEntry? + +Out-of-pocket expenses export as vendor bills matched to the report submitter's email. If no DualEntry vendor matches that email, the export fails. Make sure the report submitter exists as a vendor in DualEntry with a matching email address. + +## Which DualEntry account does a company card use? + +By default, company card and Expensify Card expenses use the workspace **Company card account**. + +If **Configure exporting to multiple accounts** is enabled, a card can instead use a card program account or a per-card account. Per-card settings take precedence over the card program account, and the card program account takes precedence over the workspace **Company card account**. + +## Do I need a bill payment or settlement account? + +A bill payment account is required only when **Sync reimbursed reports** is enabled. A settlement account is required only when **Sync Expensify Card settlements** or **Sync Travel Invoicing settlements** is enabled. diff --git a/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md new file mode 100644 index 000000000000..e889edf5515c --- /dev/null +++ b/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md @@ -0,0 +1,96 @@ +--- +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. +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. + +Once connected, the integration imports: + +- Your chart of accounts as categories. +- Your DualEntry classifications as tags when enabled. +- Tax rates (when available). + +--- + +## Who can connect to DualEntry + +To connect DualEntry, you must: + +- Be a Workspace Admin with a workspace on the Control plan. +- Be able to generate an API key in DualEntry. + +--- + +## How to connect DualEntry to Expensify + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. In the **Connections** section next to **DualEntry**, select **Connect**. +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. + +**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. + +![The Accounting page Connections section with DualEntry shown and the Connect button visible]({{site.url}}/assets/images/DualEntry_connect.png){:width="100%"} + +--- + +## What happens after you connect to DualEntry + +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 **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. + +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). + +--- + +## How to reconnect DualEntry after an authentication error + +If the saved API key becomes invalid, Expensify displays an error on the DualEntry connection. To reconnect to DualEntry: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. Next to the DualEntry connection, select the three dots **(⋮)**. +4. Select **Reconnect**. +5. Enter a valid DualEntry API key and select **Confirm**. + +--- + +# FAQ + +## Where do I generate my API key? + +Generate the API key in DualEntry, then paste it into the **API key** field during setup. The connection window includes instructions for generating an API key in DualEntry. + +## Is my API key secure? + +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 do I see an invalid credentials error after connecting? + +If your DualEntry API key is rejected or later becomes invalid, the connection shows an invalid credentials state and stops syncing. Generate a new API key in DualEntry and reconnect to restore the connection. + +## Why don't I see tax rates after connecting? + +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? + +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. + +## How can I disconnect DualEntry from a workspace? + +You can disconnect DualEntry from the **Accounting** page. Select the three dots **(⋮)** next to DualEntry and select **Disconnect**. Disconnecting DualEntry stops future synchronization and removes the connection from the workspace. diff --git a/docs/articles/new-expensify/connections/dualentry/_meta.yml b/docs/articles/new-expensify/connections/dualentry/_meta.yml new file mode 100644 index 000000000000..500580055e8c --- /dev/null +++ b/docs/articles/new-expensify/connections/dualentry/_meta.yml @@ -0,0 +1 @@ +title: DualEntry diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md new file mode 100644 index 000000000000..d654711a7476 --- /dev/null +++ b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md @@ -0,0 +1,67 @@ +--- +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. +--- + +# Connect to Intuit Enterprise Suite + +Connect Intuit Enterprise Suite to your Expensify workspace to sync your accounting data. This article walks you through connecting Intuit Enterprise Suite and completing the initial setup. + +Once connected, the integration imports: + + - Your chart of accounts as categories. + - Your dimensions, like Departments, Projects, and Classes, as tags. + - Tax rates (when enabled). + +--- + +## 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. +- Have **Accounting** enabled on your workspace, under **More features**. +- Have Intuit Enterprise Suite login credentials. + +--- + +## How to connect Intuit Enterprise Suite to your workspace + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. In the **Connections** section next to **Intuit Enterprise Suite**, select **Connect**. +4. Log in with your Intuit credentials when prompted. + +--- + +## What happens after you connect Intuit Enterprise Suite + +After the connection is established, the **Connections** section updates to show your connected Intuit Enterprise Suite integration, including: + +- The connection status and last sync timestamp. +- The selected Entity. +- The Import, Export, and Advanced configuration settings. + +![Workspace Accounting page showing Intuit Enterprise Suite connected]({{site.url}}/assets/images/Accounting_IES.png){:width="100%"} + +--- + +# FAQ + +## Why do I see an upgrade message when I click Connect for Intuit Enterprise Suite? + +The Intuit Enterprise Suite integration is only available on the Control plan. On a Collect plan workspace, you'll be prompted to upgrade before you can connect to Intuit Enterprise Suite. + +## 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. + +## Can I connect both QuickBooks Online and Intuit Enterprise Suite to the same workspace? + +No. A workspace can have only one accounting connection at a time. If QuickBooks Online is already connected, you are prompted to disconnect it before Intuit Enterprise Suite connects. + +## Should I connect QuickBooks Online instead? + +Connect **QuickBooks Online** if your Intuit subscription is Simple Start, Essentials, or Essentials Plus. Learn how to [connect to QuickBooks Online](/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online). 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 30c7b0a2b020..7f22ed3ba93f 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 @@ -9,7 +9,7 @@ order: 1 To use the QuickBooks Online connection, you must have a **QuickBooks Online account** and an Expensify **Collect** plan. -The QuickBooks Online connection also works with **Intuit Enterprise Suite**. +**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. @@ -56,7 +56,7 @@ To connect your workspace: 3. Click **More features** 4. Scroll to **Integrate** and enable the **Accounting** toggle 5. Click **Accounting** in the left-hand menu -6. Click **Set up** next to **QuickBooks Online** +6. Click **Connect** next to **QuickBooks Online** 7. Log in with your Intuit credentials when prompted This will enable the connection between QuickBooks Online and import your settings into Expensify. diff --git a/docs/articles/Unlisted/Manage-Vendor-Matching-for-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md similarity index 100% rename from docs/articles/Unlisted/Manage-Vendor-Matching-for-Intacct.md rename to docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md diff --git a/docs/articles/new-expensify/connections/xero/Configure-Xero.md b/docs/articles/new-expensify/connections/xero/Configure-Xero.md index 915dd2875e16..1af8144dd29c 100644 --- a/docs/articles/new-expensify/connections/xero/Configure-Xero.md +++ b/docs/articles/new-expensify/connections/xero/Configure-Xero.md @@ -57,6 +57,7 @@ Export settings determine how Expensify sends data to Xero. - **Export Consolidated Travel Billing expenses as** – If Consolidated Travel Billing is enabled, travel expenses export as bank transactions. Click to select the **Travel payable account** where travel expenses will post in Xero. - **Export company card expenses as** – All company card expenses are exported as bank transactions (not editable). - **Xero bank account** – Select the account to post non-reimbursable expenses. +- **Default supplier** – Select a supplier to apply to all company card (non-reimbursable) transactions on export to Xero. --- diff --git a/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md b/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md index 91bfca67d865..518b6c76489d 100644 --- a/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md +++ b/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md @@ -1,8 +1,8 @@ --- title: Claim and Verify a Domain description: Learn how to claim and verify a private domain in New Expensify to enable SAML login and unlock enhanced security features. -internalScope: Audience is Domain Admins and IT admins. Covers claiming a domain and verifying domain ownership via DNS. Does not cover SAML configuration details, Domain Members management, Domain Groups, or login troubleshooting. -keywords: [New Expensify, claim domain, verify domain, private domain, domain settings, enhanced security, SAML, domain admin, domain verification, DNS TXT record, verify domain for SAML, how to verify domain, SAML setup] +internalScope: Audience is Domain Admins and IT admins. Covers claiming a domain, requesting admin access to a domain someone else already set up, and verifying domain ownership via DNS. Does not cover SAML configuration details, Domain Members management, Domain Groups, or login troubleshooting. +keywords: [New Expensify, claim domain, verify domain, private domain, domain settings, enhanced security, SAML, domain admin, domain verification, DNS TXT record, verify domain for SAML, how to verify domain, SAML setup, domain already set up, domain exists, ask for admin access, request admin access, domain already exists in your account] --- If you have a private domain (e.g., yourcompany.com), you can claim and verify it in Expensify to manage employee permissions and enable additional security features. @@ -42,10 +42,38 @@ Depending on your email setup: - **If you're not using an email from that domain**: You'll see an error. You can't claim domains that don't match your email address. - **If your email is from that domain but not verified**: You’ll be prompted to verify your login using a security code. -- **If the domain is already claimed**: You’ll need to coordinate with an existing admin to request that your role be updated to Domain Admin. +- **If someone else already set the domain up**: You’ll land on the **Domain exists** page, where you can ask that domain’s admins for admin access. +- **If the domain is already in your account**: You’ll see the error **This domain already exists in your account.** under the **Domain name** field. Go back to the **Domains** tab and select the domain there instead. After the domain is successfully claimed, the domain will appear on the **Domains** tab in the **Not verified** state. +## How to request admin access to a domain someone else set up + +A private domain can only be set up once. If another Expensify account already set up your domain, you can ask that domain’s admins to make you a Domain Admin. + +1. From the left-hand menu, select **Workspaces**, then select the **Domains** tab. +2. Click **New domain**. + - If you have already claimed one or more domains, click **New** in the upper right to add a new domain. +3. Enter the name of the private domain (e.g., `yourcompany.com`) in the form. +4. Click **Continue**. +5. On the **Domain exists** page, click **Ask for admin access**. + +To leave without sending a request, click **Never mind** instead. Both buttons return you to the **Domains** tab. + + + +## What happens after you ask for admin access + +- Your request goes to the existing Domain Admins for that domain. An admin has to add you before you can manage it. +- The domain does **not** appear on your **Domains** tab while the request is pending, because you don’t have access to it yet. +- If you start the flow again for the same domain before an admin responds, the **Domain exists** page shows a disabled **Request sent** button so you don’t send a duplicate request. + +[Learn how to add and manage Domain Admins](/articles/new-expensify/domains/Domain-Admins) to see what you'll be able to do once an admin adds you. + ## How to verify a domain To unlock advanced security settings and enable SAML, the domain must be verified through a DNS record. @@ -68,6 +96,18 @@ Once verified, you’ll see a confirmation message and your domain will be marke Yes. Verifying the domain is required to enable and enforce SAML login. Without verification, the SAML configuration option will remain locked. +## Why does Expensify say the domain is already set up? + +Each private domain can only be set up once, and someone at your company got there first. Click **Ask for admin access** on the **Domain exists** page to request that a Domain Admin add you. + +## Why does the domain I requested access to not show on my Domains tab? + +Requesting access doesn't give you access. The domain only appears on your **Domains** tab after an existing Domain Admin adds you as a Domain Admin. + +## Why does the button say Request sent? + +You already asked for admin access to that domain and no admin has responded yet. The button is disabled so you don't send a duplicate request. + ## Can I verify multiple domains? Yes, you can claim and verify multiple domains if your organization operates under more than one. Each must be verified through its own DNS record. diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md index 54cb44213c1b..15a85d1a7e4f 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md @@ -45,7 +45,7 @@ Link a U.S. business bank account to pay the card balance: 2. Click **Issue new card** 3. Choose an existing account or [add a new bank account](https://help.expensify.com/articles/new-expensify/expenses-and-payments/Connect-a-Business-Bank-Account) as the settlement account. -If you already have an Expensify Card feed on another workspace, you can select that existing feed instead of connecting a new bank account. This links the same card feed to the current workspace, so you can manage cards across multiple workspaces with a single settlement account. +If you already have an Expensify Card feed on another workspace, you can select that existing feed instead of connecting a new bank account. This links the same card feed to the current workspace, so you can manage cards across multiple workspaces with a single settlement account. Linking an existing feed works even when the current workspace uses a currency other than USD — the currency requirement only applies when enrolling a brand-new card program. ![Click the issue card button]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_02.png){:width="100%"} @@ -194,6 +194,10 @@ You’ll need a U.S. business bank account registered to a U.S.-incorporated bus Yes. You can link a single Expensify Card feed to multiple workspaces using the same settlement account. When issuing cards on a new workspace, select an existing feed that is already connected to another workspace. Each workspace shows only its own members in the card list, but all workspaces share the same card feed and settlement account. +## Can I link an existing card feed to a workspace that isn't set to USD? + +Yes. Linking an existing Expensify Card feed to another workspace is not restricted by that workspace's currency, so you can link a USD feed to a workspace set to any currency. Enrolling a brand-new card program still requires a supported workspace currency — USD in the US, or GBP or EUR in the UK and EU. If you don't have a feed to link and the workspace uses an unsupported currency, update the workspace to a supported currency first. + ## Can an employee have multiple cards? - **Yes**: Employees can have unlimited **virtual cards** (e.g. for trips or subscriptions). diff --git a/docs/articles/new-expensify/expensify-card/Troubleshoot-Expensify-Card-issues.md b/docs/articles/new-expensify/expensify-card/Troubleshoot-Expensify-Card-issues.md index b64e886f05fd..aef0616f0034 100644 --- a/docs/articles/new-expensify/expensify-card/Troubleshoot-Expensify-Card-issues.md +++ b/docs/articles/new-expensify/expensify-card/Troubleshoot-Expensify-Card-issues.md @@ -99,8 +99,9 @@ To check for bank account issues, navigate to **Workspaces > [Workspace Name] > The workspace limit refreshes automatically after the workspace's regular Expensify Card settlement. To refresh it earlier: 1. Go to **Workspaces > [Workspace Name] > Expensify Card** -2. Click **Settle Balance** to trigger an early settlement. -3. Once the settlement clears, the workspace limit will be refreshed. +2. Click **Settle balance**. +3. In the confirmation window, click **Settle balance** again to confirm the early settlement. +4. Once the settlement clears, the workspace limit will be refreshed. diff --git a/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md b/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md index e6cddbe38ea9..67a0e3b2587e 100644 --- a/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md +++ b/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md @@ -1,7 +1,7 @@ --- title: View and Reconcile Expensify Card Expenses description: Learn how to view, verify, and export Expensify Card expenses by matching settlement withdrawals to grouped transactions in Expensify. -keywords: [New Expensify, Expensify Card reconciliation, reconcile Expensify Card withdrawals, view Expensify Card expenses, verify Expensify Card totals, export Expensify Card expenses, withdrawal-based reconciliation, Workspace Admin] +keywords: [New Expensify, Expensify Card reconciliation, reconcile Expensify Card withdrawals, view Expensify Card expenses, verify Expensify Card totals, export Expensify Card expenses, withdrawal-based reconciliation, Expensify Card current balance transactions, view transactions, Workspace Admin] internalScope: Audience is Workspace Admins reconciling Expensify Card activity. Covers the withdrawal-based reconciliation workflow under Reconciliation > Expensify Card. Does not cover company card statement matching, reimbursement reconciliation, or manual export-based reconciliation. --- @@ -34,6 +34,16 @@ Workspace Admins can reconcile Expensify Card expenses when the Expensify Card i --- +## How to View the Transactions That Make Up Your Expensify Card Current Balance + +The **Current balance** on the Expensify Card page reflects the card expenses that have not yet been withdrawn from your settlement account. You can open a filtered list of those transactions directly from the Expensify Card page. + +1. From the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [Workspace Name] > Expensify Card**. +2. Under the **Current balance** amount, click **View transactions**. +3. Review the filtered results, which show the card expenses counted toward the current balance. + +--- + ## How to Verify Expensify Card Withdrawal Totals Against Your Bank Statement 1. On your bank statement, locate Expensify Card settlement charges. diff --git a/docs/articles/new-expensify/getting-started/Submit-Workspace.md b/docs/articles/new-expensify/getting-started/Submit-Workspace.md index 9ee3f5fa0e46..4293957eb9c6 100644 --- a/docs/articles/new-expensify/getting-started/Submit-Workspace.md +++ b/docs/articles/new-expensify/getting-started/Submit-Workspace.md @@ -1,7 +1,7 @@ --- title: Use a Free Submit Workspace description: Learn how to use a free Submit workspace in New Expensify to code expenses, submit them to an approver, and track which reports have been paid. -keywords: [New Expensify, Submit workspace, free plan, get paid back, submit expenses, approver, Editor role, Received Payment] +keywords: [New Expensify, Submit workspace, free plan, get paid back, submit expenses, approver, Editor role, Received Payment, Submit workspace email link, workspace I didn't create] internalScope: Audience is employees who want to get reimbursed by their employer. Covers creating a Submit workspace, submitting expenses to an approver, and tracking payment. Does not cover Collect or Control workspace configuration. --- @@ -21,7 +21,7 @@ Premium features are not included in a Submit workspace. To use approvals, payme ## How to create a Submit workspace -You can create a Submit workspace when you create your Expensify account or on the Expensify pricing and plans page. +You can create a Submit workspace when you create your Expensify account, on the Expensify pricing and plans page, or from a link Expensify emails you. When creating your Expensify account: @@ -35,6 +35,18 @@ If you already have an Expensify account: 2. Find the Submit plan. 3. Click **Get started**. +If Expensify emails you a link to start submitting expenses: + +1. Click the link in the email. +2. Sign in to Expensify if you aren't signed in already. +3. A Submit workspace is created for you automatically, and you land on the **Expenses** page in the **Spend** tab. + +The email link doesn't create a duplicate workspace: + +- If you already belong to a workspace where you can submit expenses, no new workspace is created and nothing else about your account changes. +- If you haven't finished setting up your Expensify account yet, the link takes you through the normal account setup instead, where you can select **Submit expenses to my employer**. + +Clicking the link again while you still have the workspace doesn't create a second one. If you delete the workspace and then click the link again, a new Submit workspace is created. ## What happens after you create a Submit workspace @@ -102,7 +114,7 @@ To submit a report as a PDF: 6. Your approver can access the report to approve in Expensify by clicking the **Approve or Pay** button on the PDF or scanning the QR code on the PDF. 7. If your report has been paid outside of Expensify, you can change the status from Outstanding to Paid by opening the report, selecting **More** and **Received Payment**. -Your choice is sticky: future reports default to the last method you used, including when you submit from the **Spend** page. +Your choice is sticky: future reports default to the last method you used, including when you submit from the **Spend** tab. --- @@ -157,6 +169,10 @@ If they don't upgrade: No. Once a workspace is upgraded to Collect or Control, it can't be downgraded back to Submit. To use a Submit workspace again, delete the upgraded workspace and create a new Submit workspace. +## Why do I have a Submit workspace I didn't create? + +If you clicked a link in an email from Expensify inviting you to start submitting expenses, a Submit workspace was created for you so you could start submitting right away. There's no cost to keep it. If you don't want it, [learn how to delete a workspace](/articles/new-expensify/workspaces/Delete-a-Workspace). After you delete it, don't click the same email link again — that creates a new Submit workspace. + ## Why can't I see other members' expenses in my Submit workspace? Editors can configure the workspace, but can't see other members' submitted reports. This keeps everyone's expense data private while still allowing the group to share categories, tags, and other configurations. diff --git a/docs/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Explore-whats-changed-in-New-Expensify.md b/docs/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Explore-whats-changed-in-New-Expensify.md index 57acf1d33e3e..235c34c70649 100644 --- a/docs/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Explore-whats-changed-in-New-Expensify.md +++ b/docs/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Explore-whats-changed-in-New-Expensify.md @@ -9,7 +9,11 @@ internalScope: Audience is Expensify members transitioning from Expensify Classi New Expensify introduces new capabilities and expands several workflows that were more limited in Expensify Classic. This article highlights some of the most notable changes and links to detailed help articles where you can learn more. -If you're still getting familiar with navigation or looking for a side-by-side mapping of familiar Classic workflows, see [Coming from Expensify Classic for Submitters and Approvers](/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Coming-from-Expensify-Classic-for-Submitters-and-Approvers) or [Coming from Expensify Classic for Workspace Admins](/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Coming-from-Expensify-Classic-for-Workspace-Admins). +If you're still getting familiar with navigation or looking for a side-by-side mapping of familiar Classic workflows, see: +- [Coming from Expensify Classic for Submitters and Approvers](/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Coming-from-Expensify-Classic-for-Submitters-and-Approvers) +- [Coming from Expensify Classic for Workspace Admins](/articles/new-expensify/getting-started/Transitioning-to-New-Expensify/Coming-from-Expensify-Classic-for-Workspace-Admins). + +You can also [register](/articles/new-expensify/getting-started/How-to-Access-Expensify-Training) to attend a live training webinar or watch a recording on demand. --- 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 new file mode 100644 index 000000000000..1c6782b3a412 --- /dev/null +++ b/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md @@ -0,0 +1,90 @@ +--- +title: Troubleshoot adding your work email during account setup +description: Learn what to do when Expensify can’t add your work email during account setup, including when the email is a domain controlled login for an existing Expensify account. +keywords: [New Expensify, work email, add work email, couldn’t add work email, domain controlled login, existing Expensify account, account setup, contact methods, private domain, closed work account] +internalScope: Audience is people who signed up with a personal address on a public domain and are asked What’s your work email? during account setup. Covers who sees the step, the verify and merge path for an ordinary existing account, the messages shown when the work email can’t be added, and how to add the email afterward as a contact method. Does not cover login troubleshooting for an existing account, domain claiming, or merging two existing accounts. +--- + +# Troubleshoot adding your work email during account setup + +When you create a new Expensify account with a personal email address, Expensify asks **What’s your work email?** Connecting your work email lets you forward receipts to receipts@expensify.com and join colleagues who already use Expensify. + +Adding a work email is optional. If Expensify can’t add the address you entered, you can finish setting up your account. If the address isn’t already a login on another Expensify account, you can add it later as a contact method. + +--- + +## Who sees the work email step + +- You signed up with a personal address on a public domain, such as gmail.com. If you signed up with your company address, Expensify doesn’t ask for a work email. +- The address you enter must be on a private company domain. Public domains aren’t accepted. +- The address you enter must be different from the email you signed up with. + +--- + +## What happens if the address is already an Expensify login + +If the address is a login on an ordinary Expensify account, Expensify shows **Verify your work email** and sends a security code to that address. Enter the code and Expensify merges the account you just created into the work account, then signs you in to it. + +Some existing accounts can’t be merged this way: accounts with Two-Factor Authentication (2FA) or SSO/SAML enabled, closed accounts, and domain controlled logins. Sign in to the existing account directly instead. + +--- + +## What the Couldn’t add work email screen means + +If the address you entered is a login on an existing Expensify account that your company controls, Expensify replaces the form with a screen titled **Couldn’t add work email** that reads: + +> [your work email] is a domain controlled login for an existing Expensify account. + +Your company’s Domain Admins manage that login, so it can’t also be added to the new account you’re setting up. Click **Got it** to continue setting up your account. + +To use that address, sign in to the existing account instead of the new one. If you can’t sign in to it, ask a Domain Admin at your company for access. Learn more about [what Domain Admins manage](/articles/new-expensify/domains/Domain-Admins). + +If Expensify couldn’t add the address for any other reason, the same screen reads **We couldn’t add [your work email]. Please try again later in Settings or chat with Concierge for guidance.** Click **Got it** to continue setting up your account, then add the address as a contact method later. + + + +--- + +## How to fix each work email message during account setup + +These messages appear under the work email field, and the form stays open. Correct the address and click **Add work email** again: + +- **Please enter a valid work email from a private domain e.g. mitch@company.com** — enter an address on your company’s own domain instead of a personal address. +- **Please enter a different email than the one you signed up with** — enter your work address, not the address you used to create the account. +- **We couldn’t add your work email as you appear to be offline** — reconnect to the internet, then submit the address again. +- **This login is an existing account with Two-Factor Authentication (2FA) enabled.** — the address is already an Expensify login that can’t be merged. Sign in to that account instead, or enter a different address. +- **This login is an existing account with SSO/SAML enabled.** — the address is already an Expensify login that authenticates through your company’s identity provider. Sign in to that account instead. +- **The work account associated with this email is closed.** — ask your company admin to reactivate the account, or enter a different address. + +--- + +## How to add your work email after account setup + +Add the address as a contact method on your account: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Account** > **Profile**. +2. Click **Contact methods**. +3. Click **New contact method**. +4. Enter the email address, then follow the prompts to verify it. + +This works for an address that isn’t a login on another Expensify account. If the address is a domain controlled login, or a login on an account with 2FA or SSO/SAML enabled, adding it as a contact method fails with **Failed to add this contact method. Please reach out to Concierge for help.** Sign in to that account instead, or chat with Concierge. + +--- + +# FAQ + +## Do I have to add a work email to finish setting up my account? + +No. Adding a work email is optional. On the **What’s your work email?** form, click **Skip** to continue to the next setup step. If you’re already on the **Couldn’t add work email** screen, click **Got it** instead. + +## 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. + +## Can I merge the existing account into my new account instead? + +No, the merge only runs the other way. When Expensify can merge the accounts, it moves the new account you just created into the work account and signs you in to that one. Two company accounts on private domains can’t be merged. Learn how to [merge two Expensify accounts](/articles/new-expensify/settings/Merge-Accounts). 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 index 9d77e1ab2255..b1c4df9e99d0 100644 --- 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 @@ -32,9 +32,7 @@ You can access Insights on both web and mobile. --- -# Available Insights - -## Spend over time +## How to use Spend over time Spend over time shows how total expenses change across a selected date range. @@ -48,7 +46,7 @@ You can use Spend over time to: [Learn more about the Spend over time report](https://help.expensify.com/articles/new-expensify/insights/View-the-Spend-over-time-report). -## Top Spenders +## How to use Top Spenders Top Spenders shows which members submitted the highest total expenses in the previous calendar month. @@ -63,7 +61,7 @@ You can use Top Spenders to: --- -## Top Categories +## How to use Top Categories Top Categories shows which expense categories had the highest total spend in the previous calendar month. @@ -77,7 +75,7 @@ You can use Top Categories to: --- -## Top Merchants +## How to use Top Merchants Top Merchants shows which merchants or vendors received the highest total payments in the previous calendar month. @@ -91,6 +89,20 @@ You can use Top Merchants to: --- +## 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: @@ -149,7 +161,7 @@ If you want to build your own grouped report: This allows you to automate recurring analysis without rebuilding the search each time. -[Learn how to use search operators and grouping](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-search-operators) +[Learn how to use search operators and grouping](/articles/new-expensify/reports-and-expenses/Using-search-operators) --- diff --git a/docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md b/docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md new file mode 100644 index 000000000000..11e1e49e43e9 --- /dev/null +++ b/docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md @@ -0,0 +1,72 @@ +--- +title: View the Violations by submitter report +description: Learn how Workspace Admins and Auditors can use the Violations by submitter report to see which members submitted the most expenses with rule violations. +keywords: [New Expensify, Violations by submitter, policy violations, compliance, submitted violations, has:submitted-violation, violation, Workspace Admin, Auditor, Rules, insight, virtual CFO, spend insights, broken rule, rule violation, out of compliance] +internalScope: Audience is Workspace Admins and Auditors on Control workspaces with Rules enabled. Covers using the Violations by submitter suggested search to see who submitted the most expenses with violations. Does not cover configuring Rules, approver-stage violations, or building custom reports. +--- + +# View the Violations by submitter report + +The Violations by submitter report shows which members submitted the most expenses with violations last month. Use it to identify repeated violations, spot compliance patterns, and decide where guidance or follow-up may be needed. + +The report is a pre-built suggested search and is available only for Control workspaces with Rules enabled. + +If Rules are not enabled, [learn how to set up Workspace Rules](/articles/new-expensify/workspaces/Workspace-Rules). + +--- + +## Who can use the Violations by submitter report + +Workspace Admins and Auditors can use the Violations by submitter report on web and mobile when: + + - The workspace is on the Control plan. + - Rules are enabled for the workspace. + - The workspace has two or more members. + +--- + +## How to view the Violations by submitter report + +**Web:** +1. Use the navigation tabs on the left and select **Spend**. +2. Under **Insights**, select **Violations by submitter**. + +**Mobile:** +1. From the navigation tabs on the bottom, tap **Spend**. +2. Tap the hamburger menu in the top-left corner, then select **Violations by submitter**. + +The report groups last month’s expenses that had a violation when submitted by the member who submitted them. + +--- + +## What information the Violations by submitter report displays + +The Violations by submitter report groups expenses that had a violation when submitted by the member who submitted them. + +Each row represents a member and shows: + + - **From** – the member who submitted the expenses + - **Expenses** – the number of expenses with violations the member submitted + - **Total** – the total amount of those expenses + +Select a row to view the member’s individual expenses. The **Violations** column shows the violations associated with each expense, such as a missing receipt or an amount over a workspace limit. If an expense has multiple violations, they are separated by commas. + +![Violations by submitter report showing expanded row]({{site.url}}/assets/images/violations_by_submitter.png){:width="100%"} + +--- + +# FAQ + +## Can I customize the Violations by submitter report? + +You can change filters such as the date range, workspace, or member to explore the results further. Because Violations by submitter is a built-in suggested search, you can’t save changes directly to the suggested search. + +To create your own custom search, [learn how to use search operators to filter and analyze spend](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze). + +## Can I export the Violations by submitter report? + +Not directly — the Violations by submitter 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. + +## How is the Violations by submitter report calculated? + +The report uses expenses from the previous calendar month that had a violation at the time they were submitted, groups them by the member who submitted them, and shows the top 10 members by the number of expenses with violations. diff --git a/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md b/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md index 6de767986482..d47ba69dbcbd 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md +++ b/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md @@ -29,6 +29,20 @@ The selected attendees are added to the expense. --- +## How to add attendees to several expenses at once + +1. From the navigation tabs (on the left on web, and at the bottom on mobile), select **Spend > Expenses**. +2. Select 2 or more expenses. +3. Choose **Selected**, then select **Edit multiple**. +4. Select **Attendees**, choose the attendees, and select **Save**. +5. Select **Save** again to apply your changes to every selected expense. + +The attendees you select are added to every selected expense. + +Select 2 or more expenses, click **Selected**, and choose **Edit multiple** to set the same attendees on all of them in one step. Learn how to [edit multiple expenses at once](/articles/new-expensify/reports-and-expenses/Edit-Expenses). + +--- + ## What happens after you add attendees to an expense - The total expense is automatically split evenly across all attendees diff --git a/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md b/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md index 3705c82cbe48..facde07d8835 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md @@ -1,8 +1,8 @@ --- title: Attach and edit receipts on expenses description: Learn how to properly attach, verify, and troubleshoot receipts on expenses in New Expensify for audits and accounting. -keywords: [New Expensify, attach receipt, verify receipt, missing receipt, expense attachments, audit trail, receipt not showing, crop receipt, rotate receipt, trim receipt, receipt upload failed, receipt upload error, save receipt, delete expense, ereceipt, ereceipts, e-receipt, electronic receipt, digital receipt, auto-generated receipt] -internalScope: Audience is Expensify members and Workspace Admins. Covers attaching, replacing, and cropping receipts on expenses. Does not cover SmartScan configuration or receipt forwarding via email. +keywords: [New Expensify, attach receipt, verify receipt, missing receipt, expense attachments, audit trail, receipt not showing, crop receipt, rotate receipt, trim receipt, receipt upload failed, receipt upload error, save receipt, delete expense, ereceipt, ereceipts, e-receipt, electronic receipt, digital receipt, auto-generated receipt, add additional receipt, who can add a receipt, add additional receipt icon missing, expand receipt] +internalScope: Audience is Expensify members and Workspace Admins. Covers attaching, replacing, and cropping receipts on expenses, and who is allowed to add a receipt versus only view one. Does not cover SmartScan configuration or receipt forwarding via email. --- Make sure your receipts are attached correctly to individual expenses for audit and accounting compliance. This guide explains how to attach, verify, and troubleshoot receipts in New Expensify — and when to use report comments for additional documentation. @@ -10,9 +10,12 @@ Make sure your receipts are attached correctly to individual expenses for audit # How to Attach and Verify Receipts on Expenses ## Who can attach receipts to an expense -- **Attach or replace a receipt**: The member who created the expense, a Workspace Admin, or the current approver. +- **Attach, replace, or add an additional receipt**: The member who created the expense, a Workspace Admin, or the current approver. +- **Open a receipt to view it**: Anyone who can see the expense, including members who cannot edit it. - **Edit a receipt in an Approved or Paid report**: Requires the report to be unapproved first (see below). +On an expense in a personal chat, such as a 1:1 or group conversation, only the member who created the expense can attach, replace, or add a receipt. Everyone else in the conversation can still open the receipt to view it. + --- ## Where to Attach Receipts for Audits and Exports @@ -69,19 +72,21 @@ You can rotate a receipt to correct its orientation. Rotation is available for u Each expense has one primary receipt, but you can attach additional receipt images directly on the expense for situations where multiple receipts are needed — for example, the front and back of a paper receipt, or both the credit card slip and the itemized breakdown. +The **Add additional receipt** icon is only shown to members who can edit the expense: the member who created it, a Workspace Admin, or the current approver. Everyone else who can see the expense is shown only the **Expand** icon, which opens the receipt full screen to view it. + **On web:** 1. In the navigation tabs on the left, click **Spend**. 2. Click on the expense you want to add an additional receipt to. 3. Hover over the receipt image. -4. Click the icon in the upper right corner of the image to **Add additional receipts**. +4. Click the **Add additional receipt** icon in the upper right corner of the image. 5. Choose a receipt image to upload. **On mobile:** 1. In the navigation tabs on the bottom, tap **Spend**. 2. Tap the expense you want to add an additional receipt to. -3. Tap the icon in the upper right corner of the image to **Add additional receipts**. +3. Tap the **Add additional receipt** icon in the upper right corner of the image. 4. Take a photo or choose a file from your device. **Note:** Only the first (primary) receipt image is included in exports or downloadable reports. Additional receipt images are visible within Expensify on the expense itself. @@ -122,7 +127,10 @@ It was likely uploaded in the report comments. Receipts must be attached directl No. Each expense must have its own receipt. You can upload the same file more than once if needed. ## Can someone else upload a receipt to my expense? -Only the expense creator or a Workspace Admin can attach a receipt an expense. +Only the member who created the expense, a Workspace Admin, or the current approver can attach a receipt to an expense. Other members who can see the expense can open the receipt to view it, but they cannot add to it. + +## Why can’t I see the Add additional receipt icon on an expense? +The **Add additional receipt** icon is only shown to members who can edit the expense. You will not see it if you did not create the expense and you are not a Workspace Admin or the current approver. The **Expand** icon stays available so you can still open the receipt and view it. ## Will additional receipt images appear in exports or downloadable reports? @@ -140,4 +148,4 @@ If the upload fails (for example, due to a lost network connection), you'll see - **Delete expense** — removes the failed expense entirely. The receipt will be lost if you haven't saved it first. ## Can I attach both the card and itemized restaurant receipts? -Yes. Attach the **card receipt** as the primary receipt on the expense, then use **Add additional receipts** to attach the **itemized receipt** directly on the same expense. +Yes. Attach the **card receipt** as the primary receipt on the expense, then use **Add additional receipt** to attach the **itemized receipt** directly on the same expense. diff --git a/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md index be4d6df6a668..40f597487be0 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md @@ -1,8 +1,8 @@ --- title: Edit Expenses description: Learn how to edit individual expenses, and edit multiple expenses at once. -keywords: [bulk edit expenses, edit multiple expenses Expensify, update expenses in bulk, modify multiple expenses, inline editing, edit expense inline] -internalScope: Audience is all members. Covers editing expenses individually and in bulk editing multiple expenses at once. Does not cover creating, submitting or reopening reports. +keywords: [bulk edit expenses, edit multiple expenses Expensify, update expenses in bulk, modify multiple expenses, inline editing, edit expense inline, bulk edit attendees, add attendees to multiple expenses] +internalScope: Audience is all members. Covers editing expenses individually and in bulk editing multiple expenses at once, including adding attendees in bulk. Does not cover creating, submitting or reopening reports. --- # Edit Expenses @@ -45,6 +45,21 @@ Use edit multiple to update the same field across several expenses simultaneousl **Note:** If editing is not allowed on a field for any of the selected expenses, that field will not be available for editing. +--- + +## How to add attendees to multiple expenses at once + +**Attendees** appears in the **Edit multiple expenses** panel when every selected expense belongs to a Workspace on the Control plan that has **Attendee tracking** enabled. [Learn how to enable Workspace Rules](/articles/new-expensify/workspaces/Workspace-Rules). + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Expenses**. +2. Select 2 or more expenses using the checkboxes. +3. Click **Selected**. +4. Choose **Edit multiple**. +5. Click **Attendees**. +6. Select attendees from **Recents**, **Contacts**, or enter a name, email, or phone number. +7. Click **Save**. +8. Click **Save** again to apply your changes to every selected expense. + --- ## What expense fields can be edited @@ -70,14 +85,30 @@ On Draft and Outstanding reports, you can edit: # FAQ +## Can I edit expenses on Draft and Outstanding reports? + +Yes, expenses on Draft and Outstanding reports can still be edited. + +## Can I edit expenses on Approved reports? + +Yes. However, the report submitter will need to ask the approver to unapprove the report before the expenses can be edited again. + ## Can I edit expenses on Paid and Done reports? -No, Paid or Done reports are locked and cannot be reopened for editing. +No, expenses on Paid and Done reports are locked and cannot be reopened for editing. ## Why are some fields not editable in the Edit multiple panel? If a field is restricted for any selected expense (for example, amount on a card expense), it will be disabled in the **Edit multiple** panel. +## Why don't I see the Attendees field in Edit multiple? + +**Attendees** only appears when every expense you selected sits on a Workspace that is on the Control plan and has **Attendee tracking** enabled under **Rules**. It is not available for invoices or for personal expenses that aren't on a Workspace. + +## Does editing attendees in bulk keep the attendees already on my expenses? + +No. The attendees you select replace the ones already on selected expense. + ## Why can't I see the Edit multiple option? **Edit multiple** only appears when you select 2 or more Unreported, Draft, or Outstanding expenses that have at least one editable field. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md index 2a6745b4473a..fa9f36cbf0f9 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md @@ -84,6 +84,10 @@ Yes. Select **Export current view** to download a CSV that matches the columns c If all selected expenses are deleted, only the **Basic export** template is available. Other export templates require a report, and deleted expenses are not associated with any report. +## What happens if I refresh the page while my receipts are preparing? + +Your download keeps preparing, and the status window comes back on its own. Expensify tracks each download that is still preparing or ready to save, so when you refresh the page or close and reopen Expensify, the status window reappears with the current status. Select **Download file** once it shows **Your file is ready!**. + ## Why don't I see Download receipts for my selected expenses? **Download receipts** only appears when at least one selected expense has a receipt attached. If none of the selected expenses have receipts, the option is hidden. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md index 80637b4c3040..22f8864830a6 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md @@ -60,6 +60,13 @@ Expensify offers pre-built export templates, or you can build your own custom ex **Note** Currently, it's not possible to build custom export templates on New Expensify, they can only be created on Expensify Classic. However, once built they will be available on New Expensify when exporting reports. [Learn how to build a custom export template in Expensify Classic](/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#create-a-custom-export-template). +When you export, the file either downloads immediately or is prepared in the background: + +- **Immediate download** – **Basic export** and **Export current view**, when you export selected reports or use **Select all on this page**. +- **Prepared in the background** – When you use **Select all** to export all matching reports, or select **All Data - expense level**, **All Data - report level**, or a **Custom template**. + +While an export is being prepared, either wait for it to download automatically or select **Send me the file when it's ready** to close the export window and receive the file later. When it's ready, Expensify delivers it through Concierge and by email. If the export can't be generated, an error appears in the export window, or is delivered through Concierge if **Send me the file when it's ready** was selected. + ## How to download a single report as a PDF 1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. @@ -111,12 +118,24 @@ You can download the receipts on several reports at once in a single ZIP file. ## Where do I find the exported CSV file? -For the Basic Export template, the file downloads directly to your device. For all other templates, Concierge sends the file to you in a direct message. Open your Concierge chat in the **Inbox** to find it. +**Basic export** and **Export current view** download directly to your device. Other templates — and any export started with **Select all** — are prepared in the background; the file then downloads automatically, or is delivered through Concierge and by email if you selected **Send me the file when it's ready**. ## What happens if some reports fail to download as PDFs? When you download multiple reports as PDFs, a message tells you how many of the selected reports were exported. If any reports could not be generated, open your Concierge chat in the **Inbox** to see which reports failed. +## What happens if I refresh the page while my download is preparing? + +Your download keeps preparing, and the status window comes back on its own. Expensify tracks each download that is still preparing or ready to save, so when you refresh the page or close and reopen Expensify, the status window reappears with the current status. Select **Download file** once it shows **Your file is ready!**. + +This applies to the downloads that show a preparing status window, whether you start them from the **Reports** page or from a report's **More** menu: **Export** with any template other than **Basic export**, **Download receipts**, and **Download as PDF** when you selected more than one report. + +It does not apply to: + +- **Basic export**, which downloads straight to your device without a status window. +- **Download as PDF** for a single report, which uses its own window that does not reappear after a refresh. Start the download again to get a new PDF. +- **Send me the file when it's ready** — Concierge sends that file to you in a chat message instead, so no status window reappears. + ## Why don't I see Download receipts on a report? **Download receipts** only appears when at least one expense has a receipt attached. In a single report's **More** menu, it appears when that report has an expense with a receipt. When you select multiple reports and open **Selected**, it appears only if at least one selected report has an expense with a receipt. If none of the expenses have receipts, the option is hidden. diff --git a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md index 81d9f1ebc9c1..b1acdffcb756 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md @@ -127,6 +127,10 @@ Ensure your workspace is connected to a supported accounting platform. [Click he A status window appears while your file is prepared, and the file downloads automatically when it’s ready (if it doesn’t, use the **Download file** button). If you’d rather not wait, click/tap **Send me the file when it’s ready** and **Concierge** will send it to you in a chat message. +## What happens if I refresh the page while my file is preparing? + +Your file keeps preparing, and the status window comes back on its own. Expensify tracks each download that is still preparing or ready to save, so when you refresh the page or close and reopen Expensify, the status window reappears with the current status. Click **Download file** once it shows **Your file is ready!**. This does not apply when you choose **Send me the file when it’s ready** — **Concierge** sends that file to you in a chat message instead. + ## Can I export expenses or reports in bulk? - **CSV export**: Yes, select multiple or all items. diff --git a/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md index 4cf13a5c7091..03cfe432a2bd 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md @@ -1,7 +1,7 @@ --- title: Split Expenses description: Learn how to create and submit expenses to a workspace or individual using SmartScan, manual entry, or distance tracking in Expensify. -keywords: [New Expensify, split expenses, split by percentage, split by day, split evenly, edit split, revert split, expense allocation, SmartScan, mileage, distance expense, validation rules, remove split, modify split, unreported expenses, split unreported] +keywords: [New Expensify, split expenses, split by percentage, split by day, split evenly, edit split, revert split, expense allocation, SmartScan, mileage, distance expense, validation rules, remove split, modify split, unreported expenses, split unreported, split reimbursable, reimbursable toggle missing, cash expense default] internalScope: Audience is submitters, approvers and admins. Covers how to split, edit, and revert expenses in New Expensify using the Split feature. Does not cover Classic behavior. --- @@ -113,6 +113,10 @@ Splits are locked if they're on a report that’s approved, reimbursed, or close Yes. Each split is treated as a regular expense and follows all workspace requirements. +## Why don’t I see the Reimbursable toggle on a split? + +The workspace’s **Cash expense default** rule is set to **Always reimbursable** or **Always non-reimbursable**. Those options fix the reimbursable value for every cash expense, so each split is created with the workspace default and the **Reimbursable** toggle is hidden. Imported company card expenses are always non-reimbursable, so their splits don’t show the toggle either. [Learn how to configure Workspace Rules](/articles/new-expensify/workspaces/Workspace-Rules). + ## Can I split a distance or mileage expense? Yes. You can split distance expenses just like any other expense. However, total distance must not exceed the original value, and rows cannot be split by percentage or date. 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 cd7388a513c3..2e21a035ddfb 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 @@ -40,6 +40,7 @@ Use these core rules to create your searches: - Combine fields for **AND** conditions: `amount>50 status:approved`. - Use `-` to exclude results: `-has:receipt`. - Use quotes for exact phrases: `description:"team lunch"`. +- Wrap a value in quotes when the value itself contains a space or a comma, so it is read as one value instead of two OR conditions: `workspace:"Acme, Inc"`. - Relative dates are supported: `date:this-week`. - Start typing after `:` to see autocomplete suggestions. @@ -52,11 +53,11 @@ Use these operators to refine results across different types: | **Syntax** | **Description** | **Example** | |------------------|-------------------------------------------------------------------------------|------------------------------| | `type:` | Filter by object type (`expense`, `chat`, `trip`, `task`) | `type:expense` | -| `workspace:` | Filter by workspace name (wrap in quotes if the name has spaces) | `workspace:"Acme Inc."` | +| `workspace:` | Filter by workspace name (wrap in quotes if the name has spaces or commas) | `workspace:"Acme, Inc"` | | `from:` | Filter by sender (email, phone, display name, or `me`) | `from:alice@acme.com` | | `to:` | Filter by recipient (email, phone, display name, or `me`) | `to:me` | -**Note:** Quotes are required when filtering by names with spaces, such as `workspace:"Sales Team"`. +**Note:** Quotes are required when filtering by names that contain spaces or commas, such as `workspace:"Sales Team"` or `workspace:"Acme, Inc"`. Because commas separate **OR** conditions, an unquoted name with a comma is read as two separate values instead of one. --- @@ -64,19 +65,22 @@ Use these operators to refine results across different types: You can use the following operators to filter expenses: -- `merchant:` – expense merchant name -- `category:` – expense category label -- `tag:` – tag or multiple tags -- `amount:` / `purchase-amount:` – supports `=`, `>`, `<`, `>=`, `<=` -- `status:` – unreported, draft, outstanding, approved, paid, done -- `date:` – expense date, supports relative dates like `date:this-month`, `date:last-month`, `date:year-to-date`, `date:this-week`. Also supports comparisons (`date>=2024-01-01 date<=2024-01-31`) for date ranges -- `has:` – attachment, receipt, category, tag -- `expense-type:` – cash, card, distance, per-diem -- `receipt-type:` – ereceipt, itemized -- `reimbursable:` and `billable:` – yes or no -- `attendee:` – expense attendees (email, phone, display name, or `me`), e.g. `attendee:"Jason Mills"` -- `posted:` – credit card posted date, e.g. `posted:last-statement` -- `bank-account:` – settlement bank account an expense was reimbursed from. Start typing after the colon to choose from your bank accounts, each shown as the bank name and last four digits (e.g. `Chase xx1234`). Available only when you have at least one bank account. +| **Syntax** | **Description** | **Example** | +|---|---|---| +| `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"` | +| `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` | +| `has:` | Filter by expenses with an `attachment`, `receipt`, `category`, `tag`, or `submitted-violation` | `has:receipt` | +| `expense-type:` | Filter by expense type (`cash`, `card`, `distance`, `per-diem`) | `expense-type:card` | +| `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` | +| `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` | +| `bank-account:` | Filter by the settlement bank account an expense was reimbursed from. Start typing after the colon to choose an account, shown by bank name and last four digits. Available only when you have at least one bank account. | `bank-account:"Chase xx1234"` | **Example queries:** - `type:expense merchant:Starbucks category:Meals amount>20 has:receipt` @@ -88,21 +92,23 @@ You can use the following operators to filter expenses: You can use the following operators to filter reports: -- `report-id:` – unique report reference -- `status:` – draft, outstanding, approved, paid, done -- `submitted:` / `approved:` / `paid:` / `exported:` – supports absolute or relative dates, and comparisons for date ranges (e.g., `submitted>=2024-01-01 submitted<=2024-01-31`) -- `exported-to:` – filter by where reports or expenses were exported, such as a connected accounting integration. -- `title:` – report title -- `total:` – total amount with relative comparisons -- `amount-debited:` – amount the company was debited when a report was reimbursed across currencies, in the settlement currency. Supports `=`, `>`, `<`, `>=`, `<=` -- `amount-reimbursed:` – amount the member was reimbursed when a report was paid across currencies, in their deposit account's currency. Supports `=`, `>`, `<`, `>=`, `<=` -- `withdrawn:` – ACH withdrawal date -- `withdrawal-type:` – reimbursement, expensify-card, or central-travel-invoicing -- `paid-status:` – how the report was paid: `markedAsPaid`, `withdrawing`, or `confirmed`. Combine multiple values with commas, e.g. `paid-status:markedAsPaid,confirmed` -- `action:` – blocking report action, e.g. `action:approve` -- `submitter-user-id:` – Custom field 1 value set for the report submitter -- `submitter-payroll-id:` – Custom field 2 value set for the report submitter -- `order-deal-numbers:` – International reimbursement IDs on the report +| **Syntax** | **Description** | **Example** | +|---|---|---| +| `report-id:` | Filter by unique report reference | `report-id:123456789` | +| `status:` | Filter by report status (`draft`, `outstanding`, `approved`, `paid`, `done`) | `status:approved` | +| `submitted:` / `approved:` / `paid:` / `exported:` | Filter by the corresponding date using absolute or relative dates, or comparison operators for a date range | `submitted>=2024-01-01 submitted<=2024-01-31` | +| `exported-to:` | Filter by where reports or expenses were exported, such as a connected accounting integration | `exported-to:QuickBooks` | +| `title:` | Filter by report title | `title:"January Expenses"` | +| `total:` | Filter by report total using relative comparisons | `total:>1000` | +| `amount-debited:` | Filter by the amount the company was debited when a report was reimbursed across currencies, in the settlement currency. Supports `=`, `>`, `<`, `>=`, `<=` | `amount-debited:>1000` | +| `amount-reimbursed:` | Filter by the amount the member was reimbursed when a report was paid across currencies, in their deposit account's currency. Supports `=`, `>`, `<`, `>=`, `<=` | `amount-reimbursed:>1000` | +| `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` | +| `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` | +| `submitter-payroll-id:` | Filter by the Custom field 2 value set for the report submitter | `submitter-payroll-id:67890` | +| `order-deal-numbers:` | Filter by international reimbursement IDs on the report | `order-deal-numbers:123456` | **Example query:** `status:paid exported<=2026-01-01 exported-to:xero` @@ -113,10 +119,12 @@ You can use the following operators to filter reports: You can use the following operators to filter chats: -- `in:` – channel name or DM -- `has:` – attachment, link -- `is:` – unread, read, pinned -- `date:` – message timestamp +| **Syntax** | **Description** | **Example** | +|---|---|---| +| `in:` | Filter by channel name or direct message | `in:"General"` | +| `has:` | Filter by chats with an `attachment` or `link` | `has:attachment` | +| `is:` | Filter by chat status (`unread`, `read`, `pinned`) | `is:unread` | +| `date:` | Filter by message timestamp | `date:this-week` | **Example query:** `type:chat in:"Concierge" is:unread` @@ -127,11 +135,13 @@ You can use the following operators to filter chats: You can use the following operators to filter tasks: -- `assignee:` – assigned member (email, phone, display name, or `me`) -- `status:` – outstanding, completed -- `description:` – task description -- `title:` – task title -- `in:` – channel name or DM for tasks +| **Syntax** | **Description** | **Example** | +|---|---|---| +| `assignee:` | Filter by assigned member using an email, phone number, display name, or `me` | `assignee:me` | +| `status:` | Filter by task status (`outstanding`, `completed`) | `status:outstanding` | +| `description:` | Filter by task description | `description:"Submit expenses"` | +| `title:` | Filter by task title | `title:"Review report"` | +| `in:` | Filter by the channel name or direct message associated with a task | `in:"General"` | **Example query:** `type:task assignee:"Charlie Brown" status:outstanding` @@ -164,19 +174,21 @@ Supported values include standard ISO currency codes such as: **Group results by dimension** -Supported grouping options include: - -- `group-by:report` – Group by report -- `group-by:from` – Group by submitter -- `group-by:card` – Group by card -- `group-by:withdrawal-id` – Group by withdrawal ID -- `group-by:merchant` – Group by merchant -- `group-by:category` – Group by category -- `group-by:tag` – Group by tag -- `group-by:month` – Group by month -- `group-by:week` – Group by week -- `group-by:quarter` – Group by quarter -- `group-by:year` – Group by year +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` | +| `group-by:merchant` | Group results by merchant | `type:expense group-by:merchant` | +| `group-by:category` | Group results by category | `type:expense group-by:category` | +| `group-by:tag` | Group results by tag | `type:expense group-by:tag` | +| `group-by:month` | Group results by month | `type:expense group-by:month` | +| `group-by:week` | Group results by week | `type:expense group-by:week` | +| `group-by:quarter` | Group results by quarter | `type:expense group-by:quarter` | +| `group-by:year` | Group results by year | `type:expense group-by:year` | --- @@ -206,6 +218,9 @@ Here are some common examples: - **Top spenders (table)** `type:expense group-by:from date:last-month view:table` +- **Violations by submitter (table)** + `type:expense group-by:from submitted:last-month has:submitted-violation sort-by:group-expenses sort-order:desc view:table limit:10` + - **Spend over time (line chart)** `type:expense group-by:month date:year-to-date view:line` @@ -232,11 +247,4 @@ If the search operator isn’t recognized, the system will ignore it and return ## Do I need to use quotes for everything? -Only use quotes for values that include spaces or exact phrases, like `description:"client lunch"` or `in:"#general"`. - -## How do I find all messages from a specific person? - -You have two options: - -- Enter `type:chat from:` followed by the person's email, phone, or display name in the search bar (for example, `type:chat from:alice@acme.com`). -- Open the person's profile and select **View user history** (or **View agent history** for an agent). This opens the **Search** page filtered to that person's messages. +Only use quotes for values that include spaces, commas, or exact phrases, like `description:"client lunch"`, `workspace:"Acme, Inc"`, or `in:"#general"`. diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md index 1d2a4ccb5e69..8e2df18d94b1 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md @@ -1,7 +1,7 @@ --- title: Using Spend in New Expensify description: Learn how to use Spend in New Expensify to search, filter, customize columns, and save searches for expenses, invoices, trips, and chats. -keywords: [Spend, New Expensify, report filters, search commands, custom columns, saved searches, share saved search, My expenses saved search, default saved search, group expenses, invoices, expenses, chats, trips, reimbursement tracking, view expenses, customize report view, reporting table columns, Tag GL code, Category GL code] +keywords: [Spend, New Expensify, report filters, search commands, custom columns, saved searches, share saved search, My expenses saved search, default saved search, group expenses, invoices, expenses, chats, trips, reimbursement tracking, view expenses, customize report view, reporting table columns, Tag GL code, Category GL code, Paid by column, Paid by filter, who paid a report, Edit columns, Display menu] ---
@@ -44,9 +44,15 @@ Filter reports by type, status, category, or date to find specific information. - **Date:** This month, last month - **Status:** Outstanding, Paid - **From:** You, or someone else + - **Paid by:** The member who paid the report. On the **Expense Report** type, this sits directly below **Paid status**. On the **Expense** type, it sits directly below **Paid**. 6. Click **View Results** to see the filtered results. -![Spend page showing available report filtering options]({{site.url}}/assets/images/Filter-reports.png){:width="100%"} + + --- ## How to customize the Spend column view @@ -57,7 +63,7 @@ To customize columns: 1. Go to the **Spend** tab on the left (web only). 2. Run a search or apply filters if needed. -3. Click the **Columns** icon (next to **Filters**). +3. Click **Display**, then select **Edit columns**. 4. Use the checkboxes to show or hide specific columns. 5. Drag and drop columns to rearrange their order. 6. Click **Save** to save your column layout. @@ -67,10 +73,10 @@ You can choose from a wide range of columns, including: - **Avatar** – The profile image of the report submitter - **Date** – When the report or expense was created - **Submitted** – The date the report was submitted for approval -- **Paid status** – How a paid report was settled: **Marked as paid**, **Withdrawing**, or **Confirmed** - **Total** – The total amount of the report or expense - **Workspace** – The workspace the report belongs to -- **Paid status** – The payment stage of a paid report: Marked as paid, Withdrawing, or Confirmed +- **Paid status** – The payment stage of a paid report: **Marked as paid**, **Withdrawing**, or **Confirmed** +- **Paid by** – The member who paid the report. This stays blank until the report is paid, and it updates as soon as you pay the report from **Spend** - **Action** – Shows available actions like approve or reject - **MCC** – The Merchant Category Code from the transaction - **Tax code** – The tax code applied to the expense @@ -80,7 +86,7 @@ You can choose from a wide range of columns, including: - **Custom field 2** – The Custom field 2 value set for the report submitter - **International reimbursement IDs** – The reference IDs for international reimbursements on the report -To view the full list of available columns, click the **Columns** icon in the **Spend** tab. +To view the full list of available columns, click **Display** in the **Spend** tab, then select **Edit columns**. --- @@ -205,10 +211,10 @@ Expensify ensures cross-platform consistency where possible. However, certain fe 3. Apply the **Draft** filter to view all unsubmitted reports. ## Can I change which columns appear in my reports? -Yes! You can choose which columns to show or hide in the **Reports** and **Expenses** views on web. Use the **Columns** icon to customize your table layout. +Yes! You can choose which columns to show or hide in the **Reports** and **Expenses** views on web. Click **Display**, then select **Edit columns**, to customize your table layout. ## Can I reorder the columns? -Absolutely. Just drag and drop the columns in the order you prefer within the **Columns** settings panel. +Absolutely. Just drag and drop the columns in the order you prefer within the **Edit columns** panel. ## Can I save different column views? Yes. Each saved search stores your column layout, filters, and sorting preferences. You can save multiple versions for different workflows. diff --git a/docs/articles/new-expensify/settings/Act-as-a-Copilot.md b/docs/articles/new-expensify/settings/Act-as-a-Copilot.md index f5a739ec19c5..97587e311b1e 100644 --- a/docs/articles/new-expensify/settings/Act-as-a-Copilot.md +++ b/docs/articles/new-expensify/settings/Act-as-a-Copilot.md @@ -26,8 +26,9 @@ Depending on the access level assigned to you, you may be able to: - View expenses and chats - Create and submit expenses - Review reports - - Update account, workspace, and domain settings - - Forward receipts to the account owner's account + - Update the member's account, workspace, and domain settings + - Merge accounts on the member's behalf + - Forward receipts to the member's account Some actions may be restricted if you've been granted Limited Access. diff --git a/docs/articles/new-expensify/settings/Manage-Copilot-Access.md b/docs/articles/new-expensify/settings/Manage-Copilot-Access.md index eb863112fbed..e212a0b949e3 100644 --- a/docs/articles/new-expensify/settings/Manage-Copilot-Access.md +++ b/docs/articles/new-expensify/settings/Manage-Copilot-Access.md @@ -30,6 +30,7 @@ A Copilot can: - Create and manage expenses on your behalf - Review reports - Access account, workspace, and domain settings based on their assigned permissions + - Merge accounts on your behalf (Full Access only). The security code is sent to you, the account owner. You will have to share the security code with the person who is the Copilot for your account, so please share it with caution. A Copilot cannot: diff --git a/docs/articles/new-expensify/settings/Merge-Accounts.md b/docs/articles/new-expensify/settings/Merge-Accounts.md index fe796b5e44d1..dbfa6c5653c2 100644 --- a/docs/articles/new-expensify/settings/Merge-Accounts.md +++ b/docs/articles/new-expensify/settings/Merge-Accounts.md @@ -48,3 +48,8 @@ The following items from the merged account will be transferred to your primary - Co-pilots - Group workspace settings +## Can a Copilot merge accounts on my behalf? + +Yes, a Copilot with Full Access can merge accounts while acting on your behalf. +- When they do, Expensify sends the security code to you (the account owner), not the Copilot. You'll need to share that code with your Copilot so they can complete the merge. +- This keeps you in control, since the merge can't be finalized without the code that only you receive. diff --git a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md index b0cb40a683ff..83ab0c1f3d9a 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md +++ b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md @@ -58,6 +58,19 @@ To send a personal payment, you must first [set up your Wallet](https://help.exp 4. If you've made a payment before, the most recently used method will be selected by default. Tap the right side of the button to view other options. 5. If this is your first time paying, you’ll be asked to select a payment method after tapping **Pay**. +## How to cancel a personal payment before the recipient sets up their Wallet + +When you pay a personal expense from your Wallet and the recipient has not [set up their Wallet](https://help.expensify.com/articles/new-expensify/wallet-and-payments/Set-up-your-wallet) yet, the payment is held until they do. While it is held, you (the sender) can cancel it and have the funds returned. + +1. Open the payment report. +2. Select **More**. +3. Select **Cancel payment**. +4. When asked **Are you sure that you want to cancel this payment?**, confirm to cancel. + +The payment is cancelled and the held funds are returned to you. Once the recipient sets up their Wallet and the payment completes, **Cancel payment** is no longer available. + +**Note:** Only the sender sees the **Cancel payment** option. The recipient cannot cancel the payment. + --- # FAQ diff --git a/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md b/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md index 56ea7c45a2a1..918913ff4c36 100644 --- a/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md +++ b/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md @@ -157,7 +157,7 @@ When importing multi-level Tags, enable **These are independent tags** to import ## How to add or edit a tag GL code -Workspaces on the Control plan can assign a GL code to each tag for exporting purposes. Tag GL codes are not visible to members. +Workspaces on the Control plan can assign a GL code to each tag for exporting purposes. By default, tag GL codes are not visible to members, but you can choose to show them in the tag picker. 1. In the **navigation tabs** (on the left on web, and at the bottom on mobile), select **Workspaces** > **[workspace name]**. 2. Select **Tags**. @@ -166,6 +166,21 @@ Workspaces on the Control plan can assign a GL code to each tag for exporting pu --- +## How to show tag GL codes when selecting a tag + +By default, tag GL codes are hidden from members. Workspace Admins on the Control plan can turn on a setting that displays each tag's GL code in the tag picker, so members can see and search by GL code when selecting a tag on an expense. + +1. In the **navigation tabs** (on the left on web, and at the bottom on mobile), select **Workspaces** > **[workspace name]**. +2. Select **Tags**. +3. In the top-right corner, select **Settings**. +4. Turn on **Show GL codes when selecting a tag**. + +Once enabled, each tag displays its GL code as a second line beneath the tag name in the tag picker, and members can search for a tag by its GL code. + +**Note:** This setting only appears when GL codes are enabled for the workspace. + +--- + ## How Expensify suggests Tags automatically Expensify may suggest Tags based on how similar expenses were coded previously. @@ -228,4 +243,4 @@ Multi-level tags are available on Control workspaces only. If your Control works ## Can members see Tag GL codes? -No. Tag GL codes are visible only to Workspace Admins. If members need that information, include the GL code in the tag name (for example: `1001 - Marketing`). +By default, no. Tag GL codes are visible only to Workspace Admins. However, a Workspace Admin can turn on **Show GL codes when selecting a tag** in **Tags** > **Settings** to display each tag's GL code in the tag picker for members. If that setting is off, you can still include the GL code in the tag name (for example: `1001 - Marketing`). diff --git a/docs/articles/new-expensify/workspaces/Duplicate-Workspace.md b/docs/articles/new-expensify/workspaces/Duplicate-Workspace.md index 605628207292..12521b0b5c5e 100644 --- a/docs/articles/new-expensify/workspaces/Duplicate-Workspace.md +++ b/docs/articles/new-expensify/workspaces/Duplicate-Workspace.md @@ -61,6 +61,9 @@ Yes. If you select **Workflows** when duplicating, all approval steps and settin ## Can I change which workspace settings are copied after duplication? No. Once the new workspace is created, you’ll need to manually adjust any settings you didn’t include during duplication. +## Does duplicating a workspace copy my invoicing details? +No. When you select **Invoices**, the new workspace has invoicing enabled, but the **Invoicing details**—including **Company name**, **Company website**, and the connected bank account—are not copied. Because these details are set up per workspace, you’ll need to enter them again in the new workspace before sending an invoice. + ## Does duplicating a workspace affect the original one? No. The original workspace remains unchanged. Duplication creates a separate, independent workspace with the settings you selected. diff --git a/docs/articles/new-expensify/workspaces/Workspace-Rules.md b/docs/articles/new-expensify/workspaces/Workspace-Rules.md index 61ad391d0a3f..38edfa174ebd 100644 --- a/docs/articles/new-expensify/workspaces/Workspace-Rules.md +++ b/docs/articles/new-expensify/workspaces/Workspace-Rules.md @@ -1,7 +1,7 @@ --- title: Workspace Rules description: Configure and manage rules for your workspace to enforce expense policies and automate compliance. -keywords: [New Expensify, workspace rules, expense rules, receipt requirements, category rules, self-approvals, prohibited expenses, disable Smartscan, automate expenses, subscription expense, non-reimbursable, default expense handling, control expenses, expense categorization, rule-based expenses, compliance, itemized receipt, itemized receipts required over, merchant rules, workspace merchant rules, auto-categorize by merchant, spend rules, Expensify Card spend rules, block transactions, approve transactions, public receipt visibility, share receipt link, receipt URL] +keywords: [New Expensify, workspace rules, expense rules, receipt requirements, category rules, self-approvals, prohibited expenses, disable Smartscan, automate expenses, subscription expense, non-reimbursable, cash expense default, always reimbursable, always non-reimbursable, default expense handling, control expenses, expense categorization, rule-based expenses, compliance, itemized receipt, itemized receipts required over, merchant rules, workspace merchant rules, auto-categorize by merchant, spend rules, Expensify Card spend rules, block transactions, approve transactions, public receipt visibility, share receipt link, receipt URL] internalScope: Audience is Workspace Admins on the Control plan. Covers enabling and managing workspace-level rules such as expense rules, merchant rules, prohibited expenses, category rules, tag rules, report rules, and Expensify Card spend rules. Does not cover personal expense rules, Workspace Merchant Rules setup details, or troubleshooting specific rule outcomes. --- @@ -34,10 +34,11 @@ Once enabled, go to the **Rules** tab in the left menu to manage expense-level s - **Itemized receipt required over** – Require itemized receipts for expenses over a specific amount. - **Max expense amount** – Set a per-expense spending cap (supports decimals). - **Max expense age (Days)** – Define how old an expense can be (whole numbers only). -- **Cash expense default** - Choose whether cash expenses are reimbursable by default. Note: [Workspace Merchant Rules](/articles/new-expensify/workspaces/Workspace-Merchant-Rules) can also set reimbursable status on a per-merchant basis. +- **Cash expense default** - Choose how cash expenses are created. A cash expense is any expense that isn't an imported company card transaction, including manually created expenses, receipts, per diem, distance, and time expenses. Select **Reimbursable** or **Non-reimbursable** to set a starting value that members can change on each expense, or select **Always reimbursable** or **Always non-reimbursable** to fix the value for every cash expense. When you select an **Always** option, the **Reimbursable** toggle is hidden on the expense and on every split created from it. Note: [Workspace Merchant Rules](/articles/new-expensify/workspaces/Workspace-Merchant-Rules) can also set reimbursable status on a per-merchant basis. - **Billable default** – Choose whether expenses are billable by default. Note: [Workspace Merchant Rules](/articles/new-expensify/workspaces/Workspace-Merchant-Rules) can also set billable status on a per-merchant basis. - **Require company cards for all purchases** - Flag out-of-pocket expenses that should have been made with a company card. Only available after company cards are connected to the workspace. - **eReceipts** – Enable automatic receipt generation for all USD card transactions up to $75 (requires USD as default currency). +- **Attendee tracking** – Track the per-person cost for every expense. When enabled, members can add attendees to an expense. - **Merchant-based automation** – Automatically apply categories, tags, and other fields using Workspace Merchant Rules. - **Public receipt visibility** – Control who can view receipt images. When enabled, receipts are viewable by anyone with the URL, even people who don't have access to the report. When disabled (the default), receipts are only viewable by Expensify members with access to the report containing the receipt. diff --git a/docs/articles/travel/company-setup/Enable-Travel-on-a-Workspace.md b/docs/articles/travel/company-setup/Enable-Travel-on-a-Workspace.md index c1b7e08c1fd9..825e0e9f5035 100644 --- a/docs/articles/travel/company-setup/Enable-Travel-on-a-Workspace.md +++ b/docs/articles/travel/company-setup/Enable-Travel-on-a-Workspace.md @@ -30,7 +30,7 @@ Once Expensify Travel is enabled: - Admins can book on behalf of others if needed - All travel activity is visible in one place -Expensify Travel is available globally and included with every Expensify plan. +Expensify Travel is available globally on the Collect and Control plans. ## Learn more about setting up your travel policy diff --git a/docs/articles/travel/company-setup/Managing-Groups-and-Roles.md b/docs/articles/travel/company-setup/Managing-Groups-and-Roles.md new file mode 100644 index 000000000000..33713148f88b --- /dev/null +++ b/docs/articles/travel/company-setup/Managing-Groups-and-Roles.md @@ -0,0 +1,251 @@ +--- +title: Managing Groups and Roles +description: Learn how Travel Admins can use groups, roles, and scopes to manage user access and permissions in Expensify Travel. +keywords: [manage travel permissions, groups and roles, travel groups, travel roles, travel scopes, assign travel roles, company admins] +internalScope: Audience is Travel Admins. Covers managing user access in Expensify Travel using groups, predefined roles, scopes, group membership, and direct role assignments. Does not cover traveler provisioning, traveler profile settings, or travel policy configuration. +--- + +# How to access Expensify Travel + +* **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. +* **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. + +**Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. + +--- + +## Who can manage groups and roles? + +Only Travel Admins can manage groups, roles, scopes, and group membership. + +Users must already be provisioned to Expensify Travel before they can be added to a group or assigned a role. See [Managing Users](Managing-Users.md) for more information about how users are provisioned and managed. + +--- + +## How groups, roles, and scopes work + +Groups, roles, and scopes work together to control access in Expensify Travel: + +* **Groups** are collections of users who need the same access. +* **Roles** are predefined sets of permissions that determine what users can access and manage. +* **Scopes** determine where the permissions from a role apply. + +For example, you can create a group, assign the appropriate roles and scopes to it, then add users as members. All members receive the access assigned to that group. + +You can also assign a role and scope directly to an individual user without adding them to a group. + +Access is cumulative. If a user receives access from multiple groups or direct role assignments, removing one assignment won't remove access they still receive from another. + +--- + +## How to manage groups and roles + +1. Open **Expensify Travel**. +2. Click **Program** in the top navigation bar. +3. Navigate to **Company > People > Groups and roles**. +4. Select: + + * **Groups** to create groups and manage their roles, scopes, and members. + * **Roles** to view the predefined roles and their permissions. + +--- + +## How to create a group + +Use groups when multiple users need the same access. + +1. Go to **Program > Company > People > Groups and roles**. +2. Select the **Groups** tab. +3. Click **Add group**. +4. Enter a group name and description. +5. Click **Create**. + +Once the group is created, you can assign roles and scopes and add members. + +--- + +## How to assign roles and scopes to a group + +Roles determine what members of the group can access and manage. Each role assignment includes a scope that determines where those permissions apply. + +1. Go to **Program > Company > People > Groups and roles**. +2. Select the **Groups** tab. +3. Select the group you want to manage. +4. Under **Roles and scopes**, click **Assign role**. +5. Select one or more roles. +6. Under **Define scope**, select the scope for the role. +7. Select the organization or trip template where the role should apply. +8. Click **Assign**. + +Depending on the selected role, you can scope access by **Organization**, **Trip Template**, or both. The condition is automatically set to **is one of** and can't be changed. + +The available scope options also depend on the combination of roles you select. If you select multiple roles at once, only scope types supported by all selected roles are available. + +For example, if one selected role supports Organization and Trip Template but another only supports Organization, only Organization will be available when defining the scope. + +--- + +## How to add members to a group + +1. Go to **Program > Company > People > Groups and roles**. +2. Select the **Groups** tab. +3. Select the group you want to manage. +4. Select the **Members** tab. +5. Click **Add members**. +6. Select the users you want to add. +7. Confirm your changes. + +Members receive the roles and scopes assigned to the group. + +Users must already be provisioned to Expensify Travel before they'll appear as available members. See [Managing Users](Managing-Users.md) for more information about user provisioning. + +--- + +## How to remove a member from a group + +1. Go to **Program > Company > People > Groups and roles**. +2. Select the **Groups** tab. +3. Select the group you want to manage. +4. Select the **Members** tab. +5. Click the delete icon next to the user you want to remove. +6. Confirm the change. + +Removing a user from a group removes the access they received through that group. If the user receives the same or overlapping access from another group or a directly assigned role, that access remains. + +--- + +## How to assign a group to an individual user + +You can also manage group membership directly from a user's travel profile. + +1. Go to **Program > Company > People > Users**. +2. Select the user you want to manage. +3. Select the **Groups and roles** tab. +4. Under **Assigned groups**, click **Assign group**. +5. Select an existing group. +6. Confirm your changes. + +The user receives the roles and scopes already configured for the assigned group. + +To remove the user from a group, click the delete icon next to the group under **Assigned groups**. + +--- + +## How to assign a role directly to an individual user + +If a user needs access that doesn't apply to an entire group, you can assign a role directly to their travel profile. + +1. Go to **Program > Company > People > Users**. +2. Select the user you want to manage. +3. Select the **Groups and roles** tab. +4. Under **Assigned roles**, click **Assign role**. +5. Select one or more roles. +6. Define the scope for the selected roles. +7. Click **Assign**. + +The scope isn't predefined when you assign a role directly. You'll select the applicable scope as part of the role assignment. + +Direct role assignments work in addition to any access the user receives through their assigned groups. + +--- + +## How to view available roles and permissions + +Roles are predefined and can't be created, deleted, or modified. + +To review a role: + +1. Go to **Program > Company > People > Groups and roles**. +2. Select the **Roles** tab. +3. Select a role to open its details. +4. Review the role description and permissions. + +To customize where a role's permissions apply, configure its scope when assigning the role to a group or individual user. + +--- + +## How the Company Admins group works + +The **Company Admins** group is automatically managed by Expensify and provides full company management access in Expensify Travel. + +Users with the **Workspace Admin** role on the Travel-enabled Expensify workspace are automatically added to the Company Admins group. + +If their Expensify role changes from Workspace Admin to another workspace role, they're automatically removed from the Company Admins group. If they're later assigned the Workspace Admin role again, they're automatically added back. + +The Company Admins group can't be deleted, and its predefined roles and scopes can't be changed. + +--- + +## How to delete a group + +A custom group can't be deleted while it has assigned roles. + +1. Go to **Program > Company > People > Groups and roles**. +2. Select the **Groups** tab. +3. Select the group you want to delete. +4. Remove all roles assigned to the group. +5. Return to the **Groups** list. +6. Click the delete icon next to the group. +7. Confirm the deletion. + +You don't need to remove members before deleting the group. All members are automatically removed from the group when it's deleted. + +Access users receive from other groups or directly assigned roles isn't affected. + +--- + +# FAQ + +## Should I use a group or assign a role directly to a user? + +Use a group when multiple users need the same access. This makes it easier to manage their permissions together. + +Assign a role directly when an individual user needs access that doesn't apply to an entire group. + +## Can I create a custom role? + +No. Roles and their permissions are predefined. They can't be created, deleted, or modified. + +You can customize where a role applies by defining its scope when assigning it to a group or user. + +## What is a scope? + +A scope determines where a role's permissions apply. + +Depending on the role, access can be scoped by **Organization**, **Trip Template**, or both. The condition is automatically set to **is one of**. + +If you select multiple roles at once, only scope types supported by all selected roles will be available. + +## Why did the Trip Template scope disappear when I selected another role? + +The available scope options depend on all roles being assigned together. + +If one selected role supports Organization and Trip Template but another only supports Organization, **Trip Template** won't be available. Remove the role that only supports Organization, and Trip Template will become available again. + +## Can a user belong to more than one group? + +Yes. Users can belong to multiple groups and can also have roles assigned directly to their travel profile. + +Access from groups and directly assigned roles is cumulative. + +## How do I give someone Company Admin access? + +Assign the user the **Workspace Admin** role on the Travel-enabled Expensify workspace. Expensify will automatically add them to the Company Admins group in Expensify Travel. + +If their Workspace Admin role is removed, they'll automatically be removed from the Company Admins group. + +## Can I delete the Company Admins group? + +No. The Company Admins group is automatically managed by Expensify and can't be deleted or have its predefined roles and scopes changed. + +## Why can't I find a user when adding members to a group? + +The user must already be provisioned to Expensify Travel before they can be added to a group. + +See [Managing Users](Managing-Users.md) for provisioning requirements and troubleshooting steps. + +## Why can't I delete a group? + +A custom group can't be deleted while roles are assigned to it. + +Remove all assigned roles first, then delete the group. You don't need to remove the group's members first. diff --git a/docs/articles/travel/company-setup/Managing-Users.md b/docs/articles/travel/company-setup/Managing-Users.md index 07990388a101..b80bc9821def 100644 --- a/docs/articles/travel/company-setup/Managing-Users.md +++ b/docs/articles/travel/company-setup/Managing-Users.md @@ -1,26 +1,49 @@ --- title: Managing Users -description: Learn how travel admins can view and manage traveler profiles, roles, and configuration settings in Expensify Travel. -keywords: [manage users, travel roles, edit traveler profile, expensify travel admin, legal entity, secondary approvers, travel arranger] +description: Learn how Travel Admins can view and manage traveler profiles, groups, roles, and configuration settings in Expensify Travel. +keywords: [manage users, travel users, traveler profile, expensify travel admin, travel groups, travel roles, legal entity, secondary approvers, travel arranger] +internalScope: Audience is Travel Admins. Covers traveler provisioning, individual traveler profile settings, and user-level group and role assignments in Expensify Travel. Does not cover creating or managing groups, role definitions, scopes, or travel policy configuration. --- - -Travel admins can view, edit, and configure traveler profiles under the **Users** section of Expensify Travel. +Travel Admins can view and manage traveler profiles, access, and configuration settings under the **Users** section of Expensify Travel. --- ## How to access Expensify Travel -- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. -- **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. +* **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. +* **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. --- -## Who can configure email notifications? +## Who can manage users? + +Only Travel Admins can manage other users' traveler profiles, groups, roles, and configuration settings. + +--- + +## How users are provisioned to Expensify Travel + +Users are automatically provisioned to Expensify Travel when they belong to the Travel-enabled Expensify workspace and have it set as their default workspace. Users can't be manually added directly from Expensify Travel. + +To be provisioned to Expensify Travel, a user must: -Only users with the **Travel Admin** role can manage travel-related email settings. +* Be a member of the Travel-enabled Expensify workspace. +* Have the Travel-enabled workspace set as their default workspace. + +If either of these requirements is no longer met, the user is automatically deprovisioned from Expensify Travel. + +For example, a user will be deprovisioned if they're removed from the Travel-enabled workspace or change their default workspace to a different workspace. + +### Workspace Admins + +Users with the **Workspace Admin** role on the Travel-enabled Expensify workspace are automatically added to the **Company Admins** group in Expensify Travel. + +If their Expensify role changes from Workspace Admin to another workspace role, they're automatically removed from the Company Admins group. If they're later assigned the Workspace Admin role again, they're automatically added back to the group. + +For more information about groups and roles in Expensify Travel, see [Managing Groups and Roles](Managing-Groups-and-Roles.md). --- @@ -28,71 +51,140 @@ Only users with the **Travel Admin** role can manage travel-related email settin 1. Open **Expensify Travel**. 2. Click **Program** in the top navigation bar. -3. Navigate to **Company** > **Users** > **Travelers**. +3. Navigate to **Company** > **People** > **Users**. -The list includes all travelers in your company’s active **legal entities** (Expensify workspaces). You can: -- Search for users by email -- Download the full traveler list, which will be sent to your email +The Users page lists travelers who have been provisioned to your company in Expensify Travel. -Clicking a traveler's name opens their full profile, which includes three editable tabs. +Click a user's name to open their traveler profile and manage their settings. --- ## General tab -**Traveler Information** -- Required: First name and Last name -- Optional: Title, Middle name, Preferred name, Suffix, Tier - - _Preferred name_ is only used within the platform and does not affect bookings. +The **General** tab contains the traveler's personal, contact, employment, and company information. + +### Traveler Information -**Contact Details** -- Phone country code, phone number, and the email, which is imported from Expensify and is not editable. +* Required: First name and Last name +* Optional: Title, Middle name, Preferred name, Suffix, Tier -**Employment Details** -- Job title, Employee ID, Accounting code, Worker type -- Use the **Add manager** button to assign a direct manager, useful for routing travel approvals + * *Preferred name* is only used within the platform and does not affect bookings. -**Company Settings** -- Required: **Legal entity** (workspace) -- Optional: Office location, Department, Cost center, Country/Region +### Contact Details + +* Phone country code and phone number +* Email address, which is imported from Expensify and isn't editable + +### Employment Details + +* Job title +* Employee ID +* Accounting code +* Worker type +* Direct manager + +Use **Add manager** to assign a direct manager. This can be used to route travel approvals. + +### Company Settings + +* Required: **Legal entity** (Expensify workspace) +* Optional: Office location, Department, Cost center, Country/Region --- -## Roles tab +## How the Groups and roles tab works + +The **Groups and roles** tab shows the groups and roles assigned to an individual user. + +### Assigned groups + +Groups give multiple users the same set of travel permissions. + +To assign a group: + +1. Under **Assigned groups**, click **Assign group**. +2. Select an existing group. +3. Confirm your changes. + +To remove the user from a group, click the delete icon next to the assigned group. + +The user receives the roles and scopes configured for each group they're assigned to. -Admins can assign one of the following roles: +### Assigned roles -- **Traveler** – Can view and book travel for themselves only -- **Travel arranger** – Can book for themselves and others -- **Company admin** – Has full access to company settings and booking privileges +Roles can also be assigned directly to an individual user. + +To assign a role: + +1. Under **Assigned roles**, click **Assign role**. +2. Select one or more roles. +3. Define the scope for the selected roles. +4. Click **Assign**. + +Roles are predefined sets of permissions. The scope determines where those permissions apply. + +Access from groups and directly assigned roles is cumulative. If a user receives the same or overlapping access from multiple assignments, removing one assignment won't remove access they still receive through another. + +For more information about creating groups, assigning roles and scopes, and managing permissions, see [Managing Groups and Roles](Managing-Groups-and-Roles.md). --- ## Configuration tab -**Book travel for guests** -- Toggle this setting on to let the user book for guests (non-Expensify travelers like candidates or an employee's family member.) +The **Configuration** tab contains additional booking and approval settings for the traveler. + +### Book travel for guests + +Enable this setting to allow the user to book travel for guests who aren't Expensify travelers, such as candidates or an employee's family member. -**Secondary approvers** -- Add one or more backup approvers who will receive notifications and can approve travel requests received by the user on their behalf. - - This is helpful when using **hard approval** rules that require explicit approval of travel requests in the event the travel approver is out of the office. +### Secondary approvers -**My arrangers** -- Assign travel arrangers who can book travel for this user -- Only users with the **Travel arranger** role will appear in the search list +Add one or more secondary approvers who can receive notifications and approve travel requests on the user's behalf. -Be sure to click **Save** after editing any tab to apply your changes. +This is helpful when using **hard approval** rules that require explicit approval of travel requests if the traveler's primary approver is unavailable. + +### My arrangers + +Assign users who can arrange travel for this traveler. + +Arrangers can book and manage travel on the traveler's behalf. The users available to assign as arrangers depend on their access in Expensify Travel. + +For more information about managing Travel access, see [Managing Groups and Roles](Managing-Groups-and-Roles.md). --- # FAQ +## Why isn't a workspace member showing up in Expensify Travel? + +Confirm that the user: + +* Is a member of the Travel-enabled Expensify workspace. +* Has the Travel-enabled workspace set as their default workspace. + +Both conditions must be met for the user to be provisioned to Expensify Travel. + +Users can't be manually added directly in Expensify Travel. If they're removed from the Travel-enabled workspace or change their default workspace, they're automatically deprovisioned. + +## How do I give someone Company Admin access in Expensify Travel? + +Assign the user the **Workspace Admin** role on the Travel-enabled Expensify workspace. Once synced, they'll automatically be added to the **Company Admins** group in Expensify Travel. + +If their Workspace Admin role is removed, they'll automatically be removed from the Company Admins group. + +## Can I assign other travel permissions to a user? + +Yes. Open the user's **Groups and roles** tab to assign an existing group or assign a predefined role and scope directly to the user. + +For more information, see [Managing Groups and Roles](Managing-Groups-and-Roles.md). + ## Can I import user data like departments or cost centers from my accounting software? -Not at this time. If this functionality would benefit your team, talk to your Account Manager to share feedback and explore alternatives. -## What happens if I don’t assign a legal entity? -Every user must be assigned to a legal entity (Expensify workspace) in order to use travel features. This field is required. +Not at this time. If this functionality would benefit your team, talk to your Account Manager to share feedback and explore alternatives. ## Can users assign their own arrangers or secondary approvers? + Yes. Users can update their arrangers and secondary approvers in their own profile settings if permitted. + +Yes. Users can update their arrangers and secondary approvers in their own profile settings if permitted. diff --git a/docs/articles/travel/travel-policy/Approving-Travel.md b/docs/articles/travel/travel-policy/Approving-Travel.md index a60c40ba355e..a81d9b5fa1f5 100644 --- a/docs/articles/travel/travel-policy/Approving-Travel.md +++ b/docs/articles/travel/travel-policy/Approving-Travel.md @@ -37,48 +37,53 @@ Each trip will display relevant details and action buttons to **Approve** or **D ## How approval actions work -Approvers will see the following options for each trip: +Approvers will see different actions depending on the approval type configured for the travel policy: -- **Approve:** With hard approval, this confirms the travel booking can proceed. The booking is finalized, and the payment card is charged with the booking receipt automatically added to the traveler's Expensify account. -- **Deny:** With soft approval, this rejects the travel booking request. The booking is canceled, and the traveler will be notified. +**Hard Approval**: The booking is completed and the payment card is charged immediately to secure the selected fare, hotel room, or rental car. The approver then has 24 hours to deny the booking. If denied, the booking is canceled. +**Pre-Booking Approval**: The booking isn't completed and the payment card isn't charged until the approver approves the request. The approver has 24 hours to approve or deny the request. -Approvals can also be done via email. When a trip requires approval, approvers will receive a notification with **Approve** or **Deny** buttons directly embedded. +When a trip requires approval, approvers receive a notification with the available approval request. --- -## How to understand soft vs. hard approval +## How to understand Soft Approval, Hard Approval, and Pre-Booking Approval -- **Soft approval:** Travel can be booked and processed before the approver reviews it. Approvers are still notified and have 24 hours to deny the booking and cancel it before the fare processes. -- **Hard approval:** The traveler creates the booking in a pending state. The travel approver has 24 hours to approve the booking request. If the booking is not approved, the travel plans are canceled and no transaction is processed. +- **Soft Approval**: Travel can be booked and processed before the approver reviews it. Approvers are notified, but the booking doesn't require approval to proceed. +- **Hard Approval**: The booking is completed immediately and the payment card is charged to secure the selected price and availability. The approver has 24 hours to deny the booking. If denied, the booking is canceled. Any refund or travel credit depends on the airline, hotel, rental car provider, and the cancellation or refund terms of the fare that was booked. +- **Pre-Booking Approval**: The traveler submits the trip for approval before the booking is completed. The approver has 24 hours to approve the request. The payment card isn't charged until the request is approved and the booking is completed. -These settings are configured within each travel policy under **Program** > **Policies** > select a policy > **General** > **Policy group actions** tab. +Because a Pre-Booking Approval request doesn't reserve the fare, room, seat, or rental car while approval is pending, the original price and availability aren't guaranteed. Prices may change, or the selected option may no longer be available by the time approval is given. + +These settings are configured within each travel policy under **Program** > **Policies** > **select a policy** > **General** > **Policy group actions**. --- ## How to approve a Pre-Booking Approval request -When a travel policy uses Pre-Booking Approval, the traveler must receive approval before the booking can proceed. Unlike hard approval, no booking or payment is made until the approver acts. Pre-Booking Approval is available for Air, Hotel, and Car bookings. +When a travel policy uses Pre-Booking Approval, the traveler must receive approval before the booking can proceed. No booking is completed and no payment is charged while the request is awaiting approval. Pre-Booking Approval is available for Air, Hotel, and Car bookings. Here is how the approval process works: -1. The traveler submits a pre-booking approval request during checkout by clicking **Request approval**. +1. The traveler submits a Pre-Booking Approval request during checkout by clicking **Request approval**. 2. The approver receives an email notification with the trip details, including the origin, destination, and travel dates. 3. The approver reviews the request under **Trips** > **Approvals** or directly from the email notification. 4. The approver clicks **Approve** or **Deny**. -The approver has 24 hours to respond to the request. If the request is not approved within 24 hours, it expires and no booking is made. +The approver has 24 hours to respond to the request. If the request isn't approved within 24 hours, it expires and no booking is made. + +Because the booking isn't completed until approval is given, the price and availability shown when the request was submitted may change while the request is pending. ## What to expect after approving a Pre-Booking Approval request -After the approver approves a pre-booking request: +After the approver approves a Pre-Booking Approval request: 1. Both the approver and the traveler receive an email notification confirming the approval. -2. The traveler has 24 hours to return to the trip in Expensify Travel and finalize the booking by completing the checkout process. -3. Once the traveler confirms the booking, the payment card is charged and the receipt is automatically added to the traveler's Expensify account. +2. The traveler returns to the trip in Expensify Travel and completes the checkout process. +3. Once the booking is completed, the payment card is charged and the receipt is automatically added to the traveler's Expensify account. -If the traveler does not finalize the booking within 24 hours, the approval expires and no booking is made. The traveler would need to submit a new approval request. +The final price and availability are based on what's available when the booking is completed. A hotel room, flight, rental car, or fare that was available when the approval request was submitted may no longer be available after approval. -Pre-Booking Approval includes a price buffer for approved bookings to account for minor fare changes between approval and final booking. +If the traveler doesn't finalize the booking within the allowed approval window, the approval expires and they'll need to submit a new request. --- @@ -101,12 +106,23 @@ Yes. The approval dashboard is identical on web and mobile. You can also approve --- -## What happens if no one approves the trip? +## What happens if no one takes action on the trip? If no action is taken: -- With **soft approval**, the trip is already booked and proceeds as normal. -- With **hard approval**, the booking is canceled, and if a refundable fare was booked, a refund will be issued to the payment card. -- With **Pre-Booking Approval**, the request expires after 24 hours and no booking is made. + +- With Soft Approval, the trip is already booked and proceeds as normal. +- With Hard Approval, the booking remains confirmed after the 24-hour denial window. +- With Pre-Booking Approval, the request expires after 24 hours and no booking is made. + +--- + +## What happens if a Hard Approval booking is denied? + +The booking is canceled. + +Because the payment card was charged when the booking was originally completed, any refund or travel credit depends on the cancellation terms of the booked fare and the airline, hotel, or rental car provider's policy. + +Refundable or cancelable bookings may be eligible for a refund, while other bookings may result in a travel credit or may be non-refundable. --- diff --git a/docs/assets/images/Accounting_IES.png b/docs/assets/images/Accounting_IES.png new file mode 100644 index 000000000000..ac355b00f7e5 Binary files /dev/null and b/docs/assets/images/Accounting_IES.png differ diff --git a/docs/assets/images/DualEntry_configure.png b/docs/assets/images/DualEntry_configure.png new file mode 100644 index 000000000000..690614c2cd80 Binary files /dev/null and b/docs/assets/images/DualEntry_configure.png differ diff --git a/docs/assets/images/DualEntry_connect.png b/docs/assets/images/DualEntry_connect.png new file mode 100644 index 000000000000..ac967d9a97c8 Binary files /dev/null and b/docs/assets/images/DualEntry_connect.png differ diff --git a/docs/assets/images/high-contrast-mode.png b/docs/assets/images/high-contrast-mode.png new file mode 100644 index 000000000000..0ddd4a246182 Binary files /dev/null and b/docs/assets/images/high-contrast-mode.png differ diff --git a/docs/assets/images/violations_by_submitter.png b/docs/assets/images/violations_by_submitter.png new file mode 100644 index 000000000000..690395d7f5b3 Binary files /dev/null and b/docs/assets/images/violations_by_submitter.png differ diff --git a/docs/new-expensify/hubs/connections/dualentry.html b/docs/new-expensify/hubs/connections/dualentry.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/new-expensify/hubs/connections/dualentry.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/redirects.csv b/docs/redirects.csv index 0eca599b22c1..91c89d6f004f 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -1021,3 +1021,4 @@ https://help.expensify.com/articles/new-expensify/settings/Personal-Karma,https: https://help.expensify.com/articles/new-expensify/connections/Greenhouse,https://help.expensify.com/docs/Hidden/Greenhouse https://help.expensify.com/articles/Unlisted/Configure-Rillet,https://help.expensify.com/new-expensify/connections/rillet/Configure-Rillet 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 diff --git a/eslint-plugin-local-rules/no-direct-pre-insert-fullscreen-under-rhp.js b/eslint-plugin-local-rules/no-direct-pre-insert-fullscreen-under-rhp.js index 620435ca0587..4c445c5d49c2 100644 --- a/eslint-plugin-local-rules/no-direct-pre-insert-fullscreen-under-rhp.js +++ b/eslint-plugin-local-rules/no-direct-pre-insert-fullscreen-under-rhp.js @@ -13,7 +13,7 @@ const meta = { schema: [], messages: { useHookInstead: - 'Use usePreMountDestination for RHP-to-fullscreen pre-mounting instead of calling Navigation.preInsertFullscreenUnderRHP directly. See .claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md.', + 'Use usePreMountDestination for RHP-to-fullscreen pre-mounting instead of calling Navigation.preInsertFullscreenUnderRHP directly. See .claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md.', }, }; diff --git a/eslint-plugin-local-rules/no-raw-typography.js b/eslint-plugin-local-rules/no-raw-typography.js index c9ab7bd82dff..f2cdaefc8cf5 100644 --- a/eslint-plugin-local-rules/no-raw-typography.js +++ b/eslint-plugin-local-rules/no-raw-typography.js @@ -6,13 +6,27 @@ const meta = { description: 'Disallow raw numeric fontSize/lineHeight values. Type must come from the typography scale so it cannot drift from the design system.', recommended: 'error', }, - schema: [], + schema: [ + { + type: 'object', + properties: { + // For the styles layer, which composes tokens out of `variables`. Raw numeric literals stay banned there. + allowVariablesReferences: {type: 'boolean'}, + }, + additionalProperties: false, + }, + ], messages: { rawTypography: 'Raw `{{property}}: {{value}}` is not allowed. Use a `` or a token from src/styles/typography.ts (https://github.com/Expensify/App/issues/37503).', + rawTypographyVariable: + '`{{property}}: {{value}}` bypasses the typography scale. Use a `` or a token from src/styles/typography.ts (https://github.com/Expensify/App/issues/37503).', }, }; const BANNED_PROPERTIES = new Set(['fontSize', 'lineHeight']); +const BANNED_VARIABLE_PREFIXES = ['fontSize', 'lineHeight']; +const TYPOGRAPHY_STYLE_HELPERS = new Set(['getFontSizeStyle', 'getLineHeightStyle']); +const VARIABLES_MODULE_NAME = 'variables'; /** * @param {import('estree').Node} key @@ -34,54 +48,188 @@ function getPropertyName(key) { */ const TS_WRAPPER_TYPES = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion']); -function isNumericLiteral(node) { +function unwrap(node) { if (TS_WRAPPER_TYPES.has(node.type)) { - return isNumericLiteral(node.expression); + return unwrap(node.expression); } - if (node.type === 'Literal' && typeof node.value === 'number') { + return node; +} + +function isNumericLiteral(node) { + const unwrapped = unwrap(node); + if (unwrapped.type === 'Literal' && typeof unwrapped.value === 'number') { return true; } - return node.type === 'UnaryExpression' && (node.operator === '-' || node.operator === '+') && isNumericLiteral(node.argument); + return unwrapped.type === 'UnaryExpression' && (unwrapped.operator === '-' || unwrapped.operator === '+') && isNumericLiteral(unwrapped.argument); +} + +/** + * Matches `variables.fontSize*` / `variables.lineHeight*`, the named escape hatch around the scale. + * Syntactic match on the `variables.` shape, so a renamed or destructured import is not flagged. + * + * @param {import('estree').Node} node + * @returns {boolean} + */ +function isVariablesTypographyReference(node) { + const unwrapped = unwrap(node); + if (unwrapped.type !== 'MemberExpression' || unwrapped.computed) { + return false; + } + if (unwrapped.object.type !== 'Identifier' || unwrapped.object.name !== VARIABLES_MODULE_NAME) { + return false; + } + if (unwrapped.property.type !== 'Identifier') { + return false; + } + return BANNED_VARIABLE_PREFIXES.some((prefix) => unwrapped.property.name.startsWith(prefix)); +} + +/** + * @param {import('eslint').Scope.Scope | null} scope + * @param {string} variableName + * @returns {import('eslint').Scope.Variable | undefined} + */ +function findVariable(scope, variableName) { + for (let current = scope; current; current = current.upper) { + const variable = current.set.get(variableName); + if (variable) { + return variable; + } + } + return undefined; } /** - * Flags object properties (`{fontSize: 17}`) and JSX attributes (``) that set - * `fontSize`/`lineHeight` to a numeric literal. References to tokens or computed values are allowed. + * The expression a single-definition `const` alias was assigned, so `const size = variables.fontSizeXXSmall` + * plus `getFontSizeStyle(size)` is still caught. Only `variables.*` is traced, never bare numbers. + * + * @param {import('eslint').Scope.Variable} variable + * @returns {import('estree').Node | undefined} + */ +function getConstInitializer(variable) { + if (variable.defs.length !== 1) { + return undefined; + } + const definition = variable.defs.at(0); + if (definition.type !== 'Variable' || definition.parent.kind !== 'const' || definition.node.id.type !== 'Identifier') { + return undefined; + } + return definition.node.init ?? undefined; +} + +/** + * Flags object properties, JSX attributes, and `getFontSizeStyle()`/`getLineHeightStyle()` arguments that + * set type outside the typography scale. With `allowVariablesReferences`, only numeric literals are banned. * * @param {import('eslint').Rule.RuleContext} context * @returns {import('eslint').Rule.RuleListener} */ function create(context) { - function report(valueNode, propertyName) { + const allowVariablesReferences = context.options.at(0)?.allowVariablesReferences ?? false; + + function report(valueNode, propertyName, bannedValue) { context.report({ node: valueNode, - messageId: 'rawTypography', + messageId: bannedValue.messageId, data: { property: propertyName, - value: context.sourceCode.getText(valueNode), + value: context.sourceCode.getText(bannedValue.node), }, }); } + /** + * Walks past ternaries and `const` aliases so the banned value is found wherever it was written, + * not only when it sits directly in the banned position. + * + * @param {import('estree').Node} valueNode + * @param {Set} visitedVariables guards against cyclic aliases + * @param {boolean} isBehindAlias set once the walk has stepped through a `const`, after which bare numbers are not flagged + * @returns {{messageId: string, node: import('estree').Node} | undefined} + */ + function findBannedValue(valueNode, visitedVariables, isBehindAlias) { + const unwrapped = unwrap(valueNode); + if (!isBehindAlias && isNumericLiteral(unwrapped)) { + return {messageId: 'rawTypography', node: unwrapped}; + } + if (!allowVariablesReferences && isVariablesTypographyReference(unwrapped)) { + return {messageId: 'rawTypographyVariable', node: unwrapped}; + } + if (unwrapped.type === 'ConditionalExpression') { + return findBannedValue(unwrapped.consequent, visitedVariables, isBehindAlias) ?? findBannedValue(unwrapped.alternate, visitedVariables, isBehindAlias); + } + if (unwrapped.type !== 'Identifier') { + return undefined; + } + const variable = findVariable(context.sourceCode.getScope(unwrapped), unwrapped.name); + if (!variable || visitedVariables.has(variable)) { + return undefined; + } + visitedVariables.add(variable); + const initializer = getConstInitializer(variable); + return initializer ? findBannedValue(initializer, visitedVariables, true) : undefined; + } + + function getBannedValue(valueNode) { + return findBannedValue(valueNode, new Set(), false); + } + + /** + * `getFontSizeStyle(x)` and `StyleUtils.getLineHeightStyle(x)` both build a `{fontSize}` / + * `{lineHeight}` style, so their argument is the same escape hatch as the property itself. + */ + function getTypographyHelperName(callee) { + if (callee.type === 'Identifier') { + return callee.name; + } + if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') { + return callee.property.name; + } + return undefined; + } + return { Property(node) { if (node.computed) { return; } const propertyName = getPropertyName(node.key); - if (propertyName === undefined || !BANNED_PROPERTIES.has(propertyName) || !isNumericLiteral(node.value)) { + if (propertyName === undefined || !BANNED_PROPERTIES.has(propertyName)) { return; } - report(node.value, propertyName); + const bannedValue = getBannedValue(node.value); + if (!bannedValue) { + return; + } + report(node.value, propertyName, bannedValue); }, JSXAttribute(node) { if (node.name.type !== 'JSXIdentifier' || !BANNED_PROPERTIES.has(node.name.name)) { return; } - if (node.value?.type !== 'JSXExpressionContainer' || !isNumericLiteral(node.value.expression)) { + if (node.value?.type !== 'JSXExpressionContainer') { + return; + } + const bannedValue = getBannedValue(node.value.expression); + if (!bannedValue) { + return; + } + report(node.value.expression, node.name.name, bannedValue); + }, + CallExpression(node) { + const helperName = getTypographyHelperName(node.callee); + if (helperName === undefined || !TYPOGRAPHY_STYLE_HELPERS.has(helperName)) { + return; + } + const argument = node.arguments.at(0); + if (!argument) { + return; + } + const bannedValue = getBannedValue(argument); + if (!bannedValue) { return; } - report(node.value.expression, node.name.name); + report(argument, helperName === 'getFontSizeStyle' ? 'fontSize' : 'lineHeight', bannedValue); }, }; } diff --git a/ios/CertificatePinning.swift b/ios/CertificatePinning.swift index dbaf1da0d7c4..d9a6a5f9b8c2 100644 --- a/ios/CertificatePinning.swift +++ b/ios/CertificatePinning.swift @@ -35,10 +35,27 @@ enum CertificatePinning { #if DEBUG return #else - // Each domain pins the leaf SPKI hash (primary) plus its issuing intermediate CA SPKI hash - // (durable backup that survives leaf rotation). TrustKit requires at least two pins per domain. - let groupAExpensifyCom = ["cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="] - let groupBNewExpensify = ["G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="] + // Cloudflare-fronted expensify.com hosts (Groups A & B): Cloudflare can rotate the edge + // cert between Let's Encrypt, Google Trust Services and SSL.com without notice (the + // 2026-07-07 Let's Encrypt -> GTS rotation broke us; www has since reverted to Let's + // Encrypt). To survive leaf rotation, intermediate rotation AND a CA switch without an + // emergency release, we pin the SPKI of the ROOT of all three CAs plus each live issuing + // intermediate. Regenerate via scripts/generateCertificatePins.sh. + let cloudflareExpensify = [ + // Let's Encrypt (live CA for these hosts) + "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", // ISRG Root X1 + "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", // ISRG Root X2 + "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", // Let's Encrypt YE1 intermediate + // Google Trust Services (backup) + "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", // GTS Root R1 + "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", // GTS Root R2 + "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", // GTS Root R3 + "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", // GTS Root R4 + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", // Google Trust Services WE1 intermediate + // SSL.com (backup) + "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", // SSL.com TLS ECC Root CA 2022 + "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=", // SSL.com TLS RSA Root CA 2022 + ] let groupCIntegrations = ["7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="] let groupDTravel = ["Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="] let groupECloudfront = ["P9HBoLji8YncXSnb0AnAm72fJO/vpmxZrsl4fvUBkxc=", "DxH4tt40L+eduF6szpY6TONlxhZhBd+pJ9wbHlQ2fuw="] @@ -55,16 +72,16 @@ enum CertificatePinning { kTSKSwizzleNetworkDelegates: true, kTSKPinnedDomains: [ // Production - "www.expensify.com": domain(groupAExpensifyCom), - "secure.expensify.com": domain(groupAExpensifyCom), - "new.expensify.com": domain(groupBNewExpensify), + "www.expensify.com": domain(cloudflareExpensify), + "secure.expensify.com": domain(cloudflareExpensify), + "new.expensify.com": domain(cloudflareExpensify), "integrations.expensify.com": domain(groupCIntegrations), "travel.expensify.com": domain(groupDTravel), "d2k5nsl2zxldvw.cloudfront.net": domain(groupECloudfront), // Staging (beta/TestFlight release builds hit staging.* with __DEV__ === false) - "staging.expensify.com": domain(groupAExpensifyCom), - "staging-secure.expensify.com": domain(groupAExpensifyCom), - "staging.new.expensify.com": domain(groupBNewExpensify), + "staging.expensify.com": domain(cloudflareExpensify), + "staging-secure.expensify.com": domain(cloudflareExpensify), + "staging.new.expensify.com": domain(cloudflareExpensify), "staging.travel.expensify.com": domain(groupDTravel), ], ] diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index d5636e016440..e273f55efea5 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.69 + 9.4.77 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.69.0 + 9.4.77.2 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index c50dbd7e94b9..5200c517d4bb 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.69 + 9.4.77 CFBundleVersion - 9.4.69.0 + 9.4.77.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9a1d764449e8..3b7ce8028739 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -228,7 +228,7 @@ PODS: - fmt (12.1.0) - ForkInputMask (7.3.3) - FullStory (1.70.1) - - fullstory_react-native (1.9.0): + - fullstory_react-native (1.10.2): - boost - DoubleConversion - fast_float @@ -571,6 +571,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -596,6 +597,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -620,6 +622,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -646,6 +649,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -671,6 +675,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -696,6 +701,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -721,6 +727,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -746,6 +753,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -771,6 +779,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -796,6 +805,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -821,6 +831,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -846,6 +857,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -871,6 +883,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -896,6 +909,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -2685,7 +2699,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-keyboard-controller (1.22.2): + - react-native-keyboard-controller (1.22.4): - boost - DoubleConversion - fast_float @@ -2703,7 +2717,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-keyboard-controller/common (= 1.22.2) + - react-native-keyboard-controller/common (= 1.22.4) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2714,7 +2728,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-keyboard-controller/common (1.22.2): + - react-native-keyboard-controller/common (1.22.4): - boost - DoubleConversion - fast_float @@ -4988,7 +5002,7 @@ SPEC CHECKSUMS: fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac ForkInputMask: 55e3fbab504b22da98483e9f9a6514b98fdd2f3c FullStory: 8f87790ed749aa66497e739b065932852e57077d - fullstory_react-native: 1374480e8734b0b9ad7a5565cd94aa1215416368 + fullstory_react-native: 4ecf3c4931f5386ba7465b04c77d46c1b78b9ff7 glog: e56ede4028c4b7418e6b1195a36b1656bb35e225 GoogleAppMeasurement: 0dfca1a4b534d123de3945e28f77869d10d0d600 GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db @@ -5026,7 +5040,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 922be6f90a3addd48f57f2066dd90aacec4768b6 React: 2574546f2d017abd14d0c9b48cf2b6a0547c2591 React-callinvoker: b997d4d109c92cae4bc7f1edb08dc6a7c11a1ca2 - React-Core: 5c7a37fd611353c8bf1c12ef24cfdec81ff46549 + React-Core: ee317fb557001c3972bcf4fe15996197e65ae54d React-CoreModules: 2ae392df55453fca5602e9bb5bc16a72f36dd961 React-cxxreact: 9a58282eb607fb19d775cfaa8ff9da7be8fc99c5 React-debug: 307f174c04c2e1f51b31c31efcc3e635b1e56453 @@ -5064,7 +5078,7 @@ SPEC CHECKSUMS: react-native-document-picker: 18a9d68e6117d37ac5facd87619cbc3ff17575a7 react-native-image-picker: 1722f5d3dc5de5039d423e290dc31a717ba9605e react-native-key-command: 7538df85ed26502b2a929c0584235459b26c7a91 - react-native-keyboard-controller: f2b654475ffee6ba61b159d33a26465a94a441a0 + react-native-keyboard-controller: 600debfa979a4f0ed8433efb2f861028aa4cf5e8 react-native-netinfo: f94b3a0fc305e812f3f615989d99299d7110c2ae react-native-pager-view: c62ab82db28e2d11e7809c59085e2984a45979c5 react-native-pdf: 6a09a9be0e7ee954ea671437483316f9a28f8572 @@ -5078,7 +5092,7 @@ SPEC CHECKSUMS: React-NativeModulesApple: e477c7b5f198235d9de93702ef6b495b87b625bc React-networking: b90039a00fd63e75a78358ee5fc2ea4b4cbad736 React-oscompat: e3c95718adca6d0e7ca9e5b01a04d057f4aa8f72 - React-perflogger: 327334823748e1ecac8cb76e3aa1c072d99146af + React-perflogger: 8712c3310a467e036fceac437dc7f2a4be69e634 React-performancecdpmetrics: 992de932ea38bfc3e49f7b4eba19f6c1aca021bb React-performancetimeline: a0626b87f948b534b3fb90b696ad11863af94dd8 React-RCTActionSheet: cbe921221b0ca00d2ea586efbaa494bc990260c1 @@ -5094,7 +5108,7 @@ SPEC CHECKSUMS: React-RCTSettings: b478cd2ee4091e33bb847edef83c19aa87bf1c45 React-RCTText: 6bae9a5e52ebc012d7bc499542c42285f73d1e1d React-RCTVibration: 3ddd10141590a1800a1890248b47337d9004b7f4 - React-rendererconsistency: 8759d621ba9d2a3139c266d3462bf2e38ccc4b01 + React-rendererconsistency: d1e82296151f814802c06be35952c9fda99973b1 React-renderercss: 7b56e138a3d800fa9e17e61cdb3b293d6b8622c6 React-rendererdebug: cf7d7ca70a0b7263df1d49c87970ae6cfe7ce5d0 React-RuntimeApple: 39be405060d5dc5d1f9e4ee4e24ad09cd079a5db diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index f2bedbb7e6ca..195c5b0a5d71 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.69 + 9.4.77 CFBundleVersion - 9.4.69.0 + 9.4.77.2 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index 7b9a523b566f..14b6e257de98 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,10 @@ module.exports = { `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { - '^.+\\.[jt]sx?$': 'babel-jest', + // Reassure re-transforms ~7k files under `--max-opt=1` (V8 sparkplug only), which + // makes Babel ~half of each measure job. OXC + esbuild is native and stays fast + // without TurboFan. Test files stay on babel-jest so `jest.mock` is still hoisted. + '^.+\\.[jt]sx?$': isPerfTestRun ? '/config/babel/oxcJestTransformer.js' : 'babel-jest', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ diff --git a/jest/setup.ts b/jest/setup.ts index 3a6464839967..09de71c6a8ef 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -2,6 +2,7 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@shopify/flash-list/jestSetup'; import type {ReactNode} from 'react'; +import type React from 'react'; import type * as RNAppLogs from 'react-native-app-logs'; import type {ReadDirItem} from 'react-native-fs'; import type * as RNKeyboardController from 'react-native-keyboard-controller'; @@ -18,6 +19,7 @@ import '@src/polyfills/requestIdleCallback'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; +import setupMockLegendList from './setupMockLegendList'; // Needed for tests to have the necessary environment variables set if (!('GITHUB_REPOSITORY' in process.env)) { @@ -27,6 +29,7 @@ if (!('GITHUB_REPOSITORY' in process.env)) { setupMockImages(); mockFSLibrary(); +setupMockLegendList(); // Polyfill necessary for Onyx.init in jest/setupAfterEnv.ts Object.assign(global, {TextDecoder, TextEncoder}); diff --git a/jest/setupMockLegendList.ts b/jest/setupMockLegendList.ts new file mode 100644 index 000000000000..489bed5d311e --- /dev/null +++ b/jest/setupMockLegendList.ts @@ -0,0 +1,166 @@ +import type * as LegendListModule from '@legendapp/list/react-native'; +import type React from 'react'; +import type {ScrollViewProps, View as ReactNativeView} from 'react-native'; + +export default function setupMockLegendList() { + jest.mock('@legendapp/list/react-native', () => { + const ReactActual = jest.requireActual('react'); + const {ScrollView, View} = jest.requireActual<{ScrollView: React.ComponentType; View: typeof ReactNativeView}>('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); + + type MockLegendListProps = LegendListModule.LegendListProps; + + /** + * LegendList relies on native layout measurements that Jest does not produce. Render every item in a + * ScrollView so tests can exercise list content and callbacks without depending on another virtualized list. + */ + const MockLegendList = ReactActual.forwardRef( + ( + { + children, + data, + extraData, + getItemType, + ItemSeparatorComponent, + keyExtractor, + ListEmptyComponent, + ListFooterComponent, + ListFooterComponentStyle, + ListHeaderComponent, + ListHeaderComponentStyle, + maintainVisibleContentPosition: _maintainVisibleContentPosition, + onEndReached, + onEndReachedThreshold = 0.5, + onLoad, + onScroll, + onStartReached, + onStartReachedThreshold = 0.5, + recycleItems: _recycleItems, + renderItem, + ...scrollViewProps + }, + ref, + ) => { + const onLoadRef = ReactActual.useRef(onLoad); + const listMetricsRef = ReactActual.useRef<{contentLength: number; scroll: number; scrollLength: number} | undefined>(undefined); + const reachedEdgesRef = ReactActual.useRef({end: false, start: false}); + onLoadRef.current = onLoad; + + ReactActual.useEffect(() => { + onLoadRef.current?.({elapsedTimeInMs: 0}); + }, []); + + ReactActual.useImperativeHandle( + ref, + () => ({ + clearCaches: jest.fn(), + flashScrollIndicators: jest.fn(), + getAnimatableRef: () => null, + getNativeScrollRef: () => null, + getScrollableNode: () => null, + getScrollResponder: () => null, + getState: () => ({ + data: data ?? [], + elementAtIndex: () => undefined, + endBuffered: (data?.length ?? 0) - 1, + startBuffered: 0, + ...listMetricsRef.current, + }), + reportContentInset: jest.fn(), + scrollIndexIntoView: jest.fn(() => Promise.resolve()), + scrollItemIntoView: jest.fn(() => Promise.resolve()), + scrollToEnd: jest.fn(() => Promise.resolve()), + scrollToIndex: jest.fn(() => Promise.resolve()), + scrollToItem: jest.fn(() => Promise.resolve()), + scrollToOffset: jest.fn(() => Promise.resolve()), + setItemSize: jest.fn(), + setScrollProcessingEnabled: jest.fn(), + setVisibleContentAnchorOffset: jest.fn(), + }), + [data], + ); + + const handleScroll: NonNullable = (event) => { + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const isHorizontal = scrollViewProps.horizontal === true; + const offset = isHorizontal ? contentOffset.x : contentOffset.y; + const contentLength = isHorizontal ? contentSize.width : contentSize.height; + const visibleLength = isHorizontal ? layoutMeasurement.width : layoutMeasurement.height; + const distanceFromEnd = contentLength - visibleLength - offset; + const isWithinEndThreshold = distanceFromEnd <= visibleLength * (onEndReachedThreshold ?? 0.5); + const isWithinStartThreshold = offset <= visibleLength * (onStartReachedThreshold ?? 0.5); + listMetricsRef.current = { + contentLength, + scroll: offset, + scrollLength: visibleLength, + }; + onScroll?.(event); + + if (!isWithinEndThreshold) { + reachedEdgesRef.current.end = false; + } else if (!reachedEdgesRef.current.end) { + reachedEdgesRef.current.end = true; + onEndReached?.({distanceFromEnd}); + } + if (!isWithinStartThreshold) { + reachedEdgesRef.current.start = false; + } else if (!reachedEdgesRef.current.start) { + reachedEdgesRef.current.start = true; + onStartReached?.({distanceFromStart: offset}); + } + }; + + const renderedItems = data?.flatMap((item, index) => { + if (!renderItem) { + return []; + } + + const itemKey = keyExtractor?.(item, index) ?? String(index); + const safeExtraData: unknown = extraData; + const itemElement = ReactActual.createElement(View, {key: itemKey}, renderItem({data, extraData: safeExtraData, index, item, type: getItemType?.(item, index)})); + if (!ItemSeparatorComponent || index === data.length - 1) { + return [itemElement]; + } + + return [itemElement, ReactActual.createElement(ItemSeparatorComponent, {key: `${itemKey}-separator`, leadingItem: item})]; + }); + const header = ReactActual.isValidElement(ListHeaderComponent) ? ListHeaderComponent : ListHeaderComponent && ReactActual.createElement(ListHeaderComponent); + const footer = ReactActual.isValidElement(ListFooterComponent) ? ListFooterComponent : ListFooterComponent && ReactActual.createElement(ListFooterComponent); + const empty = ReactActual.isValidElement(ListEmptyComponent) ? ListEmptyComponent : ListEmptyComponent && ReactActual.createElement(ListEmptyComponent); + let content = children; + if (data) { + content = data.length > 0 ? renderedItems : empty; + } + + return ReactActual.createElement( + ScrollView, + {...scrollViewProps, onScroll: handleScroll}, + header && ReactActual.createElement(View, {style: ListHeaderComponentStyle}, header), + content, + footer && ReactActual.createElement(View, {style: ListFooterComponentStyle}, footer), + ); + }, + ); + const LegendList = jest.fn((props: MockLegendListProps & {ref?: React.Ref}) => ReactActual.createElement(MockLegendList, props)); + + const useRecyclingState = (valueOrInitializer: T | (() => T)) => ReactActual.useState(valueOrInitializer); + + return { + ...LegendListActual, + LegendList, + useAdaptiveRender: () => 'normal', + useAdaptiveRenderChange: jest.fn(), + useIsLastItem: () => false, + useListScrollSize: () => ({height: 0, width: 0}), + useRecyclingEffect: jest.fn(), + useRecyclingState, + useSyncLayout: () => jest.fn(), + useViewability: jest.fn(), + useViewabilityAmount: jest.fn(), + }; + }); + + jest.mock('@legendapp/list/reanimated', () => ({ + AnimatedLegendList: jest.requireMock('@legendapp/list/react-native').LegendList, + })); +} diff --git a/knip.json b/knip.json index 5ba328041087..1f84a7fa0b71 100644 --- a/knip.json +++ b/knip.json @@ -15,6 +15,7 @@ "tests/globals.d.ts", ".storybook/**/*.{js,ts,tsx}", "metro.config.js", + "config/babel/oxcJestTransformer.js", "eslint.changed.config.mjs", "react-native.config.js", "rock.config.mjs", @@ -87,5 +88,5 @@ "diff-so-fancy", "@babel/plugin-proposal-class-properties" ], - "ignoreBinaries": ["metro-symbolicate", "mkcert"] + "ignoreBinaries": ["metro-symbolicate", "mkcert", "openssl"] } diff --git a/modules/ExpensifyNitroUtils/tsconfig.json b/modules/ExpensifyNitroUtils/tsconfig.json index f3a65135dd9f..0c146ba3226e 100644 --- a/modules/ExpensifyNitroUtils/tsconfig.json +++ b/modules/ExpensifyNitroUtils/tsconfig.json @@ -1,6 +1,10 @@ { - "extends": "../../tsconfig.json", + // The app project, not the root solution: the solution owns no compiler options. + "extends": "../../tsconfig.app.json", "compilerOptions": { + // Without this the app project's build info path is inherited and this module would write + // over it. + "tsBuildInfoFile": "./tsconfig.ts7.tsbuildinfo", "outDir": "./lib" }, "include": ["src/**/*"] diff --git a/package-lock.json b/package-lock.json index 0a4ff4da6ea0..f89afed38cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.69-0", + "version": "9.4.77-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.69-0", + "version": "9.4.77-2", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -30,9 +30,10 @@ "@formatjs/intl-numberformat": "^8.10.3", "@formatjs/intl-pluralrules": "^5.2.14", "@fullstory/browser": "^2.0.6", - "@fullstory/react-native": "^1.9.0", + "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", + "@legendapp/list": "^3.3.10", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -53,8 +54,8 @@ "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.3.2", "@sbaiahmed1/react-native-biometrics": "0.15.0", - "@sentry/core": "10.47.0", - "@sentry/react-native": "8.7.0", + "@sentry/core": "10.73.0", + "@sentry/react-native": "8.25.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", @@ -119,12 +120,12 @@ "react-native-image-picker": "^7.1.2", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8a1c3b7359977462cdd4e63b09ea45afbd3d77c7", "react-native-key-command": "1.0.14", - "react-native-keyboard-controller": "1.22.2", + "react-native-keyboard-controller": "1.22.4", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.5.4", "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.109", + "react-native-onyx": "3.0.111", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -167,7 +168,7 @@ "@callstack/reassure-compare": "^1.0.0-rc.4", "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@fullstory/babel-plugin-annotate-react": "^2.4.0", - "@fullstory/babel-plugin-react-native": "^1.5.2", + "@fullstory/babel-plugin-react-native": "^1.7.1", "@googleapis/androidpublisher": "^37.0.0", "@jest/globals": "^29.7.0", "@jridgewell/remapping": "^2.3.5", @@ -250,7 +251,6 @@ "eslint-plugin-storybook": "10.4.6", "eslint-plugin-testing-library": "^7.11.0", "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", - "eslint-seatbelt": "^0.1.3", "glob": "^10.4.5", "http-server": "^14.1.1", "jest": "29.7.0", @@ -259,7 +259,7 @@ "jest-environment-jsdom": "^29.7.0", "jest-expo": "57.0.2", "jest-transformer-svg": "^2.0.1", - "knip": "^6.14.0", + "knip": "^6.34.0", "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", @@ -4823,21 +4823,32 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -9420,9 +9431,9 @@ } }, "node_modules/@fullstory/babel-plugin-react-native": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-react-native/-/babel-plugin-react-native-1.6.1.tgz", - "integrity": "sha512-bRKzMdYdZlG1RKVNJMv3UXmXvJ2OIdvpwsOxJzCnG0LTINv8bqJ2s/9pXIj1O73ppPDfWRESMChlPjEn5JcLuw==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-react-native/-/babel-plugin-react-native-1.7.1.tgz", + "integrity": "sha512-wCsE7LT16meUr/G/lXGh/QzekbEK40J0U1s3KK4SyDOo0WFRLcOn62nbGmYwfODi2U2+/Fli4GR0wSq7Z/bg+A==", "license": "MIT", "dependencies": { "@babel/parser": "^7.0.0", @@ -9437,13 +9448,13 @@ } }, "node_modules/@fullstory/react-native": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@fullstory/react-native/-/react-native-1.9.0.tgz", - "integrity": "sha512-KZSyAlX9WmIm2r/0b9U4auBGrxmdEC3M/MolA5eiQFgTMdSXpoRscupfJMvzHssygR1TlYUxKf0mvxM3rPj0Yg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@fullstory/react-native/-/react-native-1.10.2.tgz", + "integrity": "sha512-WhYjw+KnbuvVTF+go5SY5BbzYFoTg8HvHOGPpghjeigWu+DOc5ERN/zDTIGoDcCDI1Q8KDMczsj1GC7rtsXLlQ==", "license": "MIT", "dependencies": { - "@fullstory/babel-plugin-annotate-react": "^2.3.2", - "@fullstory/babel-plugin-react-native": "^1.6.1" + "@fullstory/babel-plugin-annotate-react": "~2.4.0", + "@fullstory/babel-plugin-react-native": "~1.7.0" }, "peerDependencies": { "expo": ">=47.0.0", @@ -11397,6 +11408,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@legendapp/list": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.10.tgz", + "integrity": "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", @@ -12270,9 +12301,9 @@ ] }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", - "integrity": "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.147.0.tgz", + "integrity": "sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==", "cpu": [ "arm" ], @@ -12286,14 +12317,179 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.147.0.tgz", + "integrity": "sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.147.0.tgz", + "integrity": "sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.147.0.tgz", + "integrity": "sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.147.0.tgz", + "integrity": "sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.147.0.tgz", + "integrity": "sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.147.0.tgz", + "integrity": "sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.147.0.tgz", + "integrity": "sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.147.0.tgz", + "integrity": "sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.130.0.tgz", - "integrity": "sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.147.0.tgz", + "integrity": "sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.147.0.tgz", + "integrity": "sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -12304,13 +12500,76 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.130.0.tgz", - "integrity": "sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.147.0.tgz", + "integrity": "sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.147.0.tgz", + "integrity": "sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.147.0.tgz", + "integrity": "sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.147.0.tgz", + "integrity": "sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -12321,9 +12580,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.130.0.tgz", - "integrity": "sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.147.0.tgz", + "integrity": "sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==", "cpu": [ "arm64" ], @@ -12337,10 +12596,27 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.147.0.tgz", + "integrity": "sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.130.0.tgz", - "integrity": "sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.147.0.tgz", + "integrity": "sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==", "cpu": [ "ia32" ], @@ -12354,10 +12630,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.147.0.tgz", + "integrity": "sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", - "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", "cpu": [ "arm" ], @@ -12369,9 +12672,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", - "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", "cpu": [ "arm64" ], @@ -12383,9 +12686,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", - "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", "cpu": [ "arm64" ], @@ -12397,9 +12700,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", - "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", "cpu": [ "x64" ], @@ -12411,9 +12714,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", - "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", "cpu": [ "x64" ], @@ -12425,9 +12728,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", - "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", "cpu": [ "arm" ], @@ -12439,9 +12742,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", - "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", "cpu": [ "arm" ], @@ -12453,13 +12756,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", - "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -12467,13 +12773,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", - "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -12481,13 +12790,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", - "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -12495,13 +12807,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", - "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -12509,13 +12824,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", - "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -12523,13 +12841,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", - "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -12537,13 +12858,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", - "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -12551,13 +12875,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", - "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -12565,9 +12892,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", - "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", "cpu": [ "arm64" ], @@ -12579,9 +12906,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", - "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", "cpu": [ "wasm32" ], @@ -12589,16 +12916,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", - "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", "cpu": [ "arm64" ], @@ -12609,24 +12938,10 @@ "win32" ] }, - "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", - "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", - "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", "cpu": [ "x64" ], @@ -12764,9 +13079,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12784,9 +13096,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12804,9 +13113,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12824,9 +13130,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12844,9 +13147,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12864,9 +13164,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12884,9 +13181,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12904,9 +13198,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -15891,56 +16182,6 @@ "react-native": "*" } }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.47.0.tgz", - "integrity": "sha512-bVFRAeJWMBcBCvJKIFCMJ1/yQToL4vPGqfmlnDZeypcxkqUDKQ/Y3ziLHXoDL2sx0lagcgU2vH1QhCQ67Aujjw==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.47.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.47.0.tgz", - "integrity": "sha512-pdvMmi4dQpX5S/vAAzrhHPIw3T3HjUgDNgUiCBrlp7N9/6zGO2gNPhUnNekP+CjgI/z0rvf49RLqlDenpNrMOg==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.47.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.47.0.tgz", - "integrity": "sha512-ScdovxP7hJxgMt70+7hFvwT02GIaIUAxdEM/YPsayZBeCoAukPW8WiwztJfoKtsfPyKJ5A6f0H3PIxTPcA9Row==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.47.0", - "@sentry/core": "10.47.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.47.0.tgz", - "integrity": "sha512-A5OY8friSe6g8WAK4L8IeOPiEd9D3Ps40DzRH5j2f6SUja0t90mKMvHRcRf8zq0d4BkdB+JM7tjOkwxpuv8heA==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.47.0", - "@sentry/core": "10.47.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@sentry/babel-plugin-component-annotate": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.2.0.tgz", @@ -15952,16 +16193,30 @@ } }, "node_modules/@sentry/browser": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.47.0.tgz", - "integrity": "sha512-rC0agZdxKA5XWfL4VwPOr/rJMogXDqZgnVzr93YWpFn9DMZT/7LzxSJVPIJwRUjx3bFEby3PcTa3YaX7pxm1AA==", + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", + "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/feedback": "10.73.0", + "@sentry/replay": "10.73.0", + "@sentry/replay-canvas": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", + "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.47.0", - "@sentry-internal/feedback": "10.47.0", - "@sentry-internal/replay": "10.47.0", - "@sentry-internal/replay-canvas": "10.47.0", - "@sentry/core": "10.47.0" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" }, "engines": { "node": ">=18" @@ -16417,23 +16672,239 @@ "node": ">=18" } }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@sentry/core": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.47.0.tgz", - "integrity": "sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==", + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz", + "integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==", "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@sentry/expo-upload-sourcemaps/-/expo-upload-sourcemaps-8.25.0.tgz", + "integrity": "sha512-eiAXxyO20iHiwIWNQ67MMa8arNn1/XM65+8RvDrv3/tBxFnWvfcBX0vjqU/oWERUYANfZiQ/tjDjo9aMov8C3Q==", + "license": "MIT", + "dependencies": { + "@sentry/cli": "3.7.0" + }, + "bin": { + "expo-upload-sourcemaps": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@expo/env": "*", + "dotenv": "*" + }, + "peerDependenciesMeta": { + "@expo/env": { + "optional": true + }, + "dotenv": { + "optional": true + } + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", + "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.7.0", + "@sentry/cli-linux-arm": "3.7.0", + "@sentry/cli-linux-arm64": "3.7.0", + "@sentry/cli-linux-i686": "3.7.0", + "@sentry/cli-linux-x64": "3.7.0", + "@sentry/cli-win32-arm64": "3.7.0", + "@sentry/cli-win32-i686": "3.7.0", + "@sentry/cli-win32-x64": "3.7.0" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-darwin": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", + "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-arm": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", + "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", + "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", + "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", + "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-win32-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", + "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-win32-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", + "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-win32-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", + "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", + "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0" + }, "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.47.0.tgz", - "integrity": "sha512-ZtJV6xxF8jUVE9e3YQUG3Do0XapG1GjniyLyqMPgN6cNvs/HaRJODf7m60By+VGqcl5XArEjEPTvx8CdPUXDfA==", + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", + "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.47.0", - "@sentry/core": "10.47.0" + "@sentry/browser": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" }, "engines": { "node": ">=18" @@ -16443,17 +16914,17 @@ } }, "node_modules/@sentry/react-native": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-8.7.0.tgz", - "integrity": "sha512-15c7L/DW+Po2/6jmwEB+9A1F6SEKOjfUsKZ3wW059Qsksk2gkCS0eeOlkYs20L4ucnstDdDtQD846tZsaeGf7Q==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-8.25.0.tgz", + "integrity": "sha512-iYsa32WfiZN8JDaSxT1n0YwFUqvmAOWSRQaOonpxg7kkC77EQP1bOIkfWQvIfXbhrhwmRPTR8jhSQcLw0c/DDQ==", "license": "MIT", "dependencies": { - "@sentry/babel-plugin-component-annotate": "5.1.1", - "@sentry/browser": "10.47.0", - "@sentry/cli": "3.3.5", - "@sentry/core": "10.47.0", - "@sentry/react": "10.47.0", - "@sentry/types": "10.47.0" + "@sentry/browser": "10.73.0", + "@sentry/bundler-plugins": "10.73.0", + "@sentry/cli": "3.7.0", + "@sentry/core": "10.73.0", + "@sentry/expo-upload-sourcemaps": "8.25.0", + "@sentry/react": "10.73.0" }, "bin": { "sentry-eas-build-on-complete": "scripts/eas-build-hook.js", @@ -16472,19 +16943,205 @@ } } }, - "node_modules/@sentry/react-native/node_modules/@sentry/babel-plugin-component-annotate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.1.1.tgz", - "integrity": "sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==", + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-10.73.0.tgz", + "integrity": "sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==", "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/cli": "^2.58.6", + "@sentry/core": "10.73.0", + "dotenv": "^17.4.2", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, "engines": { "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0", + "webpack": ">=5.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.3.5.tgz", - "integrity": "sha512-eyLHTj0rpeCsOUX+1ZU8UEWRXy6nXvTXNdhtAt1t6YXan9gSsAexZf28zVmDcYcP8WRbK0D2JMLp7NcaQCQgEA==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", + "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", "hasInstallScript": true, "license": "FSL-1.1-MIT", "dependencies": { @@ -16500,20 +17157,20 @@ "node": ">= 18" }, "optionalDependencies": { - "@sentry/cli-darwin": "3.3.5", - "@sentry/cli-linux-arm": "3.3.5", - "@sentry/cli-linux-arm64": "3.3.5", - "@sentry/cli-linux-i686": "3.3.5", - "@sentry/cli-linux-x64": "3.3.5", - "@sentry/cli-win32-arm64": "3.3.5", - "@sentry/cli-win32-i686": "3.3.5", - "@sentry/cli-win32-x64": "3.3.5" + "@sentry/cli-darwin": "3.7.0", + "@sentry/cli-linux-arm": "3.7.0", + "@sentry/cli-linux-arm64": "3.7.0", + "@sentry/cli-linux-i686": "3.7.0", + "@sentry/cli-linux-x64": "3.7.0", + "@sentry/cli-win32-arm64": "3.7.0", + "@sentry/cli-win32-i686": "3.7.0", + "@sentry/cli-win32-x64": "3.7.0" } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-darwin": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.3.5.tgz", - "integrity": "sha512-E/SIY6+j2nt6Ri9nMt78sYle3LiF6uZyz4HGmvcEMU6HXjegmAayhy0J10JST+vZTzN6VixD8sUsa5UeJiOPcg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", + "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", "license": "FSL-1.1-MIT", "optional": true, "os": [ @@ -16524,9 +17181,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-arm": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.3.5.tgz", - "integrity": "sha512-EGuEIvC2OQyar/vu+jAQEmovTMgxpoxdx5knnzL5dLhIemjEUNqwv/sXq+m/Aj+ThqCMofcTWB2TOZXsTtl0Tw==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", + "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", "cpu": [ "arm" ], @@ -16542,9 +17199,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-arm64": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.3.5.tgz", - "integrity": "sha512-/W7HTk2OFKD0bguTvQR1ue6pkFQWaGiqPafOSIQKyq0aGfbZhBn/Uj+IRefgMZMhJQ29xRz0y/iGRGKE+ef4Vg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", + "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", "cpu": [ "arm64" ], @@ -16560,9 +17217,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-i686": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.3.5.tgz", - "integrity": "sha512-qODMEWLEeUNp3IUlwwISB37EXSo8qgMmHQuLKfxDjpIKw+7NAFfptOloqPrHkLWK3TzFr+Nv643wIKZaYrz28Q==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", + "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", "cpu": [ "x86", "ia32" @@ -16579,9 +17236,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-x64": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.3.5.tgz", - "integrity": "sha512-DCz7lQ4PySjQ1WvWOQ/uTdwauRo1D7hSHazxZ+fUAnK/epSPM9qLkjDMlD8uM5CaLoR8+ZTs7N94vV5LZs2QpA==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", + "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", "cpu": [ "x64" ], @@ -16597,9 +17254,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-win32-arm64": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.3.5.tgz", - "integrity": "sha512-VMNsHiyZcP8Ft3fcK/1zoO4L66soe1eSfXg2tglFQSc/2MYA5v1Br9B1GtjBwDIc3EmdPtFZhOGLyqIzszMxJw==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", + "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", "cpu": [ "arm64" ], @@ -16613,9 +17270,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-win32-i686": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.3.5.tgz", - "integrity": "sha512-BE6aHOIpsm4jgavsvvXNcSikAr/8NSva3rk1N3BzoOLuX+dcFxBI60K1i2VzB1vsgtivJJo9YySNCi60dBgWTg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", + "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", "cpu": [ "x86", "ia32" @@ -16630,9 +17287,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-win32-x64": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.3.5.tgz", - "integrity": "sha512-MSU+PtBuiLjEbiPFOvxk4CI3TCagwkIg9kvJ+DrI3+pBY0Sga/dOyeWKTIgb01xSVcfjdw0UkpU52VCvzTT9ew==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", + "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", "cpu": [ "x64" ], @@ -16645,13 +17302,117 @@ "node": ">=18" } }, - "node_modules/@sentry/types": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-10.47.0.tgz", - "integrity": "sha512-WhV/ZO/a+3pU00rjXtb6WeZFZYn0Kz3JTc8Xvp7cx/D+kQJBm6b942TGb32UvnsglALLBxRyIBG+QO2u5r6cXw==", + "node_modules/@sentry/react-native/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/react-native/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.47.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@sentry/react-native/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sentry/react-native/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/react-native/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@sentry/react-native/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/react-native/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/replay": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", + "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", + "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0", + "@sentry/replay": "10.73.0" }, "engines": { "node": ">=18" @@ -18439,9 +19200,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/geojson": { @@ -20060,7 +20821,6 @@ }, "node_modules/agent-base": { "version": "6.0.2", - "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -20269,16 +21029,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -22064,58 +22814,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz", - "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^4.0.2", - "chalk": "^2.4.2", - "table-layout": "^1.0.2", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/command-line-usage/node_modules/typical": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", - "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/commander": { "version": "12.1.0", "license": "MIT", @@ -23425,16 +24123,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -23737,7 +24425,7 @@ }, "node_modules/dotenv": { "version": "16.4.5", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -24980,31 +25668,6 @@ "node": ">=4.0" } }, - "node_modules/eslint-seatbelt": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/eslint-seatbelt/-/eslint-seatbelt-0.1.3.tgz", - "integrity": "sha512-S8vot6z0Sr4wN1hGVmh3ex8hb7pSm0YNwZNS2Kkqd7CbtwjWQkqXIpoqZSEEW7PaCDMK6G6RBoXXRW/JygxTJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-command-line-args": "^2.5.1" - }, - "bin": { - "eslint-seatbelt": "dist/command.js" - }, - "peerDependencies": { - "@types/eslint": "*", - "eslint": "*" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint": { - "optional": true - } - } - }, "node_modules/eslint-visitor-keys": { "version": "2.1.0", "dev": true, @@ -26289,22 +26952,8 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, - "node_modules/find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/find-up": { "version": "5.0.0", - "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -26332,7 +26981,6 @@ }, "node_modules/find-up/node_modules/path-exists": { "version": "4.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -26607,13 +27255,14 @@ } }, "node_modules/formatly": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", - "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.0.tgz", + "integrity": "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==", "dev": true, "license": "MIT", "dependencies": { - "fd-package-json": "^2.0.0" + "fd-package-json": "^2.0.0", + "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" @@ -26993,9 +27642,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -27634,7 +28283,6 @@ }, "node_modules/https-proxy-agent": { "version": "5.0.1", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -31178,9 +31826,9 @@ } }, "node_modules/knip": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.14.0.tgz", - "integrity": "sha512-yEI9ysdGQ3h77gLObvovH0KUYs6ITtJ1f6owmXRalOO32TbolYvHY7Z+2AEOXqw0ZWeh9219/agh2K/GmtfsxQ==", + "version": "6.34.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.34.0.tgz", + "integrity": "sha512-bbHIrnGspYwe4EBPjjx+lvkUor0F2qfKQc5BPzPI4SOAImYA+k2ueVpIcF7d/W1LEVT7XoJUPt6zELeGZhXBgA==", "dev": true, "funding": [ { @@ -31195,19 +31843,18 @@ "license": "ISC", "dependencies": { "fdir": "^6.5.0", - "formatly": "^0.3.0", - "get-tsconfig": "4.14.0", + "formatly": "^0.7.0", + "get-tsconfig": "4.14.3", "jiti": "^2.7.0", - "minimist": "^1.2.8", - "oxc-parser": "^0.130.0", - "oxc-resolver": "^11.19.1", - "picomatch": "^4.0.4", - "smol-toml": "^1.6.1", + "oxc-parser": "^0.147.0", + "oxc-resolver": "11.24.2", + "picomatch": "^4.0.7", + "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", - "tinyglobby": "^0.2.16", - "unbash": "^3.0.0", + "tinyglobby": "^0.2.17", + "unbash": "^4.0.10", "yaml": "^2.9.0", - "zod": "^4.1.11" + "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", @@ -31217,273 +31864,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/knip/node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.130.0.tgz", - "integrity": "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.130.0.tgz", - "integrity": "sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.130.0.tgz", - "integrity": "sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.130.0.tgz", - "integrity": "sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.130.0.tgz", - "integrity": "sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.130.0.tgz", - "integrity": "sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.130.0.tgz", - "integrity": "sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.130.0.tgz", - "integrity": "sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.130.0.tgz", - "integrity": "sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.130.0.tgz", - "integrity": "sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.130.0.tgz", - "integrity": "sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.130.0.tgz", - "integrity": "sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.130.0.tgz", - "integrity": "sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.130.0.tgz", - "integrity": "sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.130.0.tgz", - "integrity": "sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/knip/node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/knip/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -31512,48 +31892,10 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/knip/node_modules/oxc-parser": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.130.0.tgz", - "integrity": "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.130.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.130.0", - "@oxc-parser/binding-android-arm64": "0.130.0", - "@oxc-parser/binding-darwin-arm64": "0.130.0", - "@oxc-parser/binding-darwin-x64": "0.130.0", - "@oxc-parser/binding-freebsd-x64": "0.130.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", - "@oxc-parser/binding-linux-arm64-musl": "0.130.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", - "@oxc-parser/binding-linux-x64-gnu": "0.130.0", - "@oxc-parser/binding-linux-x64-musl": "0.130.0", - "@oxc-parser/binding-openharmony-arm64": "0.130.0", - "@oxc-parser/binding-wasm32-wasi": "0.130.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", - "@oxc-parser/binding-win32-x64-msvc": "0.130.0" - } - }, "node_modules/knip/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -31576,10 +31918,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/knip/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/knip/node_modules/zod": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", - "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "dev": true, "license": "MIT", "funding": { @@ -32136,7 +32495,6 @@ }, "node_modules/locate-path": { "version": "6.0.0", - "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -32390,7 +32748,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -33983,36 +34340,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-parser": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.147.0.tgz", + "integrity": "sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.147.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.147.0", + "@oxc-parser/binding-android-arm64": "0.147.0", + "@oxc-parser/binding-darwin-arm64": "0.147.0", + "@oxc-parser/binding-darwin-x64": "0.147.0", + "@oxc-parser/binding-freebsd-x64": "0.147.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.147.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.147.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.147.0", + "@oxc-parser/binding-linux-arm64-musl": "0.147.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.147.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.147.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.147.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.147.0", + "@oxc-parser/binding-linux-x64-gnu": "0.147.0", + "@oxc-parser/binding-linux-x64-musl": "0.147.0", + "@oxc-parser/binding-openharmony-arm64": "0.147.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.147.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.147.0", + "@oxc-parser/binding-win32-x64-msvc": "0.147.0" + } + }, "node_modules/oxc-resolver": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", - "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.19.1", - "@oxc-resolver/binding-android-arm64": "11.19.1", - "@oxc-resolver/binding-darwin-arm64": "11.19.1", - "@oxc-resolver/binding-darwin-x64": "11.19.1", - "@oxc-resolver/binding-freebsd-x64": "11.19.1", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", - "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", - "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", - "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-x64-musl": "11.19.1", - "@oxc-resolver/binding-openharmony-arm64": "11.19.1", - "@oxc-resolver/binding-wasm32-wasi": "11.19.1", - "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", - "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", - "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "node_modules/oxc-transform-react": { @@ -34103,7 +34496,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -34117,7 +34509,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -34141,6 +34532,13 @@ "version": "1.0.0", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/pako": { "version": "2.1.0", "license": "(MIT AND Zlib)" @@ -35647,9 +36045,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.22.2.tgz", - "integrity": "sha512-O5NfkDCzNFJPkcyCNKdaRV9ouEeUNml/x5PMpKt3m5zkER0HM/qPCgg6ajpFieZSOUhZRrdUWd+966Abs2oERw==", + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.22.4.tgz", + "integrity": "sha512-2by3oJBQqKH9UAydlBeiEpivxIyTbt345bmverLym7vbEp4NXckWQLF0STnVuXYccRak5/sd3wMYDJPhv/9+3g==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" @@ -35907,9 +36305,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.109", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.109.tgz", - "integrity": "sha512-/+oUOd/0xz8fxUUMvjoO1KUmRhr0pbEOtug8KMzdMNwqt2GGX+tgEMMvD/3JcEsKWvN8sZUlefQJwBI0oBnhxg==", + "version": "3.0.111", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.111.tgz", + "integrity": "sha512-Y8vGElYidypujOIhdCU6HE69ZH1ZEvyWShZvXiKBMVjWhUmQ/dDCtbk6ypc+q1LS3lJ5EIjNPNmQD668hUKbsQ==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -36713,16 +37111,6 @@ "dev": true, "license": "MIT" }, - "node_modules/reduce-flatten": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", - "integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -37902,9 +38290,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -39051,13 +39439,6 @@ "node": ">=0.6.19" } }, - "node_modules/string-format": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", - "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", - "dev": true, - "license": "WTFPL OR MIT" - }, "node_modules/string-length": { "version": "4.0.2", "dev": true, @@ -39473,42 +39854,6 @@ "version": "6.2.0", "license": "MIT" }, - "node_modules/table-layout": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-1.0.2.tgz", - "integrity": "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-back": "^4.0.1", - "deep-extend": "~0.6.0", - "typical": "^5.2.0", - "wordwrapjs": "^4.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/table-layout/node_modules/array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/table-layout/node_modules/typical": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", - "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -39973,98 +40318,6 @@ } } }, - "node_modules/ts-command-line-args": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/ts-command-line-args/-/ts-command-line-args-2.5.1.tgz", - "integrity": "sha512-H69ZwTw3rFHb5WYpQya40YAX2/w7Ut75uUECbgBIsLmM+BNuYnxsltfyyLMxy6sEeKxgijLTnQtLd0nKd6+IYw==", - "dev": true, - "license": "ISC", - "dependencies": { - "chalk": "^4.1.0", - "command-line-args": "^5.1.1", - "command-line-usage": "^6.1.0", - "string-format": "^2.0.0" - }, - "bin": { - "write-markdown": "dist/write-markdown.js" - } - }, - "node_modules/ts-command-line-args/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ts-command-line-args/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ts-command-line-args/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/ts-command-line-args/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-command-line-args/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ts-command-line-args/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ts-dedent": { "version": "2.2.0", "dev": true, @@ -40345,16 +40598,6 @@ "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", "license": "MIT" }, - "node_modules/typical": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", - "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ua-parser-js": { "version": "0.7.35", "funding": [ @@ -40373,9 +40616,9 @@ } }, "node_modules/unbash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", - "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.11.tgz", + "integrity": "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==", "dev": true, "license": "ISC", "engines": { @@ -41169,30 +41412,6 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrapjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz", - "integrity": "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==", - "dev": true, - "license": "MIT", - "dependencies": { - "reduce-flatten": "^2.0.0", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/wordwrapjs/node_modules/typical": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", - "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/workbox-background-sync": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", @@ -41876,7 +42095,6 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 13bb589af272..74fe9db8c128 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.69-0", + "version": "9.4.77-2", "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.", @@ -35,6 +35,8 @@ "build": "ENV=production bun ./node_modules/.bin/rsbuild build --config config/rsbuild/rsbuild.config.ts && bun ./scripts/combine-web-sourcemaps.ts", "build-staging": "ENV=staging bun ./node_modules/.bin/rsbuild build --config config/rsbuild/rsbuild.config.ts && bun ./scripts/combine-web-sourcemaps.ts", "build-adhoc": "ENV=adhoc bun ./node_modules/.bin/rsbuild build --config config/rsbuild/rsbuild.config.ts && bun ./scripts/combine-web-sourcemaps.ts", + "benchmark-app-startup": "bun scripts/benchmarkAppStartup.ts", + "bootstrap-device": "bun scripts/bootstrapForDevice.ts", "createDocsRoutes": "bun .github/scripts/createDocsRoutes.ts", "generateAllowedUrls": "bun .github/scripts/generateAllowedUrls.ts", "detectRedirectCycle": "bun .github/scripts/detectRedirectCycle.ts", @@ -47,9 +49,9 @@ "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", "typecheck": "bun scripts/typecheck.ts", - "lint": "bun scripts/lint.ts", + "lint": "bun scripts/lint/index.ts", "lint-changed": "./scripts/lintChanged.sh", - "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- bun scripts/lint.ts {{changed}}", + "lint-watch": "onchange '**/*.{js,jsx,ts,tsx,mjs,cjs}' -- bun scripts/lint/index.ts {{changed}}", "eslint-report": "bun scripts/eslint-report.ts", "knip": "KNIP=true knip --include dependencies --exclude unlisted --no-exit-code --reporter compact", "knip:full": "KNIP=true knip --reporter compact", @@ -104,9 +106,10 @@ "@formatjs/intl-numberformat": "^8.10.3", "@formatjs/intl-pluralrules": "^5.2.14", "@fullstory/browser": "^2.0.6", - "@fullstory/react-native": "^1.9.0", + "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", + "@legendapp/list": "^3.3.10", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -127,8 +130,8 @@ "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.3.2", "@sbaiahmed1/react-native-biometrics": "0.15.0", - "@sentry/core": "10.47.0", - "@sentry/react-native": "8.7.0", + "@sentry/core": "10.73.0", + "@sentry/react-native": "8.25.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", @@ -193,12 +196,12 @@ "react-native-image-picker": "^7.1.2", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8a1c3b7359977462cdd4e63b09ea45afbd3d77c7", "react-native-key-command": "1.0.14", - "react-native-keyboard-controller": "1.22.2", + "react-native-keyboard-controller": "1.22.4", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.5.4", "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.109", + "react-native-onyx": "3.0.111", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -241,7 +244,7 @@ "@callstack/reassure-compare": "^1.0.0-rc.4", "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@fullstory/babel-plugin-annotate-react": "^2.4.0", - "@fullstory/babel-plugin-react-native": "^1.5.2", + "@fullstory/babel-plugin-react-native": "^1.7.1", "@googleapis/androidpublisher": "^37.0.0", "@jest/globals": "^29.7.0", "@jridgewell/remapping": "^2.3.5", @@ -324,7 +327,6 @@ "eslint-plugin-storybook": "10.4.6", "eslint-plugin-testing-library": "^7.11.0", "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", - "eslint-seatbelt": "^0.1.3", "glob": "^10.4.5", "http-server": "^14.1.1", "jest": "29.7.0", @@ -333,7 +335,7 @@ "jest-environment-jsdom": "^29.7.0", "jest-expo": "57.0.2", "jest-transformer-svg": "^2.0.1", - "knip": "^6.14.0", + "knip": "^6.34.0", "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", diff --git a/patches/@fullstory/react-native/@fullstory+react-native+1.9.0.patch b/patches/@fullstory/react-native/@fullstory+react-native+1.9.0.patch deleted file mode 100644 index e71e7887fcce..000000000000 --- a/patches/@fullstory/react-native/@fullstory+react-native+1.9.0.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/node_modules/@fullstory/react-native/ios/FullStory.mm b/node_modules/@fullstory/react-native/ios/FullStory.mm -index bd30520..26a834a 100644 ---- a/node_modules/@fullstory/react-native/ios/FullStory.mm -+++ b/node_modules/@fullstory/react-native/ios/FullStory.mm -@@ -22,6 +22,11 @@ - return self; - } - -+- (void)invalidate { -+ // Clear the event emitter callback to prevent a use-after-free crash. -+ _eventEmitterCallback = nullptr; -+} -+ - NSString *const PagesAPIError = @"Unable to access native FullStory pages API and call %@. Pages API will not function correctly. Make sure that your plugin is at least version 1.41; if the issue persists, please contact FullStory Support."; - - RCT_EXPORT_MODULE() -@@ -200,11 +205,13 @@ RCT_EXPORT_METHOD(updatePage:(NSString *)nonce pageProperties:(NSDictionary *)pa - } - - #ifdef RCT_NEW_ARCH_ENABLED -- [self emitOnSessionStarted:@{ -- @"replayStartUrl": sessionUrl, -- @"replayNowUrl": [FS currentSessionURL: true] ?: @"", -- @"sessionId": FS.currentSession ?: @"", -- }]; -+ if (_eventEmitterCallback) { -+ [self emitOnSessionStarted:@{ -+ @"replayStartUrl": sessionUrl, -+ @"replayNowUrl": [FS currentSessionURL: true] ?: @"", -+ @"sessionId": FS.currentSession ?: @"", -+ }]; -+ } - #endif - } - diff --git a/patches/@fullstory/react-native/details.md b/patches/@fullstory/react-native/details.md deleted file mode 100644 index f7fbb0b65bcf..000000000000 --- a/patches/@fullstory/react-native/details.md +++ /dev/null @@ -1,28 +0,0 @@ -# `@fullstory/react-native` patches - -### [@fullstory+react-native+1.9.0.patch](@fullstory+react-native+1.9.0.patch) - -- Reason: - - ``` - Version 1.9.0 introduced the onFullstoryDidStartSession event emitter API (PR #147). - The implementation stores a C++ lambda — which implicitly captures `this` (the - NativeFullStorySpecJSI TurboModule object) — inside the ObjC FullStory module's - _eventEmitterCallback field. - - In HybridApp, the JS runtime is torn down when switching from ND to OD (after sign in with SAML), - destroying the C++ TurboModule. However, the ObjC FullStory instance stays alive as - an FS SDK delegate. When the app returns to the foreground, FullStory fires - fullstoryDidStartSession:, which invokes _eventEmitterCallback — a lambda that now - holds a dangling pointer to the destroyed C++ object. Accessing the freed - eventEmitterMap_ causes a crash in std::__hash_table::__rehash_unique - (bad_array_new_length / overflow_error). - - The fix adds an invalidate method that clears _eventEmitterCallback (destroying the - dangling lambda when the bridge tears down) and guards the emitOnSessionStarted: call - so it is skipped if the callback has already been cleared. - ``` - -- Upstream PR/issue: 🛑 TODO -- E/App issue: https://github.com/Expensify/App/issues/91225 -- PR introducing patch: 🛑 TODO diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch index 521561d5ef2b..c11114fa1de0 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch @@ -56,10 +56,10 @@ index 3b69234..a9474c1 100644 - layoutManager.getLayout(index).isWidthMeasured); + const isFullyMeasured = (index) => layoutManager.getLayout(index).isHeightMeasured && + layoutManager.getLayout(index).isWidthMeasured; -+ // With an explicit initialScrollIndex, also wait for the drawDistance buffer to be measured before -+ // completing the first layout, so estimate-driven layout shifts converge before anything is on screen. ++ // When scrolling to a positive initialScrollIndex, also wait for the drawDistance buffer to be measured before ++ // completing the first layout, so estimate-driven layout shifts converge before anything is on screen. Index 0 offsets by at most the list header, and the -1 sentinel does not scroll at all, so both keep stock progressive render. + let targetIndices = visibleIndices; -+ if (this.propsRef.initialScrollIndex !== undefined && visibleIndices.length > 0 && visibleIndices.every(isFullyMeasured)) { ++ if ((this.propsRef.initialScrollIndex ?? 0) > 0 && visibleIndices.length > 0 && visibleIndices.every(isFullyMeasured)) { + const windowSize = this.propsRef.horizontal ? this.getWindowSize().width : this.getWindowSize().height; + const viewportStart = this.engagedIndicesTracker.scrollOffset; + // Cover the worst-case one-sided buffer the engaged tracker can mount after diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index c2a5178df699..3e4eeab058c3 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -167,7 +167,7 @@ 3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below. 4. **`recomputeLayouts` range** in `applyInitialScrollAdjustment` (`RecyclerViewManager.js`): the recompute that precedes reading the target offset is widened from `recomputeLayouts(0, initialScrollIndex)` to `recomputeLayouts(0, this.getDataLength() - 1)`, so every item gets a measured/re-estimated layout before the positioning. 5. **Deferred re-scroll reads the latest offset** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): the `setTimeout(0)` re-scroll used to close over the `offset` from its own commit. When a later commit recomputed a newer offset before that timeout fired, the stale timeout snapped the list back to the outdated offset — a visible jump. The offset is now stored in `latestInitialScrollOffsetRef` and read at fire-time, so any pending re-scroll targets the current offset instead of a stale one. - 6. **Progressive render covers the drawDistance buffer** in `renderProgressively` (`RecyclerViewManager.js`): with an explicit `initialScrollIndex`, the drawDistance buffer used to mount right after first paint; its measurements re-estimated every still-unmeasured item before the target, which could collapse the content height below the applied scroll offset and make the native ScrollView clamp. Now the progressive-render phase also waits for the buffer around the viewport to be measured, so the layout converges before anything is painted. Only applies when `initialScrollIndex` is set; other lists keep stock behavior. + 6. **Progressive render covers the drawDistance buffer** in `renderProgressively` (`RecyclerViewManager.js`): with an explicit `initialScrollIndex`, the drawDistance buffer used to mount right after first paint; its measurements re-estimated every still-unmeasured item before the target, which could collapse the content height below the applied scroll offset and make the native ScrollView clamp. Now the progressive-render phase also waits for the buffer around the viewport to be measured, so the layout converges before anything is painted. Applies only to a **positive** `initialScrollIndex`. The `-1` sentinel that the selection lists synthesize for "nothing focused" scrolls nowhere, and index `0` offsets by at most the `ListHeaderComponent` height, so both keep stock progressive render (they used to trip this branch, because `-1 !== undefined`). - Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. - Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4) - E/App issue: https://github.com/Expensify/App/issues/92152 diff --git a/patches/eslint-seatbelt/details.md b/patches/eslint-seatbelt/details.md deleted file mode 100644 index 7ae1081af0e5..000000000000 --- a/patches/eslint-seatbelt/details.md +++ /dev/null @@ -1,57 +0,0 @@ -# `eslint-seatbelt` patches - -### [eslint-seatbelt+0.1.3+001+thread-safety.patch](eslint-seatbelt+0.1.3+001+thread-safety.patch) - -- Reason: - - ``` - Without this, running `npm run lint` with `--concurrency=auto` races on - the atomic rename of the TSV and crashes with: - ENOENT: no such file or directory, rename '.../eslint.seatbelt.tsv.wip*.tmp' - -> 'config/eslint/eslint.seatbelt.tsv' - Falling back to `--concurrency=1` is too slow for this repo. - ``` - -- Upstream PR/issue: https://github.com/justjake/eslint-seatbelt/pull/27 -- E/App issue: N/A -- PR introducing patch: https://github.com/Expensify/App/pull/88566 - -### [eslint-seatbelt+0.1.3+002+read-only.patch](eslint-seatbelt+0.1.3+002+read-only.patch) - -- Reason: - - Adds a `readOnly` config option (and `SEATBELT_READ_ONLY` env var) that causes - `eslint-seatbelt` to still read and validate the seatbelt file, but never - write updates back to disk. We enable this in developer worktrees - (`readOnly: !process.env.CI` in `config/eslint/eslint.config.mjs`) so - fixing baselined errors doesn't dirty the worktree with an unrelated TSV - rewrite. In CI, the setting is off so the `push: main` lint job can - auto-commit tightenings back to `main` as OSBotify (see - [`.github/workflows/lint.yml`](../../.github/workflows/lint.yml)). - - Precedence: - - `SEATBELT_READ_ONLY` env var overrides the setting. - - `SEATBELT_INCREASE` overrides `readOnly` (intentional loosening still writes). - - `SEATBELT_DISABLE` short-circuits both. - - `SEATBELT_FROZEN` remains orthogonal. - -- Upstream PR/issue: https://github.com/justjake/eslint-seatbelt/pull/29 -- E/App issue: N/A -- PR introducing patch: https://github.com/Expensify/App/pull/88566 - -### [eslint-seatbelt+0.1.3+003+readonly-type-declarations.patch](eslint-seatbelt+0.1.3+003+readonly-type-declarations.patch) - -- Reason: - - Patch 002 added the `readOnly` config option to the runtime and to `README.md`, - but never added it to the `SeatbeltConfig` TypeScript interface, so `SeatbeltConfig` - and the derived `SeatbeltArgs` type didn't know about it. Any consumer of - `eslint-seatbelt/api` that reads or sets `readOnly` (e.g. `scripts/lint.ts`, which - prunes baseline rows for deleted files using the same readOnly semantics as the rest - of the seatbelt baseline) failed to typecheck. This adds the missing `readOnly?: boolean` - field to both the `.d.ts` and `.d.mts` copies of the interface, matching the JSDoc - already shipped in `README.md` by patch 002. - -- Upstream PR/issue: N/A (the `readOnly` option itself is an Expensify-only patch, not upstream) -- E/App issue: N/A -- PR introducing patch: https://github.com/Expensify/App/pull/98665 diff --git a/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+001+thread-safety.patch b/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+001+thread-safety.patch deleted file mode 100644 index 043263966788..000000000000 --- a/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+001+thread-safety.patch +++ /dev/null @@ -1,4022 +0,0 @@ -diff --git a/node_modules/eslint-seatbelt/README.md b/node_modules/eslint-seatbelt/README.md -index 31e16a9..ddcaa34 100644 ---- a/node_modules/eslint-seatbelt/README.md -+++ b/node_modules/eslint-seatbelt/README.md -@@ -409,7 +409,6 @@ This project uses `pnpm` for package management. - - ### Improvement ideas - --- [ ] Finish SEATBELT_THREADSAFE implementation - - [ ] Set SEATBELT_DISABLE=1 during git merge/rebase events - - [ ] Add SEATBELT_DISABLE_IN_EDITOR config option - - [ ] Integration tests -diff --git a/node_modules/eslint-seatbelt/dist/api.js b/node_modules/eslint-seatbelt/dist/api.js -index e7d5ef9..afa6177 100644 ---- a/node_modules/eslint-seatbelt/dist/api.js -+++ b/node_modules/eslint-seatbelt/dist/api.js -@@ -1,7 +1,7 @@ - "use strict";Object.defineProperty(exports, "__esModule", {value: true}); - - --var _chunkK7UHJBLMjs = require('./chunk-K7UHJBLM.js'); -+var _chunkNTFTCWX7js = require('./chunk-NTFTCWX7.js'); - - - -@@ -23,56 +23,8 @@ var _chunkK7UHJBLMjs = require('./chunk-K7UHJBLM.js'); - - - --var _chunkTVRMUM3Fjs = require('./chunk-TVRMUM3F.js'); -+var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); - --// src/FileLock.ts --var _fs = require('fs'); --var { O_CREAT, O_EXCL, O_RDWR } = _fs.constants; --var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); --var FileLock = class { -- constructor(filename) { -- this.filename = filename; -- } -- -- tryLock() { -- this.assertNotLocked(); -- try { -- this.fd = _fs.openSync.call(void 0, this.filename, O_CREAT | O_EXCL | O_RDWR); -- return true; -- } catch (e) { -- if (_chunkK7UHJBLMjs.isErrno.call(void 0, e, "EEXIST")) { -- return false; -- } -- throw e; -- } -- } -- waitLock(timeoutMs) { -- const deadline = Date.now() + timeoutMs; -- while (!this.tryLock()) { -- if (Date.now() > deadline) { -- throw new Error(`Timed out waiting for lock on ${this.filename}`); -- } -- Atomics.wait(waitBuffer, 0, 0, 1); -- } -- } -- isLocked() { -- return this.fd !== void 0; -- } -- unlock() { -- if (this.fd !== void 0) { -- _fs.closeSync.call(void 0, this.fd); -- _fs.rmSync.call(void 0, this.filename); -- this.fd = void 0; -- } -- } -- assertNotLocked() { -- if (this.fd !== void 0) { -- throw new Error( -- `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -- ); -- } -- } --}; - - - -@@ -95,6 +47,5 @@ var FileLock = class { - - - -- --exports.FileLock = FileLock; exports.SEATBELT_DISABLE = _chunkTVRMUM3Fjs.SEATBELT_DISABLE; exports.SEATBELT_FILE = _chunkTVRMUM3Fjs.SEATBELT_FILE; exports.SEATBELT_FILE_NAME = _chunkTVRMUM3Fjs.SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = _chunkTVRMUM3Fjs.SEATBELT_FROZEN; exports.SEATBELT_INCREASE = _chunkTVRMUM3Fjs.SEATBELT_INCREASE; exports.SEATBELT_KEEP = _chunkTVRMUM3Fjs.SEATBELT_KEEP; exports.SEATBELT_PWD = _chunkTVRMUM3Fjs.SEATBELT_PWD; exports.SEATBELT_QUIET = _chunkTVRMUM3Fjs.SEATBELT_QUIET; exports.SEATBELT_ROOT = _chunkTVRMUM3Fjs.SEATBELT_ROOT; exports.SEATBELT_THREADSAFE = _chunkTVRMUM3Fjs.SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = _chunkTVRMUM3Fjs.SEATBELT_VERBOSE; exports.SeatbeltArgs = _chunkTVRMUM3Fjs.SeatbeltArgs; exports.SeatbeltConfig = _chunkTVRMUM3Fjs.SeatbeltConfig; exports.SeatbeltConfigSchema = _chunkTVRMUM3Fjs.SeatbeltConfigSchema; exports.SeatbeltEnv = _chunkTVRMUM3Fjs.SeatbeltEnv; exports.SeatbeltFile = _chunkK7UHJBLMjs.SeatbeltFile; exports.formatFilename = _chunkTVRMUM3Fjs.formatFilename; exports.formatRuleId = _chunkTVRMUM3Fjs.formatRuleId; exports.logStderr = _chunkTVRMUM3Fjs.logStderr; exports.logStdout = _chunkTVRMUM3Fjs.logStdout; exports.padVarName = _chunkTVRMUM3Fjs.padVarName; -+exports.FileLock = _chunkNTFTCWX7js.FileLock; exports.SEATBELT_DISABLE = _chunkZVY5S6JSjs.SEATBELT_DISABLE; exports.SEATBELT_FILE = _chunkZVY5S6JSjs.SEATBELT_FILE; exports.SEATBELT_FILE_NAME = _chunkZVY5S6JSjs.SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = _chunkZVY5S6JSjs.SEATBELT_FROZEN; exports.SEATBELT_INCREASE = _chunkZVY5S6JSjs.SEATBELT_INCREASE; exports.SEATBELT_KEEP = _chunkZVY5S6JSjs.SEATBELT_KEEP; exports.SEATBELT_PWD = _chunkZVY5S6JSjs.SEATBELT_PWD; exports.SEATBELT_QUIET = _chunkZVY5S6JSjs.SEATBELT_QUIET; exports.SEATBELT_ROOT = _chunkZVY5S6JSjs.SEATBELT_ROOT; exports.SEATBELT_THREADSAFE = _chunkZVY5S6JSjs.SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = _chunkZVY5S6JSjs.SEATBELT_VERBOSE; exports.SeatbeltArgs = _chunkZVY5S6JSjs.SeatbeltArgs; exports.SeatbeltConfig = _chunkZVY5S6JSjs.SeatbeltConfig; exports.SeatbeltConfigSchema = _chunkZVY5S6JSjs.SeatbeltConfigSchema; exports.SeatbeltEnv = _chunkZVY5S6JSjs.SeatbeltEnv; exports.SeatbeltFile = _chunkNTFTCWX7js.SeatbeltFile; exports.formatFilename = _chunkZVY5S6JSjs.formatFilename; exports.formatRuleId = _chunkZVY5S6JSjs.formatRuleId; exports.logStderr = _chunkZVY5S6JSjs.logStderr; exports.logStdout = _chunkZVY5S6JSjs.logStdout; exports.padVarName = _chunkZVY5S6JSjs.padVarName; - //# sourceMappingURL=api.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/api.mjs b/node_modules/eslint-seatbelt/dist/api.mjs -index c4e8548..efa51d7 100644 ---- a/node_modules/eslint-seatbelt/dist/api.mjs -+++ b/node_modules/eslint-seatbelt/dist/api.mjs -@@ -1,7 +1,7 @@ - import { -- SeatbeltFile, -- isErrno --} from "./chunk-OKIIDZIF.mjs"; -+ FileLock, -+ SeatbeltFile -+} from "./chunk-5FFUIU4M.mjs"; - import { - SEATBELT_DISABLE, - SEATBELT_FILE, -@@ -23,56 +23,7 @@ import { - logStderr, - logStdout, - padVarName --} from "./chunk-7ZO4DCZA.mjs"; -- --// src/FileLock.ts --import { openSync, closeSync, constants, rmSync } from "node:fs"; --var { O_CREAT, O_EXCL, O_RDWR } = constants; --var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); --var FileLock = class { -- constructor(filename) { -- this.filename = filename; -- } -- fd; -- tryLock() { -- this.assertNotLocked(); -- try { -- this.fd = openSync(this.filename, O_CREAT | O_EXCL | O_RDWR); -- return true; -- } catch (e) { -- if (isErrno(e, "EEXIST")) { -- return false; -- } -- throw e; -- } -- } -- waitLock(timeoutMs) { -- const deadline = Date.now() + timeoutMs; -- while (!this.tryLock()) { -- if (Date.now() > deadline) { -- throw new Error(`Timed out waiting for lock on ${this.filename}`); -- } -- Atomics.wait(waitBuffer, 0, 0, 1); -- } -- } -- isLocked() { -- return this.fd !== void 0; -- } -- unlock() { -- if (this.fd !== void 0) { -- closeSync(this.fd); -- rmSync(this.filename); -- this.fd = void 0; -- } -- } -- assertNotLocked() { -- if (this.fd !== void 0) { -- throw new Error( -- `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -- ); -- } -- } --}; -+} from "./chunk-ULACHCKT.mjs"; - export { - FileLock, - SEATBELT_DISABLE, -diff --git a/node_modules/eslint-seatbelt/dist/chunk-5FFUIU4M.mjs b/node_modules/eslint-seatbelt/dist/chunk-5FFUIU4M.mjs -new file mode 100644 -index 0000000..025f420 ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-5FFUIU4M.mjs -@@ -0,0 +1,480 @@ -+import { -+ SEATBELT_FROZEN, -+ SEATBELT_KEEP, -+ SeatbeltArgs, -+ formatFilename, -+ formatRuleId, -+ name -+} from "./chunk-ULACHCKT.mjs"; -+ -+// src/FileLock.ts -+import { openSync, writeSync, closeSync, readFileSync, constants, rmSync } from "node:fs"; -+ -+// src/errorHanding.ts -+function appendErrorContext(error, context) { -+ if (error instanceof Error) { -+ error.message += ` -+ ${context}`; -+ } -+} -+function isErrno(error, code) { -+ return error instanceof Error && "code" in error && error.code === code; -+} -+ -+// src/FileLock.ts -+var { O_CREAT, O_EXCL, O_RDWR } = constants; -+var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); -+var heldLocks = /* @__PURE__ */ new Set(); -+var cleanupHooksInstalled = false; -+var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; -+function installCleanupHooks() { -+ if (cleanupHooksInstalled) return; -+ cleanupHooksInstalled = true; -+ const release = () => { -+ for (const lock of heldLocks) { -+ try { -+ lock.unlock(); -+ } catch { -+ } -+ } -+ }; -+ process.on("exit", release); -+ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { -+ process.on(signal, () => { -+ release(); -+ process.exit(SIGNAL_EXIT_CODES[signal]); -+ }); -+ } -+} -+var FileLock = class { -+ constructor(filename) { -+ this.filename = filename; -+ } -+ fd; -+ tryLock() { -+ this.assertNotLocked(); -+ try { -+ this.fd = openSync(this.filename, O_CREAT | O_EXCL | O_RDWR); -+ writeSync(this.fd, `${process.pid} -+`); -+ heldLocks.add(this); -+ installCleanupHooks(); -+ return true; -+ } catch (e) { -+ if (isErrno(e, "EEXIST")) { -+ return false; -+ } -+ throw e; -+ } -+ } -+ waitLock(timeoutMs) { -+ const deadline = Date.now() + timeoutMs; -+ let attemptedRecovery = false; -+ while (!this.tryLock()) { -+ if (Date.now() > deadline) { -+ if (!attemptedRecovery && this.reclaimIfStale()) { -+ attemptedRecovery = true; -+ continue; -+ } -+ throw new Error(`Timed out waiting for lock on ${this.filename}`); -+ } -+ Atomics.wait(waitBuffer, 0, 0, 1); -+ } -+ } -+ isLocked() { -+ return this.fd !== void 0; -+ } -+ unlock() { -+ if (this.fd !== void 0) { -+ closeSync(this.fd); -+ try { -+ rmSync(this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ this.fd = void 0; -+ heldLocks.delete(this); -+ } -+ } -+ assertNotLocked() { -+ if (this.fd !== void 0) { -+ throw new Error( -+ `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -+ ); -+ } -+ } -+ reclaimIfStale() { -+ let contents; -+ try { -+ contents = readFileSync(this.filename, "utf8"); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) return true; -+ throw e; -+ } -+ const pid = Number.parseInt(contents.trim(), 10); -+ if (!Number.isFinite(pid) || pid <= 0) return false; -+ try { -+ process.kill(pid, 0); -+ return false; -+ } catch (e) { -+ if (!isErrno(e, "ESRCH")) throw e; -+ } -+ try { -+ rmSync(this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ return true; -+ } -+}; -+ -+// src/SeatbeltFile.ts -+import * as os from "node:os"; -+import * as fs from "node:fs"; -+import path, * as nodePath from "node:path"; -+var LOCK_TIMEOUT_MS = 3e4; -+function encodeLine(line) { -+ const { filename, ruleId, maxErrors } = line; -+ return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} -+`; -+} -+function decodeLine(line, index) { -+ try { -+ const lineParts = line.split(" "); -+ if (lineParts.length !== 3) { -+ throw new Error( -+ `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -+ ); -+ } -+ let filename; -+ try { -+ filename = JSON.parse(lineParts[0]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 1 (filename)"); -+ throw e; -+ } -+ let ruleId; -+ try { -+ ruleId = JSON.parse(lineParts[1]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -+ throw e; -+ } -+ let maxErrors; -+ try { -+ maxErrors = JSON.parse(lineParts[2]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -+ throw e; -+ } -+ return { -+ encoded: line, -+ filename, -+ ruleId, -+ maxErrors -+ }; -+ } catch (e) { -+ appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -+ throw e; -+ } -+} -+var COMMENT_LINE_REGEX = /^\s*#/; -+var NON_EMPTY_LINE_REGEX = /\S+/; -+var DEFAULT_FILE_HEADER = ` -+# ${name} temporarily allowed errors -+# docs: https://github.com/justjake/${name}#readme -+`.trim(); -+var SeatbeltFile = class _SeatbeltFile { -+ constructor(filename, data, comments = "") { -+ this.filename = filename; -+ this.data = data; -+ this.comments = comments; -+ this.filename = path.resolve(this.filename); -+ this.dirname = path.dirname(this.filename); -+ } -+ static readSync(filename) { -+ const text = fs.readFileSync(filename, "utf8"); -+ try { -+ return _SeatbeltFile.parse(filename, text); -+ } catch (e) { -+ appendErrorContext(e, `in seatbelt file \`${filename}\``); -+ throw e; -+ } -+ } -+ /** -+ * Read `filename` if it exists, otherwise create a new empty seatbelt file object -+ * that will write to that filename. -+ */ -+ static openSync(filename) { -+ try { -+ return _SeatbeltFile.readSync(filename); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) { -+ return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -+ } -+ throw e; -+ } -+ } -+ static parse(filename, text) { -+ const data = /* @__PURE__ */ new Map(); -+ const split = text.split(/(?<=\n)/); -+ const lines = split.filter( -+ (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -+ ).map(decodeLine); -+ const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -+ lines.forEach((line) => { -+ let fileState = data.get(line.filename); -+ if (!fileState) { -+ fileState = { maxErrors: void 0, lines: [] }; -+ data.set(line.filename, fileState); -+ } -+ fileState.lines.push(line); -+ }); -+ return new _SeatbeltFile(filename, data, comments.trim()); -+ } -+ static fromJSON(json) { -+ const data = new Map( -+ Object.entries(json.data).map(([filename, maxErrors]) => [ -+ filename, -+ { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -+ ]) -+ ); -+ return new _SeatbeltFile(json.filename, data); -+ } -+ changed = false; -+ dirname; -+ useTempDirForWrites = true; -+ *filenames() { -+ for (const filename of this.data.keys()) { -+ yield this.toAbsolutePath(filename); -+ } -+ } -+ getMaxErrors(filename) { -+ const fileState = this.data.get(this.toRelativePath(filename)); -+ if (!fileState) { -+ return void 0; -+ } -+ fileState.maxErrors ??= parseMaxErrors(fileState.lines); -+ return fileState.maxErrors; -+ } -+ removeFile(filename, args) { -+ const relativeFilename = this.toRelativePath(filename); -+ if (!this.data.has(relativeFilename)) { -+ return false; -+ } -+ SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${formatFilename(filename)}: ${SEATBELT_FROZEN}: didn't remove max errors` : `${formatFilename(filename)}: remove max errors` -+ ); -+ if (args.frozen) { -+ return false; -+ } -+ this.data.delete(relativeFilename); -+ this.changed = true; -+ return true; -+ } -+ updateMaxErrors(filename, args, ruleToErrorCount) { -+ const removedRules = /* @__PURE__ */ new Set(); -+ let increasedRulesCount = 0; -+ let decreasedRulesCount = 0; -+ this.getMaxErrors(filename); -+ const relativeFilename = this.toRelativePath(filename); -+ const maxErrors = this.data.get(relativeFilename)?.maxErrors ?? /* @__PURE__ */ new Map(); -+ ruleToErrorCount.forEach((errorCount, ruleId) => { -+ const maxErrorCount = maxErrors.get(ruleId) ?? 0; -+ if (errorCount === maxErrorCount) { -+ return; -+ } -+ if (errorCount < maxErrorCount || SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -+ SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -+ ); -+ maxErrors.set(ruleId, errorCount); -+ if (errorCount > maxErrorCount) { -+ increasedRulesCount++; -+ } else { -+ decreasedRulesCount++; -+ } -+ } -+ }); -+ if (args.verbose || args.keepRules !== "all") { -+ maxErrors.forEach((maxErrorCount, ruleId) => { -+ const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -+ if (!shouldRemove) { -+ return; -+ } -+ if (SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -+ SeatbeltArgs.verboseLog( -+ args, -+ () => `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -+ ); -+ return; -+ } -+ SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${0}` -+ ); -+ maxErrors.delete(ruleId); -+ removedRules.add(ruleId); -+ }); -+ } -+ const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -+ if (changed && !args.frozen) { -+ const file = this.data.get(relativeFilename); -+ if (file) { -+ file.maxErrors = maxErrors; -+ } else { -+ this.data.set(relativeFilename, { -+ maxErrors, -+ lines: [] -+ }); -+ } -+ this.changed = true; -+ } -+ return { removedRules, increasedRulesCount, decreasedRulesCount }; -+ } -+ /** Atomic read -> apply delta -> write. Takes an exclusive file lock when `args.threadsafe`. */ -+ updateFileMaxErrors(args, filename, ruleToErrorCount) { -+ return this.withOptionalLock(args, () => { -+ const before = this.getMaxErrors(filename); -+ const ruleToMaxErrorCountBefore = before ? new Map(before) : void 0; -+ const result = this.updateMaxErrors(filename, args, ruleToErrorCount); -+ if (!args.frozen) { -+ this.flushChanges(); -+ } -+ return { ruleToMaxErrorCountBefore, ...result }; -+ }); -+ } -+ /** Drop entries whose source file no longer exists. Takes an exclusive file lock when `args.threadsafe`. */ -+ cleanUpRemovedFiles(args) { -+ return this.withOptionalLock(args, () => { -+ let removedFiles = 0; -+ for (const filename of Array.from(this.filenames())) { -+ if (!fs.existsSync(filename)) { -+ if (this.removeFile(filename, args)) { -+ removedFiles++; -+ } -+ } -+ } -+ if (!args.frozen) { -+ this.flushChanges(); -+ } -+ return { removedFiles }; -+ }); -+ } -+ withOptionalLock(args, fn) { -+ if (!args.threadsafe) { -+ return fn(); -+ } -+ const lock = new FileLock(`${this.filename}.lock`); -+ lock.waitLock(LOCK_TIMEOUT_MS); -+ try { -+ this.readSync(); -+ return fn(); -+ } finally { -+ lock.unlock(); -+ } -+ } -+ toDataString() { -+ const lines = []; -+ this.data.forEach((fileState, filename) => { -+ if (fileState.maxErrors) { -+ fileState.lines = []; -+ fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -+ fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -+ }); -+ fileState.lines.sort( -+ (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -+ ); -+ } -+ fileState.lines.forEach((line) => { -+ const encoded = line.encoded ??= encodeLine(line); -+ lines.push(encoded); -+ }); -+ }); -+ lines.sort(); -+ if (this.comments) { -+ return this.comments + "\n\n" + lines.join(""); -+ } else { -+ return lines.join(""); -+ } -+ } -+ readSync() { -+ const nextStateFile = _SeatbeltFile.openSync(this.filename); -+ if (nextStateFile) { -+ this.data = nextStateFile.data; -+ this.changed = false; -+ return true; -+ } -+ return false; -+ } -+ flushChanges() { -+ if (this.changed) { -+ this.writeSync(); -+ this.changed = false; -+ return { updated: true }; -+ } -+ return { updated: false }; -+ } -+ writeSync() { -+ const dataString = this.toDataString(); -+ const dir = nodePath.dirname(this.filename); -+ const base = nodePath.basename(this.filename); -+ const tempFile = nodePath.join( -+ this.useTempDirForWrites ? os.tmpdir() : dir, -+ `.${base}.wip${process.pid}.${Date.now()}.tmp` -+ ); -+ fs.mkdirSync(dir, { recursive: true }); -+ fs.writeFileSync(tempFile, dataString, "utf8"); -+ try { -+ fs.renameSync(tempFile, this.filename); -+ } catch (error) { -+ if (isErrno(error, "EXDEV")) { -+ this.useTempDirForWrites = false; -+ fs.copyFileSync(tempFile, this.filename); -+ fs.rmSync(tempFile); -+ return; -+ } -+ throw error; -+ } -+ } -+ toJSON() { -+ const data = Object.fromEntries( -+ Array.from(this.data.keys()).map((filename) => { -+ const maxErrors = this.getMaxErrors(filename); -+ if (!maxErrors) { -+ throw new Error(`${name} bug: expected errors for existing key`); -+ } -+ return [filename, Object.fromEntries(maxErrors)]; -+ }) -+ ); -+ return { filename: this.filename, data }; -+ } -+ toRelativePath(filename) { -+ if (!nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.relative(this.dirname, filename); -+ } -+ toAbsolutePath(filename) { -+ if (nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.resolve(this.dirname, filename); -+ } -+}; -+function parseMaxErrors(lines) { -+ const maxErrors = /* @__PURE__ */ new Map(); -+ lines.forEach((line) => { -+ maxErrors.set(line.ruleId, line.maxErrors); -+ }); -+ return maxErrors; -+} -+ -+export { -+ appendErrorContext, -+ FileLock, -+ SeatbeltFile -+}; -+//# sourceMappingURL=chunk-5FFUIU4M.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-7ZO4DCZA.mjs b/node_modules/eslint-seatbelt/dist/chunk-7ZO4DCZA.mjs -deleted file mode 100644 -index 3a572f0..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-7ZO4DCZA.mjs -+++ /dev/null -@@ -1,427 +0,0 @@ --var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -- get: (a, b) => (typeof require !== "undefined" ? require : a)[b] --}) : x)(function(x) { -- if (typeof require !== "undefined") return require.apply(this, arguments); -- throw Error('Dynamic require of "' + x + '" is not supported'); --}); -- --// package.json --var name = "eslint-seatbelt"; --var version = "0.1.3"; --var package_default = { -- name, -- version, -- description: "Gradually tighten ESLint rules in your codebase", -- keywords: [ -- "eslint", -- "incremental", -- "gradual", -- "workflow", -- "processor", -- "linting" -- ], -- author: { -- name: "Jake Teton-Landis", -- url: "https://jake.tl" -- }, -- repository: { -- type: "git", -- url: "git+https://github.com/justjake/eslint-seatbelt.git" -- }, -- bugs: { -- url: "https://github.com/justjake/eslint-seatbelt/issues" -- }, -- scripts: { -- build: "./scripts/make-json-schemas.ts && tsc && tsup", -- test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -- lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -- }, -- types: "dist/index.d.ts", -- import: "./dist/index.mjs", -- main: "dist/index.js", -- exports: { -- ".": { -- types: "./dist/index.d.ts", -- import: "./dist/index.mjs", -- default: "./dist/index.js" -- }, -- "./api": { -- types: "./dist/api.d.ts", -- import: "./dist/api.mjs", -- default: "./dist/api.js" -- } -- }, -- bin: { -- "eslint-seatbelt": "./dist/command.js" -- }, -- files: [ -- "!*.tsbuildinfo", -- "!src/**/*.test.ts", -- "src", -- "dist" -- ], -- license: "MIT", -- peerDependencies: { -- "@types/eslint": "*", -- eslint: "*" -- }, -- peerDependenciesMeta: { -- eslint: { -- optional: true -- }, -- "@types/eslint": { -- optional: true -- } -- }, -- devDependencies: { -- "@eslint/compat": "1.2.3", -- "@eslint/js": "9.15.0", -- "@types/eslint__js": "8.42.3", -- "@types/node": "22.9.0", -- "@typescript-eslint/rule-tester": "8.14.0", -- "@typescript-eslint/utils": "8.14.0", -- eslint: "9.14.0", -- prettier: "3.3.3", -- tsup: "8.3.5", -- tsx: "4.19.2", -- typescript: "5.6.3", -- "typescript-eslint": "8.14.0", -- "typescript-json-schema": "0.65.1" -- }, -- dependencies: { -- "ts-command-line-args": "^2.5.1" -- }, -- packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" --}; -- --// src/SeatbeltConfig.ts --import path from "node:path"; -- --// src/repoIntegration.ts --import fs from "node:fs"; --import nodePath from "node:path"; --function findAncestorDirectory(path2, predicate) { -- let lastPath = void 0; -- while (path2 !== lastPath) { -- if (predicate(path2)) { -- return path2; -- } -- lastPath = path2; -- path2 = nodePath.dirname(path2); -- } --} --function isGitRoot(dir) { -- return fs.existsSync(nodePath.join(dir, ".git")); --} --function findRepoRoot(path2) { -- return findAncestorDirectory(path2, isGitRoot); --} -- --// src/SeatbeltConfig.ts --var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; --var SEATBELT_FROZEN = "SEATBELT_FROZEN"; --var SEATBELT_INCREASE = "SEATBELT_INCREASE"; --var SEATBELT_KEEP = "SEATBELT_KEEP"; --var SEATBELT_FILE = "SEATBELT_FILE"; --var SEATBELT_PWD = "SEATBELT_PWD"; --var SEATBELT_DISABLE = "SEATBELT_DISABLE"; --var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; --var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; --var SEATBELT_QUIET = "SEATBELT_QUIET"; --var SEATBELT_ROOT = "SEATBELT_ROOT"; --var ENV_VARS = { -- SEATBELT_FROZEN, -- SEATBELT_INCREASE, -- SEATBELT_KEEP, -- SEATBELT_FILE, -- SEATBELT_PWD, -- SEATBELT_DISABLE, -- SEATBELT_THREADSAFE, -- SEATBELT_VERBOSE, -- SEATBELT_QUIET, -- SEATBELT_ROOT, -- CI: "CI", -- JEST_WORKER_ID: "JEST_WORKER_ID" --}; --var SeatbeltConfig = { -- withEnvOverrides(config, env) { -- return { -- ...SeatbeltConfig.fromFallbackEnv(env), -- ...config, -- ...SeatbeltConfig.fromEnvOverrides(env) -- }; -- }, -- fromFallbackEnv(env, log) { -- const config = {}; -- const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -- if (isCI) { -- config.frozen = true; -- log?.(`${padVarName("CI")} config.frozen defaults to`, true); -- } -- if (env.JEST_WORKER_ID) { -- config.threadsafe = true; -- log?.( -- `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -- true -- ); -- } -- return config; -- }, -- fromEnvOverrides(env, log) { -- const config = { -- pwd: env[SEATBELT_PWD] || process.cwd() -- }; -- const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -- if (verbose !== void 0) { -- config.verbose = verbose; -- log?.(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose); -- } -- const seatbeltFile = env[SEATBELT_FILE]; -- if (seatbeltFile) { -- const rootRelative = path.isAbsolute(seatbeltFile) ? seatbeltFile : path.join(config.pwd, seatbeltFile); -- config.seatbeltFile = rootRelative; -- log?.(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative); -- } -- const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -- if (disable !== void 0) { -- config.disable = disable; -- log?.(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable); -- } -- const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -- if (frozen !== void 0) { -- config.frozen = frozen; -- log?.(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen); -- } -- const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -- if (increase !== void 0) { -- config.allowIncreaseRules = increase; -- log?.( -- `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -- increase -- ); -- } -- const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -- if (keep !== void 0) { -- config.keepRules = keep; -- log?.(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep); -- } -- const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -- if (threadsafe !== void 0) { -- config.threadsafe = threadsafe; -- log?.( -- `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -- threadsafe -- ); -- } -- const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -- if (quiet !== void 0) { -- config.quiet = quiet; -- log?.(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet); -- } -- const root = env[SEATBELT_ROOT]; -- if (root) { -- config.root = root; -- log?.(`${padVarName(SEATBELT_ROOT)} config.root =`, root); -- } -- return config; -- } --}; --var SeatbeltEnv = { -- parseRuleSetEnvVar(value) { -- if (value === void 0) { -- return void 0; -- } -- if (!value) { -- return []; -- } -- const lower = value.toLowerCase(); -- if (lower === "all" || lower === "1" || lower === "true") { -- return "all"; -- } -- return value.split(/[\s,]+/g).filter(Boolean); -- }, -- readBooleanEnvVar(value) { -- if (value === void 0 || value === "") { -- return void 0; -- } -- const lower = value.toLowerCase(); -- if (lower === "false" || lower === "0" || lower === "no") { -- return false; -- } -- return Boolean(value); -- } --}; --var logStdout = (...message) => ( -- // eslint-disable-next-line no-console -- console.log(`[${name}]:`, ...message) --); --var logStderr = (...message) => ( -- // eslint-disable-next-line no-console -- console.error(`[${name}]:`, ...message) --); --var SeatbeltArgs = { -- fromConfig(config) { -- const cwd = config.pwd ?? process.cwd(); -- const seatbeltFile = config.seatbeltFile ?? SeatbeltArgs.findSeatbeltFile(cwd); -- const root = config.root ?? findRepoRoot(seatbeltFile) ?? path.dirname(seatbeltFile); -- return { -- seatbeltFile, -- root, -- keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(config.keepRules ?? []), -- allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(config.allowIncreaseRules ?? []), -- frozen: config.frozen ?? false, -- disable: config.disable ?? false, -- quiet: config.quiet ?? false, -- threadsafe: config.threadsafe ?? false, -- verbose: config.verbose ?? false -- }; -- }, -- getLogger(args) { -- if (typeof args.verbose === "function") { -- return args.verbose; -- } -- if (args.verbose === "stdout") { -- return logStdout; -- } -- return logStderr; -- }, -- ruleSetHas(ruleSet, ruleId) { -- return ruleSet === "all" || ruleSet.has(ruleId); -- }, -- verboseLog(args, makeMessage) { -- if (args.verbose) { -- const message = makeMessage(); -- const log = SeatbeltArgs.getLogger(args); -- if (typeof message === "string") { -- log(message); -- } else { -- log(...message); -- } -- } -- }, -- findSeatbeltFile(cwd) { -- return `${cwd}/${SEATBELT_FILE_NAME}`; -- } --}; --var envVarMaxLength = 0; --function padVarName(name2) { -- envVarMaxLength ||= Math.max( -- ...Object.values(ENV_VARS).map((name3) => name3.length) -- ); -- return `${name2}:`.padEnd(envVarMaxLength + 1); --} --function formatFilename(filename) { -- const relative = path.relative( -- process.env[SEATBELT_PWD] ?? process.cwd(), -- filename -- ); -- return relative ? relative : filename; --} --function formatRuleId(ruleId) { -- if (ruleId === null) { -- return `unknown rule`; -- } -- return `rule ${ruleId}`; --} -- --// src/jsonSchema/SeatbeltConfigSchema.ts --var SeatbeltConfigSchema = { -- description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -- type: "object", -- properties: { -- seatbeltFile: { -- description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -- type: "string" -- }, -- keepRules: { -- description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- allowIncreaseRules: { -- description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- frozen: { -- description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- disable: { -- description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- quiet: { -- description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -- type: "boolean" -- }, -- threadsafe: { -- description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- verbose: { -- description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -- anyOf: [ -- { -- enum: [false, "stderr", "stdout", true] -- }, -- { -- type: "object" -- } -- ] -- }, -- root: { -- description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -- type: "string" -- } -- }, -- $schema: "http://json-schema.org/draft-07/schema#" --}; -- --export { -- __require, -- name, -- version, -- package_default, -- SEATBELT_FILE_NAME, -- SEATBELT_FROZEN, -- SEATBELT_INCREASE, -- SEATBELT_KEEP, -- SEATBELT_FILE, -- SEATBELT_PWD, -- SEATBELT_DISABLE, -- SEATBELT_THREADSAFE, -- SEATBELT_VERBOSE, -- SEATBELT_QUIET, -- SEATBELT_ROOT, -- SeatbeltConfig, -- SeatbeltEnv, -- logStdout, -- logStderr, -- SeatbeltArgs, -- padVarName, -- formatFilename, -- formatRuleId, -- SeatbeltConfigSchema --}; --//# sourceMappingURL=chunk-7ZO4DCZA.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-K7UHJBLM.js b/node_modules/eslint-seatbelt/dist/chunk-K7UHJBLM.js -deleted file mode 100644 -index 17e3bfa..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-K7UHJBLM.js -+++ /dev/null -@@ -1,329 +0,0 @@ --"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; -- -- -- -- -- -- --var _chunkTVRMUM3Fjs = require('./chunk-TVRMUM3F.js'); -- --// src/SeatbeltFile.ts --var _os = require('os'); var os = _interopRequireWildcard(_os); --var _fs = require('fs'); var fs = _interopRequireWildcard(_fs); --var _path = require('path'); var nodePath = _interopRequireWildcard(_path); -- --// src/errorHanding.ts --function appendErrorContext(error, context) { -- if (error instanceof Error) { -- error.message += ` -- ${context}`; -- } --} --function isErrno(error, code) { -- return error instanceof Error && "code" in error && error.code === code; --} -- --// src/SeatbeltFile.ts --function encodeLine(line) { -- const { filename, ruleId, maxErrors } = line; -- return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} --`; --} --function decodeLine(line, index) { -- try { -- const lineParts = line.split(" "); -- if (lineParts.length !== 3) { -- throw new Error( -- `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -- ); -- } -- let filename; -- try { -- filename = JSON.parse(lineParts[0]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 1 (filename)"); -- throw e; -- } -- let ruleId; -- try { -- ruleId = JSON.parse(lineParts[1]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -- throw e; -- } -- let maxErrors; -- try { -- maxErrors = JSON.parse(lineParts[2]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -- throw e; -- } -- return { -- encoded: line, -- filename, -- ruleId, -- maxErrors -- }; -- } catch (e) { -- appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -- throw e; -- } --} --var COMMENT_LINE_REGEX = /^\s*#/; --var NON_EMPTY_LINE_REGEX = /\S+/; --var DEFAULT_FILE_HEADER = ` --# ${_chunkTVRMUM3Fjs.name} temporarily allowed errors --# docs: https://github.com/justjake/${_chunkTVRMUM3Fjs.name}#readme --`.trim(); --var SeatbeltFile = (_class = class _SeatbeltFile { -- constructor(filename, data, comments = "") {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this); -- this.filename = filename; -- this.data = data; -- this.comments = comments; -- this.filename = nodePath.default.resolve(this.filename); -- this.dirname = nodePath.default.dirname(this.filename); -- } -- static readSync(filename) { -- const text = fs.readFileSync(filename, "utf8"); -- try { -- return _SeatbeltFile.parse(filename, text); -- } catch (e) { -- appendErrorContext(e, `in seatbelt file \`${filename}\``); -- throw e; -- } -- } -- /** -- * Read `filename` if it exists, otherwise create a new empty seatbelt file object -- * that will write to that filename. -- */ -- static openSync(filename) { -- try { -- return _SeatbeltFile.readSync(filename); -- } catch (e) { -- if (isErrno(e, "ENOENT")) { -- return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -- } -- throw e; -- } -- } -- static parse(filename, text) { -- const data = /* @__PURE__ */ new Map(); -- const split = text.split(/(?<=\n)/); -- const lines = split.filter( -- (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -- ).map(decodeLine); -- const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -- lines.forEach((line) => { -- let fileState = data.get(line.filename); -- if (!fileState) { -- fileState = { maxErrors: void 0, lines: [] }; -- data.set(line.filename, fileState); -- } -- fileState.lines.push(line); -- }); -- return new _SeatbeltFile(filename, data, comments.trim()); -- } -- static fromJSON(json) { -- const data = new Map( -- Object.entries(json.data).map(([filename, maxErrors]) => [ -- filename, -- { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -- ]) -- ); -- return new _SeatbeltFile(json.filename, data); -- } -- __init() {this.changed = false} -- -- __init2() {this.useTempDirForWrites = true} -- *filenames() { -- for (const filename of this.data.keys()) { -- yield this.toAbsolutePath(filename); -- } -- } -- getMaxErrors(filename) { -- const fileState = this.data.get(this.toRelativePath(filename)); -- if (!fileState) { -- return void 0; -- } -- fileState.maxErrors ??= parseMaxErrors(fileState.lines); -- return fileState.maxErrors; -- } -- removeFile(filename, args) { -- const relativeFilename = this.toRelativePath(filename); -- if (!this.data.has(relativeFilename)) { -- return false; -- } -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.SEATBELT_FROZEN}: didn't remove max errors` : `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: remove max errors` -- ); -- if (args.frozen) { -- return false; -- } -- this.data.delete(relativeFilename); -- this.changed = true; -- return true; -- } -- updateMaxErrors(filename, args, ruleToErrorCount) { -- const removedRules = /* @__PURE__ */ new Set(); -- let increasedRulesCount = 0; -- let decreasedRulesCount = 0; -- this.getMaxErrors(filename); -- const relativeFilename = this.toRelativePath(filename); -- const maxErrors = _nullishCoalesce(_optionalChain([this, 'access', _ => _.data, 'access', _2 => _2.get, 'call', _3 => _3(relativeFilename), 'optionalAccess', _4 => _4.maxErrors]), () => ( /* @__PURE__ */ new Map())); -- ruleToErrorCount.forEach((errorCount, ruleId) => { -- const maxErrorCount = _nullishCoalesce(maxErrors.get(ruleId), () => ( 0)); -- if (errorCount === maxErrorCount) { -- return; -- } -- if (errorCount < maxErrorCount || _chunkTVRMUM3Fjs.SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, ruleId)}: ${_chunkTVRMUM3Fjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -- ); -- maxErrors.set(ruleId, errorCount); -- if (errorCount > maxErrorCount) { -- increasedRulesCount++; -- } else { -- decreasedRulesCount++; -- } -- } -- }); -- if (args.verbose || args.keepRules !== "all") { -- maxErrors.forEach((maxErrorCount, ruleId) => { -- const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -- if (!shouldRemove) { -- return; -- } -- if (_chunkTVRMUM3Fjs.SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -- args, -- () => `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, ruleId)}: ${_chunkTVRMUM3Fjs.SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -- ); -- return; -- } -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, ruleId)}: ${_chunkTVRMUM3Fjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${0}` -- ); -- maxErrors.delete(ruleId); -- removedRules.add(ruleId); -- }); -- } -- const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -- if (changed && !args.frozen) { -- const file = this.data.get(relativeFilename); -- if (file) { -- file.maxErrors = maxErrors; -- } else { -- this.data.set(relativeFilename, { -- maxErrors, -- lines: [] -- }); -- } -- this.changed = true; -- } -- return { removedRules, increasedRulesCount, decreasedRulesCount }; -- } -- toDataString() { -- const lines = []; -- this.data.forEach((fileState, filename) => { -- if (fileState.maxErrors) { -- fileState.lines = []; -- fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -- fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -- }); -- fileState.lines.sort( -- (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -- ); -- } -- fileState.lines.forEach((line) => { -- const encoded = line.encoded ??= encodeLine(line); -- lines.push(encoded); -- }); -- }); -- lines.sort(); -- if (this.comments) { -- return this.comments + "\n\n" + lines.join(""); -- } else { -- return lines.join(""); -- } -- } -- readSync() { -- const nextStateFile = _SeatbeltFile.openSync(this.filename); -- if (nextStateFile) { -- this.data = nextStateFile.data; -- this.changed = false; -- return true; -- } -- return false; -- } -- flushChanges() { -- if (this.changed) { -- this.writeSync(); -- this.changed = false; -- return { updated: true }; -- } -- return { updated: false }; -- } -- writeSync() { -- const dataString = this.toDataString(); -- const dir = nodePath.dirname(this.filename); -- const base = nodePath.basename(this.filename); -- const tempFile = nodePath.join( -- this.useTempDirForWrites ? os.tmpdir() : dir, -- `.${base}.wip${process.pid}.${Date.now()}.tmp` -- ); -- fs.mkdirSync(dir, { recursive: true }); -- fs.writeFileSync(tempFile, dataString, "utf8"); -- try { -- fs.renameSync(tempFile, this.filename); -- } catch (error) { -- if (isErrno(error, "EXDEV")) { -- this.useTempDirForWrites = false; -- fs.copyFileSync(tempFile, this.filename); -- fs.rmSync(tempFile); -- return; -- } -- throw error; -- } -- } -- toJSON() { -- const data = Object.fromEntries( -- Array.from(this.data.keys()).map((filename) => { -- const maxErrors = this.getMaxErrors(filename); -- if (!maxErrors) { -- throw new Error(`${_chunkTVRMUM3Fjs.name} bug: expected errors for existing key`); -- } -- return [filename, Object.fromEntries(maxErrors)]; -- }) -- ); -- return { filename: this.filename, data }; -- } -- toRelativePath(filename) { -- if (!nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.relative(this.dirname, filename); -- } -- toAbsolutePath(filename) { -- if (nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.resolve(this.dirname, filename); -- } --}, _class); --function parseMaxErrors(lines) { -- const maxErrors = /* @__PURE__ */ new Map(); -- lines.forEach((line) => { -- maxErrors.set(line.ruleId, line.maxErrors); -- }); -- return maxErrors; --} -- -- -- -- -- --exports.appendErrorContext = appendErrorContext; exports.isErrno = isErrno; exports.SeatbeltFile = SeatbeltFile; --//# sourceMappingURL=chunk-K7UHJBLM.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-NTFTCWX7.js b/node_modules/eslint-seatbelt/dist/chunk-NTFTCWX7.js -new file mode 100644 -index 0000000..f3e2e3d ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-NTFTCWX7.js -@@ -0,0 +1,480 @@ -+"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; -+ -+ -+ -+ -+ -+ -+var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); -+ -+// src/FileLock.ts -+var _fs = require('fs'); var fs = _interopRequireWildcard(_fs); -+ -+// src/errorHanding.ts -+function appendErrorContext(error, context) { -+ if (error instanceof Error) { -+ error.message += ` -+ ${context}`; -+ } -+} -+function isErrno(error, code) { -+ return error instanceof Error && "code" in error && error.code === code; -+} -+ -+// src/FileLock.ts -+var { O_CREAT, O_EXCL, O_RDWR } = _fs.constants; -+var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); -+var heldLocks = /* @__PURE__ */ new Set(); -+var cleanupHooksInstalled = false; -+var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; -+function installCleanupHooks() { -+ if (cleanupHooksInstalled) return; -+ cleanupHooksInstalled = true; -+ const release = () => { -+ for (const lock of heldLocks) { -+ try { -+ lock.unlock(); -+ } catch (e2) { -+ } -+ } -+ }; -+ process.on("exit", release); -+ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { -+ process.on(signal, () => { -+ release(); -+ process.exit(SIGNAL_EXIT_CODES[signal]); -+ }); -+ } -+} -+var FileLock = class { -+ constructor(filename) { -+ this.filename = filename; -+ } -+ -+ tryLock() { -+ this.assertNotLocked(); -+ try { -+ this.fd = _fs.openSync.call(void 0, this.filename, O_CREAT | O_EXCL | O_RDWR); -+ _fs.writeSync.call(void 0, this.fd, `${process.pid} -+`); -+ heldLocks.add(this); -+ installCleanupHooks(); -+ return true; -+ } catch (e) { -+ if (isErrno(e, "EEXIST")) { -+ return false; -+ } -+ throw e; -+ } -+ } -+ waitLock(timeoutMs) { -+ const deadline = Date.now() + timeoutMs; -+ let attemptedRecovery = false; -+ while (!this.tryLock()) { -+ if (Date.now() > deadline) { -+ if (!attemptedRecovery && this.reclaimIfStale()) { -+ attemptedRecovery = true; -+ continue; -+ } -+ throw new Error(`Timed out waiting for lock on ${this.filename}`); -+ } -+ Atomics.wait(waitBuffer, 0, 0, 1); -+ } -+ } -+ isLocked() { -+ return this.fd !== void 0; -+ } -+ unlock() { -+ if (this.fd !== void 0) { -+ _fs.closeSync.call(void 0, this.fd); -+ try { -+ _fs.rmSync.call(void 0, this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ this.fd = void 0; -+ heldLocks.delete(this); -+ } -+ } -+ assertNotLocked() { -+ if (this.fd !== void 0) { -+ throw new Error( -+ `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -+ ); -+ } -+ } -+ reclaimIfStale() { -+ let contents; -+ try { -+ contents = _fs.readFileSync.call(void 0, this.filename, "utf8"); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) return true; -+ throw e; -+ } -+ const pid = Number.parseInt(contents.trim(), 10); -+ if (!Number.isFinite(pid) || pid <= 0) return false; -+ try { -+ process.kill(pid, 0); -+ return false; -+ } catch (e) { -+ if (!isErrno(e, "ESRCH")) throw e; -+ } -+ try { -+ _fs.rmSync.call(void 0, this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ return true; -+ } -+}; -+ -+// src/SeatbeltFile.ts -+var _os = require('os'); var os = _interopRequireWildcard(_os); -+ -+var _path = require('path'); var nodePath = _interopRequireWildcard(_path); -+var LOCK_TIMEOUT_MS = 3e4; -+function encodeLine(line) { -+ const { filename, ruleId, maxErrors } = line; -+ return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} -+`; -+} -+function decodeLine(line, index) { -+ try { -+ const lineParts = line.split(" "); -+ if (lineParts.length !== 3) { -+ throw new Error( -+ `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -+ ); -+ } -+ let filename; -+ try { -+ filename = JSON.parse(lineParts[0]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 1 (filename)"); -+ throw e; -+ } -+ let ruleId; -+ try { -+ ruleId = JSON.parse(lineParts[1]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -+ throw e; -+ } -+ let maxErrors; -+ try { -+ maxErrors = JSON.parse(lineParts[2]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -+ throw e; -+ } -+ return { -+ encoded: line, -+ filename, -+ ruleId, -+ maxErrors -+ }; -+ } catch (e) { -+ appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -+ throw e; -+ } -+} -+var COMMENT_LINE_REGEX = /^\s*#/; -+var NON_EMPTY_LINE_REGEX = /\S+/; -+var DEFAULT_FILE_HEADER = ` -+# ${_chunkZVY5S6JSjs.name} temporarily allowed errors -+# docs: https://github.com/justjake/${_chunkZVY5S6JSjs.name}#readme -+`.trim(); -+var SeatbeltFile = (_class = class _SeatbeltFile { -+ constructor(filename, data, comments = "") {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this); -+ this.filename = filename; -+ this.data = data; -+ this.comments = comments; -+ this.filename = nodePath.default.resolve(this.filename); -+ this.dirname = nodePath.default.dirname(this.filename); -+ } -+ static readSync(filename) { -+ const text = fs.readFileSync(filename, "utf8"); -+ try { -+ return _SeatbeltFile.parse(filename, text); -+ } catch (e) { -+ appendErrorContext(e, `in seatbelt file \`${filename}\``); -+ throw e; -+ } -+ } -+ /** -+ * Read `filename` if it exists, otherwise create a new empty seatbelt file object -+ * that will write to that filename. -+ */ -+ static openSync(filename) { -+ try { -+ return _SeatbeltFile.readSync(filename); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) { -+ return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -+ } -+ throw e; -+ } -+ } -+ static parse(filename, text) { -+ const data = /* @__PURE__ */ new Map(); -+ const split = text.split(/(?<=\n)/); -+ const lines = split.filter( -+ (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -+ ).map(decodeLine); -+ const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -+ lines.forEach((line) => { -+ let fileState = data.get(line.filename); -+ if (!fileState) { -+ fileState = { maxErrors: void 0, lines: [] }; -+ data.set(line.filename, fileState); -+ } -+ fileState.lines.push(line); -+ }); -+ return new _SeatbeltFile(filename, data, comments.trim()); -+ } -+ static fromJSON(json) { -+ const data = new Map( -+ Object.entries(json.data).map(([filename, maxErrors]) => [ -+ filename, -+ { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -+ ]) -+ ); -+ return new _SeatbeltFile(json.filename, data); -+ } -+ __init() {this.changed = false} -+ -+ __init2() {this.useTempDirForWrites = true} -+ *filenames() { -+ for (const filename of this.data.keys()) { -+ yield this.toAbsolutePath(filename); -+ } -+ } -+ getMaxErrors(filename) { -+ const fileState = this.data.get(this.toRelativePath(filename)); -+ if (!fileState) { -+ return void 0; -+ } -+ fileState.maxErrors ??= parseMaxErrors(fileState.lines); -+ return fileState.maxErrors; -+ } -+ removeFile(filename, args) { -+ const relativeFilename = this.toRelativePath(filename); -+ if (!this.data.has(relativeFilename)) { -+ return false; -+ } -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: didn't remove max errors` : `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: remove max errors` -+ ); -+ if (args.frozen) { -+ return false; -+ } -+ this.data.delete(relativeFilename); -+ this.changed = true; -+ return true; -+ } -+ updateMaxErrors(filename, args, ruleToErrorCount) { -+ const removedRules = /* @__PURE__ */ new Set(); -+ let increasedRulesCount = 0; -+ let decreasedRulesCount = 0; -+ this.getMaxErrors(filename); -+ const relativeFilename = this.toRelativePath(filename); -+ const maxErrors = _nullishCoalesce(_optionalChain([this, 'access', _ => _.data, 'access', _2 => _2.get, 'call', _3 => _3(relativeFilename), 'optionalAccess', _4 => _4.maxErrors]), () => ( /* @__PURE__ */ new Map())); -+ ruleToErrorCount.forEach((errorCount, ruleId) => { -+ const maxErrorCount = _nullishCoalesce(maxErrors.get(ruleId), () => ( 0)); -+ if (errorCount === maxErrorCount) { -+ return; -+ } -+ if (errorCount < maxErrorCount || _chunkZVY5S6JSjs.SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -+ ); -+ maxErrors.set(ruleId, errorCount); -+ if (errorCount > maxErrorCount) { -+ increasedRulesCount++; -+ } else { -+ decreasedRulesCount++; -+ } -+ } -+ }); -+ if (args.verbose || args.keepRules !== "all") { -+ maxErrors.forEach((maxErrorCount, ruleId) => { -+ const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -+ if (!shouldRemove) { -+ return; -+ } -+ if (_chunkZVY5S6JSjs.SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ args, -+ () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: ${_chunkZVY5S6JSjs.SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -+ ); -+ return; -+ } -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${0}` -+ ); -+ maxErrors.delete(ruleId); -+ removedRules.add(ruleId); -+ }); -+ } -+ const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -+ if (changed && !args.frozen) { -+ const file = this.data.get(relativeFilename); -+ if (file) { -+ file.maxErrors = maxErrors; -+ } else { -+ this.data.set(relativeFilename, { -+ maxErrors, -+ lines: [] -+ }); -+ } -+ this.changed = true; -+ } -+ return { removedRules, increasedRulesCount, decreasedRulesCount }; -+ } -+ /** Atomic read -> apply delta -> write. Takes an exclusive file lock when `args.threadsafe`. */ -+ updateFileMaxErrors(args, filename, ruleToErrorCount) { -+ return this.withOptionalLock(args, () => { -+ const before = this.getMaxErrors(filename); -+ const ruleToMaxErrorCountBefore = before ? new Map(before) : void 0; -+ const result = this.updateMaxErrors(filename, args, ruleToErrorCount); -+ if (!args.frozen) { -+ this.flushChanges(); -+ } -+ return { ruleToMaxErrorCountBefore, ...result }; -+ }); -+ } -+ /** Drop entries whose source file no longer exists. Takes an exclusive file lock when `args.threadsafe`. */ -+ cleanUpRemovedFiles(args) { -+ return this.withOptionalLock(args, () => { -+ let removedFiles = 0; -+ for (const filename of Array.from(this.filenames())) { -+ if (!fs.existsSync(filename)) { -+ if (this.removeFile(filename, args)) { -+ removedFiles++; -+ } -+ } -+ } -+ if (!args.frozen) { -+ this.flushChanges(); -+ } -+ return { removedFiles }; -+ }); -+ } -+ withOptionalLock(args, fn) { -+ if (!args.threadsafe) { -+ return fn(); -+ } -+ const lock = new FileLock(`${this.filename}.lock`); -+ lock.waitLock(LOCK_TIMEOUT_MS); -+ try { -+ this.readSync(); -+ return fn(); -+ } finally { -+ lock.unlock(); -+ } -+ } -+ toDataString() { -+ const lines = []; -+ this.data.forEach((fileState, filename) => { -+ if (fileState.maxErrors) { -+ fileState.lines = []; -+ fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -+ fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -+ }); -+ fileState.lines.sort( -+ (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -+ ); -+ } -+ fileState.lines.forEach((line) => { -+ const encoded = line.encoded ??= encodeLine(line); -+ lines.push(encoded); -+ }); -+ }); -+ lines.sort(); -+ if (this.comments) { -+ return this.comments + "\n\n" + lines.join(""); -+ } else { -+ return lines.join(""); -+ } -+ } -+ readSync() { -+ const nextStateFile = _SeatbeltFile.openSync(this.filename); -+ if (nextStateFile) { -+ this.data = nextStateFile.data; -+ this.changed = false; -+ return true; -+ } -+ return false; -+ } -+ flushChanges() { -+ if (this.changed) { -+ this.writeSync(); -+ this.changed = false; -+ return { updated: true }; -+ } -+ return { updated: false }; -+ } -+ writeSync() { -+ const dataString = this.toDataString(); -+ const dir = nodePath.dirname(this.filename); -+ const base = nodePath.basename(this.filename); -+ const tempFile = nodePath.join( -+ this.useTempDirForWrites ? os.tmpdir() : dir, -+ `.${base}.wip${process.pid}.${Date.now()}.tmp` -+ ); -+ fs.mkdirSync(dir, { recursive: true }); -+ fs.writeFileSync(tempFile, dataString, "utf8"); -+ try { -+ fs.renameSync(tempFile, this.filename); -+ } catch (error) { -+ if (isErrno(error, "EXDEV")) { -+ this.useTempDirForWrites = false; -+ fs.copyFileSync(tempFile, this.filename); -+ fs.rmSync(tempFile); -+ return; -+ } -+ throw error; -+ } -+ } -+ toJSON() { -+ const data = Object.fromEntries( -+ Array.from(this.data.keys()).map((filename) => { -+ const maxErrors = this.getMaxErrors(filename); -+ if (!maxErrors) { -+ throw new Error(`${_chunkZVY5S6JSjs.name} bug: expected errors for existing key`); -+ } -+ return [filename, Object.fromEntries(maxErrors)]; -+ }) -+ ); -+ return { filename: this.filename, data }; -+ } -+ toRelativePath(filename) { -+ if (!nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.relative(this.dirname, filename); -+ } -+ toAbsolutePath(filename) { -+ if (nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.resolve(this.dirname, filename); -+ } -+}, _class); -+function parseMaxErrors(lines) { -+ const maxErrors = /* @__PURE__ */ new Map(); -+ lines.forEach((line) => { -+ maxErrors.set(line.ruleId, line.maxErrors); -+ }); -+ return maxErrors; -+} -+ -+ -+ -+ -+ -+exports.appendErrorContext = appendErrorContext; exports.FileLock = FileLock; exports.SeatbeltFile = SeatbeltFile; -+//# sourceMappingURL=chunk-NTFTCWX7.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-OKIIDZIF.mjs b/node_modules/eslint-seatbelt/dist/chunk-OKIIDZIF.mjs -deleted file mode 100644 -index 4e478ef..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-OKIIDZIF.mjs -+++ /dev/null -@@ -1,329 +0,0 @@ --import { -- SEATBELT_FROZEN, -- SEATBELT_KEEP, -- SeatbeltArgs, -- formatFilename, -- formatRuleId, -- name --} from "./chunk-7ZO4DCZA.mjs"; -- --// src/SeatbeltFile.ts --import * as os from "node:os"; --import * as fs from "node:fs"; --import path, * as nodePath from "node:path"; -- --// src/errorHanding.ts --function appendErrorContext(error, context) { -- if (error instanceof Error) { -- error.message += ` -- ${context}`; -- } --} --function isErrno(error, code) { -- return error instanceof Error && "code" in error && error.code === code; --} -- --// src/SeatbeltFile.ts --function encodeLine(line) { -- const { filename, ruleId, maxErrors } = line; -- return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} --`; --} --function decodeLine(line, index) { -- try { -- const lineParts = line.split(" "); -- if (lineParts.length !== 3) { -- throw new Error( -- `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -- ); -- } -- let filename; -- try { -- filename = JSON.parse(lineParts[0]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 1 (filename)"); -- throw e; -- } -- let ruleId; -- try { -- ruleId = JSON.parse(lineParts[1]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -- throw e; -- } -- let maxErrors; -- try { -- maxErrors = JSON.parse(lineParts[2]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -- throw e; -- } -- return { -- encoded: line, -- filename, -- ruleId, -- maxErrors -- }; -- } catch (e) { -- appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -- throw e; -- } --} --var COMMENT_LINE_REGEX = /^\s*#/; --var NON_EMPTY_LINE_REGEX = /\S+/; --var DEFAULT_FILE_HEADER = ` --# ${name} temporarily allowed errors --# docs: https://github.com/justjake/${name}#readme --`.trim(); --var SeatbeltFile = class _SeatbeltFile { -- constructor(filename, data, comments = "") { -- this.filename = filename; -- this.data = data; -- this.comments = comments; -- this.filename = path.resolve(this.filename); -- this.dirname = path.dirname(this.filename); -- } -- static readSync(filename) { -- const text = fs.readFileSync(filename, "utf8"); -- try { -- return _SeatbeltFile.parse(filename, text); -- } catch (e) { -- appendErrorContext(e, `in seatbelt file \`${filename}\``); -- throw e; -- } -- } -- /** -- * Read `filename` if it exists, otherwise create a new empty seatbelt file object -- * that will write to that filename. -- */ -- static openSync(filename) { -- try { -- return _SeatbeltFile.readSync(filename); -- } catch (e) { -- if (isErrno(e, "ENOENT")) { -- return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -- } -- throw e; -- } -- } -- static parse(filename, text) { -- const data = /* @__PURE__ */ new Map(); -- const split = text.split(/(?<=\n)/); -- const lines = split.filter( -- (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -- ).map(decodeLine); -- const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -- lines.forEach((line) => { -- let fileState = data.get(line.filename); -- if (!fileState) { -- fileState = { maxErrors: void 0, lines: [] }; -- data.set(line.filename, fileState); -- } -- fileState.lines.push(line); -- }); -- return new _SeatbeltFile(filename, data, comments.trim()); -- } -- static fromJSON(json) { -- const data = new Map( -- Object.entries(json.data).map(([filename, maxErrors]) => [ -- filename, -- { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -- ]) -- ); -- return new _SeatbeltFile(json.filename, data); -- } -- changed = false; -- dirname; -- useTempDirForWrites = true; -- *filenames() { -- for (const filename of this.data.keys()) { -- yield this.toAbsolutePath(filename); -- } -- } -- getMaxErrors(filename) { -- const fileState = this.data.get(this.toRelativePath(filename)); -- if (!fileState) { -- return void 0; -- } -- fileState.maxErrors ??= parseMaxErrors(fileState.lines); -- return fileState.maxErrors; -- } -- removeFile(filename, args) { -- const relativeFilename = this.toRelativePath(filename); -- if (!this.data.has(relativeFilename)) { -- return false; -- } -- SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${formatFilename(filename)}: ${SEATBELT_FROZEN}: didn't remove max errors` : `${formatFilename(filename)}: remove max errors` -- ); -- if (args.frozen) { -- return false; -- } -- this.data.delete(relativeFilename); -- this.changed = true; -- return true; -- } -- updateMaxErrors(filename, args, ruleToErrorCount) { -- const removedRules = /* @__PURE__ */ new Set(); -- let increasedRulesCount = 0; -- let decreasedRulesCount = 0; -- this.getMaxErrors(filename); -- const relativeFilename = this.toRelativePath(filename); -- const maxErrors = this.data.get(relativeFilename)?.maxErrors ?? /* @__PURE__ */ new Map(); -- ruleToErrorCount.forEach((errorCount, ruleId) => { -- const maxErrorCount = maxErrors.get(ruleId) ?? 0; -- if (errorCount === maxErrorCount) { -- return; -- } -- if (errorCount < maxErrorCount || SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -- SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -- ); -- maxErrors.set(ruleId, errorCount); -- if (errorCount > maxErrorCount) { -- increasedRulesCount++; -- } else { -- decreasedRulesCount++; -- } -- } -- }); -- if (args.verbose || args.keepRules !== "all") { -- maxErrors.forEach((maxErrorCount, ruleId) => { -- const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -- if (!shouldRemove) { -- return; -- } -- if (SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -- SeatbeltArgs.verboseLog( -- args, -- () => `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -- ); -- return; -- } -- SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${0}` -- ); -- maxErrors.delete(ruleId); -- removedRules.add(ruleId); -- }); -- } -- const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -- if (changed && !args.frozen) { -- const file = this.data.get(relativeFilename); -- if (file) { -- file.maxErrors = maxErrors; -- } else { -- this.data.set(relativeFilename, { -- maxErrors, -- lines: [] -- }); -- } -- this.changed = true; -- } -- return { removedRules, increasedRulesCount, decreasedRulesCount }; -- } -- toDataString() { -- const lines = []; -- this.data.forEach((fileState, filename) => { -- if (fileState.maxErrors) { -- fileState.lines = []; -- fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -- fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -- }); -- fileState.lines.sort( -- (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -- ); -- } -- fileState.lines.forEach((line) => { -- const encoded = line.encoded ??= encodeLine(line); -- lines.push(encoded); -- }); -- }); -- lines.sort(); -- if (this.comments) { -- return this.comments + "\n\n" + lines.join(""); -- } else { -- return lines.join(""); -- } -- } -- readSync() { -- const nextStateFile = _SeatbeltFile.openSync(this.filename); -- if (nextStateFile) { -- this.data = nextStateFile.data; -- this.changed = false; -- return true; -- } -- return false; -- } -- flushChanges() { -- if (this.changed) { -- this.writeSync(); -- this.changed = false; -- return { updated: true }; -- } -- return { updated: false }; -- } -- writeSync() { -- const dataString = this.toDataString(); -- const dir = nodePath.dirname(this.filename); -- const base = nodePath.basename(this.filename); -- const tempFile = nodePath.join( -- this.useTempDirForWrites ? os.tmpdir() : dir, -- `.${base}.wip${process.pid}.${Date.now()}.tmp` -- ); -- fs.mkdirSync(dir, { recursive: true }); -- fs.writeFileSync(tempFile, dataString, "utf8"); -- try { -- fs.renameSync(tempFile, this.filename); -- } catch (error) { -- if (isErrno(error, "EXDEV")) { -- this.useTempDirForWrites = false; -- fs.copyFileSync(tempFile, this.filename); -- fs.rmSync(tempFile); -- return; -- } -- throw error; -- } -- } -- toJSON() { -- const data = Object.fromEntries( -- Array.from(this.data.keys()).map((filename) => { -- const maxErrors = this.getMaxErrors(filename); -- if (!maxErrors) { -- throw new Error(`${name} bug: expected errors for existing key`); -- } -- return [filename, Object.fromEntries(maxErrors)]; -- }) -- ); -- return { filename: this.filename, data }; -- } -- toRelativePath(filename) { -- if (!nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.relative(this.dirname, filename); -- } -- toAbsolutePath(filename) { -- if (nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.resolve(this.dirname, filename); -- } --}; --function parseMaxErrors(lines) { -- const maxErrors = /* @__PURE__ */ new Map(); -- lines.forEach((line) => { -- maxErrors.set(line.ruleId, line.maxErrors); -- }); -- return maxErrors; --} -- --export { -- appendErrorContext, -- isErrno, -- SeatbeltFile --}; --//# sourceMappingURL=chunk-OKIIDZIF.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-TVRMUM3F.js b/node_modules/eslint-seatbelt/dist/chunk-TVRMUM3F.js -deleted file mode 100644 -index f3a3c0b..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-TVRMUM3F.js -+++ /dev/null -@@ -1,427 +0,0 @@ --"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -- get: (a, b) => (typeof require !== "undefined" ? require : a)[b] --}) : x)(function(x) { -- if (typeof require !== "undefined") return require.apply(this, arguments); -- throw Error('Dynamic require of "' + x + '" is not supported'); --}); -- --// package.json --var name = "eslint-seatbelt"; --var version = "0.1.3"; --var package_default = { -- name, -- version, -- description: "Gradually tighten ESLint rules in your codebase", -- keywords: [ -- "eslint", -- "incremental", -- "gradual", -- "workflow", -- "processor", -- "linting" -- ], -- author: { -- name: "Jake Teton-Landis", -- url: "https://jake.tl" -- }, -- repository: { -- type: "git", -- url: "git+https://github.com/justjake/eslint-seatbelt.git" -- }, -- bugs: { -- url: "https://github.com/justjake/eslint-seatbelt/issues" -- }, -- scripts: { -- build: "./scripts/make-json-schemas.ts && tsc && tsup", -- test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -- lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -- }, -- types: "dist/index.d.ts", -- import: "./dist/index.mjs", -- main: "dist/index.js", -- exports: { -- ".": { -- types: "./dist/index.d.ts", -- import: "./dist/index.mjs", -- default: "./dist/index.js" -- }, -- "./api": { -- types: "./dist/api.d.ts", -- import: "./dist/api.mjs", -- default: "./dist/api.js" -- } -- }, -- bin: { -- "eslint-seatbelt": "./dist/command.js" -- }, -- files: [ -- "!*.tsbuildinfo", -- "!src/**/*.test.ts", -- "src", -- "dist" -- ], -- license: "MIT", -- peerDependencies: { -- "@types/eslint": "*", -- eslint: "*" -- }, -- peerDependenciesMeta: { -- eslint: { -- optional: true -- }, -- "@types/eslint": { -- optional: true -- } -- }, -- devDependencies: { -- "@eslint/compat": "1.2.3", -- "@eslint/js": "9.15.0", -- "@types/eslint__js": "8.42.3", -- "@types/node": "22.9.0", -- "@typescript-eslint/rule-tester": "8.14.0", -- "@typescript-eslint/utils": "8.14.0", -- eslint: "9.14.0", -- prettier: "3.3.3", -- tsup: "8.3.5", -- tsx: "4.19.2", -- typescript: "5.6.3", -- "typescript-eslint": "8.14.0", -- "typescript-json-schema": "0.65.1" -- }, -- dependencies: { -- "ts-command-line-args": "^2.5.1" -- }, -- packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" --}; -- --// src/SeatbeltConfig.ts --var _path = require('path'); var _path2 = _interopRequireDefault(_path); -- --// src/repoIntegration.ts --var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs); -- --function findAncestorDirectory(path2, predicate) { -- let lastPath = void 0; -- while (path2 !== lastPath) { -- if (predicate(path2)) { -- return path2; -- } -- lastPath = path2; -- path2 = _path2.default.dirname(path2); -- } --} --function isGitRoot(dir) { -- return _fs2.default.existsSync(_path2.default.join(dir, ".git")); --} --function findRepoRoot(path2) { -- return findAncestorDirectory(path2, isGitRoot); --} -- --// src/SeatbeltConfig.ts --var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; --var SEATBELT_FROZEN = "SEATBELT_FROZEN"; --var SEATBELT_INCREASE = "SEATBELT_INCREASE"; --var SEATBELT_KEEP = "SEATBELT_KEEP"; --var SEATBELT_FILE = "SEATBELT_FILE"; --var SEATBELT_PWD = "SEATBELT_PWD"; --var SEATBELT_DISABLE = "SEATBELT_DISABLE"; --var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; --var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; --var SEATBELT_QUIET = "SEATBELT_QUIET"; --var SEATBELT_ROOT = "SEATBELT_ROOT"; --var ENV_VARS = { -- SEATBELT_FROZEN, -- SEATBELT_INCREASE, -- SEATBELT_KEEP, -- SEATBELT_FILE, -- SEATBELT_PWD, -- SEATBELT_DISABLE, -- SEATBELT_THREADSAFE, -- SEATBELT_VERBOSE, -- SEATBELT_QUIET, -- SEATBELT_ROOT, -- CI: "CI", -- JEST_WORKER_ID: "JEST_WORKER_ID" --}; --var SeatbeltConfig = { -- withEnvOverrides(config, env) { -- return { -- ...SeatbeltConfig.fromFallbackEnv(env), -- ...config, -- ...SeatbeltConfig.fromEnvOverrides(env) -- }; -- }, -- fromFallbackEnv(env, log) { -- const config = {}; -- const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -- if (isCI) { -- config.frozen = true; -- _optionalChain([log, 'optionalCall', _ => _(`${padVarName("CI")} config.frozen defaults to`, true)]); -- } -- if (env.JEST_WORKER_ID) { -- config.threadsafe = true; -- _optionalChain([log, 'optionalCall', _2 => _2( -- `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -- true -- )]); -- } -- return config; -- }, -- fromEnvOverrides(env, log) { -- const config = { -- pwd: env[SEATBELT_PWD] || process.cwd() -- }; -- const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -- if (verbose !== void 0) { -- config.verbose = verbose; -- _optionalChain([log, 'optionalCall', _3 => _3(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose)]); -- } -- const seatbeltFile = env[SEATBELT_FILE]; -- if (seatbeltFile) { -- const rootRelative = _path2.default.isAbsolute(seatbeltFile) ? seatbeltFile : _path2.default.join(config.pwd, seatbeltFile); -- config.seatbeltFile = rootRelative; -- _optionalChain([log, 'optionalCall', _4 => _4(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative)]); -- } -- const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -- if (disable !== void 0) { -- config.disable = disable; -- _optionalChain([log, 'optionalCall', _5 => _5(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable)]); -- } -- const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -- if (frozen !== void 0) { -- config.frozen = frozen; -- _optionalChain([log, 'optionalCall', _6 => _6(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen)]); -- } -- const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -- if (increase !== void 0) { -- config.allowIncreaseRules = increase; -- _optionalChain([log, 'optionalCall', _7 => _7( -- `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -- increase -- )]); -- } -- const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -- if (keep !== void 0) { -- config.keepRules = keep; -- _optionalChain([log, 'optionalCall', _8 => _8(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep)]); -- } -- const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -- if (threadsafe !== void 0) { -- config.threadsafe = threadsafe; -- _optionalChain([log, 'optionalCall', _9 => _9( -- `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -- threadsafe -- )]); -- } -- const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -- if (quiet !== void 0) { -- config.quiet = quiet; -- _optionalChain([log, 'optionalCall', _10 => _10(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet)]); -- } -- const root = env[SEATBELT_ROOT]; -- if (root) { -- config.root = root; -- _optionalChain([log, 'optionalCall', _11 => _11(`${padVarName(SEATBELT_ROOT)} config.root =`, root)]); -- } -- return config; -- } --}; --var SeatbeltEnv = { -- parseRuleSetEnvVar(value) { -- if (value === void 0) { -- return void 0; -- } -- if (!value) { -- return []; -- } -- const lower = value.toLowerCase(); -- if (lower === "all" || lower === "1" || lower === "true") { -- return "all"; -- } -- return value.split(/[\s,]+/g).filter(Boolean); -- }, -- readBooleanEnvVar(value) { -- if (value === void 0 || value === "") { -- return void 0; -- } -- const lower = value.toLowerCase(); -- if (lower === "false" || lower === "0" || lower === "no") { -- return false; -- } -- return Boolean(value); -- } --}; --var logStdout = (...message) => ( -- // eslint-disable-next-line no-console -- console.log(`[${name}]:`, ...message) --); --var logStderr = (...message) => ( -- // eslint-disable-next-line no-console -- console.error(`[${name}]:`, ...message) --); --var SeatbeltArgs = { -- fromConfig(config) { -- const cwd = _nullishCoalesce(config.pwd, () => ( process.cwd())); -- const seatbeltFile = _nullishCoalesce(config.seatbeltFile, () => ( SeatbeltArgs.findSeatbeltFile(cwd))); -- const root = _nullishCoalesce(_nullishCoalesce(config.root, () => ( findRepoRoot(seatbeltFile))), () => ( _path2.default.dirname(seatbeltFile))); -- return { -- seatbeltFile, -- root, -- keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(_nullishCoalesce(config.keepRules, () => ( []))), -- allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(_nullishCoalesce(config.allowIncreaseRules, () => ( []))), -- frozen: _nullishCoalesce(config.frozen, () => ( false)), -- disable: _nullishCoalesce(config.disable, () => ( false)), -- quiet: _nullishCoalesce(config.quiet, () => ( false)), -- threadsafe: _nullishCoalesce(config.threadsafe, () => ( false)), -- verbose: _nullishCoalesce(config.verbose, () => ( false)) -- }; -- }, -- getLogger(args) { -- if (typeof args.verbose === "function") { -- return args.verbose; -- } -- if (args.verbose === "stdout") { -- return logStdout; -- } -- return logStderr; -- }, -- ruleSetHas(ruleSet, ruleId) { -- return ruleSet === "all" || ruleSet.has(ruleId); -- }, -- verboseLog(args, makeMessage) { -- if (args.verbose) { -- const message = makeMessage(); -- const log = SeatbeltArgs.getLogger(args); -- if (typeof message === "string") { -- log(message); -- } else { -- log(...message); -- } -- } -- }, -- findSeatbeltFile(cwd) { -- return `${cwd}/${SEATBELT_FILE_NAME}`; -- } --}; --var envVarMaxLength = 0; --function padVarName(name2) { -- envVarMaxLength ||= Math.max( -- ...Object.values(ENV_VARS).map((name3) => name3.length) -- ); -- return `${name2}:`.padEnd(envVarMaxLength + 1); --} --function formatFilename(filename) { -- const relative = _path2.default.relative( -- _nullishCoalesce(process.env[SEATBELT_PWD], () => ( process.cwd())), -- filename -- ); -- return relative ? relative : filename; --} --function formatRuleId(ruleId) { -- if (ruleId === null) { -- return `unknown rule`; -- } -- return `rule ${ruleId}`; --} -- --// src/jsonSchema/SeatbeltConfigSchema.ts --var SeatbeltConfigSchema = { -- description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -- type: "object", -- properties: { -- seatbeltFile: { -- description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -- type: "string" -- }, -- keepRules: { -- description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- allowIncreaseRules: { -- description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- frozen: { -- description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- disable: { -- description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- quiet: { -- description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -- type: "boolean" -- }, -- threadsafe: { -- description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- verbose: { -- description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -- anyOf: [ -- { -- enum: [false, "stderr", "stdout", true] -- }, -- { -- type: "object" -- } -- ] -- }, -- root: { -- description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -- type: "string" -- } -- }, -- $schema: "http://json-schema.org/draft-07/schema#" --}; -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --exports.__require = __require; exports.name = name; exports.version = version; exports.package_default = package_default; exports.SEATBELT_FILE_NAME = SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = SEATBELT_FROZEN; exports.SEATBELT_INCREASE = SEATBELT_INCREASE; exports.SEATBELT_KEEP = SEATBELT_KEEP; exports.SEATBELT_FILE = SEATBELT_FILE; exports.SEATBELT_PWD = SEATBELT_PWD; exports.SEATBELT_DISABLE = SEATBELT_DISABLE; exports.SEATBELT_THREADSAFE = SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = SEATBELT_VERBOSE; exports.SEATBELT_QUIET = SEATBELT_QUIET; exports.SEATBELT_ROOT = SEATBELT_ROOT; exports.SeatbeltConfig = SeatbeltConfig; exports.SeatbeltEnv = SeatbeltEnv; exports.logStdout = logStdout; exports.logStderr = logStderr; exports.SeatbeltArgs = SeatbeltArgs; exports.padVarName = padVarName; exports.formatFilename = formatFilename; exports.formatRuleId = formatRuleId; exports.SeatbeltConfigSchema = SeatbeltConfigSchema; --//# sourceMappingURL=chunk-TVRMUM3F.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-ULACHCKT.mjs b/node_modules/eslint-seatbelt/dist/chunk-ULACHCKT.mjs -new file mode 100644 -index 0000000..b90ad17 ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-ULACHCKT.mjs -@@ -0,0 +1,435 @@ -+var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b] -+}) : x)(function(x) { -+ if (typeof require !== "undefined") return require.apply(this, arguments); -+ throw Error('Dynamic require of "' + x + '" is not supported'); -+}); -+ -+// package.json -+var name = "eslint-seatbelt"; -+var version = "0.1.3"; -+var package_default = { -+ name, -+ version, -+ description: "Gradually tighten ESLint rules in your codebase", -+ keywords: [ -+ "eslint", -+ "incremental", -+ "gradual", -+ "workflow", -+ "processor", -+ "linting" -+ ], -+ author: { -+ name: "Jake Teton-Landis", -+ url: "https://jake.tl" -+ }, -+ repository: { -+ type: "git", -+ url: "git+https://github.com/justjake/eslint-seatbelt.git" -+ }, -+ bugs: { -+ url: "https://github.com/justjake/eslint-seatbelt/issues" -+ }, -+ scripts: { -+ build: "./scripts/make-json-schemas.ts && tsc && tsup", -+ test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -+ lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -+ }, -+ types: "dist/index.d.ts", -+ import: "./dist/index.mjs", -+ main: "dist/index.js", -+ exports: { -+ ".": { -+ types: "./dist/index.d.ts", -+ import: "./dist/index.mjs", -+ default: "./dist/index.js" -+ }, -+ "./api": { -+ types: "./dist/api.d.ts", -+ import: "./dist/api.mjs", -+ default: "./dist/api.js" -+ } -+ }, -+ bin: { -+ "eslint-seatbelt": "./dist/command.js" -+ }, -+ files: [ -+ "!*.tsbuildinfo", -+ "!src/**/*.test.ts", -+ "src", -+ "dist" -+ ], -+ license: "MIT", -+ peerDependencies: { -+ "@types/eslint": "*", -+ eslint: "*" -+ }, -+ peerDependenciesMeta: { -+ eslint: { -+ optional: true -+ }, -+ "@types/eslint": { -+ optional: true -+ } -+ }, -+ devDependencies: { -+ "@eslint/compat": "1.2.3", -+ "@eslint/js": "9.15.0", -+ "@types/eslint__js": "8.42.3", -+ "@types/node": "22.9.0", -+ "@typescript-eslint/rule-tester": "8.14.0", -+ "@typescript-eslint/utils": "8.14.0", -+ eslint: "9.14.0", -+ prettier: "3.3.3", -+ tsup: "8.3.5", -+ tsx: "4.19.2", -+ typescript: "5.6.3", -+ "typescript-eslint": "8.14.0", -+ "typescript-json-schema": "0.65.1" -+ }, -+ dependencies: { -+ "ts-command-line-args": "^2.5.1" -+ }, -+ packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" -+}; -+ -+// src/SeatbeltConfig.ts -+import path from "node:path"; -+import { isMainThread } from "node:worker_threads"; -+ -+// src/repoIntegration.ts -+import fs from "node:fs"; -+import nodePath from "node:path"; -+function findAncestorDirectory(path2, predicate) { -+ let lastPath = void 0; -+ while (path2 !== lastPath) { -+ if (predicate(path2)) { -+ return path2; -+ } -+ lastPath = path2; -+ path2 = nodePath.dirname(path2); -+ } -+} -+function isGitRoot(dir) { -+ return fs.existsSync(nodePath.join(dir, ".git")); -+} -+function findRepoRoot(path2) { -+ return findAncestorDirectory(path2, isGitRoot); -+} -+ -+// src/SeatbeltConfig.ts -+var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; -+var SEATBELT_FROZEN = "SEATBELT_FROZEN"; -+var SEATBELT_INCREASE = "SEATBELT_INCREASE"; -+var SEATBELT_KEEP = "SEATBELT_KEEP"; -+var SEATBELT_FILE = "SEATBELT_FILE"; -+var SEATBELT_PWD = "SEATBELT_PWD"; -+var SEATBELT_DISABLE = "SEATBELT_DISABLE"; -+var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; -+var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; -+var SEATBELT_QUIET = "SEATBELT_QUIET"; -+var SEATBELT_ROOT = "SEATBELT_ROOT"; -+var ENV_VARS = { -+ SEATBELT_FROZEN, -+ SEATBELT_INCREASE, -+ SEATBELT_KEEP, -+ SEATBELT_FILE, -+ SEATBELT_PWD, -+ SEATBELT_DISABLE, -+ SEATBELT_THREADSAFE, -+ SEATBELT_VERBOSE, -+ SEATBELT_QUIET, -+ SEATBELT_ROOT, -+ CI: "CI", -+ JEST_WORKER_ID: "JEST_WORKER_ID" -+}; -+var SeatbeltConfig = { -+ withEnvOverrides(config, env) { -+ return { -+ ...SeatbeltConfig.fromFallbackEnv(env), -+ ...config, -+ ...SeatbeltConfig.fromEnvOverrides(env) -+ }; -+ }, -+ fromFallbackEnv(env, log) { -+ const config = {}; -+ const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -+ if (isCI) { -+ config.frozen = true; -+ log?.(`${padVarName("CI")} config.frozen defaults to`, true); -+ } -+ if (env.JEST_WORKER_ID) { -+ config.threadsafe = true; -+ log?.( -+ `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -+ true -+ ); -+ } -+ if (!isMainThread) { -+ config.threadsafe = true; -+ log?.( -+ `${padVarName("worker_threads")} config.threadsafe defaults to`, -+ true -+ ); -+ } -+ return config; -+ }, -+ fromEnvOverrides(env, log) { -+ const config = { -+ pwd: env[SEATBELT_PWD] || process.cwd() -+ }; -+ const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -+ if (verbose !== void 0) { -+ config.verbose = verbose; -+ log?.(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose); -+ } -+ const seatbeltFile = env[SEATBELT_FILE]; -+ if (seatbeltFile) { -+ const rootRelative = path.isAbsolute(seatbeltFile) ? seatbeltFile : path.join(config.pwd, seatbeltFile); -+ config.seatbeltFile = rootRelative; -+ log?.(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative); -+ } -+ const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -+ if (disable !== void 0) { -+ config.disable = disable; -+ log?.(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable); -+ } -+ const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -+ if (frozen !== void 0) { -+ config.frozen = frozen; -+ log?.(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen); -+ } -+ const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -+ if (increase !== void 0) { -+ config.allowIncreaseRules = increase; -+ log?.( -+ `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -+ increase -+ ); -+ } -+ const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -+ if (keep !== void 0) { -+ config.keepRules = keep; -+ log?.(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep); -+ } -+ const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -+ if (threadsafe !== void 0) { -+ config.threadsafe = threadsafe; -+ log?.( -+ `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -+ threadsafe -+ ); -+ } -+ const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -+ if (quiet !== void 0) { -+ config.quiet = quiet; -+ log?.(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet); -+ } -+ const root = env[SEATBELT_ROOT]; -+ if (root) { -+ config.root = root; -+ log?.(`${padVarName(SEATBELT_ROOT)} config.root =`, root); -+ } -+ return config; -+ } -+}; -+var SeatbeltEnv = { -+ parseRuleSetEnvVar(value) { -+ if (value === void 0) { -+ return void 0; -+ } -+ if (!value) { -+ return []; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "all" || lower === "1" || lower === "true") { -+ return "all"; -+ } -+ return value.split(/[\s,]+/g).filter(Boolean); -+ }, -+ readBooleanEnvVar(value) { -+ if (value === void 0 || value === "") { -+ return void 0; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "false" || lower === "0" || lower === "no") { -+ return false; -+ } -+ return Boolean(value); -+ } -+}; -+var logStdout = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.log(`[${name}]:`, ...message) -+); -+var logStderr = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.error(`[${name}]:`, ...message) -+); -+var SeatbeltArgs = { -+ fromConfig(config) { -+ const cwd = config.pwd ?? process.cwd(); -+ const seatbeltFile = config.seatbeltFile ?? SeatbeltArgs.findSeatbeltFile(cwd); -+ const root = config.root ?? findRepoRoot(seatbeltFile) ?? path.dirname(seatbeltFile); -+ return { -+ seatbeltFile, -+ root, -+ keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(config.keepRules ?? []), -+ allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(config.allowIncreaseRules ?? []), -+ frozen: config.frozen ?? false, -+ disable: config.disable ?? false, -+ quiet: config.quiet ?? false, -+ threadsafe: config.threadsafe ?? false, -+ verbose: config.verbose ?? false -+ }; -+ }, -+ getLogger(args) { -+ if (typeof args.verbose === "function") { -+ return args.verbose; -+ } -+ if (args.verbose === "stdout") { -+ return logStdout; -+ } -+ return logStderr; -+ }, -+ ruleSetHas(ruleSet, ruleId) { -+ return ruleSet === "all" || ruleSet.has(ruleId); -+ }, -+ verboseLog(args, makeMessage) { -+ if (args.verbose) { -+ const message = makeMessage(); -+ const log = SeatbeltArgs.getLogger(args); -+ if (typeof message === "string") { -+ log(message); -+ } else { -+ log(...message); -+ } -+ } -+ }, -+ findSeatbeltFile(cwd) { -+ return `${cwd}/${SEATBELT_FILE_NAME}`; -+ } -+}; -+var envVarMaxLength = 0; -+function padVarName(name2) { -+ envVarMaxLength ||= Math.max( -+ ...Object.values(ENV_VARS).map((name3) => name3.length) -+ ); -+ return `${name2}:`.padEnd(envVarMaxLength + 1); -+} -+function formatFilename(filename) { -+ const relative = path.relative( -+ process.env[SEATBELT_PWD] ?? process.cwd(), -+ filename -+ ); -+ return relative ? relative : filename; -+} -+function formatRuleId(ruleId) { -+ if (ruleId === null) { -+ return `unknown rule`; -+ } -+ return `rule ${ruleId}`; -+} -+ -+// src/jsonSchema/SeatbeltConfigSchema.ts -+var SeatbeltConfigSchema = { -+ description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -+ type: "object", -+ properties: { -+ seatbeltFile: { -+ description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -+ type: "string" -+ }, -+ keepRules: { -+ description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ allowIncreaseRules: { -+ description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ frozen: { -+ description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ disable: { -+ description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ quiet: { -+ description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -+ type: "boolean" -+ }, -+ threadsafe: { -+ description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set)\nor inside a Node `worker_threads` worker (e.g. ESLint `--concurrency`).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ verbose: { -+ description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -+ anyOf: [ -+ { -+ enum: [false, "stderr", "stdout", true] -+ }, -+ { -+ type: "object" -+ } -+ ] -+ }, -+ root: { -+ description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -+ type: "string" -+ } -+ }, -+ $schema: "http://json-schema.org/draft-07/schema#" -+}; -+ -+export { -+ __require, -+ name, -+ version, -+ package_default, -+ SEATBELT_FILE_NAME, -+ SEATBELT_FROZEN, -+ SEATBELT_INCREASE, -+ SEATBELT_KEEP, -+ SEATBELT_FILE, -+ SEATBELT_PWD, -+ SEATBELT_DISABLE, -+ SEATBELT_THREADSAFE, -+ SEATBELT_VERBOSE, -+ SEATBELT_QUIET, -+ SEATBELT_ROOT, -+ SeatbeltConfig, -+ SeatbeltEnv, -+ logStdout, -+ logStderr, -+ SeatbeltArgs, -+ padVarName, -+ formatFilename, -+ formatRuleId, -+ SeatbeltConfigSchema -+}; -+//# sourceMappingURL=chunk-ULACHCKT.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-ZVY5S6JS.js b/node_modules/eslint-seatbelt/dist/chunk-ZVY5S6JS.js -new file mode 100644 -index 0000000..ee27681 ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-ZVY5S6JS.js -@@ -0,0 +1,435 @@ -+"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b] -+}) : x)(function(x) { -+ if (typeof require !== "undefined") return require.apply(this, arguments); -+ throw Error('Dynamic require of "' + x + '" is not supported'); -+}); -+ -+// package.json -+var name = "eslint-seatbelt"; -+var version = "0.1.3"; -+var package_default = { -+ name, -+ version, -+ description: "Gradually tighten ESLint rules in your codebase", -+ keywords: [ -+ "eslint", -+ "incremental", -+ "gradual", -+ "workflow", -+ "processor", -+ "linting" -+ ], -+ author: { -+ name: "Jake Teton-Landis", -+ url: "https://jake.tl" -+ }, -+ repository: { -+ type: "git", -+ url: "git+https://github.com/justjake/eslint-seatbelt.git" -+ }, -+ bugs: { -+ url: "https://github.com/justjake/eslint-seatbelt/issues" -+ }, -+ scripts: { -+ build: "./scripts/make-json-schemas.ts && tsc && tsup", -+ test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -+ lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -+ }, -+ types: "dist/index.d.ts", -+ import: "./dist/index.mjs", -+ main: "dist/index.js", -+ exports: { -+ ".": { -+ types: "./dist/index.d.ts", -+ import: "./dist/index.mjs", -+ default: "./dist/index.js" -+ }, -+ "./api": { -+ types: "./dist/api.d.ts", -+ import: "./dist/api.mjs", -+ default: "./dist/api.js" -+ } -+ }, -+ bin: { -+ "eslint-seatbelt": "./dist/command.js" -+ }, -+ files: [ -+ "!*.tsbuildinfo", -+ "!src/**/*.test.ts", -+ "src", -+ "dist" -+ ], -+ license: "MIT", -+ peerDependencies: { -+ "@types/eslint": "*", -+ eslint: "*" -+ }, -+ peerDependenciesMeta: { -+ eslint: { -+ optional: true -+ }, -+ "@types/eslint": { -+ optional: true -+ } -+ }, -+ devDependencies: { -+ "@eslint/compat": "1.2.3", -+ "@eslint/js": "9.15.0", -+ "@types/eslint__js": "8.42.3", -+ "@types/node": "22.9.0", -+ "@typescript-eslint/rule-tester": "8.14.0", -+ "@typescript-eslint/utils": "8.14.0", -+ eslint: "9.14.0", -+ prettier: "3.3.3", -+ tsup: "8.3.5", -+ tsx: "4.19.2", -+ typescript: "5.6.3", -+ "typescript-eslint": "8.14.0", -+ "typescript-json-schema": "0.65.1" -+ }, -+ dependencies: { -+ "ts-command-line-args": "^2.5.1" -+ }, -+ packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" -+}; -+ -+// src/SeatbeltConfig.ts -+var _path = require('path'); var _path2 = _interopRequireDefault(_path); -+var _worker_threads = require('worker_threads'); -+ -+// src/repoIntegration.ts -+var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs); -+ -+function findAncestorDirectory(path2, predicate) { -+ let lastPath = void 0; -+ while (path2 !== lastPath) { -+ if (predicate(path2)) { -+ return path2; -+ } -+ lastPath = path2; -+ path2 = _path2.default.dirname(path2); -+ } -+} -+function isGitRoot(dir) { -+ return _fs2.default.existsSync(_path2.default.join(dir, ".git")); -+} -+function findRepoRoot(path2) { -+ return findAncestorDirectory(path2, isGitRoot); -+} -+ -+// src/SeatbeltConfig.ts -+var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; -+var SEATBELT_FROZEN = "SEATBELT_FROZEN"; -+var SEATBELT_INCREASE = "SEATBELT_INCREASE"; -+var SEATBELT_KEEP = "SEATBELT_KEEP"; -+var SEATBELT_FILE = "SEATBELT_FILE"; -+var SEATBELT_PWD = "SEATBELT_PWD"; -+var SEATBELT_DISABLE = "SEATBELT_DISABLE"; -+var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; -+var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; -+var SEATBELT_QUIET = "SEATBELT_QUIET"; -+var SEATBELT_ROOT = "SEATBELT_ROOT"; -+var ENV_VARS = { -+ SEATBELT_FROZEN, -+ SEATBELT_INCREASE, -+ SEATBELT_KEEP, -+ SEATBELT_FILE, -+ SEATBELT_PWD, -+ SEATBELT_DISABLE, -+ SEATBELT_THREADSAFE, -+ SEATBELT_VERBOSE, -+ SEATBELT_QUIET, -+ SEATBELT_ROOT, -+ CI: "CI", -+ JEST_WORKER_ID: "JEST_WORKER_ID" -+}; -+var SeatbeltConfig = { -+ withEnvOverrides(config, env) { -+ return { -+ ...SeatbeltConfig.fromFallbackEnv(env), -+ ...config, -+ ...SeatbeltConfig.fromEnvOverrides(env) -+ }; -+ }, -+ fromFallbackEnv(env, log) { -+ const config = {}; -+ const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -+ if (isCI) { -+ config.frozen = true; -+ _optionalChain([log, 'optionalCall', _ => _(`${padVarName("CI")} config.frozen defaults to`, true)]); -+ } -+ if (env.JEST_WORKER_ID) { -+ config.threadsafe = true; -+ _optionalChain([log, 'optionalCall', _2 => _2( -+ `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -+ true -+ )]); -+ } -+ if (!_worker_threads.isMainThread) { -+ config.threadsafe = true; -+ _optionalChain([log, 'optionalCall', _3 => _3( -+ `${padVarName("worker_threads")} config.threadsafe defaults to`, -+ true -+ )]); -+ } -+ return config; -+ }, -+ fromEnvOverrides(env, log) { -+ const config = { -+ pwd: env[SEATBELT_PWD] || process.cwd() -+ }; -+ const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -+ if (verbose !== void 0) { -+ config.verbose = verbose; -+ _optionalChain([log, 'optionalCall', _4 => _4(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose)]); -+ } -+ const seatbeltFile = env[SEATBELT_FILE]; -+ if (seatbeltFile) { -+ const rootRelative = _path2.default.isAbsolute(seatbeltFile) ? seatbeltFile : _path2.default.join(config.pwd, seatbeltFile); -+ config.seatbeltFile = rootRelative; -+ _optionalChain([log, 'optionalCall', _5 => _5(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative)]); -+ } -+ const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -+ if (disable !== void 0) { -+ config.disable = disable; -+ _optionalChain([log, 'optionalCall', _6 => _6(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable)]); -+ } -+ const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -+ if (frozen !== void 0) { -+ config.frozen = frozen; -+ _optionalChain([log, 'optionalCall', _7 => _7(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen)]); -+ } -+ const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -+ if (increase !== void 0) { -+ config.allowIncreaseRules = increase; -+ _optionalChain([log, 'optionalCall', _8 => _8( -+ `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -+ increase -+ )]); -+ } -+ const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -+ if (keep !== void 0) { -+ config.keepRules = keep; -+ _optionalChain([log, 'optionalCall', _9 => _9(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep)]); -+ } -+ const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -+ if (threadsafe !== void 0) { -+ config.threadsafe = threadsafe; -+ _optionalChain([log, 'optionalCall', _10 => _10( -+ `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -+ threadsafe -+ )]); -+ } -+ const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -+ if (quiet !== void 0) { -+ config.quiet = quiet; -+ _optionalChain([log, 'optionalCall', _11 => _11(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet)]); -+ } -+ const root = env[SEATBELT_ROOT]; -+ if (root) { -+ config.root = root; -+ _optionalChain([log, 'optionalCall', _12 => _12(`${padVarName(SEATBELT_ROOT)} config.root =`, root)]); -+ } -+ return config; -+ } -+}; -+var SeatbeltEnv = { -+ parseRuleSetEnvVar(value) { -+ if (value === void 0) { -+ return void 0; -+ } -+ if (!value) { -+ return []; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "all" || lower === "1" || lower === "true") { -+ return "all"; -+ } -+ return value.split(/[\s,]+/g).filter(Boolean); -+ }, -+ readBooleanEnvVar(value) { -+ if (value === void 0 || value === "") { -+ return void 0; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "false" || lower === "0" || lower === "no") { -+ return false; -+ } -+ return Boolean(value); -+ } -+}; -+var logStdout = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.log(`[${name}]:`, ...message) -+); -+var logStderr = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.error(`[${name}]:`, ...message) -+); -+var SeatbeltArgs = { -+ fromConfig(config) { -+ const cwd = _nullishCoalesce(config.pwd, () => ( process.cwd())); -+ const seatbeltFile = _nullishCoalesce(config.seatbeltFile, () => ( SeatbeltArgs.findSeatbeltFile(cwd))); -+ const root = _nullishCoalesce(_nullishCoalesce(config.root, () => ( findRepoRoot(seatbeltFile))), () => ( _path2.default.dirname(seatbeltFile))); -+ return { -+ seatbeltFile, -+ root, -+ keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(_nullishCoalesce(config.keepRules, () => ( []))), -+ allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(_nullishCoalesce(config.allowIncreaseRules, () => ( []))), -+ frozen: _nullishCoalesce(config.frozen, () => ( false)), -+ disable: _nullishCoalesce(config.disable, () => ( false)), -+ quiet: _nullishCoalesce(config.quiet, () => ( false)), -+ threadsafe: _nullishCoalesce(config.threadsafe, () => ( false)), -+ verbose: _nullishCoalesce(config.verbose, () => ( false)) -+ }; -+ }, -+ getLogger(args) { -+ if (typeof args.verbose === "function") { -+ return args.verbose; -+ } -+ if (args.verbose === "stdout") { -+ return logStdout; -+ } -+ return logStderr; -+ }, -+ ruleSetHas(ruleSet, ruleId) { -+ return ruleSet === "all" || ruleSet.has(ruleId); -+ }, -+ verboseLog(args, makeMessage) { -+ if (args.verbose) { -+ const message = makeMessage(); -+ const log = SeatbeltArgs.getLogger(args); -+ if (typeof message === "string") { -+ log(message); -+ } else { -+ log(...message); -+ } -+ } -+ }, -+ findSeatbeltFile(cwd) { -+ return `${cwd}/${SEATBELT_FILE_NAME}`; -+ } -+}; -+var envVarMaxLength = 0; -+function padVarName(name2) { -+ envVarMaxLength ||= Math.max( -+ ...Object.values(ENV_VARS).map((name3) => name3.length) -+ ); -+ return `${name2}:`.padEnd(envVarMaxLength + 1); -+} -+function formatFilename(filename) { -+ const relative = _path2.default.relative( -+ _nullishCoalesce(process.env[SEATBELT_PWD], () => ( process.cwd())), -+ filename -+ ); -+ return relative ? relative : filename; -+} -+function formatRuleId(ruleId) { -+ if (ruleId === null) { -+ return `unknown rule`; -+ } -+ return `rule ${ruleId}`; -+} -+ -+// src/jsonSchema/SeatbeltConfigSchema.ts -+var SeatbeltConfigSchema = { -+ description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -+ type: "object", -+ properties: { -+ seatbeltFile: { -+ description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -+ type: "string" -+ }, -+ keepRules: { -+ description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ allowIncreaseRules: { -+ description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ frozen: { -+ description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ disable: { -+ description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ quiet: { -+ description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -+ type: "boolean" -+ }, -+ threadsafe: { -+ description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set)\nor inside a Node `worker_threads` worker (e.g. ESLint `--concurrency`).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ verbose: { -+ description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -+ anyOf: [ -+ { -+ enum: [false, "stderr", "stdout", true] -+ }, -+ { -+ type: "object" -+ } -+ ] -+ }, -+ root: { -+ description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -+ type: "string" -+ } -+ }, -+ $schema: "http://json-schema.org/draft-07/schema#" -+}; -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+exports.__require = __require; exports.name = name; exports.version = version; exports.package_default = package_default; exports.SEATBELT_FILE_NAME = SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = SEATBELT_FROZEN; exports.SEATBELT_INCREASE = SEATBELT_INCREASE; exports.SEATBELT_KEEP = SEATBELT_KEEP; exports.SEATBELT_FILE = SEATBELT_FILE; exports.SEATBELT_PWD = SEATBELT_PWD; exports.SEATBELT_DISABLE = SEATBELT_DISABLE; exports.SEATBELT_THREADSAFE = SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = SEATBELT_VERBOSE; exports.SEATBELT_QUIET = SEATBELT_QUIET; exports.SEATBELT_ROOT = SEATBELT_ROOT; exports.SeatbeltConfig = SeatbeltConfig; exports.SeatbeltEnv = SeatbeltEnv; exports.logStdout = logStdout; exports.logStderr = logStderr; exports.SeatbeltArgs = SeatbeltArgs; exports.padVarName = padVarName; exports.formatFilename = formatFilename; exports.formatRuleId = formatRuleId; exports.SeatbeltConfigSchema = SeatbeltConfigSchema; -+//# sourceMappingURL=chunk-ZVY5S6JS.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/command.js b/node_modules/eslint-seatbelt/dist/command.js -index 0a25e80..fa29aa4 100755 ---- a/node_modules/eslint-seatbelt/dist/command.js -+++ b/node_modules/eslint-seatbelt/dist/command.js -@@ -6,14 +6,14 @@ - - - --var _chunkTVRMUM3Fjs = require('./chunk-TVRMUM3F.js'); -+var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); - - // src/command.ts - var _tscommandlineargs = require('ts-command-line-args'); - var ZERO_WIDTH_SPACE = "\u200B"; - function parseArgs() { -- const fallback = _chunkTVRMUM3Fjs.SeatbeltConfig.fromFallbackEnv(process.env); -- const overrides = _chunkTVRMUM3Fjs.SeatbeltConfig.fromEnvOverrides(process.env); -+ const fallback = _chunkZVY5S6JSjs.SeatbeltConfig.fromFallbackEnv(process.env); -+ const overrides = _chunkZVY5S6JSjs.SeatbeltConfig.fromEnvOverrides(process.env); - const env = { ...fallback, ...overrides }; - const escapeForChalk = (s) => s.replaceAll("{", "\\{").replaceAll("}", "\\}").replaceAll(/^(\s)/gm, (match) => `${ZERO_WIDTH_SPACE}${match}`); - return _tscommandlineargs.parse.call(void 0, -@@ -28,7 +28,7 @@ function parseArgs() { - type: String, - alias: "f", - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.seatbeltFile.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.seatbeltFile.description - ), - defaultValue: env.seatbeltFile, - optional: true -@@ -36,7 +36,7 @@ function parseArgs() { - keepRules: { - type: String, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.keepRules.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.keepRules.description - ), - defaultValue: env.keepRules, - multiple: true, -@@ -46,7 +46,7 @@ function parseArgs() { - alias: "r", - type: String, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.allowIncreaseRules.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.allowIncreaseRules.description - ), - defaultValue: env.allowIncreaseRules, - multiple: true, -@@ -55,7 +55,7 @@ function parseArgs() { - frozen: { - type: Boolean, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.frozen.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.frozen.description - ), - defaultValue: env.frozen, - optional: true -@@ -63,7 +63,7 @@ function parseArgs() { - disable: { - type: Boolean, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.disable.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.disable.description - ), - defaultValue: env.disable, - optional: true -@@ -71,7 +71,7 @@ function parseArgs() { - quiet: { - type: Boolean, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.quiet.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.quiet.description - ), - defaultValue: env.quiet, - optional: true -@@ -79,7 +79,7 @@ function parseArgs() { - threadsafe: { - type: Boolean, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.threadsafe.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.threadsafe.description - ), - defaultValue: env.threadsafe, - optional: true -@@ -87,7 +87,7 @@ function parseArgs() { - verbose: { - type: Boolean, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.verbose.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.verbose.description - ), - defaultValue: env.verbose, - optional: true -@@ -95,7 +95,7 @@ function parseArgs() { - root: { - type: String, - description: escapeForChalk( -- _chunkTVRMUM3Fjs.SeatbeltConfigSchema.properties.root.description -+ _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.root.description - ), - defaultValue: env.root, - optional: true -@@ -125,8 +125,8 @@ function parseArgs() { - helpArg: "help", - headerContentSections: [ - { -- header: _chunkTVRMUM3Fjs.name, -- content: `Turns command-line arguments into ${_chunkTVRMUM3Fjs.name} environment variables, then call 'eslint' or another command with them.` -+ header: _chunkZVY5S6JSjs.name, -+ content: `Turns command-line arguments into ${_chunkZVY5S6JSjs.name} environment variables, then call 'eslint' or another command with them.` - } - ] - } -@@ -137,16 +137,16 @@ var stderr = (...args) => console.error(...args); - function main() { - const argsConfig = parseArgs(); - if (argsConfig.version) { -- stdout(`v${_chunkTVRMUM3Fjs.version}`); -+ stdout(`v${_chunkZVY5S6JSjs.version}`); - return; - } - if (argsConfig.verbose) { - stderr("Parsed config:", argsConfig); - } -- _chunkTVRMUM3Fjs.logStderr.call(void 0, "command not implemented"); -+ _chunkZVY5S6JSjs.logStderr.call(void 0, "command not implemented"); - process.exit(1); - } --if (_chunkTVRMUM3Fjs.__require.main === module) { -+if (_chunkZVY5S6JSjs.__require.main === module) { - main(); - } - //# sourceMappingURL=command.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/command.mjs b/node_modules/eslint-seatbelt/dist/command.mjs -index f2ef8a6..31b8b7b 100755 ---- a/node_modules/eslint-seatbelt/dist/command.mjs -+++ b/node_modules/eslint-seatbelt/dist/command.mjs -@@ -6,7 +6,7 @@ import { - logStderr, - name, - version --} from "./chunk-7ZO4DCZA.mjs"; -+} from "./chunk-ULACHCKT.mjs"; - - // src/command.ts - import { parse } from "ts-command-line-args"; -diff --git a/node_modules/eslint-seatbelt/dist/index.js b/node_modules/eslint-seatbelt/dist/index.js -index ba9e3a0..2d71ee2 100644 ---- a/node_modules/eslint-seatbelt/dist/index.js -+++ b/node_modules/eslint-seatbelt/dist/index.js -@@ -1,7 +1,7 @@ --"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } -+"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } - - --var _chunkK7UHJBLMjs = require('./chunk-K7UHJBLM.js'); -+var _chunkNTFTCWX7js = require('./chunk-NTFTCWX7.js'); - - - -@@ -14,10 +14,9 @@ var _chunkK7UHJBLMjs = require('./chunk-K7UHJBLM.js'); - - - --var _chunkTVRMUM3Fjs = require('./chunk-TVRMUM3F.js'); -+var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); - - // src/pluginGlobals.ts --var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs); - var ANY_CONFIG_DISABLED = false; - var LAST_VERBOSE_ARGS; - var VERBOSE_SEATBELT_FILES = /* @__PURE__ */ new Set(); -@@ -33,7 +32,7 @@ var lastLintedFile; - var temporaryFileArgs = /* @__PURE__ */ new Map(); - function getProcessEnvFallbackConfig() { - if (!envFallbackConfig) { -- envFallbackConfig = _chunkTVRMUM3Fjs.SeatbeltConfig.fromFallbackEnv( -+ envFallbackConfig = _chunkZVY5S6JSjs.SeatbeltConfig.fromFallbackEnv( - process.env - ); - hasAnyEnvVars = Object.keys(envFallbackConfig).length > 0; -@@ -42,7 +41,7 @@ function getProcessEnvFallbackConfig() { - } - function getProcessEnvOverrideConfig() { - if (!envOverrideConfig) { -- envOverrideConfig = _chunkTVRMUM3Fjs.SeatbeltConfig.fromEnvOverrides( -+ envOverrideConfig = _chunkZVY5S6JSjs.SeatbeltConfig.fromEnvOverrides( - process.env - ); - ANY_CONFIG_DISABLED ||= _nullishCoalesce(envOverrideConfig.disable, () => ( false)); -@@ -74,7 +73,7 @@ function configToArgs(config) { - ...config, - ...getProcessEnvOverrideConfig() - }; -- args = _chunkTVRMUM3Fjs.SeatbeltArgs.fromConfig(compiledConfig); -+ args = _chunkZVY5S6JSjs.SeatbeltArgs.fromConfig(compiledConfig); - ANY_CONFIG_DISABLED ||= args.disable; - if (args.verbose) { - LAST_VERBOSE_ARGS = args; -@@ -87,9 +86,9 @@ function configToArgs(config) { - } - return args; - } --var configureRuleName = `${_chunkTVRMUM3Fjs.name}/configure`; -+var configureRuleName = `${_chunkZVY5S6JSjs.name}/configure`; - function logRuleSetupHint() { -- _chunkTVRMUM3Fjs.logStderr.call(void 0, -+ _chunkZVY5S6JSjs.logStderr.call(void 0, - ` - Make sure you have rule ${configureRuleName} enabled in your ESLint config for all files: - -@@ -98,16 +97,16 @@ Make sure you have rule ${configureRuleName} enabled in your ESLint config for a - "${configureRuleName}": "error", - } - --Docs: https://github.com/justjake/${_chunkTVRMUM3Fjs.name}#setup` -+Docs: https://github.com/justjake/${_chunkZVY5S6JSjs.name}#setup` - ); - } - function logConfig(args, baseConfig) { -- const log = _chunkTVRMUM3Fjs.SeatbeltArgs.getLogger(args); -- _chunkTVRMUM3Fjs.SeatbeltConfig.fromFallbackEnv(process.env, log); -+ const log = _chunkZVY5S6JSjs.SeatbeltArgs.getLogger(args); -+ _chunkZVY5S6JSjs.SeatbeltConfig.fromFallbackEnv(process.env, log); - for (const [key, value] of Object.entries(baseConfig)) { -- log(`${_chunkTVRMUM3Fjs.padVarName.call(void 0, "ESLint settings")} config.${key} =`, value); -+ log(`${_chunkZVY5S6JSjs.padVarName.call(void 0, "ESLint settings")} config.${key} =`, value); - } -- _chunkTVRMUM3Fjs.SeatbeltConfig.fromEnvOverrides(process.env, log); -+ _chunkZVY5S6JSjs.SeatbeltConfig.fromEnvOverrides(process.env, log); - } - function pushFileArgs(filename, args) { - lastLintedFile = { filename, args }; -@@ -124,13 +123,13 @@ function popFileArgs(filename) { - } - if (!hasAnyEnvVars) { - if (lastLintedFile) { -- _chunkTVRMUM3Fjs.logStderr.call(void 0, -+ _chunkZVY5S6JSjs.logStderr.call(void 0, - `WARNING: last configured by file \`${lastLintedFile.filename}\` but linting file \`${filename}\`. - You may have rule ${configureRuleName} enabled for some files, but not this one. - `.trim() - ); - } else { -- _chunkTVRMUM3Fjs.logStderr.call(void 0, -+ _chunkZVY5S6JSjs.logStderr.call(void 0, - `WARNING: rule ${configureRuleName} not enabled in ESLint config and no SEATBELT environment variables set` - ); - } -@@ -141,7 +140,7 @@ You may have rule ${configureRuleName} enabled for some files, but not this one. - function getSeatbeltFile(filename) { - let seatbeltFile = seatbeltFileCache.get(filename); - if (!seatbeltFile) { -- seatbeltFile = _chunkK7UHJBLMjs.SeatbeltFile.openSync(filename); -+ seatbeltFile = _chunkNTFTCWX7js.SeatbeltFile.openSync(filename); - seatbeltFileCache.set(filename, seatbeltFile); - } - return seatbeltFile; -@@ -187,7 +186,7 @@ function isEslintCli() { - } - - // src/SeatbeltProcessor.ts --var { name: name2, version: version2 } = _chunkTVRMUM3Fjs.package_default; -+var { name: name2, version: version2 } = _chunkZVY5S6JSjs.package_default; - var SeatbeltProcessor = { - supportsAutofix: true, - meta: { -@@ -213,7 +212,7 @@ var SeatbeltProcessor = { - return messages; - } - const seatbeltFile = getSeatbeltFile(args.seatbeltFile); -- if (args.threadsafe || !isEslintCli()) { -+ if (!isEslintCli()) { - seatbeltFile.readSync(); - } - const ruleToErrorCount = countRuleIds(messages); -@@ -268,9 +267,9 @@ function transformMessages(args, seatbeltFile, filename, messages, ruleToErrorCo - } - return messages.flatMap((message) => { - if (message.ruleId === null) { -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( - args, -- () => `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}:${message.line}:${message.column}: cannot transform message with null ruleId` -+ () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}:${message.line}:${message.column}: cannot transform message with null ruleId` - ); - return message; - } -@@ -284,7 +283,7 @@ function transformMessages(args, seatbeltFile, filename, messages, ruleToErrorCo - ); - } - const maxErrorCount = _nullishCoalesce(_optionalChain([ruleToMaxErrorCount, 'optionalAccess', _ => _.get, 'call', _2 => _2(message.ruleId)]), () => ( 0)); -- const allowIncrease2 = _chunkTVRMUM3Fjs.SeatbeltArgs.ruleSetHas( -+ const allowIncrease2 = _chunkZVY5S6JSjs.SeatbeltArgs.ruleSetHas( - args.allowIncreaseRules, - message.ruleId - ); -@@ -302,17 +301,17 @@ function transformMessages(args, seatbeltFile, filename, messages, ruleToErrorCo - ); - } - if (verboseOnce(message.ruleId)) { -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( - args, -- () => `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, message.ruleId)}: error: ${errorCount} ${pluralErrors(errorCount)} found > max ${maxErrorCount}` -+ () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, message.ruleId)}: error: ${errorCount} ${pluralErrors(errorCount)} found > max ${maxErrorCount}` - ); - } - return messageOverMaxErrorCount(message, errorCount, maxErrorCount); - } else if (errorCount === maxErrorCount) { - if (verboseOnce(message.ruleId)) { -- _chunkTVRMUM3Fjs.SeatbeltArgs.verboseLog( -+ _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( - args, -- () => `${_chunkTVRMUM3Fjs.formatFilename.call(void 0, filename)}: ${_chunkTVRMUM3Fjs.formatRuleId.call(void 0, message.ruleId)}: ok: ${errorCount} ${pluralErrors(errorCount)} found == max ${maxErrorCount}` -+ () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, message.ruleId)}: ok: ${errorCount} ${pluralErrors(errorCount)} found == max ${maxErrorCount}` - ); - } - if (args.quiet) { -@@ -364,20 +363,10 @@ function maybeWriteStateUpdate(args, stateFile, filename, ruleToErrorCount) { - if (args.disable) { - return; - } -- if (args.threadsafe) { -- stateFile.readSync(); -- } -- const ruleToMaxErrorCount = stateFile.getMaxErrors(filename); -- const { removedRules } = stateFile.updateMaxErrors( -- filename, -- args, -- ruleToErrorCount -- ); -- if (!args.frozen) { -- stateFile.flushChanges(); -- } else if (removedRules && removedRules.size > 0) { -+ const { ruleToMaxErrorCountBefore, removedRules } = stateFile.updateFileMaxErrors(args, filename, ruleToErrorCount); -+ if (args.frozen && removedRules.size > 0) { - return Array.from(removedRules).map((ruleId) => { -- const maxErrorCount = _optionalChain([ruleToMaxErrorCount, 'optionalAccess', _3 => _3.get, 'call', _4 => _4(ruleId)]); -+ const maxErrorCount = _optionalChain([ruleToMaxErrorCountBefore, 'optionalAccess', _3 => _3.get, 'call', _4 => _4(ruleId)]); - if (maxErrorCount === void 0) { - throw new Error( - `${name2} bug: maxErrorCount not found for removed frozen rule ${ruleId}` -@@ -413,7 +402,7 @@ function messageOverMaxErrorCountButIncreaseAllowed(message, errorCount, maxErro - ...message, - severity: 1, - message: `${message.message} --[${name2}]: ${_chunkTVRMUM3Fjs.SEATBELT_INCREASE}: Temporarily allowing ${increaseCount} new ${pluralErrors(increaseCount)} of this type. -+[${name2}]: ${_chunkZVY5S6JSjs.SEATBELT_INCREASE}: Temporarily allowing ${increaseCount} new ${pluralErrors(increaseCount)} of this type. - `.trim() - }; - } -@@ -443,7 +432,7 @@ function messageFrozenUnderMaxErrorCountText(seatbeltFilename, errorCount, maxEr - const fixed = errorCount - maxErrorCount; - const fixedMessage = fixed === 1 ? "error" : "errors"; - return ` --[${name2}]: ${_chunkTVRMUM3Fjs.SEATBELT_FROZEN}: Expected ${maxErrorCount} ${pluralErrors(maxErrorCount)}, found ${errorCount}. -+[${name2}]: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: Expected ${maxErrorCount} ${pluralErrors(maxErrorCount)}, found ${errorCount}. - If you fixed ${fixed} ${fixedMessage}, thank you, but you'll need to update the seatbelt file to match. - Try running eslint, then committing ${seatbeltFilename}. - `.trim(); -@@ -460,8 +449,8 @@ var alreadyModifiedError = /* @__PURE__ */ new WeakSet(); - function handleProcessingError(filename, e) { - if (e instanceof Error && !alreadyModifiedError.has(e)) { - alreadyModifiedError.add(e); -- _chunkK7UHJBLMjs.appendErrorContext.call(void 0, e, `while processing \`${filename}\``); -- _chunkK7UHJBLMjs.appendErrorContext.call(void 0, -+ _chunkNTFTCWX7js.appendErrorContext.call(void 0, e, `while processing \`${filename}\``); -+ _chunkNTFTCWX7js.appendErrorContext.call(void 0, - e, - `this may be a bug in ${name2}@${version2} or a problem with your setup` - ); -@@ -476,16 +465,16 @@ function pluralErrors(count) { - var configure = { - meta: { - docs: { -- description: `Applies ${_chunkTVRMUM3Fjs.name} configuration from ESLint config`, -- url: `https://github.com/justjake/${_chunkTVRMUM3Fjs.name}` -+ description: `Applies ${_chunkZVY5S6JSjs.name} configuration from ESLint config`, -+ url: `https://github.com/justjake/${_chunkZVY5S6JSjs.name}` - }, -- schema: [_chunkTVRMUM3Fjs.SeatbeltConfigSchema] -+ schema: [_chunkZVY5S6JSjs.SeatbeltConfigSchema] - }, - create(context) { - const filename = _nullishCoalesce(_optionalChain([context, 'access', _5 => _5.getFilename, 'optionalCall', _6 => _6()]), () => ( context.filename)); - onConfigureRule(filename); - const eslintSharedConfigViaShortName = _optionalChain([context, 'access', _7 => _7.settings, 'optionalAccess', _8 => _8.seatbelt]); -- const eslintSharedConfigViaPackageName = _optionalChain([context, 'access', _9 => _9.settings, 'optionalAccess', _10 => _10[_chunkTVRMUM3Fjs.name]]); -+ const eslintSharedConfigViaPackageName = _optionalChain([context, 'access', _9 => _9.settings, 'optionalAccess', _10 => _10[_chunkZVY5S6JSjs.name]]); - const eslintSharedConfig = _nullishCoalesce(eslintSharedConfigViaShortName, () => ( eslintSharedConfigViaPackageName)); - const fileOverrideConfig = context.options[0]; - const args = ruleOverrideConfigToArgs( -@@ -498,7 +487,7 @@ var configure = { - }; - - // src/index.ts --var { name: name3, version: version3 } = _chunkTVRMUM3Fjs.package_default; -+var { name: name3, version: version3 } = _chunkZVY5S6JSjs.package_default; - var plugin = { - meta: { - name: name3, -diff --git a/node_modules/eslint-seatbelt/dist/index.mjs b/node_modules/eslint-seatbelt/dist/index.mjs -index add8f2e..5b23433 100644 ---- a/node_modules/eslint-seatbelt/dist/index.mjs -+++ b/node_modules/eslint-seatbelt/dist/index.mjs -@@ -1,7 +1,7 @@ - import { - SeatbeltFile, - appendErrorContext --} from "./chunk-OKIIDZIF.mjs"; -+} from "./chunk-5FFUIU4M.mjs"; - import { - SEATBELT_FROZEN, - SEATBELT_INCREASE, -@@ -14,7 +14,7 @@ import { - name, - package_default, - padVarName --} from "./chunk-7ZO4DCZA.mjs"; -+} from "./chunk-ULACHCKT.mjs"; - - // src/pluginGlobals.ts - var ANY_CONFIG_DISABLED = false; -@@ -212,7 +212,7 @@ var SeatbeltProcessor = { - return messages; - } - const seatbeltFile = getSeatbeltFile(args.seatbeltFile); -- if (args.threadsafe || !isEslintCli()) { -+ if (!isEslintCli()) { - seatbeltFile.readSync(); - } - const ruleToErrorCount = countRuleIds(messages); -@@ -363,20 +363,10 @@ function maybeWriteStateUpdate(args, stateFile, filename, ruleToErrorCount) { - if (args.disable) { - return; - } -- if (args.threadsafe) { -- stateFile.readSync(); -- } -- const ruleToMaxErrorCount = stateFile.getMaxErrors(filename); -- const { removedRules } = stateFile.updateMaxErrors( -- filename, -- args, -- ruleToErrorCount -- ); -- if (!args.frozen) { -- stateFile.flushChanges(); -- } else if (removedRules && removedRules.size > 0) { -+ const { ruleToMaxErrorCountBefore, removedRules } = stateFile.updateFileMaxErrors(args, filename, ruleToErrorCount); -+ if (args.frozen && removedRules.size > 0) { - return Array.from(removedRules).map((ruleId) => { -- const maxErrorCount = ruleToMaxErrorCount?.get(ruleId); -+ const maxErrorCount = ruleToMaxErrorCountBefore?.get(ruleId); - if (maxErrorCount === void 0) { - throw new Error( - `${name2} bug: maxErrorCount not found for removed frozen rule ${ruleId}` diff --git a/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+002+read-only.patch b/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+002+read-only.patch deleted file mode 100644 index 88f4e70ab1fe..000000000000 --- a/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+002+read-only.patch +++ /dev/null @@ -1,4319 +0,0 @@ -diff --git a/node_modules/eslint-seatbelt/README.md b/node_modules/eslint-seatbelt/README.md -index ddcaa34..598c0d6 100644 ---- a/node_modules/eslint-seatbelt/README.md -+++ b/node_modules/eslint-seatbelt/README.md -@@ -89,6 +89,37 @@ module.exports = { - - 1. `SEATBELT_FROZEN=1 eslint` or `CI=1 eslint` - -+### Keep the worktree clean during local development (auto-ratchet workflow) -+ -+If you don't want developers to hand-commit changes to `eslint.seatbelt.tsv` -+every time they fix a baselined error, use `SEATBELT_READ_ONLY=1` locally and -+let a post-merge job ratchet the baseline on the default branch. -+ -+1. Local / editor runs: `SEATBELT_READ_ONLY=1 eslint` -+ - Counts go down → lint passes, `eslint.seatbelt.tsv` is NOT rewritten. -+ - Counts go up (no `SEATBELT_INCREASE`) → lint fails as normal. -+ - `SEATBELT_INCREASE=` overrides `SEATBELT_READ_ONLY` so the baseline -+ gets persisted when a developer intentionally loosens a rule. -+2. PR CI: `CI=1 eslint` (frozen). Any drift in `eslint.seatbelt.tsv` fails -+ the build; ephemeral writes during the run are discarded with the runner. -+3. On push to default branch: run `eslint` (no flags) in a bot job. If the -+ lint run produces a diff to `eslint.seatbelt.tsv`, commit and push it. -+ -+Example ESLint config that enables read-only mode outside CI: -+ -+```js -+// in eslint.config.js -+export default [ -+ { -+ settings: { -+ seatbelt: { -+ readOnly: !process.env.CI, -+ }, -+ }, -+ }, -+] -+``` -+ - ### Introduce ESLint to an existing codebase - - eslint-seatbelt makes it easy to introduce ESLint to an existing unlinted codebase. -@@ -336,6 +367,41 @@ export interface SeatbeltConfig { - * ] - */ - disable?: boolean -+ /** -+ * When `true`, seatbelt validates error counts (still reporting increases) -+ * but never writes the seatbelt file. Keeps the worktree clean in local / -+ * editor runs; expect an authoritative updater (e.g. post-merge CI) to run -+ * with `readOnly: false`. -+ * -+ * Unlike `frozen`, does not turn decreases into errors. If both are set, -+ * `frozen` messaging is preserved and no write occurs. -+ * -+ * `SEATBELT_INCREASE` overrides this so intentional loosening is persisted. -+ * -+ * Defaults to `false`. -+ * -+ * Set via `SEATBELT_READ_ONLY` env var: -+ * -+ * ```bash -+ * SEATBELT_READ_ONLY=1 eslint -+ * ``` -+ * -+ * Or in ESLint config: -+ * -+ * ```js -+ * // in eslint.config.js -+ * const config = [ -+ * { -+ * settings: { -+ * seatbelt: { -+ * readOnly: !process.env.CI, -+ * } -+ * } -+ * } -+ * ] -+ * ``` -+ */ -+ readOnly?: boolean - /** - * By default seatbelt assumes that only one ESLint process will read and - * write to the seatbelt file at a time. -diff --git a/node_modules/eslint-seatbelt/dist/api.js b/node_modules/eslint-seatbelt/dist/api.js -index afa6177..b416cf8 100644 ---- a/node_modules/eslint-seatbelt/dist/api.js -+++ b/node_modules/eslint-seatbelt/dist/api.js -@@ -1,7 +1,7 @@ - "use strict";Object.defineProperty(exports, "__esModule", {value: true}); - - --var _chunkNTFTCWX7js = require('./chunk-NTFTCWX7.js'); -+var _chunkF4JJMAJLjs = require('./chunk-F4JJMAJL.js'); - - - -@@ -23,8 +23,8 @@ var _chunkNTFTCWX7js = require('./chunk-NTFTCWX7.js'); - - - --var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); - -+var _chunkTDWD7IZMjs = require('./chunk-TDWD7IZM.js'); - - - -@@ -47,5 +47,7 @@ var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); - - - --exports.FileLock = _chunkNTFTCWX7js.FileLock; exports.SEATBELT_DISABLE = _chunkZVY5S6JSjs.SEATBELT_DISABLE; exports.SEATBELT_FILE = _chunkZVY5S6JSjs.SEATBELT_FILE; exports.SEATBELT_FILE_NAME = _chunkZVY5S6JSjs.SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = _chunkZVY5S6JSjs.SEATBELT_FROZEN; exports.SEATBELT_INCREASE = _chunkZVY5S6JSjs.SEATBELT_INCREASE; exports.SEATBELT_KEEP = _chunkZVY5S6JSjs.SEATBELT_KEEP; exports.SEATBELT_PWD = _chunkZVY5S6JSjs.SEATBELT_PWD; exports.SEATBELT_QUIET = _chunkZVY5S6JSjs.SEATBELT_QUIET; exports.SEATBELT_ROOT = _chunkZVY5S6JSjs.SEATBELT_ROOT; exports.SEATBELT_THREADSAFE = _chunkZVY5S6JSjs.SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = _chunkZVY5S6JSjs.SEATBELT_VERBOSE; exports.SeatbeltArgs = _chunkZVY5S6JSjs.SeatbeltArgs; exports.SeatbeltConfig = _chunkZVY5S6JSjs.SeatbeltConfig; exports.SeatbeltConfigSchema = _chunkZVY5S6JSjs.SeatbeltConfigSchema; exports.SeatbeltEnv = _chunkZVY5S6JSjs.SeatbeltEnv; exports.SeatbeltFile = _chunkNTFTCWX7js.SeatbeltFile; exports.formatFilename = _chunkZVY5S6JSjs.formatFilename; exports.formatRuleId = _chunkZVY5S6JSjs.formatRuleId; exports.logStderr = _chunkZVY5S6JSjs.logStderr; exports.logStdout = _chunkZVY5S6JSjs.logStdout; exports.padVarName = _chunkZVY5S6JSjs.padVarName; -+ -+ -+exports.FileLock = _chunkF4JJMAJLjs.FileLock; exports.SEATBELT_DISABLE = _chunkTDWD7IZMjs.SEATBELT_DISABLE; exports.SEATBELT_FILE = _chunkTDWD7IZMjs.SEATBELT_FILE; exports.SEATBELT_FILE_NAME = _chunkTDWD7IZMjs.SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = _chunkTDWD7IZMjs.SEATBELT_FROZEN; exports.SEATBELT_INCREASE = _chunkTDWD7IZMjs.SEATBELT_INCREASE; exports.SEATBELT_KEEP = _chunkTDWD7IZMjs.SEATBELT_KEEP; exports.SEATBELT_PWD = _chunkTDWD7IZMjs.SEATBELT_PWD; exports.SEATBELT_QUIET = _chunkTDWD7IZMjs.SEATBELT_QUIET; exports.SEATBELT_READ_ONLY = _chunkTDWD7IZMjs.SEATBELT_READ_ONLY; exports.SEATBELT_ROOT = _chunkTDWD7IZMjs.SEATBELT_ROOT; exports.SEATBELT_THREADSAFE = _chunkTDWD7IZMjs.SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = _chunkTDWD7IZMjs.SEATBELT_VERBOSE; exports.SeatbeltArgs = _chunkTDWD7IZMjs.SeatbeltArgs; exports.SeatbeltConfig = _chunkTDWD7IZMjs.SeatbeltConfig; exports.SeatbeltConfigSchema = _chunkTDWD7IZMjs.SeatbeltConfigSchema; exports.SeatbeltEnv = _chunkTDWD7IZMjs.SeatbeltEnv; exports.SeatbeltFile = _chunkF4JJMAJLjs.SeatbeltFile; exports.formatFilename = _chunkTDWD7IZMjs.formatFilename; exports.formatRuleId = _chunkTDWD7IZMjs.formatRuleId; exports.logStderr = _chunkTDWD7IZMjs.logStderr; exports.logStdout = _chunkTDWD7IZMjs.logStdout; exports.padVarName = _chunkTDWD7IZMjs.padVarName; - //# sourceMappingURL=api.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/api.mjs b/node_modules/eslint-seatbelt/dist/api.mjs -index efa51d7..61d1606 100644 ---- a/node_modules/eslint-seatbelt/dist/api.mjs -+++ b/node_modules/eslint-seatbelt/dist/api.mjs -@@ -1,7 +1,7 @@ - import { - FileLock, - SeatbeltFile --} from "./chunk-5FFUIU4M.mjs"; -+} from "./chunk-QGLWIAEE.mjs"; - import { - SEATBELT_DISABLE, - SEATBELT_FILE, -@@ -11,6 +11,7 @@ import { - SEATBELT_KEEP, - SEATBELT_PWD, - SEATBELT_QUIET, -+ SEATBELT_READ_ONLY, - SEATBELT_ROOT, - SEATBELT_THREADSAFE, - SEATBELT_VERBOSE, -@@ -23,7 +24,7 @@ import { - logStderr, - logStdout, - padVarName --} from "./chunk-ULACHCKT.mjs"; -+} from "./chunk-U6KIQG2A.mjs"; - export { - FileLock, - SEATBELT_DISABLE, -@@ -34,6 +35,7 @@ export { - SEATBELT_KEEP, - SEATBELT_PWD, - SEATBELT_QUIET, -+ SEATBELT_READ_ONLY, - SEATBELT_ROOT, - SEATBELT_THREADSAFE, - SEATBELT_VERBOSE, -diff --git a/node_modules/eslint-seatbelt/dist/chunk-5FFUIU4M.mjs b/node_modules/eslint-seatbelt/dist/chunk-5FFUIU4M.mjs -deleted file mode 100644 -index 025f420..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-5FFUIU4M.mjs -+++ /dev/null -@@ -1,480 +0,0 @@ --import { -- SEATBELT_FROZEN, -- SEATBELT_KEEP, -- SeatbeltArgs, -- formatFilename, -- formatRuleId, -- name --} from "./chunk-ULACHCKT.mjs"; -- --// src/FileLock.ts --import { openSync, writeSync, closeSync, readFileSync, constants, rmSync } from "node:fs"; -- --// src/errorHanding.ts --function appendErrorContext(error, context) { -- if (error instanceof Error) { -- error.message += ` -- ${context}`; -- } --} --function isErrno(error, code) { -- return error instanceof Error && "code" in error && error.code === code; --} -- --// src/FileLock.ts --var { O_CREAT, O_EXCL, O_RDWR } = constants; --var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); --var heldLocks = /* @__PURE__ */ new Set(); --var cleanupHooksInstalled = false; --var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; --function installCleanupHooks() { -- if (cleanupHooksInstalled) return; -- cleanupHooksInstalled = true; -- const release = () => { -- for (const lock of heldLocks) { -- try { -- lock.unlock(); -- } catch { -- } -- } -- }; -- process.on("exit", release); -- for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { -- process.on(signal, () => { -- release(); -- process.exit(SIGNAL_EXIT_CODES[signal]); -- }); -- } --} --var FileLock = class { -- constructor(filename) { -- this.filename = filename; -- } -- fd; -- tryLock() { -- this.assertNotLocked(); -- try { -- this.fd = openSync(this.filename, O_CREAT | O_EXCL | O_RDWR); -- writeSync(this.fd, `${process.pid} --`); -- heldLocks.add(this); -- installCleanupHooks(); -- return true; -- } catch (e) { -- if (isErrno(e, "EEXIST")) { -- return false; -- } -- throw e; -- } -- } -- waitLock(timeoutMs) { -- const deadline = Date.now() + timeoutMs; -- let attemptedRecovery = false; -- while (!this.tryLock()) { -- if (Date.now() > deadline) { -- if (!attemptedRecovery && this.reclaimIfStale()) { -- attemptedRecovery = true; -- continue; -- } -- throw new Error(`Timed out waiting for lock on ${this.filename}`); -- } -- Atomics.wait(waitBuffer, 0, 0, 1); -- } -- } -- isLocked() { -- return this.fd !== void 0; -- } -- unlock() { -- if (this.fd !== void 0) { -- closeSync(this.fd); -- try { -- rmSync(this.filename); -- } catch (e) { -- if (!isErrno(e, "ENOENT")) throw e; -- } -- this.fd = void 0; -- heldLocks.delete(this); -- } -- } -- assertNotLocked() { -- if (this.fd !== void 0) { -- throw new Error( -- `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -- ); -- } -- } -- reclaimIfStale() { -- let contents; -- try { -- contents = readFileSync(this.filename, "utf8"); -- } catch (e) { -- if (isErrno(e, "ENOENT")) return true; -- throw e; -- } -- const pid = Number.parseInt(contents.trim(), 10); -- if (!Number.isFinite(pid) || pid <= 0) return false; -- try { -- process.kill(pid, 0); -- return false; -- } catch (e) { -- if (!isErrno(e, "ESRCH")) throw e; -- } -- try { -- rmSync(this.filename); -- } catch (e) { -- if (!isErrno(e, "ENOENT")) throw e; -- } -- return true; -- } --}; -- --// src/SeatbeltFile.ts --import * as os from "node:os"; --import * as fs from "node:fs"; --import path, * as nodePath from "node:path"; --var LOCK_TIMEOUT_MS = 3e4; --function encodeLine(line) { -- const { filename, ruleId, maxErrors } = line; -- return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} --`; --} --function decodeLine(line, index) { -- try { -- const lineParts = line.split(" "); -- if (lineParts.length !== 3) { -- throw new Error( -- `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -- ); -- } -- let filename; -- try { -- filename = JSON.parse(lineParts[0]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 1 (filename)"); -- throw e; -- } -- let ruleId; -- try { -- ruleId = JSON.parse(lineParts[1]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -- throw e; -- } -- let maxErrors; -- try { -- maxErrors = JSON.parse(lineParts[2]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -- throw e; -- } -- return { -- encoded: line, -- filename, -- ruleId, -- maxErrors -- }; -- } catch (e) { -- appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -- throw e; -- } --} --var COMMENT_LINE_REGEX = /^\s*#/; --var NON_EMPTY_LINE_REGEX = /\S+/; --var DEFAULT_FILE_HEADER = ` --# ${name} temporarily allowed errors --# docs: https://github.com/justjake/${name}#readme --`.trim(); --var SeatbeltFile = class _SeatbeltFile { -- constructor(filename, data, comments = "") { -- this.filename = filename; -- this.data = data; -- this.comments = comments; -- this.filename = path.resolve(this.filename); -- this.dirname = path.dirname(this.filename); -- } -- static readSync(filename) { -- const text = fs.readFileSync(filename, "utf8"); -- try { -- return _SeatbeltFile.parse(filename, text); -- } catch (e) { -- appendErrorContext(e, `in seatbelt file \`${filename}\``); -- throw e; -- } -- } -- /** -- * Read `filename` if it exists, otherwise create a new empty seatbelt file object -- * that will write to that filename. -- */ -- static openSync(filename) { -- try { -- return _SeatbeltFile.readSync(filename); -- } catch (e) { -- if (isErrno(e, "ENOENT")) { -- return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -- } -- throw e; -- } -- } -- static parse(filename, text) { -- const data = /* @__PURE__ */ new Map(); -- const split = text.split(/(?<=\n)/); -- const lines = split.filter( -- (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -- ).map(decodeLine); -- const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -- lines.forEach((line) => { -- let fileState = data.get(line.filename); -- if (!fileState) { -- fileState = { maxErrors: void 0, lines: [] }; -- data.set(line.filename, fileState); -- } -- fileState.lines.push(line); -- }); -- return new _SeatbeltFile(filename, data, comments.trim()); -- } -- static fromJSON(json) { -- const data = new Map( -- Object.entries(json.data).map(([filename, maxErrors]) => [ -- filename, -- { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -- ]) -- ); -- return new _SeatbeltFile(json.filename, data); -- } -- changed = false; -- dirname; -- useTempDirForWrites = true; -- *filenames() { -- for (const filename of this.data.keys()) { -- yield this.toAbsolutePath(filename); -- } -- } -- getMaxErrors(filename) { -- const fileState = this.data.get(this.toRelativePath(filename)); -- if (!fileState) { -- return void 0; -- } -- fileState.maxErrors ??= parseMaxErrors(fileState.lines); -- return fileState.maxErrors; -- } -- removeFile(filename, args) { -- const relativeFilename = this.toRelativePath(filename); -- if (!this.data.has(relativeFilename)) { -- return false; -- } -- SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${formatFilename(filename)}: ${SEATBELT_FROZEN}: didn't remove max errors` : `${formatFilename(filename)}: remove max errors` -- ); -- if (args.frozen) { -- return false; -- } -- this.data.delete(relativeFilename); -- this.changed = true; -- return true; -- } -- updateMaxErrors(filename, args, ruleToErrorCount) { -- const removedRules = /* @__PURE__ */ new Set(); -- let increasedRulesCount = 0; -- let decreasedRulesCount = 0; -- this.getMaxErrors(filename); -- const relativeFilename = this.toRelativePath(filename); -- const maxErrors = this.data.get(relativeFilename)?.maxErrors ?? /* @__PURE__ */ new Map(); -- ruleToErrorCount.forEach((errorCount, ruleId) => { -- const maxErrorCount = maxErrors.get(ruleId) ?? 0; -- if (errorCount === maxErrorCount) { -- return; -- } -- if (errorCount < maxErrorCount || SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -- SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -- ); -- maxErrors.set(ruleId, errorCount); -- if (errorCount > maxErrorCount) { -- increasedRulesCount++; -- } else { -- decreasedRulesCount++; -- } -- } -- }); -- if (args.verbose || args.keepRules !== "all") { -- maxErrors.forEach((maxErrorCount, ruleId) => { -- const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -- if (!shouldRemove) { -- return; -- } -- if (SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -- SeatbeltArgs.verboseLog( -- args, -- () => `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -- ); -- return; -- } -- SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${0}` -- ); -- maxErrors.delete(ruleId); -- removedRules.add(ruleId); -- }); -- } -- const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -- if (changed && !args.frozen) { -- const file = this.data.get(relativeFilename); -- if (file) { -- file.maxErrors = maxErrors; -- } else { -- this.data.set(relativeFilename, { -- maxErrors, -- lines: [] -- }); -- } -- this.changed = true; -- } -- return { removedRules, increasedRulesCount, decreasedRulesCount }; -- } -- /** Atomic read -> apply delta -> write. Takes an exclusive file lock when `args.threadsafe`. */ -- updateFileMaxErrors(args, filename, ruleToErrorCount) { -- return this.withOptionalLock(args, () => { -- const before = this.getMaxErrors(filename); -- const ruleToMaxErrorCountBefore = before ? new Map(before) : void 0; -- const result = this.updateMaxErrors(filename, args, ruleToErrorCount); -- if (!args.frozen) { -- this.flushChanges(); -- } -- return { ruleToMaxErrorCountBefore, ...result }; -- }); -- } -- /** Drop entries whose source file no longer exists. Takes an exclusive file lock when `args.threadsafe`. */ -- cleanUpRemovedFiles(args) { -- return this.withOptionalLock(args, () => { -- let removedFiles = 0; -- for (const filename of Array.from(this.filenames())) { -- if (!fs.existsSync(filename)) { -- if (this.removeFile(filename, args)) { -- removedFiles++; -- } -- } -- } -- if (!args.frozen) { -- this.flushChanges(); -- } -- return { removedFiles }; -- }); -- } -- withOptionalLock(args, fn) { -- if (!args.threadsafe) { -- return fn(); -- } -- const lock = new FileLock(`${this.filename}.lock`); -- lock.waitLock(LOCK_TIMEOUT_MS); -- try { -- this.readSync(); -- return fn(); -- } finally { -- lock.unlock(); -- } -- } -- toDataString() { -- const lines = []; -- this.data.forEach((fileState, filename) => { -- if (fileState.maxErrors) { -- fileState.lines = []; -- fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -- fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -- }); -- fileState.lines.sort( -- (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -- ); -- } -- fileState.lines.forEach((line) => { -- const encoded = line.encoded ??= encodeLine(line); -- lines.push(encoded); -- }); -- }); -- lines.sort(); -- if (this.comments) { -- return this.comments + "\n\n" + lines.join(""); -- } else { -- return lines.join(""); -- } -- } -- readSync() { -- const nextStateFile = _SeatbeltFile.openSync(this.filename); -- if (nextStateFile) { -- this.data = nextStateFile.data; -- this.changed = false; -- return true; -- } -- return false; -- } -- flushChanges() { -- if (this.changed) { -- this.writeSync(); -- this.changed = false; -- return { updated: true }; -- } -- return { updated: false }; -- } -- writeSync() { -- const dataString = this.toDataString(); -- const dir = nodePath.dirname(this.filename); -- const base = nodePath.basename(this.filename); -- const tempFile = nodePath.join( -- this.useTempDirForWrites ? os.tmpdir() : dir, -- `.${base}.wip${process.pid}.${Date.now()}.tmp` -- ); -- fs.mkdirSync(dir, { recursive: true }); -- fs.writeFileSync(tempFile, dataString, "utf8"); -- try { -- fs.renameSync(tempFile, this.filename); -- } catch (error) { -- if (isErrno(error, "EXDEV")) { -- this.useTempDirForWrites = false; -- fs.copyFileSync(tempFile, this.filename); -- fs.rmSync(tempFile); -- return; -- } -- throw error; -- } -- } -- toJSON() { -- const data = Object.fromEntries( -- Array.from(this.data.keys()).map((filename) => { -- const maxErrors = this.getMaxErrors(filename); -- if (!maxErrors) { -- throw new Error(`${name} bug: expected errors for existing key`); -- } -- return [filename, Object.fromEntries(maxErrors)]; -- }) -- ); -- return { filename: this.filename, data }; -- } -- toRelativePath(filename) { -- if (!nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.relative(this.dirname, filename); -- } -- toAbsolutePath(filename) { -- if (nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.resolve(this.dirname, filename); -- } --}; --function parseMaxErrors(lines) { -- const maxErrors = /* @__PURE__ */ new Map(); -- lines.forEach((line) => { -- maxErrors.set(line.ruleId, line.maxErrors); -- }); -- return maxErrors; --} -- --export { -- appendErrorContext, -- FileLock, -- SeatbeltFile --}; --//# sourceMappingURL=chunk-5FFUIU4M.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-F4JJMAJL.js b/node_modules/eslint-seatbelt/dist/chunk-F4JJMAJL.js -new file mode 100644 -index 0000000..ae0f5b6 ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-F4JJMAJL.js -@@ -0,0 +1,480 @@ -+"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; -+ -+ -+ -+ -+ -+ -+var _chunkTDWD7IZMjs = require('./chunk-TDWD7IZM.js'); -+ -+// src/FileLock.ts -+var _fs = require('fs'); var fs = _interopRequireWildcard(_fs); -+ -+// src/errorHanding.ts -+function appendErrorContext(error, context) { -+ if (error instanceof Error) { -+ error.message += ` -+ ${context}`; -+ } -+} -+function isErrno(error, code) { -+ return error instanceof Error && "code" in error && error.code === code; -+} -+ -+// src/FileLock.ts -+var { O_CREAT, O_EXCL, O_RDWR } = _fs.constants; -+var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); -+var heldLocks = /* @__PURE__ */ new Set(); -+var cleanupHooksInstalled = false; -+var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; -+function installCleanupHooks() { -+ if (cleanupHooksInstalled) return; -+ cleanupHooksInstalled = true; -+ const release = () => { -+ for (const lock of heldLocks) { -+ try { -+ lock.unlock(); -+ } catch (e2) { -+ } -+ } -+ }; -+ process.on("exit", release); -+ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { -+ process.on(signal, () => { -+ release(); -+ process.exit(SIGNAL_EXIT_CODES[signal]); -+ }); -+ } -+} -+var FileLock = class { -+ constructor(filename) { -+ this.filename = filename; -+ } -+ -+ tryLock() { -+ this.assertNotLocked(); -+ try { -+ this.fd = _fs.openSync.call(void 0, this.filename, O_CREAT | O_EXCL | O_RDWR); -+ _fs.writeSync.call(void 0, this.fd, `${process.pid} -+`); -+ heldLocks.add(this); -+ installCleanupHooks(); -+ return true; -+ } catch (e) { -+ if (isErrno(e, "EEXIST")) { -+ return false; -+ } -+ throw e; -+ } -+ } -+ waitLock(timeoutMs) { -+ const deadline = Date.now() + timeoutMs; -+ let attemptedRecovery = false; -+ while (!this.tryLock()) { -+ if (Date.now() > deadline) { -+ if (!attemptedRecovery && this.reclaimIfStale()) { -+ attemptedRecovery = true; -+ continue; -+ } -+ throw new Error(`Timed out waiting for lock on ${this.filename}`); -+ } -+ Atomics.wait(waitBuffer, 0, 0, 1); -+ } -+ } -+ isLocked() { -+ return this.fd !== void 0; -+ } -+ unlock() { -+ if (this.fd !== void 0) { -+ _fs.closeSync.call(void 0, this.fd); -+ try { -+ _fs.rmSync.call(void 0, this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ this.fd = void 0; -+ heldLocks.delete(this); -+ } -+ } -+ assertNotLocked() { -+ if (this.fd !== void 0) { -+ throw new Error( -+ `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -+ ); -+ } -+ } -+ reclaimIfStale() { -+ let contents; -+ try { -+ contents = _fs.readFileSync.call(void 0, this.filename, "utf8"); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) return true; -+ throw e; -+ } -+ const pid = Number.parseInt(contents.trim(), 10); -+ if (!Number.isFinite(pid) || pid <= 0) return false; -+ try { -+ process.kill(pid, 0); -+ return false; -+ } catch (e) { -+ if (!isErrno(e, "ESRCH")) throw e; -+ } -+ try { -+ _fs.rmSync.call(void 0, this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ return true; -+ } -+}; -+ -+// src/SeatbeltFile.ts -+var _os = require('os'); var os = _interopRequireWildcard(_os); -+ -+var _path = require('path'); var nodePath = _interopRequireWildcard(_path); -+var LOCK_TIMEOUT_MS = 3e4; -+function encodeLine(line) { -+ const { filename, ruleId, maxErrors } = line; -+ return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} -+`; -+} -+function decodeLine(line, index) { -+ try { -+ const lineParts = line.split(" "); -+ if (lineParts.length !== 3) { -+ throw new Error( -+ `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -+ ); -+ } -+ let filename; -+ try { -+ filename = JSON.parse(lineParts[0]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 1 (filename)"); -+ throw e; -+ } -+ let ruleId; -+ try { -+ ruleId = JSON.parse(lineParts[1]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -+ throw e; -+ } -+ let maxErrors; -+ try { -+ maxErrors = JSON.parse(lineParts[2]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -+ throw e; -+ } -+ return { -+ encoded: line, -+ filename, -+ ruleId, -+ maxErrors -+ }; -+ } catch (e) { -+ appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -+ throw e; -+ } -+} -+var COMMENT_LINE_REGEX = /^\s*#/; -+var NON_EMPTY_LINE_REGEX = /\S+/; -+var DEFAULT_FILE_HEADER = ` -+# ${_chunkTDWD7IZMjs.name} temporarily allowed errors -+# docs: https://github.com/justjake/${_chunkTDWD7IZMjs.name}#readme -+`.trim(); -+var SeatbeltFile = (_class = class _SeatbeltFile { -+ constructor(filename, data, comments = "") {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this); -+ this.filename = filename; -+ this.data = data; -+ this.comments = comments; -+ this.filename = nodePath.default.resolve(this.filename); -+ this.dirname = nodePath.default.dirname(this.filename); -+ } -+ static readSync(filename) { -+ const text = fs.readFileSync(filename, "utf8"); -+ try { -+ return _SeatbeltFile.parse(filename, text); -+ } catch (e) { -+ appendErrorContext(e, `in seatbelt file \`${filename}\``); -+ throw e; -+ } -+ } -+ /** -+ * Read `filename` if it exists, otherwise create a new empty seatbelt file object -+ * that will write to that filename. -+ */ -+ static openSync(filename) { -+ try { -+ return _SeatbeltFile.readSync(filename); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) { -+ return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -+ } -+ throw e; -+ } -+ } -+ static parse(filename, text) { -+ const data = /* @__PURE__ */ new Map(); -+ const split = text.split(/(?<=\n)/); -+ const lines = split.filter( -+ (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -+ ).map(decodeLine); -+ const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -+ lines.forEach((line) => { -+ let fileState = data.get(line.filename); -+ if (!fileState) { -+ fileState = { maxErrors: void 0, lines: [] }; -+ data.set(line.filename, fileState); -+ } -+ fileState.lines.push(line); -+ }); -+ return new _SeatbeltFile(filename, data, comments.trim()); -+ } -+ static fromJSON(json) { -+ const data = new Map( -+ Object.entries(json.data).map(([filename, maxErrors]) => [ -+ filename, -+ { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -+ ]) -+ ); -+ return new _SeatbeltFile(json.filename, data); -+ } -+ __init() {this.changed = false} -+ -+ __init2() {this.useTempDirForWrites = true} -+ *filenames() { -+ for (const filename of this.data.keys()) { -+ yield this.toAbsolutePath(filename); -+ } -+ } -+ getMaxErrors(filename) { -+ const fileState = this.data.get(this.toRelativePath(filename)); -+ if (!fileState) { -+ return void 0; -+ } -+ fileState.maxErrors ??= parseMaxErrors(fileState.lines); -+ return fileState.maxErrors; -+ } -+ removeFile(filename, args) { -+ const relativeFilename = this.toRelativePath(filename); -+ if (!this.data.has(relativeFilename)) { -+ return false; -+ } -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.SEATBELT_FROZEN}: didn't remove max errors` : `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: remove max errors` -+ ); -+ if (args.frozen) { -+ return false; -+ } -+ this.data.delete(relativeFilename); -+ this.changed = true; -+ return true; -+ } -+ updateMaxErrors(filename, args, ruleToErrorCount) { -+ const removedRules = /* @__PURE__ */ new Set(); -+ let increasedRulesCount = 0; -+ let decreasedRulesCount = 0; -+ this.getMaxErrors(filename); -+ const relativeFilename = this.toRelativePath(filename); -+ const maxErrors = _nullishCoalesce(_optionalChain([this, 'access', _ => _.data, 'access', _2 => _2.get, 'call', _3 => _3(relativeFilename), 'optionalAccess', _4 => _4.maxErrors]), () => ( /* @__PURE__ */ new Map())); -+ ruleToErrorCount.forEach((errorCount, ruleId) => { -+ const maxErrorCount = _nullishCoalesce(maxErrors.get(ruleId), () => ( 0)); -+ if (errorCount === maxErrorCount) { -+ return; -+ } -+ if (errorCount < maxErrorCount || _chunkTDWD7IZMjs.SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, ruleId)}: ${_chunkTDWD7IZMjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -+ ); -+ maxErrors.set(ruleId, errorCount); -+ if (errorCount > maxErrorCount) { -+ increasedRulesCount++; -+ } else { -+ decreasedRulesCount++; -+ } -+ } -+ }); -+ if (args.verbose || args.keepRules !== "all") { -+ maxErrors.forEach((maxErrorCount, ruleId) => { -+ const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -+ if (!shouldRemove) { -+ return; -+ } -+ if (_chunkTDWD7IZMjs.SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( -+ args, -+ () => `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, ruleId)}: ${_chunkTDWD7IZMjs.SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -+ ); -+ return; -+ } -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, ruleId)}: ${_chunkTDWD7IZMjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${0}` -+ ); -+ maxErrors.delete(ruleId); -+ removedRules.add(ruleId); -+ }); -+ } -+ const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -+ if (changed && !args.frozen) { -+ const file = this.data.get(relativeFilename); -+ if (file) { -+ file.maxErrors = maxErrors; -+ } else { -+ this.data.set(relativeFilename, { -+ maxErrors, -+ lines: [] -+ }); -+ } -+ this.changed = true; -+ } -+ return { removedRules, increasedRulesCount, decreasedRulesCount }; -+ } -+ /** Atomic read -> apply delta -> write. Takes an exclusive file lock when `args.threadsafe`. */ -+ updateFileMaxErrors(args, filename, ruleToErrorCount) { -+ return this.withOptionalLock(args, () => { -+ const before = this.getMaxErrors(filename); -+ const ruleToMaxErrorCountBefore = before ? new Map(before) : void 0; -+ const result = this.updateMaxErrors(filename, args, ruleToErrorCount); -+ if (!args.frozen && !args.readOnly) { -+ this.flushChanges(); -+ } -+ return { ruleToMaxErrorCountBefore, ...result }; -+ }); -+ } -+ /** Drop entries whose source file no longer exists. Takes an exclusive file lock when `args.threadsafe`. */ -+ cleanUpRemovedFiles(args) { -+ return this.withOptionalLock(args, () => { -+ let removedFiles = 0; -+ for (const filename of Array.from(this.filenames())) { -+ if (!fs.existsSync(filename)) { -+ if (this.removeFile(filename, args)) { -+ removedFiles++; -+ } -+ } -+ } -+ if (!args.frozen && !args.readOnly) { -+ this.flushChanges(); -+ } -+ return { removedFiles }; -+ }); -+ } -+ withOptionalLock(args, fn) { -+ if (!args.threadsafe) { -+ return fn(); -+ } -+ const lock = new FileLock(`${this.filename}.lock`); -+ lock.waitLock(LOCK_TIMEOUT_MS); -+ try { -+ this.readSync(); -+ return fn(); -+ } finally { -+ lock.unlock(); -+ } -+ } -+ toDataString() { -+ const lines = []; -+ this.data.forEach((fileState, filename) => { -+ if (fileState.maxErrors) { -+ fileState.lines = []; -+ fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -+ fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -+ }); -+ fileState.lines.sort( -+ (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -+ ); -+ } -+ fileState.lines.forEach((line) => { -+ const encoded = line.encoded ??= encodeLine(line); -+ lines.push(encoded); -+ }); -+ }); -+ lines.sort(); -+ if (this.comments) { -+ return this.comments + "\n\n" + lines.join(""); -+ } else { -+ return lines.join(""); -+ } -+ } -+ readSync() { -+ const nextStateFile = _SeatbeltFile.openSync(this.filename); -+ if (nextStateFile) { -+ this.data = nextStateFile.data; -+ this.changed = false; -+ return true; -+ } -+ return false; -+ } -+ flushChanges() { -+ if (this.changed) { -+ this.writeSync(); -+ this.changed = false; -+ return { updated: true }; -+ } -+ return { updated: false }; -+ } -+ writeSync() { -+ const dataString = this.toDataString(); -+ const dir = nodePath.dirname(this.filename); -+ const base = nodePath.basename(this.filename); -+ const tempFile = nodePath.join( -+ this.useTempDirForWrites ? os.tmpdir() : dir, -+ `.${base}.wip${process.pid}.${Date.now()}.tmp` -+ ); -+ fs.mkdirSync(dir, { recursive: true }); -+ fs.writeFileSync(tempFile, dataString, "utf8"); -+ try { -+ fs.renameSync(tempFile, this.filename); -+ } catch (error) { -+ if (isErrno(error, "EXDEV")) { -+ this.useTempDirForWrites = false; -+ fs.copyFileSync(tempFile, this.filename); -+ fs.rmSync(tempFile); -+ return; -+ } -+ throw error; -+ } -+ } -+ toJSON() { -+ const data = Object.fromEntries( -+ Array.from(this.data.keys()).map((filename) => { -+ const maxErrors = this.getMaxErrors(filename); -+ if (!maxErrors) { -+ throw new Error(`${_chunkTDWD7IZMjs.name} bug: expected errors for existing key`); -+ } -+ return [filename, Object.fromEntries(maxErrors)]; -+ }) -+ ); -+ return { filename: this.filename, data }; -+ } -+ toRelativePath(filename) { -+ if (!nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.relative(this.dirname, filename); -+ } -+ toAbsolutePath(filename) { -+ if (nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.resolve(this.dirname, filename); -+ } -+}, _class); -+function parseMaxErrors(lines) { -+ const maxErrors = /* @__PURE__ */ new Map(); -+ lines.forEach((line) => { -+ maxErrors.set(line.ruleId, line.maxErrors); -+ }); -+ return maxErrors; -+} -+ -+ -+ -+ -+ -+exports.appendErrorContext = appendErrorContext; exports.FileLock = FileLock; exports.SeatbeltFile = SeatbeltFile; -+//# sourceMappingURL=chunk-F4JJMAJL.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-NTFTCWX7.js b/node_modules/eslint-seatbelt/dist/chunk-NTFTCWX7.js -deleted file mode 100644 -index f3e2e3d..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-NTFTCWX7.js -+++ /dev/null -@@ -1,480 +0,0 @@ --"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; -- -- -- -- -- -- --var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); -- --// src/FileLock.ts --var _fs = require('fs'); var fs = _interopRequireWildcard(_fs); -- --// src/errorHanding.ts --function appendErrorContext(error, context) { -- if (error instanceof Error) { -- error.message += ` -- ${context}`; -- } --} --function isErrno(error, code) { -- return error instanceof Error && "code" in error && error.code === code; --} -- --// src/FileLock.ts --var { O_CREAT, O_EXCL, O_RDWR } = _fs.constants; --var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); --var heldLocks = /* @__PURE__ */ new Set(); --var cleanupHooksInstalled = false; --var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; --function installCleanupHooks() { -- if (cleanupHooksInstalled) return; -- cleanupHooksInstalled = true; -- const release = () => { -- for (const lock of heldLocks) { -- try { -- lock.unlock(); -- } catch (e2) { -- } -- } -- }; -- process.on("exit", release); -- for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { -- process.on(signal, () => { -- release(); -- process.exit(SIGNAL_EXIT_CODES[signal]); -- }); -- } --} --var FileLock = class { -- constructor(filename) { -- this.filename = filename; -- } -- -- tryLock() { -- this.assertNotLocked(); -- try { -- this.fd = _fs.openSync.call(void 0, this.filename, O_CREAT | O_EXCL | O_RDWR); -- _fs.writeSync.call(void 0, this.fd, `${process.pid} --`); -- heldLocks.add(this); -- installCleanupHooks(); -- return true; -- } catch (e) { -- if (isErrno(e, "EEXIST")) { -- return false; -- } -- throw e; -- } -- } -- waitLock(timeoutMs) { -- const deadline = Date.now() + timeoutMs; -- let attemptedRecovery = false; -- while (!this.tryLock()) { -- if (Date.now() > deadline) { -- if (!attemptedRecovery && this.reclaimIfStale()) { -- attemptedRecovery = true; -- continue; -- } -- throw new Error(`Timed out waiting for lock on ${this.filename}`); -- } -- Atomics.wait(waitBuffer, 0, 0, 1); -- } -- } -- isLocked() { -- return this.fd !== void 0; -- } -- unlock() { -- if (this.fd !== void 0) { -- _fs.closeSync.call(void 0, this.fd); -- try { -- _fs.rmSync.call(void 0, this.filename); -- } catch (e) { -- if (!isErrno(e, "ENOENT")) throw e; -- } -- this.fd = void 0; -- heldLocks.delete(this); -- } -- } -- assertNotLocked() { -- if (this.fd !== void 0) { -- throw new Error( -- `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -- ); -- } -- } -- reclaimIfStale() { -- let contents; -- try { -- contents = _fs.readFileSync.call(void 0, this.filename, "utf8"); -- } catch (e) { -- if (isErrno(e, "ENOENT")) return true; -- throw e; -- } -- const pid = Number.parseInt(contents.trim(), 10); -- if (!Number.isFinite(pid) || pid <= 0) return false; -- try { -- process.kill(pid, 0); -- return false; -- } catch (e) { -- if (!isErrno(e, "ESRCH")) throw e; -- } -- try { -- _fs.rmSync.call(void 0, this.filename); -- } catch (e) { -- if (!isErrno(e, "ENOENT")) throw e; -- } -- return true; -- } --}; -- --// src/SeatbeltFile.ts --var _os = require('os'); var os = _interopRequireWildcard(_os); -- --var _path = require('path'); var nodePath = _interopRequireWildcard(_path); --var LOCK_TIMEOUT_MS = 3e4; --function encodeLine(line) { -- const { filename, ruleId, maxErrors } = line; -- return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} --`; --} --function decodeLine(line, index) { -- try { -- const lineParts = line.split(" "); -- if (lineParts.length !== 3) { -- throw new Error( -- `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -- ); -- } -- let filename; -- try { -- filename = JSON.parse(lineParts[0]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 1 (filename)"); -- throw e; -- } -- let ruleId; -- try { -- ruleId = JSON.parse(lineParts[1]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -- throw e; -- } -- let maxErrors; -- try { -- maxErrors = JSON.parse(lineParts[2]); -- } catch (e) { -- appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -- throw e; -- } -- return { -- encoded: line, -- filename, -- ruleId, -- maxErrors -- }; -- } catch (e) { -- appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -- throw e; -- } --} --var COMMENT_LINE_REGEX = /^\s*#/; --var NON_EMPTY_LINE_REGEX = /\S+/; --var DEFAULT_FILE_HEADER = ` --# ${_chunkZVY5S6JSjs.name} temporarily allowed errors --# docs: https://github.com/justjake/${_chunkZVY5S6JSjs.name}#readme --`.trim(); --var SeatbeltFile = (_class = class _SeatbeltFile { -- constructor(filename, data, comments = "") {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this); -- this.filename = filename; -- this.data = data; -- this.comments = comments; -- this.filename = nodePath.default.resolve(this.filename); -- this.dirname = nodePath.default.dirname(this.filename); -- } -- static readSync(filename) { -- const text = fs.readFileSync(filename, "utf8"); -- try { -- return _SeatbeltFile.parse(filename, text); -- } catch (e) { -- appendErrorContext(e, `in seatbelt file \`${filename}\``); -- throw e; -- } -- } -- /** -- * Read `filename` if it exists, otherwise create a new empty seatbelt file object -- * that will write to that filename. -- */ -- static openSync(filename) { -- try { -- return _SeatbeltFile.readSync(filename); -- } catch (e) { -- if (isErrno(e, "ENOENT")) { -- return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -- } -- throw e; -- } -- } -- static parse(filename, text) { -- const data = /* @__PURE__ */ new Map(); -- const split = text.split(/(?<=\n)/); -- const lines = split.filter( -- (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -- ).map(decodeLine); -- const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -- lines.forEach((line) => { -- let fileState = data.get(line.filename); -- if (!fileState) { -- fileState = { maxErrors: void 0, lines: [] }; -- data.set(line.filename, fileState); -- } -- fileState.lines.push(line); -- }); -- return new _SeatbeltFile(filename, data, comments.trim()); -- } -- static fromJSON(json) { -- const data = new Map( -- Object.entries(json.data).map(([filename, maxErrors]) => [ -- filename, -- { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -- ]) -- ); -- return new _SeatbeltFile(json.filename, data); -- } -- __init() {this.changed = false} -- -- __init2() {this.useTempDirForWrites = true} -- *filenames() { -- for (const filename of this.data.keys()) { -- yield this.toAbsolutePath(filename); -- } -- } -- getMaxErrors(filename) { -- const fileState = this.data.get(this.toRelativePath(filename)); -- if (!fileState) { -- return void 0; -- } -- fileState.maxErrors ??= parseMaxErrors(fileState.lines); -- return fileState.maxErrors; -- } -- removeFile(filename, args) { -- const relativeFilename = this.toRelativePath(filename); -- if (!this.data.has(relativeFilename)) { -- return false; -- } -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: didn't remove max errors` : `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: remove max errors` -- ); -- if (args.frozen) { -- return false; -- } -- this.data.delete(relativeFilename); -- this.changed = true; -- return true; -- } -- updateMaxErrors(filename, args, ruleToErrorCount) { -- const removedRules = /* @__PURE__ */ new Set(); -- let increasedRulesCount = 0; -- let decreasedRulesCount = 0; -- this.getMaxErrors(filename); -- const relativeFilename = this.toRelativePath(filename); -- const maxErrors = _nullishCoalesce(_optionalChain([this, 'access', _ => _.data, 'access', _2 => _2.get, 'call', _3 => _3(relativeFilename), 'optionalAccess', _4 => _4.maxErrors]), () => ( /* @__PURE__ */ new Map())); -- ruleToErrorCount.forEach((errorCount, ruleId) => { -- const maxErrorCount = _nullishCoalesce(maxErrors.get(ruleId), () => ( 0)); -- if (errorCount === maxErrorCount) { -- return; -- } -- if (errorCount < maxErrorCount || _chunkZVY5S6JSjs.SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -- ); -- maxErrors.set(ruleId, errorCount); -- if (errorCount > maxErrorCount) { -- increasedRulesCount++; -- } else { -- decreasedRulesCount++; -- } -- } -- }); -- if (args.verbose || args.keepRules !== "all") { -- maxErrors.forEach((maxErrorCount, ruleId) => { -- const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -- if (!shouldRemove) { -- return; -- } -- if (_chunkZVY5S6JSjs.SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -- args, -- () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: ${_chunkZVY5S6JSjs.SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -- ); -- return; -- } -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -- args, -- () => args.frozen ? `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, ruleId)}: update max errors ${maxErrorCount} -> ${0}` -- ); -- maxErrors.delete(ruleId); -- removedRules.add(ruleId); -- }); -- } -- const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -- if (changed && !args.frozen) { -- const file = this.data.get(relativeFilename); -- if (file) { -- file.maxErrors = maxErrors; -- } else { -- this.data.set(relativeFilename, { -- maxErrors, -- lines: [] -- }); -- } -- this.changed = true; -- } -- return { removedRules, increasedRulesCount, decreasedRulesCount }; -- } -- /** Atomic read -> apply delta -> write. Takes an exclusive file lock when `args.threadsafe`. */ -- updateFileMaxErrors(args, filename, ruleToErrorCount) { -- return this.withOptionalLock(args, () => { -- const before = this.getMaxErrors(filename); -- const ruleToMaxErrorCountBefore = before ? new Map(before) : void 0; -- const result = this.updateMaxErrors(filename, args, ruleToErrorCount); -- if (!args.frozen) { -- this.flushChanges(); -- } -- return { ruleToMaxErrorCountBefore, ...result }; -- }); -- } -- /** Drop entries whose source file no longer exists. Takes an exclusive file lock when `args.threadsafe`. */ -- cleanUpRemovedFiles(args) { -- return this.withOptionalLock(args, () => { -- let removedFiles = 0; -- for (const filename of Array.from(this.filenames())) { -- if (!fs.existsSync(filename)) { -- if (this.removeFile(filename, args)) { -- removedFiles++; -- } -- } -- } -- if (!args.frozen) { -- this.flushChanges(); -- } -- return { removedFiles }; -- }); -- } -- withOptionalLock(args, fn) { -- if (!args.threadsafe) { -- return fn(); -- } -- const lock = new FileLock(`${this.filename}.lock`); -- lock.waitLock(LOCK_TIMEOUT_MS); -- try { -- this.readSync(); -- return fn(); -- } finally { -- lock.unlock(); -- } -- } -- toDataString() { -- const lines = []; -- this.data.forEach((fileState, filename) => { -- if (fileState.maxErrors) { -- fileState.lines = []; -- fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -- fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -- }); -- fileState.lines.sort( -- (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -- ); -- } -- fileState.lines.forEach((line) => { -- const encoded = line.encoded ??= encodeLine(line); -- lines.push(encoded); -- }); -- }); -- lines.sort(); -- if (this.comments) { -- return this.comments + "\n\n" + lines.join(""); -- } else { -- return lines.join(""); -- } -- } -- readSync() { -- const nextStateFile = _SeatbeltFile.openSync(this.filename); -- if (nextStateFile) { -- this.data = nextStateFile.data; -- this.changed = false; -- return true; -- } -- return false; -- } -- flushChanges() { -- if (this.changed) { -- this.writeSync(); -- this.changed = false; -- return { updated: true }; -- } -- return { updated: false }; -- } -- writeSync() { -- const dataString = this.toDataString(); -- const dir = nodePath.dirname(this.filename); -- const base = nodePath.basename(this.filename); -- const tempFile = nodePath.join( -- this.useTempDirForWrites ? os.tmpdir() : dir, -- `.${base}.wip${process.pid}.${Date.now()}.tmp` -- ); -- fs.mkdirSync(dir, { recursive: true }); -- fs.writeFileSync(tempFile, dataString, "utf8"); -- try { -- fs.renameSync(tempFile, this.filename); -- } catch (error) { -- if (isErrno(error, "EXDEV")) { -- this.useTempDirForWrites = false; -- fs.copyFileSync(tempFile, this.filename); -- fs.rmSync(tempFile); -- return; -- } -- throw error; -- } -- } -- toJSON() { -- const data = Object.fromEntries( -- Array.from(this.data.keys()).map((filename) => { -- const maxErrors = this.getMaxErrors(filename); -- if (!maxErrors) { -- throw new Error(`${_chunkZVY5S6JSjs.name} bug: expected errors for existing key`); -- } -- return [filename, Object.fromEntries(maxErrors)]; -- }) -- ); -- return { filename: this.filename, data }; -- } -- toRelativePath(filename) { -- if (!nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.relative(this.dirname, filename); -- } -- toAbsolutePath(filename) { -- if (nodePath.isAbsolute(filename)) { -- return filename; -- } -- return nodePath.resolve(this.dirname, filename); -- } --}, _class); --function parseMaxErrors(lines) { -- const maxErrors = /* @__PURE__ */ new Map(); -- lines.forEach((line) => { -- maxErrors.set(line.ruleId, line.maxErrors); -- }); -- return maxErrors; --} -- -- -- -- -- --exports.appendErrorContext = appendErrorContext; exports.FileLock = FileLock; exports.SeatbeltFile = SeatbeltFile; --//# sourceMappingURL=chunk-NTFTCWX7.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-QGLWIAEE.mjs b/node_modules/eslint-seatbelt/dist/chunk-QGLWIAEE.mjs -new file mode 100644 -index 0000000..4dd47c5 ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-QGLWIAEE.mjs -@@ -0,0 +1,480 @@ -+import { -+ SEATBELT_FROZEN, -+ SEATBELT_KEEP, -+ SeatbeltArgs, -+ formatFilename, -+ formatRuleId, -+ name -+} from "./chunk-U6KIQG2A.mjs"; -+ -+// src/FileLock.ts -+import { openSync, writeSync, closeSync, readFileSync, constants, rmSync } from "node:fs"; -+ -+// src/errorHanding.ts -+function appendErrorContext(error, context) { -+ if (error instanceof Error) { -+ error.message += ` -+ ${context}`; -+ } -+} -+function isErrno(error, code) { -+ return error instanceof Error && "code" in error && error.code === code; -+} -+ -+// src/FileLock.ts -+var { O_CREAT, O_EXCL, O_RDWR } = constants; -+var waitBuffer = new Int32Array(new SharedArrayBuffer(4)); -+var heldLocks = /* @__PURE__ */ new Set(); -+var cleanupHooksInstalled = false; -+var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; -+function installCleanupHooks() { -+ if (cleanupHooksInstalled) return; -+ cleanupHooksInstalled = true; -+ const release = () => { -+ for (const lock of heldLocks) { -+ try { -+ lock.unlock(); -+ } catch { -+ } -+ } -+ }; -+ process.on("exit", release); -+ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { -+ process.on(signal, () => { -+ release(); -+ process.exit(SIGNAL_EXIT_CODES[signal]); -+ }); -+ } -+} -+var FileLock = class { -+ constructor(filename) { -+ this.filename = filename; -+ } -+ fd; -+ tryLock() { -+ this.assertNotLocked(); -+ try { -+ this.fd = openSync(this.filename, O_CREAT | O_EXCL | O_RDWR); -+ writeSync(this.fd, `${process.pid} -+`); -+ heldLocks.add(this); -+ installCleanupHooks(); -+ return true; -+ } catch (e) { -+ if (isErrno(e, "EEXIST")) { -+ return false; -+ } -+ throw e; -+ } -+ } -+ waitLock(timeoutMs) { -+ const deadline = Date.now() + timeoutMs; -+ let attemptedRecovery = false; -+ while (!this.tryLock()) { -+ if (Date.now() > deadline) { -+ if (!attemptedRecovery && this.reclaimIfStale()) { -+ attemptedRecovery = true; -+ continue; -+ } -+ throw new Error(`Timed out waiting for lock on ${this.filename}`); -+ } -+ Atomics.wait(waitBuffer, 0, 0, 1); -+ } -+ } -+ isLocked() { -+ return this.fd !== void 0; -+ } -+ unlock() { -+ if (this.fd !== void 0) { -+ closeSync(this.fd); -+ try { -+ rmSync(this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ this.fd = void 0; -+ heldLocks.delete(this); -+ } -+ } -+ assertNotLocked() { -+ if (this.fd !== void 0) { -+ throw new Error( -+ `FileLock "${this.filename}" is already locked by this process [pid ${process.pid}]` -+ ); -+ } -+ } -+ reclaimIfStale() { -+ let contents; -+ try { -+ contents = readFileSync(this.filename, "utf8"); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) return true; -+ throw e; -+ } -+ const pid = Number.parseInt(contents.trim(), 10); -+ if (!Number.isFinite(pid) || pid <= 0) return false; -+ try { -+ process.kill(pid, 0); -+ return false; -+ } catch (e) { -+ if (!isErrno(e, "ESRCH")) throw e; -+ } -+ try { -+ rmSync(this.filename); -+ } catch (e) { -+ if (!isErrno(e, "ENOENT")) throw e; -+ } -+ return true; -+ } -+}; -+ -+// src/SeatbeltFile.ts -+import * as os from "node:os"; -+import * as fs from "node:fs"; -+import path, * as nodePath from "node:path"; -+var LOCK_TIMEOUT_MS = 3e4; -+function encodeLine(line) { -+ const { filename, ruleId, maxErrors } = line; -+ return `${JSON.stringify(filename)} ${JSON.stringify(ruleId)} ${maxErrors} -+`; -+} -+function decodeLine(line, index) { -+ try { -+ const lineParts = line.split(" "); -+ if (lineParts.length !== 3) { -+ throw new Error( -+ `Expected 3 tab-separated JSON strings, instead have ${lineParts.length}` -+ ); -+ } -+ let filename; -+ try { -+ filename = JSON.parse(lineParts[0]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 1 (filename)"); -+ throw e; -+ } -+ let ruleId; -+ try { -+ ruleId = JSON.parse(lineParts[1]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 2 (RuleId)"); -+ throw e; -+ } -+ let maxErrors; -+ try { -+ maxErrors = JSON.parse(lineParts[2]); -+ } catch (e) { -+ appendErrorContext(e, "at tab-separated column 3 (maxErrors)"); -+ throw e; -+ } -+ return { -+ encoded: line, -+ filename, -+ ruleId, -+ maxErrors -+ }; -+ } catch (e) { -+ appendErrorContext(e, `at line ${index + 1}: \`${line.trim()}\``); -+ throw e; -+ } -+} -+var COMMENT_LINE_REGEX = /^\s*#/; -+var NON_EMPTY_LINE_REGEX = /\S+/; -+var DEFAULT_FILE_HEADER = ` -+# ${name} temporarily allowed errors -+# docs: https://github.com/justjake/${name}#readme -+`.trim(); -+var SeatbeltFile = class _SeatbeltFile { -+ constructor(filename, data, comments = "") { -+ this.filename = filename; -+ this.data = data; -+ this.comments = comments; -+ this.filename = path.resolve(this.filename); -+ this.dirname = path.dirname(this.filename); -+ } -+ static readSync(filename) { -+ const text = fs.readFileSync(filename, "utf8"); -+ try { -+ return _SeatbeltFile.parse(filename, text); -+ } catch (e) { -+ appendErrorContext(e, `in seatbelt file \`${filename}\``); -+ throw e; -+ } -+ } -+ /** -+ * Read `filename` if it exists, otherwise create a new empty seatbelt file object -+ * that will write to that filename. -+ */ -+ static openSync(filename) { -+ try { -+ return _SeatbeltFile.readSync(filename); -+ } catch (e) { -+ if (isErrno(e, "ENOENT")) { -+ return new _SeatbeltFile(filename, /* @__PURE__ */ new Map(), DEFAULT_FILE_HEADER); -+ } -+ throw e; -+ } -+ } -+ static parse(filename, text) { -+ const data = /* @__PURE__ */ new Map(); -+ const split = text.split(/(?<=\n)/); -+ const lines = split.filter( -+ (line) => NON_EMPTY_LINE_REGEX.test(line) && !COMMENT_LINE_REGEX.test(line) -+ ).map(decodeLine); -+ const comments = split.filter((line) => COMMENT_LINE_REGEX.test(line)).join(""); -+ lines.forEach((line) => { -+ let fileState = data.get(line.filename); -+ if (!fileState) { -+ fileState = { maxErrors: void 0, lines: [] }; -+ data.set(line.filename, fileState); -+ } -+ fileState.lines.push(line); -+ }); -+ return new _SeatbeltFile(filename, data, comments.trim()); -+ } -+ static fromJSON(json) { -+ const data = new Map( -+ Object.entries(json.data).map(([filename, maxErrors]) => [ -+ filename, -+ { maxErrors: new Map(Object.entries(maxErrors)), lines: [] } -+ ]) -+ ); -+ return new _SeatbeltFile(json.filename, data); -+ } -+ changed = false; -+ dirname; -+ useTempDirForWrites = true; -+ *filenames() { -+ for (const filename of this.data.keys()) { -+ yield this.toAbsolutePath(filename); -+ } -+ } -+ getMaxErrors(filename) { -+ const fileState = this.data.get(this.toRelativePath(filename)); -+ if (!fileState) { -+ return void 0; -+ } -+ fileState.maxErrors ??= parseMaxErrors(fileState.lines); -+ return fileState.maxErrors; -+ } -+ removeFile(filename, args) { -+ const relativeFilename = this.toRelativePath(filename); -+ if (!this.data.has(relativeFilename)) { -+ return false; -+ } -+ SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${formatFilename(filename)}: ${SEATBELT_FROZEN}: didn't remove max errors` : `${formatFilename(filename)}: remove max errors` -+ ); -+ if (args.frozen) { -+ return false; -+ } -+ this.data.delete(relativeFilename); -+ this.changed = true; -+ return true; -+ } -+ updateMaxErrors(filename, args, ruleToErrorCount) { -+ const removedRules = /* @__PURE__ */ new Set(); -+ let increasedRulesCount = 0; -+ let decreasedRulesCount = 0; -+ this.getMaxErrors(filename); -+ const relativeFilename = this.toRelativePath(filename); -+ const maxErrors = this.data.get(relativeFilename)?.maxErrors ?? /* @__PURE__ */ new Map(); -+ ruleToErrorCount.forEach((errorCount, ruleId) => { -+ const maxErrorCount = maxErrors.get(ruleId) ?? 0; -+ if (errorCount === maxErrorCount) { -+ return; -+ } -+ if (errorCount < maxErrorCount || SeatbeltArgs.ruleSetHas(args.allowIncreaseRules, ruleId)) { -+ SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${errorCount}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${errorCount}` -+ ); -+ maxErrors.set(ruleId, errorCount); -+ if (errorCount > maxErrorCount) { -+ increasedRulesCount++; -+ } else { -+ decreasedRulesCount++; -+ } -+ } -+ }); -+ if (args.verbose || args.keepRules !== "all") { -+ maxErrors.forEach((maxErrorCount, ruleId) => { -+ const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleId); -+ if (!shouldRemove) { -+ return; -+ } -+ if (SeatbeltArgs.ruleSetHas(args.keepRules, ruleId)) { -+ SeatbeltArgs.verboseLog( -+ args, -+ () => `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_KEEP}: didn't update max errors ${maxErrorCount} -> ${0}` -+ ); -+ return; -+ } -+ SeatbeltArgs.verboseLog( -+ args, -+ () => args.frozen ? `${formatFilename(filename)}: ${formatRuleId(ruleId)}: ${SEATBELT_FROZEN}: didn't update max errors ${maxErrorCount} -> ${0}` : `${formatFilename(filename)}: ${formatRuleId(ruleId)}: update max errors ${maxErrorCount} -> ${0}` -+ ); -+ maxErrors.delete(ruleId); -+ removedRules.add(ruleId); -+ }); -+ } -+ const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; -+ if (changed && !args.frozen) { -+ const file = this.data.get(relativeFilename); -+ if (file) { -+ file.maxErrors = maxErrors; -+ } else { -+ this.data.set(relativeFilename, { -+ maxErrors, -+ lines: [] -+ }); -+ } -+ this.changed = true; -+ } -+ return { removedRules, increasedRulesCount, decreasedRulesCount }; -+ } -+ /** Atomic read -> apply delta -> write. Takes an exclusive file lock when `args.threadsafe`. */ -+ updateFileMaxErrors(args, filename, ruleToErrorCount) { -+ return this.withOptionalLock(args, () => { -+ const before = this.getMaxErrors(filename); -+ const ruleToMaxErrorCountBefore = before ? new Map(before) : void 0; -+ const result = this.updateMaxErrors(filename, args, ruleToErrorCount); -+ if (!args.frozen && !args.readOnly) { -+ this.flushChanges(); -+ } -+ return { ruleToMaxErrorCountBefore, ...result }; -+ }); -+ } -+ /** Drop entries whose source file no longer exists. Takes an exclusive file lock when `args.threadsafe`. */ -+ cleanUpRemovedFiles(args) { -+ return this.withOptionalLock(args, () => { -+ let removedFiles = 0; -+ for (const filename of Array.from(this.filenames())) { -+ if (!fs.existsSync(filename)) { -+ if (this.removeFile(filename, args)) { -+ removedFiles++; -+ } -+ } -+ } -+ if (!args.frozen && !args.readOnly) { -+ this.flushChanges(); -+ } -+ return { removedFiles }; -+ }); -+ } -+ withOptionalLock(args, fn) { -+ if (!args.threadsafe) { -+ return fn(); -+ } -+ const lock = new FileLock(`${this.filename}.lock`); -+ lock.waitLock(LOCK_TIMEOUT_MS); -+ try { -+ this.readSync(); -+ return fn(); -+ } finally { -+ lock.unlock(); -+ } -+ } -+ toDataString() { -+ const lines = []; -+ this.data.forEach((fileState, filename) => { -+ if (fileState.maxErrors) { -+ fileState.lines = []; -+ fileState.maxErrors.forEach((maxErrorCount, ruleId) => { -+ fileState.lines.push({ filename, ruleId, maxErrors: maxErrorCount }); -+ }); -+ fileState.lines.sort( -+ (a, b) => a.ruleId === b.ruleId ? 0 : a.ruleId < b.ruleId ? -1 : 1 -+ ); -+ } -+ fileState.lines.forEach((line) => { -+ const encoded = line.encoded ??= encodeLine(line); -+ lines.push(encoded); -+ }); -+ }); -+ lines.sort(); -+ if (this.comments) { -+ return this.comments + "\n\n" + lines.join(""); -+ } else { -+ return lines.join(""); -+ } -+ } -+ readSync() { -+ const nextStateFile = _SeatbeltFile.openSync(this.filename); -+ if (nextStateFile) { -+ this.data = nextStateFile.data; -+ this.changed = false; -+ return true; -+ } -+ return false; -+ } -+ flushChanges() { -+ if (this.changed) { -+ this.writeSync(); -+ this.changed = false; -+ return { updated: true }; -+ } -+ return { updated: false }; -+ } -+ writeSync() { -+ const dataString = this.toDataString(); -+ const dir = nodePath.dirname(this.filename); -+ const base = nodePath.basename(this.filename); -+ const tempFile = nodePath.join( -+ this.useTempDirForWrites ? os.tmpdir() : dir, -+ `.${base}.wip${process.pid}.${Date.now()}.tmp` -+ ); -+ fs.mkdirSync(dir, { recursive: true }); -+ fs.writeFileSync(tempFile, dataString, "utf8"); -+ try { -+ fs.renameSync(tempFile, this.filename); -+ } catch (error) { -+ if (isErrno(error, "EXDEV")) { -+ this.useTempDirForWrites = false; -+ fs.copyFileSync(tempFile, this.filename); -+ fs.rmSync(tempFile); -+ return; -+ } -+ throw error; -+ } -+ } -+ toJSON() { -+ const data = Object.fromEntries( -+ Array.from(this.data.keys()).map((filename) => { -+ const maxErrors = this.getMaxErrors(filename); -+ if (!maxErrors) { -+ throw new Error(`${name} bug: expected errors for existing key`); -+ } -+ return [filename, Object.fromEntries(maxErrors)]; -+ }) -+ ); -+ return { filename: this.filename, data }; -+ } -+ toRelativePath(filename) { -+ if (!nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.relative(this.dirname, filename); -+ } -+ toAbsolutePath(filename) { -+ if (nodePath.isAbsolute(filename)) { -+ return filename; -+ } -+ return nodePath.resolve(this.dirname, filename); -+ } -+}; -+function parseMaxErrors(lines) { -+ const maxErrors = /* @__PURE__ */ new Map(); -+ lines.forEach((line) => { -+ maxErrors.set(line.ruleId, line.maxErrors); -+ }); -+ return maxErrors; -+} -+ -+export { -+ appendErrorContext, -+ FileLock, -+ SeatbeltFile -+}; -+//# sourceMappingURL=chunk-QGLWIAEE.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-TDWD7IZM.js b/node_modules/eslint-seatbelt/dist/chunk-TDWD7IZM.js -new file mode 100644 -index 0000000..e136721 ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-TDWD7IZM.js -@@ -0,0 +1,453 @@ -+"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b] -+}) : x)(function(x) { -+ if (typeof require !== "undefined") return require.apply(this, arguments); -+ throw Error('Dynamic require of "' + x + '" is not supported'); -+}); -+ -+// package.json -+var name = "eslint-seatbelt"; -+var version = "0.1.3"; -+var package_default = { -+ name, -+ version, -+ description: "Gradually tighten ESLint rules in your codebase", -+ keywords: [ -+ "eslint", -+ "incremental", -+ "gradual", -+ "workflow", -+ "processor", -+ "linting" -+ ], -+ author: { -+ name: "Jake Teton-Landis", -+ url: "https://jake.tl" -+ }, -+ repository: { -+ type: "git", -+ url: "git+https://github.com/justjake/eslint-seatbelt.git" -+ }, -+ bugs: { -+ url: "https://github.com/justjake/eslint-seatbelt/issues" -+ }, -+ scripts: { -+ build: "./scripts/make-json-schemas.ts && tsc && tsup", -+ test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -+ lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -+ }, -+ types: "dist/index.d.ts", -+ import: "./dist/index.mjs", -+ main: "dist/index.js", -+ exports: { -+ ".": { -+ types: "./dist/index.d.ts", -+ import: "./dist/index.mjs", -+ default: "./dist/index.js" -+ }, -+ "./api": { -+ types: "./dist/api.d.ts", -+ import: "./dist/api.mjs", -+ default: "./dist/api.js" -+ } -+ }, -+ bin: { -+ "eslint-seatbelt": "./dist/command.js" -+ }, -+ files: [ -+ "!*.tsbuildinfo", -+ "!src/**/*.test.ts", -+ "src", -+ "dist" -+ ], -+ license: "MIT", -+ peerDependencies: { -+ "@types/eslint": "*", -+ eslint: "*" -+ }, -+ peerDependenciesMeta: { -+ eslint: { -+ optional: true -+ }, -+ "@types/eslint": { -+ optional: true -+ } -+ }, -+ devDependencies: { -+ "@eslint/compat": "1.2.3", -+ "@eslint/js": "9.15.0", -+ "@types/eslint__js": "8.42.3", -+ "@types/node": "22.9.0", -+ "@typescript-eslint/rule-tester": "8.14.0", -+ "@typescript-eslint/utils": "8.14.0", -+ eslint: "9.14.0", -+ prettier: "3.3.3", -+ tsup: "8.3.5", -+ tsx: "4.19.2", -+ typescript: "5.6.3", -+ "typescript-eslint": "8.14.0", -+ "typescript-json-schema": "0.65.1" -+ }, -+ dependencies: { -+ "ts-command-line-args": "^2.5.1" -+ }, -+ packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" -+}; -+ -+// src/SeatbeltConfig.ts -+var _path = require('path'); var _path2 = _interopRequireDefault(_path); -+var _worker_threads = require('worker_threads'); -+ -+// src/repoIntegration.ts -+var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs); -+ -+function findAncestorDirectory(path2, predicate) { -+ let lastPath = void 0; -+ while (path2 !== lastPath) { -+ if (predicate(path2)) { -+ return path2; -+ } -+ lastPath = path2; -+ path2 = _path2.default.dirname(path2); -+ } -+} -+function isGitRoot(dir) { -+ return _fs2.default.existsSync(_path2.default.join(dir, ".git")); -+} -+function findRepoRoot(path2) { -+ return findAncestorDirectory(path2, isGitRoot); -+} -+ -+// src/SeatbeltConfig.ts -+var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; -+var SEATBELT_FROZEN = "SEATBELT_FROZEN"; -+var SEATBELT_INCREASE = "SEATBELT_INCREASE"; -+var SEATBELT_KEEP = "SEATBELT_KEEP"; -+var SEATBELT_FILE = "SEATBELT_FILE"; -+var SEATBELT_PWD = "SEATBELT_PWD"; -+var SEATBELT_DISABLE = "SEATBELT_DISABLE"; -+var SEATBELT_READ_ONLY = "SEATBELT_READ_ONLY"; -+var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; -+var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; -+var SEATBELT_QUIET = "SEATBELT_QUIET"; -+var SEATBELT_ROOT = "SEATBELT_ROOT"; -+var ENV_VARS = { -+ SEATBELT_FROZEN, -+ SEATBELT_INCREASE, -+ SEATBELT_KEEP, -+ SEATBELT_FILE, -+ SEATBELT_PWD, -+ SEATBELT_DISABLE, -+ SEATBELT_READ_ONLY, -+ SEATBELT_THREADSAFE, -+ SEATBELT_VERBOSE, -+ SEATBELT_QUIET, -+ SEATBELT_ROOT, -+ CI: "CI", -+ JEST_WORKER_ID: "JEST_WORKER_ID" -+}; -+var SeatbeltConfig = { -+ withEnvOverrides(config, env) { -+ return { -+ ...SeatbeltConfig.fromFallbackEnv(env), -+ ...config, -+ ...SeatbeltConfig.fromEnvOverrides(env) -+ }; -+ }, -+ fromFallbackEnv(env, log) { -+ const config = {}; -+ const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -+ if (isCI) { -+ config.frozen = true; -+ _optionalChain([log, 'optionalCall', _ => _(`${padVarName("CI")} config.frozen defaults to`, true)]); -+ } -+ if (env.JEST_WORKER_ID) { -+ config.threadsafe = true; -+ _optionalChain([log, 'optionalCall', _2 => _2( -+ `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -+ true -+ )]); -+ } -+ if (!_worker_threads.isMainThread) { -+ config.threadsafe = true; -+ _optionalChain([log, 'optionalCall', _3 => _3( -+ `${padVarName("worker_threads")} config.threadsafe defaults to`, -+ true -+ )]); -+ } -+ return config; -+ }, -+ fromEnvOverrides(env, log) { -+ const config = { -+ pwd: env[SEATBELT_PWD] || process.cwd() -+ }; -+ const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -+ if (verbose !== void 0) { -+ config.verbose = verbose; -+ _optionalChain([log, 'optionalCall', _4 => _4(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose)]); -+ } -+ const seatbeltFile = env[SEATBELT_FILE]; -+ if (seatbeltFile) { -+ const rootRelative = _path2.default.isAbsolute(seatbeltFile) ? seatbeltFile : _path2.default.join(config.pwd, seatbeltFile); -+ config.seatbeltFile = rootRelative; -+ _optionalChain([log, 'optionalCall', _5 => _5(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative)]); -+ } -+ const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -+ if (disable !== void 0) { -+ config.disable = disable; -+ _optionalChain([log, 'optionalCall', _6 => _6(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable)]); -+ } -+ const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -+ if (frozen !== void 0) { -+ config.frozen = frozen; -+ _optionalChain([log, 'optionalCall', _7 => _7(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen)]); -+ } -+ const readOnly = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_READ_ONLY]); -+ if (readOnly !== void 0) { -+ config.readOnly = readOnly; -+ _optionalChain([log, 'optionalCall', _8 => _8(`${padVarName(SEATBELT_READ_ONLY)} config.readOnly =`, readOnly)]); -+ } -+ const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -+ if (increase !== void 0) { -+ config.allowIncreaseRules = increase; -+ _optionalChain([log, 'optionalCall', _9 => _9( -+ `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -+ increase -+ )]); -+ config.readOnly = false; -+ _optionalChain([log, 'optionalCall', _10 => _10( -+ `${padVarName(SEATBELT_INCREASE)} overrides config.readOnly =`, -+ false -+ )]); -+ } -+ const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -+ if (keep !== void 0) { -+ config.keepRules = keep; -+ _optionalChain([log, 'optionalCall', _11 => _11(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep)]); -+ } -+ const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -+ if (threadsafe !== void 0) { -+ config.threadsafe = threadsafe; -+ _optionalChain([log, 'optionalCall', _12 => _12( -+ `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -+ threadsafe -+ )]); -+ } -+ const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -+ if (quiet !== void 0) { -+ config.quiet = quiet; -+ _optionalChain([log, 'optionalCall', _13 => _13(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet)]); -+ } -+ const root = env[SEATBELT_ROOT]; -+ if (root) { -+ config.root = root; -+ _optionalChain([log, 'optionalCall', _14 => _14(`${padVarName(SEATBELT_ROOT)} config.root =`, root)]); -+ } -+ return config; -+ } -+}; -+var SeatbeltEnv = { -+ parseRuleSetEnvVar(value) { -+ if (value === void 0) { -+ return void 0; -+ } -+ if (!value) { -+ return []; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "all" || lower === "1" || lower === "true") { -+ return "all"; -+ } -+ return value.split(/[\s,]+/g).filter(Boolean); -+ }, -+ readBooleanEnvVar(value) { -+ if (value === void 0 || value === "") { -+ return void 0; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "false" || lower === "0" || lower === "no") { -+ return false; -+ } -+ return Boolean(value); -+ } -+}; -+var logStdout = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.log(`[${name}]:`, ...message) -+); -+var logStderr = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.error(`[${name}]:`, ...message) -+); -+var SeatbeltArgs = { -+ fromConfig(config) { -+ const cwd = _nullishCoalesce(config.pwd, () => ( process.cwd())); -+ const seatbeltFile = _nullishCoalesce(config.seatbeltFile, () => ( SeatbeltArgs.findSeatbeltFile(cwd))); -+ const root = _nullishCoalesce(_nullishCoalesce(config.root, () => ( findRepoRoot(seatbeltFile))), () => ( _path2.default.dirname(seatbeltFile))); -+ return { -+ seatbeltFile, -+ root, -+ keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(_nullishCoalesce(config.keepRules, () => ( []))), -+ allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(_nullishCoalesce(config.allowIncreaseRules, () => ( []))), -+ frozen: _nullishCoalesce(config.frozen, () => ( false)), -+ disable: _nullishCoalesce(config.disable, () => ( false)), -+ readOnly: _nullishCoalesce(config.readOnly, () => ( false)), -+ quiet: _nullishCoalesce(config.quiet, () => ( false)), -+ threadsafe: _nullishCoalesce(config.threadsafe, () => ( false)), -+ verbose: _nullishCoalesce(config.verbose, () => ( false)) -+ }; -+ }, -+ getLogger(args) { -+ if (typeof args.verbose === "function") { -+ return args.verbose; -+ } -+ if (args.verbose === "stdout") { -+ return logStdout; -+ } -+ return logStderr; -+ }, -+ ruleSetHas(ruleSet, ruleId) { -+ return ruleSet === "all" || ruleSet.has(ruleId); -+ }, -+ verboseLog(args, makeMessage) { -+ if (args.verbose) { -+ const message = makeMessage(); -+ const log = SeatbeltArgs.getLogger(args); -+ if (typeof message === "string") { -+ log(message); -+ } else { -+ log(...message); -+ } -+ } -+ }, -+ findSeatbeltFile(cwd) { -+ return `${cwd}/${SEATBELT_FILE_NAME}`; -+ } -+}; -+var envVarMaxLength = 0; -+function padVarName(name2) { -+ envVarMaxLength ||= Math.max( -+ ...Object.values(ENV_VARS).map((name3) => name3.length) -+ ); -+ return `${name2}:`.padEnd(envVarMaxLength + 1); -+} -+function formatFilename(filename) { -+ const relative = _path2.default.relative( -+ _nullishCoalesce(process.env[SEATBELT_PWD], () => ( process.cwd())), -+ filename -+ ); -+ return relative ? relative : filename; -+} -+function formatRuleId(ruleId) { -+ if (ruleId === null) { -+ return `unknown rule`; -+ } -+ return `rule ${ruleId}`; -+} -+ -+// src/jsonSchema/SeatbeltConfigSchema.ts -+var SeatbeltConfigSchema = { -+ description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -+ type: "object", -+ properties: { -+ seatbeltFile: { -+ description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -+ type: "string" -+ }, -+ keepRules: { -+ description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ allowIncreaseRules: { -+ description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ frozen: { -+ description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ disable: { -+ description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ readOnly: { -+ description: "When `true`, seatbelt validates error counts (still reporting increases)\nbut never writes the seatbelt file. Keeps the worktree clean in local /\neditor runs; expect an authoritative updater (e.g. post-merge CI) to run\nwith `readOnly: false`.\n\nUnlike `frozen`, does not turn decreases into errors. If both are set,\n`frozen` messaging is preserved and no write occurs.\n\n`SEATBELT_INCREASE` overrides this so intentional loosening is persisted.\n\nDefaults to `false`.\n\nSet via `SEATBELT_READ_ONLY` env var:\n\n```bash\nSEATBELT_READ_ONLY=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n readOnly: !process.env.CI,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ quiet: { -+ description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -+ type: "boolean" -+ }, -+ threadsafe: { -+ description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set)\nor inside a Node `worker_threads` worker (e.g. ESLint `--concurrency`).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ verbose: { -+ description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -+ anyOf: [ -+ { -+ enum: [false, "stderr", "stdout", true] -+ }, -+ { -+ type: "object" -+ } -+ ] -+ }, -+ root: { -+ description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -+ type: "string" -+ } -+ }, -+ $schema: "http://json-schema.org/draft-07/schema#" -+}; -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+exports.__require = __require; exports.name = name; exports.version = version; exports.package_default = package_default; exports.SEATBELT_FILE_NAME = SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = SEATBELT_FROZEN; exports.SEATBELT_INCREASE = SEATBELT_INCREASE; exports.SEATBELT_KEEP = SEATBELT_KEEP; exports.SEATBELT_FILE = SEATBELT_FILE; exports.SEATBELT_PWD = SEATBELT_PWD; exports.SEATBELT_DISABLE = SEATBELT_DISABLE; exports.SEATBELT_READ_ONLY = SEATBELT_READ_ONLY; exports.SEATBELT_THREADSAFE = SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = SEATBELT_VERBOSE; exports.SEATBELT_QUIET = SEATBELT_QUIET; exports.SEATBELT_ROOT = SEATBELT_ROOT; exports.SeatbeltConfig = SeatbeltConfig; exports.SeatbeltEnv = SeatbeltEnv; exports.logStdout = logStdout; exports.logStderr = logStderr; exports.SeatbeltArgs = SeatbeltArgs; exports.padVarName = padVarName; exports.formatFilename = formatFilename; exports.formatRuleId = formatRuleId; exports.SeatbeltConfigSchema = SeatbeltConfigSchema; -+//# sourceMappingURL=chunk-TDWD7IZM.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-U6KIQG2A.mjs b/node_modules/eslint-seatbelt/dist/chunk-U6KIQG2A.mjs -new file mode 100644 -index 0000000..cffef9f ---- /dev/null -+++ b/node_modules/eslint-seatbelt/dist/chunk-U6KIQG2A.mjs -@@ -0,0 +1,453 @@ -+var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b] -+}) : x)(function(x) { -+ if (typeof require !== "undefined") return require.apply(this, arguments); -+ throw Error('Dynamic require of "' + x + '" is not supported'); -+}); -+ -+// package.json -+var name = "eslint-seatbelt"; -+var version = "0.1.3"; -+var package_default = { -+ name, -+ version, -+ description: "Gradually tighten ESLint rules in your codebase", -+ keywords: [ -+ "eslint", -+ "incremental", -+ "gradual", -+ "workflow", -+ "processor", -+ "linting" -+ ], -+ author: { -+ name: "Jake Teton-Landis", -+ url: "https://jake.tl" -+ }, -+ repository: { -+ type: "git", -+ url: "git+https://github.com/justjake/eslint-seatbelt.git" -+ }, -+ bugs: { -+ url: "https://github.com/justjake/eslint-seatbelt/issues" -+ }, -+ scripts: { -+ build: "./scripts/make-json-schemas.ts && tsc && tsup", -+ test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -+ lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -+ }, -+ types: "dist/index.d.ts", -+ import: "./dist/index.mjs", -+ main: "dist/index.js", -+ exports: { -+ ".": { -+ types: "./dist/index.d.ts", -+ import: "./dist/index.mjs", -+ default: "./dist/index.js" -+ }, -+ "./api": { -+ types: "./dist/api.d.ts", -+ import: "./dist/api.mjs", -+ default: "./dist/api.js" -+ } -+ }, -+ bin: { -+ "eslint-seatbelt": "./dist/command.js" -+ }, -+ files: [ -+ "!*.tsbuildinfo", -+ "!src/**/*.test.ts", -+ "src", -+ "dist" -+ ], -+ license: "MIT", -+ peerDependencies: { -+ "@types/eslint": "*", -+ eslint: "*" -+ }, -+ peerDependenciesMeta: { -+ eslint: { -+ optional: true -+ }, -+ "@types/eslint": { -+ optional: true -+ } -+ }, -+ devDependencies: { -+ "@eslint/compat": "1.2.3", -+ "@eslint/js": "9.15.0", -+ "@types/eslint__js": "8.42.3", -+ "@types/node": "22.9.0", -+ "@typescript-eslint/rule-tester": "8.14.0", -+ "@typescript-eslint/utils": "8.14.0", -+ eslint: "9.14.0", -+ prettier: "3.3.3", -+ tsup: "8.3.5", -+ tsx: "4.19.2", -+ typescript: "5.6.3", -+ "typescript-eslint": "8.14.0", -+ "typescript-json-schema": "0.65.1" -+ }, -+ dependencies: { -+ "ts-command-line-args": "^2.5.1" -+ }, -+ packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" -+}; -+ -+// src/SeatbeltConfig.ts -+import path from "node:path"; -+import { isMainThread } from "node:worker_threads"; -+ -+// src/repoIntegration.ts -+import fs from "node:fs"; -+import nodePath from "node:path"; -+function findAncestorDirectory(path2, predicate) { -+ let lastPath = void 0; -+ while (path2 !== lastPath) { -+ if (predicate(path2)) { -+ return path2; -+ } -+ lastPath = path2; -+ path2 = nodePath.dirname(path2); -+ } -+} -+function isGitRoot(dir) { -+ return fs.existsSync(nodePath.join(dir, ".git")); -+} -+function findRepoRoot(path2) { -+ return findAncestorDirectory(path2, isGitRoot); -+} -+ -+// src/SeatbeltConfig.ts -+var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; -+var SEATBELT_FROZEN = "SEATBELT_FROZEN"; -+var SEATBELT_INCREASE = "SEATBELT_INCREASE"; -+var SEATBELT_KEEP = "SEATBELT_KEEP"; -+var SEATBELT_FILE = "SEATBELT_FILE"; -+var SEATBELT_PWD = "SEATBELT_PWD"; -+var SEATBELT_DISABLE = "SEATBELT_DISABLE"; -+var SEATBELT_READ_ONLY = "SEATBELT_READ_ONLY"; -+var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; -+var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; -+var SEATBELT_QUIET = "SEATBELT_QUIET"; -+var SEATBELT_ROOT = "SEATBELT_ROOT"; -+var ENV_VARS = { -+ SEATBELT_FROZEN, -+ SEATBELT_INCREASE, -+ SEATBELT_KEEP, -+ SEATBELT_FILE, -+ SEATBELT_PWD, -+ SEATBELT_DISABLE, -+ SEATBELT_READ_ONLY, -+ SEATBELT_THREADSAFE, -+ SEATBELT_VERBOSE, -+ SEATBELT_QUIET, -+ SEATBELT_ROOT, -+ CI: "CI", -+ JEST_WORKER_ID: "JEST_WORKER_ID" -+}; -+var SeatbeltConfig = { -+ withEnvOverrides(config, env) { -+ return { -+ ...SeatbeltConfig.fromFallbackEnv(env), -+ ...config, -+ ...SeatbeltConfig.fromEnvOverrides(env) -+ }; -+ }, -+ fromFallbackEnv(env, log) { -+ const config = {}; -+ const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -+ if (isCI) { -+ config.frozen = true; -+ log?.(`${padVarName("CI")} config.frozen defaults to`, true); -+ } -+ if (env.JEST_WORKER_ID) { -+ config.threadsafe = true; -+ log?.( -+ `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -+ true -+ ); -+ } -+ if (!isMainThread) { -+ config.threadsafe = true; -+ log?.( -+ `${padVarName("worker_threads")} config.threadsafe defaults to`, -+ true -+ ); -+ } -+ return config; -+ }, -+ fromEnvOverrides(env, log) { -+ const config = { -+ pwd: env[SEATBELT_PWD] || process.cwd() -+ }; -+ const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -+ if (verbose !== void 0) { -+ config.verbose = verbose; -+ log?.(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose); -+ } -+ const seatbeltFile = env[SEATBELT_FILE]; -+ if (seatbeltFile) { -+ const rootRelative = path.isAbsolute(seatbeltFile) ? seatbeltFile : path.join(config.pwd, seatbeltFile); -+ config.seatbeltFile = rootRelative; -+ log?.(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative); -+ } -+ const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -+ if (disable !== void 0) { -+ config.disable = disable; -+ log?.(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable); -+ } -+ const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -+ if (frozen !== void 0) { -+ config.frozen = frozen; -+ log?.(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen); -+ } -+ const readOnly = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_READ_ONLY]); -+ if (readOnly !== void 0) { -+ config.readOnly = readOnly; -+ log?.(`${padVarName(SEATBELT_READ_ONLY)} config.readOnly =`, readOnly); -+ } -+ const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -+ if (increase !== void 0) { -+ config.allowIncreaseRules = increase; -+ log?.( -+ `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -+ increase -+ ); -+ config.readOnly = false; -+ log?.( -+ `${padVarName(SEATBELT_INCREASE)} overrides config.readOnly =`, -+ false -+ ); -+ } -+ const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -+ if (keep !== void 0) { -+ config.keepRules = keep; -+ log?.(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep); -+ } -+ const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -+ if (threadsafe !== void 0) { -+ config.threadsafe = threadsafe; -+ log?.( -+ `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -+ threadsafe -+ ); -+ } -+ const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -+ if (quiet !== void 0) { -+ config.quiet = quiet; -+ log?.(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet); -+ } -+ const root = env[SEATBELT_ROOT]; -+ if (root) { -+ config.root = root; -+ log?.(`${padVarName(SEATBELT_ROOT)} config.root =`, root); -+ } -+ return config; -+ } -+}; -+var SeatbeltEnv = { -+ parseRuleSetEnvVar(value) { -+ if (value === void 0) { -+ return void 0; -+ } -+ if (!value) { -+ return []; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "all" || lower === "1" || lower === "true") { -+ return "all"; -+ } -+ return value.split(/[\s,]+/g).filter(Boolean); -+ }, -+ readBooleanEnvVar(value) { -+ if (value === void 0 || value === "") { -+ return void 0; -+ } -+ const lower = value.toLowerCase(); -+ if (lower === "false" || lower === "0" || lower === "no") { -+ return false; -+ } -+ return Boolean(value); -+ } -+}; -+var logStdout = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.log(`[${name}]:`, ...message) -+); -+var logStderr = (...message) => ( -+ // eslint-disable-next-line no-console -+ console.error(`[${name}]:`, ...message) -+); -+var SeatbeltArgs = { -+ fromConfig(config) { -+ const cwd = config.pwd ?? process.cwd(); -+ const seatbeltFile = config.seatbeltFile ?? SeatbeltArgs.findSeatbeltFile(cwd); -+ const root = config.root ?? findRepoRoot(seatbeltFile) ?? path.dirname(seatbeltFile); -+ return { -+ seatbeltFile, -+ root, -+ keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(config.keepRules ?? []), -+ allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(config.allowIncreaseRules ?? []), -+ frozen: config.frozen ?? false, -+ disable: config.disable ?? false, -+ readOnly: config.readOnly ?? false, -+ quiet: config.quiet ?? false, -+ threadsafe: config.threadsafe ?? false, -+ verbose: config.verbose ?? false -+ }; -+ }, -+ getLogger(args) { -+ if (typeof args.verbose === "function") { -+ return args.verbose; -+ } -+ if (args.verbose === "stdout") { -+ return logStdout; -+ } -+ return logStderr; -+ }, -+ ruleSetHas(ruleSet, ruleId) { -+ return ruleSet === "all" || ruleSet.has(ruleId); -+ }, -+ verboseLog(args, makeMessage) { -+ if (args.verbose) { -+ const message = makeMessage(); -+ const log = SeatbeltArgs.getLogger(args); -+ if (typeof message === "string") { -+ log(message); -+ } else { -+ log(...message); -+ } -+ } -+ }, -+ findSeatbeltFile(cwd) { -+ return `${cwd}/${SEATBELT_FILE_NAME}`; -+ } -+}; -+var envVarMaxLength = 0; -+function padVarName(name2) { -+ envVarMaxLength ||= Math.max( -+ ...Object.values(ENV_VARS).map((name3) => name3.length) -+ ); -+ return `${name2}:`.padEnd(envVarMaxLength + 1); -+} -+function formatFilename(filename) { -+ const relative = path.relative( -+ process.env[SEATBELT_PWD] ?? process.cwd(), -+ filename -+ ); -+ return relative ? relative : filename; -+} -+function formatRuleId(ruleId) { -+ if (ruleId === null) { -+ return `unknown rule`; -+ } -+ return `rule ${ruleId}`; -+} -+ -+// src/jsonSchema/SeatbeltConfigSchema.ts -+var SeatbeltConfigSchema = { -+ description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -+ type: "object", -+ properties: { -+ seatbeltFile: { -+ description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -+ type: "string" -+ }, -+ keepRules: { -+ description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ allowIncreaseRules: { -+ description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -+ anyOf: [ -+ { -+ type: "array", -+ items: { -+ type: "string" -+ } -+ }, -+ { -+ const: "all", -+ type: "string" -+ } -+ ] -+ }, -+ frozen: { -+ description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ disable: { -+ description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ readOnly: { -+ description: "When `true`, seatbelt validates error counts (still reporting increases)\nbut never writes the seatbelt file. Keeps the worktree clean in local /\neditor runs; expect an authoritative updater (e.g. post-merge CI) to run\nwith `readOnly: false`.\n\nUnlike `frozen`, does not turn decreases into errors. If both are set,\n`frozen` messaging is preserved and no write occurs.\n\n`SEATBELT_INCREASE` overrides this so intentional loosening is persisted.\n\nDefaults to `false`.\n\nSet via `SEATBELT_READ_ONLY` env var:\n\n```bash\nSEATBELT_READ_ONLY=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n readOnly: !process.env.CI,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ quiet: { -+ description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -+ type: "boolean" -+ }, -+ threadsafe: { -+ description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set)\nor inside a Node `worker_threads` worker (e.g. ESLint `--concurrency`).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -+ type: "boolean" -+ }, -+ verbose: { -+ description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -+ anyOf: [ -+ { -+ enum: [false, "stderr", "stdout", true] -+ }, -+ { -+ type: "object" -+ } -+ ] -+ }, -+ root: { -+ description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -+ type: "string" -+ } -+ }, -+ $schema: "http://json-schema.org/draft-07/schema#" -+}; -+ -+export { -+ __require, -+ name, -+ version, -+ package_default, -+ SEATBELT_FILE_NAME, -+ SEATBELT_FROZEN, -+ SEATBELT_INCREASE, -+ SEATBELT_KEEP, -+ SEATBELT_FILE, -+ SEATBELT_PWD, -+ SEATBELT_DISABLE, -+ SEATBELT_READ_ONLY, -+ SEATBELT_THREADSAFE, -+ SEATBELT_VERBOSE, -+ SEATBELT_QUIET, -+ SEATBELT_ROOT, -+ SeatbeltConfig, -+ SeatbeltEnv, -+ logStdout, -+ logStderr, -+ SeatbeltArgs, -+ padVarName, -+ formatFilename, -+ formatRuleId, -+ SeatbeltConfigSchema -+}; -+//# sourceMappingURL=chunk-U6KIQG2A.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-ULACHCKT.mjs b/node_modules/eslint-seatbelt/dist/chunk-ULACHCKT.mjs -deleted file mode 100644 -index b90ad17..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-ULACHCKT.mjs -+++ /dev/null -@@ -1,435 +0,0 @@ --var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -- get: (a, b) => (typeof require !== "undefined" ? require : a)[b] --}) : x)(function(x) { -- if (typeof require !== "undefined") return require.apply(this, arguments); -- throw Error('Dynamic require of "' + x + '" is not supported'); --}); -- --// package.json --var name = "eslint-seatbelt"; --var version = "0.1.3"; --var package_default = { -- name, -- version, -- description: "Gradually tighten ESLint rules in your codebase", -- keywords: [ -- "eslint", -- "incremental", -- "gradual", -- "workflow", -- "processor", -- "linting" -- ], -- author: { -- name: "Jake Teton-Landis", -- url: "https://jake.tl" -- }, -- repository: { -- type: "git", -- url: "git+https://github.com/justjake/eslint-seatbelt.git" -- }, -- bugs: { -- url: "https://github.com/justjake/eslint-seatbelt/issues" -- }, -- scripts: { -- build: "./scripts/make-json-schemas.ts && tsc && tsup", -- test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -- lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -- }, -- types: "dist/index.d.ts", -- import: "./dist/index.mjs", -- main: "dist/index.js", -- exports: { -- ".": { -- types: "./dist/index.d.ts", -- import: "./dist/index.mjs", -- default: "./dist/index.js" -- }, -- "./api": { -- types: "./dist/api.d.ts", -- import: "./dist/api.mjs", -- default: "./dist/api.js" -- } -- }, -- bin: { -- "eslint-seatbelt": "./dist/command.js" -- }, -- files: [ -- "!*.tsbuildinfo", -- "!src/**/*.test.ts", -- "src", -- "dist" -- ], -- license: "MIT", -- peerDependencies: { -- "@types/eslint": "*", -- eslint: "*" -- }, -- peerDependenciesMeta: { -- eslint: { -- optional: true -- }, -- "@types/eslint": { -- optional: true -- } -- }, -- devDependencies: { -- "@eslint/compat": "1.2.3", -- "@eslint/js": "9.15.0", -- "@types/eslint__js": "8.42.3", -- "@types/node": "22.9.0", -- "@typescript-eslint/rule-tester": "8.14.0", -- "@typescript-eslint/utils": "8.14.0", -- eslint: "9.14.0", -- prettier: "3.3.3", -- tsup: "8.3.5", -- tsx: "4.19.2", -- typescript: "5.6.3", -- "typescript-eslint": "8.14.0", -- "typescript-json-schema": "0.65.1" -- }, -- dependencies: { -- "ts-command-line-args": "^2.5.1" -- }, -- packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" --}; -- --// src/SeatbeltConfig.ts --import path from "node:path"; --import { isMainThread } from "node:worker_threads"; -- --// src/repoIntegration.ts --import fs from "node:fs"; --import nodePath from "node:path"; --function findAncestorDirectory(path2, predicate) { -- let lastPath = void 0; -- while (path2 !== lastPath) { -- if (predicate(path2)) { -- return path2; -- } -- lastPath = path2; -- path2 = nodePath.dirname(path2); -- } --} --function isGitRoot(dir) { -- return fs.existsSync(nodePath.join(dir, ".git")); --} --function findRepoRoot(path2) { -- return findAncestorDirectory(path2, isGitRoot); --} -- --// src/SeatbeltConfig.ts --var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; --var SEATBELT_FROZEN = "SEATBELT_FROZEN"; --var SEATBELT_INCREASE = "SEATBELT_INCREASE"; --var SEATBELT_KEEP = "SEATBELT_KEEP"; --var SEATBELT_FILE = "SEATBELT_FILE"; --var SEATBELT_PWD = "SEATBELT_PWD"; --var SEATBELT_DISABLE = "SEATBELT_DISABLE"; --var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; --var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; --var SEATBELT_QUIET = "SEATBELT_QUIET"; --var SEATBELT_ROOT = "SEATBELT_ROOT"; --var ENV_VARS = { -- SEATBELT_FROZEN, -- SEATBELT_INCREASE, -- SEATBELT_KEEP, -- SEATBELT_FILE, -- SEATBELT_PWD, -- SEATBELT_DISABLE, -- SEATBELT_THREADSAFE, -- SEATBELT_VERBOSE, -- SEATBELT_QUIET, -- SEATBELT_ROOT, -- CI: "CI", -- JEST_WORKER_ID: "JEST_WORKER_ID" --}; --var SeatbeltConfig = { -- withEnvOverrides(config, env) { -- return { -- ...SeatbeltConfig.fromFallbackEnv(env), -- ...config, -- ...SeatbeltConfig.fromEnvOverrides(env) -- }; -- }, -- fromFallbackEnv(env, log) { -- const config = {}; -- const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -- if (isCI) { -- config.frozen = true; -- log?.(`${padVarName("CI")} config.frozen defaults to`, true); -- } -- if (env.JEST_WORKER_ID) { -- config.threadsafe = true; -- log?.( -- `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -- true -- ); -- } -- if (!isMainThread) { -- config.threadsafe = true; -- log?.( -- `${padVarName("worker_threads")} config.threadsafe defaults to`, -- true -- ); -- } -- return config; -- }, -- fromEnvOverrides(env, log) { -- const config = { -- pwd: env[SEATBELT_PWD] || process.cwd() -- }; -- const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -- if (verbose !== void 0) { -- config.verbose = verbose; -- log?.(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose); -- } -- const seatbeltFile = env[SEATBELT_FILE]; -- if (seatbeltFile) { -- const rootRelative = path.isAbsolute(seatbeltFile) ? seatbeltFile : path.join(config.pwd, seatbeltFile); -- config.seatbeltFile = rootRelative; -- log?.(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative); -- } -- const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -- if (disable !== void 0) { -- config.disable = disable; -- log?.(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable); -- } -- const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -- if (frozen !== void 0) { -- config.frozen = frozen; -- log?.(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen); -- } -- const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -- if (increase !== void 0) { -- config.allowIncreaseRules = increase; -- log?.( -- `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -- increase -- ); -- } -- const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -- if (keep !== void 0) { -- config.keepRules = keep; -- log?.(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep); -- } -- const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -- if (threadsafe !== void 0) { -- config.threadsafe = threadsafe; -- log?.( -- `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -- threadsafe -- ); -- } -- const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -- if (quiet !== void 0) { -- config.quiet = quiet; -- log?.(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet); -- } -- const root = env[SEATBELT_ROOT]; -- if (root) { -- config.root = root; -- log?.(`${padVarName(SEATBELT_ROOT)} config.root =`, root); -- } -- return config; -- } --}; --var SeatbeltEnv = { -- parseRuleSetEnvVar(value) { -- if (value === void 0) { -- return void 0; -- } -- if (!value) { -- return []; -- } -- const lower = value.toLowerCase(); -- if (lower === "all" || lower === "1" || lower === "true") { -- return "all"; -- } -- return value.split(/[\s,]+/g).filter(Boolean); -- }, -- readBooleanEnvVar(value) { -- if (value === void 0 || value === "") { -- return void 0; -- } -- const lower = value.toLowerCase(); -- if (lower === "false" || lower === "0" || lower === "no") { -- return false; -- } -- return Boolean(value); -- } --}; --var logStdout = (...message) => ( -- // eslint-disable-next-line no-console -- console.log(`[${name}]:`, ...message) --); --var logStderr = (...message) => ( -- // eslint-disable-next-line no-console -- console.error(`[${name}]:`, ...message) --); --var SeatbeltArgs = { -- fromConfig(config) { -- const cwd = config.pwd ?? process.cwd(); -- const seatbeltFile = config.seatbeltFile ?? SeatbeltArgs.findSeatbeltFile(cwd); -- const root = config.root ?? findRepoRoot(seatbeltFile) ?? path.dirname(seatbeltFile); -- return { -- seatbeltFile, -- root, -- keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(config.keepRules ?? []), -- allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(config.allowIncreaseRules ?? []), -- frozen: config.frozen ?? false, -- disable: config.disable ?? false, -- quiet: config.quiet ?? false, -- threadsafe: config.threadsafe ?? false, -- verbose: config.verbose ?? false -- }; -- }, -- getLogger(args) { -- if (typeof args.verbose === "function") { -- return args.verbose; -- } -- if (args.verbose === "stdout") { -- return logStdout; -- } -- return logStderr; -- }, -- ruleSetHas(ruleSet, ruleId) { -- return ruleSet === "all" || ruleSet.has(ruleId); -- }, -- verboseLog(args, makeMessage) { -- if (args.verbose) { -- const message = makeMessage(); -- const log = SeatbeltArgs.getLogger(args); -- if (typeof message === "string") { -- log(message); -- } else { -- log(...message); -- } -- } -- }, -- findSeatbeltFile(cwd) { -- return `${cwd}/${SEATBELT_FILE_NAME}`; -- } --}; --var envVarMaxLength = 0; --function padVarName(name2) { -- envVarMaxLength ||= Math.max( -- ...Object.values(ENV_VARS).map((name3) => name3.length) -- ); -- return `${name2}:`.padEnd(envVarMaxLength + 1); --} --function formatFilename(filename) { -- const relative = path.relative( -- process.env[SEATBELT_PWD] ?? process.cwd(), -- filename -- ); -- return relative ? relative : filename; --} --function formatRuleId(ruleId) { -- if (ruleId === null) { -- return `unknown rule`; -- } -- return `rule ${ruleId}`; --} -- --// src/jsonSchema/SeatbeltConfigSchema.ts --var SeatbeltConfigSchema = { -- description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -- type: "object", -- properties: { -- seatbeltFile: { -- description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -- type: "string" -- }, -- keepRules: { -- description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- allowIncreaseRules: { -- description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- frozen: { -- description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- disable: { -- description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- quiet: { -- description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -- type: "boolean" -- }, -- threadsafe: { -- description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set)\nor inside a Node `worker_threads` worker (e.g. ESLint `--concurrency`).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- verbose: { -- description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -- anyOf: [ -- { -- enum: [false, "stderr", "stdout", true] -- }, -- { -- type: "object" -- } -- ] -- }, -- root: { -- description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -- type: "string" -- } -- }, -- $schema: "http://json-schema.org/draft-07/schema#" --}; -- --export { -- __require, -- name, -- version, -- package_default, -- SEATBELT_FILE_NAME, -- SEATBELT_FROZEN, -- SEATBELT_INCREASE, -- SEATBELT_KEEP, -- SEATBELT_FILE, -- SEATBELT_PWD, -- SEATBELT_DISABLE, -- SEATBELT_THREADSAFE, -- SEATBELT_VERBOSE, -- SEATBELT_QUIET, -- SEATBELT_ROOT, -- SeatbeltConfig, -- SeatbeltEnv, -- logStdout, -- logStderr, -- SeatbeltArgs, -- padVarName, -- formatFilename, -- formatRuleId, -- SeatbeltConfigSchema --}; --//# sourceMappingURL=chunk-ULACHCKT.mjs.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/chunk-ZVY5S6JS.js b/node_modules/eslint-seatbelt/dist/chunk-ZVY5S6JS.js -deleted file mode 100644 -index ee27681..0000000 ---- a/node_modules/eslint-seatbelt/dist/chunk-ZVY5S6JS.js -+++ /dev/null -@@ -1,435 +0,0 @@ --"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { -- get: (a, b) => (typeof require !== "undefined" ? require : a)[b] --}) : x)(function(x) { -- if (typeof require !== "undefined") return require.apply(this, arguments); -- throw Error('Dynamic require of "' + x + '" is not supported'); --}); -- --// package.json --var name = "eslint-seatbelt"; --var version = "0.1.3"; --var package_default = { -- name, -- version, -- description: "Gradually tighten ESLint rules in your codebase", -- keywords: [ -- "eslint", -- "incremental", -- "gradual", -- "workflow", -- "processor", -- "linting" -- ], -- author: { -- name: "Jake Teton-Landis", -- url: "https://jake.tl" -- }, -- repository: { -- type: "git", -- url: "git+https://github.com/justjake/eslint-seatbelt.git" -- }, -- bugs: { -- url: "https://github.com/justjake/eslint-seatbelt/issues" -- }, -- scripts: { -- build: "./scripts/make-json-schemas.ts && tsc && tsup", -- test: "node --test --require tsx/cjs $(find src -name '*.test.ts')", -- lint: "pnpm build && NODE_OPTIONS='--enable-source-maps' eslint ." -- }, -- types: "dist/index.d.ts", -- import: "./dist/index.mjs", -- main: "dist/index.js", -- exports: { -- ".": { -- types: "./dist/index.d.ts", -- import: "./dist/index.mjs", -- default: "./dist/index.js" -- }, -- "./api": { -- types: "./dist/api.d.ts", -- import: "./dist/api.mjs", -- default: "./dist/api.js" -- } -- }, -- bin: { -- "eslint-seatbelt": "./dist/command.js" -- }, -- files: [ -- "!*.tsbuildinfo", -- "!src/**/*.test.ts", -- "src", -- "dist" -- ], -- license: "MIT", -- peerDependencies: { -- "@types/eslint": "*", -- eslint: "*" -- }, -- peerDependenciesMeta: { -- eslint: { -- optional: true -- }, -- "@types/eslint": { -- optional: true -- } -- }, -- devDependencies: { -- "@eslint/compat": "1.2.3", -- "@eslint/js": "9.15.0", -- "@types/eslint__js": "8.42.3", -- "@types/node": "22.9.0", -- "@typescript-eslint/rule-tester": "8.14.0", -- "@typescript-eslint/utils": "8.14.0", -- eslint: "9.14.0", -- prettier: "3.3.3", -- tsup: "8.3.5", -- tsx: "4.19.2", -- typescript: "5.6.3", -- "typescript-eslint": "8.14.0", -- "typescript-json-schema": "0.65.1" -- }, -- dependencies: { -- "ts-command-line-args": "^2.5.1" -- }, -- packageManager: "pnpm@10.2.1+sha1.48adf39a4ab751eda7b73b99447d1f0b6d227e02" --}; -- --// src/SeatbeltConfig.ts --var _path = require('path'); var _path2 = _interopRequireDefault(_path); --var _worker_threads = require('worker_threads'); -- --// src/repoIntegration.ts --var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs); -- --function findAncestorDirectory(path2, predicate) { -- let lastPath = void 0; -- while (path2 !== lastPath) { -- if (predicate(path2)) { -- return path2; -- } -- lastPath = path2; -- path2 = _path2.default.dirname(path2); -- } --} --function isGitRoot(dir) { -- return _fs2.default.existsSync(_path2.default.join(dir, ".git")); --} --function findRepoRoot(path2) { -- return findAncestorDirectory(path2, isGitRoot); --} -- --// src/SeatbeltConfig.ts --var SEATBELT_FILE_NAME = "eslint.seatbelt.tsv"; --var SEATBELT_FROZEN = "SEATBELT_FROZEN"; --var SEATBELT_INCREASE = "SEATBELT_INCREASE"; --var SEATBELT_KEEP = "SEATBELT_KEEP"; --var SEATBELT_FILE = "SEATBELT_FILE"; --var SEATBELT_PWD = "SEATBELT_PWD"; --var SEATBELT_DISABLE = "SEATBELT_DISABLE"; --var SEATBELT_THREADSAFE = "SEATBELT_THREADSAFE"; --var SEATBELT_VERBOSE = "SEATBELT_VERBOSE"; --var SEATBELT_QUIET = "SEATBELT_QUIET"; --var SEATBELT_ROOT = "SEATBELT_ROOT"; --var ENV_VARS = { -- SEATBELT_FROZEN, -- SEATBELT_INCREASE, -- SEATBELT_KEEP, -- SEATBELT_FILE, -- SEATBELT_PWD, -- SEATBELT_DISABLE, -- SEATBELT_THREADSAFE, -- SEATBELT_VERBOSE, -- SEATBELT_QUIET, -- SEATBELT_ROOT, -- CI: "CI", -- JEST_WORKER_ID: "JEST_WORKER_ID" --}; --var SeatbeltConfig = { -- withEnvOverrides(config, env) { -- return { -- ...SeatbeltConfig.fromFallbackEnv(env), -- ...config, -- ...SeatbeltConfig.fromEnvOverrides(env) -- }; -- }, -- fromFallbackEnv(env, log) { -- const config = {}; -- const isCI = SeatbeltEnv.readBooleanEnvVar(env.CI); -- if (isCI) { -- config.frozen = true; -- _optionalChain([log, 'optionalCall', _ => _(`${padVarName("CI")} config.frozen defaults to`, true)]); -- } -- if (env.JEST_WORKER_ID) { -- config.threadsafe = true; -- _optionalChain([log, 'optionalCall', _2 => _2( -- `${padVarName("JEST_WORKER_ID")} config.threadsafe defaults to`, -- true -- )]); -- } -- if (!_worker_threads.isMainThread) { -- config.threadsafe = true; -- _optionalChain([log, 'optionalCall', _3 => _3( -- `${padVarName("worker_threads")} config.threadsafe defaults to`, -- true -- )]); -- } -- return config; -- }, -- fromEnvOverrides(env, log) { -- const config = { -- pwd: env[SEATBELT_PWD] || process.cwd() -- }; -- const verbose = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_VERBOSE]); -- if (verbose !== void 0) { -- config.verbose = verbose; -- _optionalChain([log, 'optionalCall', _4 => _4(`${padVarName(SEATBELT_VERBOSE)} config.verbose =`, verbose)]); -- } -- const seatbeltFile = env[SEATBELT_FILE]; -- if (seatbeltFile) { -- const rootRelative = _path2.default.isAbsolute(seatbeltFile) ? seatbeltFile : _path2.default.join(config.pwd, seatbeltFile); -- config.seatbeltFile = rootRelative; -- _optionalChain([log, 'optionalCall', _5 => _5(`${padVarName(SEATBELT_FILE)} config.seatbeltFile =`, rootRelative)]); -- } -- const disable = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_DISABLE]); -- if (disable !== void 0) { -- config.disable = disable; -- _optionalChain([log, 'optionalCall', _6 => _6(`${padVarName(SEATBELT_DISABLE)} config.disable =`, disable)]); -- } -- const frozen = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_FROZEN]); -- if (frozen !== void 0) { -- config.frozen = frozen; -- _optionalChain([log, 'optionalCall', _7 => _7(`${padVarName(SEATBELT_FROZEN)} config.frozen =`, frozen)]); -- } -- const increase = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_INCREASE]); -- if (increase !== void 0) { -- config.allowIncreaseRules = increase; -- _optionalChain([log, 'optionalCall', _8 => _8( -- `${padVarName(SEATBELT_INCREASE)} config.allowIncreaseRules =`, -- increase -- )]); -- } -- const keep = SeatbeltEnv.parseRuleSetEnvVar(env[SEATBELT_KEEP]); -- if (keep !== void 0) { -- config.keepRules = keep; -- _optionalChain([log, 'optionalCall', _9 => _9(`${padVarName(SEATBELT_KEEP)} config.keepRules =`, keep)]); -- } -- const threadsafe = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_THREADSAFE]); -- if (threadsafe !== void 0) { -- config.threadsafe = threadsafe; -- _optionalChain([log, 'optionalCall', _10 => _10( -- `${padVarName(SEATBELT_THREADSAFE)} config.threadsafe =`, -- threadsafe -- )]); -- } -- const quiet = SeatbeltEnv.readBooleanEnvVar(env[SEATBELT_QUIET]); -- if (quiet !== void 0) { -- config.quiet = quiet; -- _optionalChain([log, 'optionalCall', _11 => _11(`${padVarName(SEATBELT_QUIET)} config.quiet =`, quiet)]); -- } -- const root = env[SEATBELT_ROOT]; -- if (root) { -- config.root = root; -- _optionalChain([log, 'optionalCall', _12 => _12(`${padVarName(SEATBELT_ROOT)} config.root =`, root)]); -- } -- return config; -- } --}; --var SeatbeltEnv = { -- parseRuleSetEnvVar(value) { -- if (value === void 0) { -- return void 0; -- } -- if (!value) { -- return []; -- } -- const lower = value.toLowerCase(); -- if (lower === "all" || lower === "1" || lower === "true") { -- return "all"; -- } -- return value.split(/[\s,]+/g).filter(Boolean); -- }, -- readBooleanEnvVar(value) { -- if (value === void 0 || value === "") { -- return void 0; -- } -- const lower = value.toLowerCase(); -- if (lower === "false" || lower === "0" || lower === "no") { -- return false; -- } -- return Boolean(value); -- } --}; --var logStdout = (...message) => ( -- // eslint-disable-next-line no-console -- console.log(`[${name}]:`, ...message) --); --var logStderr = (...message) => ( -- // eslint-disable-next-line no-console -- console.error(`[${name}]:`, ...message) --); --var SeatbeltArgs = { -- fromConfig(config) { -- const cwd = _nullishCoalesce(config.pwd, () => ( process.cwd())); -- const seatbeltFile = _nullishCoalesce(config.seatbeltFile, () => ( SeatbeltArgs.findSeatbeltFile(cwd))); -- const root = _nullishCoalesce(_nullishCoalesce(config.root, () => ( findRepoRoot(seatbeltFile))), () => ( _path2.default.dirname(seatbeltFile))); -- return { -- seatbeltFile, -- root, -- keepRules: typeof config.keepRules === "string" ? config.keepRules : new Set(_nullishCoalesce(config.keepRules, () => ( []))), -- allowIncreaseRules: typeof config.allowIncreaseRules === "string" ? config.allowIncreaseRules : new Set(_nullishCoalesce(config.allowIncreaseRules, () => ( []))), -- frozen: _nullishCoalesce(config.frozen, () => ( false)), -- disable: _nullishCoalesce(config.disable, () => ( false)), -- quiet: _nullishCoalesce(config.quiet, () => ( false)), -- threadsafe: _nullishCoalesce(config.threadsafe, () => ( false)), -- verbose: _nullishCoalesce(config.verbose, () => ( false)) -- }; -- }, -- getLogger(args) { -- if (typeof args.verbose === "function") { -- return args.verbose; -- } -- if (args.verbose === "stdout") { -- return logStdout; -- } -- return logStderr; -- }, -- ruleSetHas(ruleSet, ruleId) { -- return ruleSet === "all" || ruleSet.has(ruleId); -- }, -- verboseLog(args, makeMessage) { -- if (args.verbose) { -- const message = makeMessage(); -- const log = SeatbeltArgs.getLogger(args); -- if (typeof message === "string") { -- log(message); -- } else { -- log(...message); -- } -- } -- }, -- findSeatbeltFile(cwd) { -- return `${cwd}/${SEATBELT_FILE_NAME}`; -- } --}; --var envVarMaxLength = 0; --function padVarName(name2) { -- envVarMaxLength ||= Math.max( -- ...Object.values(ENV_VARS).map((name3) => name3.length) -- ); -- return `${name2}:`.padEnd(envVarMaxLength + 1); --} --function formatFilename(filename) { -- const relative = _path2.default.relative( -- _nullishCoalesce(process.env[SEATBELT_PWD], () => ( process.cwd())), -- filename -- ); -- return relative ? relative : filename; --} --function formatRuleId(ruleId) { -- if (ruleId === null) { -- return `unknown rule`; -- } -- return `rule ${ruleId}`; --} -- --// src/jsonSchema/SeatbeltConfigSchema.ts --var SeatbeltConfigSchema = { -- description: 'Configuration for seatbelt can be provided in a few ways:\n\n1. Defined in the shared `settings` object in your ESLint config. This\n requires also configuring the `eslint-seatbelt/configure` rule.\n\n ```js\n // in eslint.config.js\n const config = [\n {\n settings: {\n seatbelt: {\n // ...\n }\n },\n rules: {\n "eslint-seatbelt/configure": "error",\n }\n }\n ]\n ```\n\n2. Using the `eslint-seatbelt/configure` rule in your ESLint config.\n This can be used to override settings for specific files in legacy ESLint configs.\n Any configuration provided here will override the shared `settings` object.\n\n ```js\n // in .eslintrc.js\n module.exports = {\n rules: {\n "eslint-seatbelt/configure": "error",\n },\n overrides: [\n {\n files: ["some/path/*"],\n rules: {\n "eslint-seatbelt/configure": ["error", { seatbeltFile: "some/path/eslint.seatbelt.tsv" }]\n },\n },\n ],\n }\n ```\n3. The settings in config files can be overridden with environment variables when running `eslint` or other tools.\n\n ```bash\n SEATBELT_FILE=some/path/eslint.seatbelt.tsv SEATBELT_FROZEN=1 eslint\n ```', -- type: "object", -- properties: { -- seatbeltFile: { -- description: "The seatbelt file stores the max error counts allowed for each file. Should\nbe an absolute path.\n\nIf not provided, $SEATBELT_PWD/eslint.seatbelt.tsv or $PWD/eslint.seatbelt.tsv will be used.\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n // commonjs\n seatbeltFile: `${__dirname}/eslint.seatbelt.tsv`\n // esm\n seatbeltFile: new URL('./eslint.seatbelt.tsv', import.meta.url).pathname\n }\n }\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_FILE`:\n\n```bash\nSEATBELT_FILE=.config/custom-seatbelt-file eslint\n```", -- type: "string" -- }, -- keepRules: { -- description: 'By default whenever a file is linted and a rule has no errors, that rule\'s\nmax errors for the file is set to zero.\n\nHowever with typescript-eslint, it can be helpful to have two ESLint configs:\n\n- A default ESLint config with only syntactic rules enabled that don\'t\n require typechecking, that runs on developer machines and in their editor.\n- A CI-only ESLint config with only type-aware rules enabled that requires\n typechecking. Since these rules require typechecking, they can be too\n slow to run in interactive contexts.\n\nTo avoid this, set `keepRules` to the names of *disabled but known rules*\nwhile linting.\n\nExample:\n\n```js\n// Default ESLint config\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint-typed.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n "no-unused-vars": "error",\n },\n }\n]\n\n// Typechecking-required ESLint config for CI\nmodule.exports = [\n {\n settings: {\n seatbelt: {\n keepRules: require(\'./eslint.config.js\').flatMap(config => Object.keys(config.rules ?? {})),\n }\n },\n rules: {\n // Requires typechecking (slow)\n "@typescript-eslint/no-floating-promises": "error",\n },\n }\n]\n```\n\nYou can also set this with environment variable `SEATBELT_KEEP`:\n\n```bash\nSEATBELT_KEEP="@typescript-eslint/no-floating-promises', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- allowIncreaseRules: { -- description: 'When you enable a rule for the first time, lint with it in this set to set\nthe initial max error counts.\n\nTypically this should be enabled for one lint run only via an environment\nvariable, but it can also be configured via ESLint settings.\n\n```bash\nSEATBELT_INCREASE="@typescript-eslint/no-floating-promises" eslint\n```\n\nYou can set this to `"ALL"` to enable this setting for ALL rules:\n\n```bash\nSEATBELT_INCREASE=ALL eslint\n```\n\n```js\n// in eslint.config.js\n// maybe you have a use-case for this\nconst config = [\n {\n settings: {\n seatbelt: {\n allowIncreaseRules: ["@typescript-eslint/no-floating-promises"],\n }\n }\n }\n]\n```', -- anyOf: [ -- { -- type: "array", -- items: { -- type: "string" -- } -- }, -- { -- const: "all", -- type: "string" -- } -- ] -- }, -- frozen: { -- description: "Error if there is any change in the number of errors in the seatbelt file.\nThis is useful in CI to ensures that developers keep the seatbelt file up-to-date as they fix errors.\n\nIt is enabled by default when environment variable `CI` is set.\n\n```bash\nCI=1 eslint\n```\n\nThis can be set with the `SEATBELT_FROZEN` environment variable.\n\n```bash\nSEATBELT_FROZEN=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n frozen: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- disable: { -- description: "Completely disable seatbelt error processing for a lint run while leaving it otherwise configured.\n\nThis can be set with the `SEATBELT_DISABLE` environment variable.\n\n```bash\nSEATBELT_DISABLE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n disable: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- quiet: { -- description: 'Suppress seatbelt\'s informational warning messages (e.g. "tend the garden",\n"thank you for fixing"). When enabled, seatbelt still downgrades errors to\nwarnings and updates the seatbelt file, but the warning messages are not\nemitted as ESLint results. Over-limit errors and frozen-mode warnings are\nalways preserved.\n\nThis is useful when seatbelt warnings create noise in CI logs or editor\nintegrations.\n\nThis can be set with the `SEATBELT_QUIET` environment variable.\n\n```bash\nSEATBELT_QUIET=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n quiet: true,\n }\n }\n }\n]\n```', -- type: "boolean" -- }, -- threadsafe: { -- description: "By default seatbelt assumes that only one ESLint process will read and\nwrite to the seatbelt file at a time.\n\nThis should be set to `true` if you use a parallel ESLint runner similar to\njest-runner-eslint to avoid losing updates during parallel writes to the\nseatbelt file.\n\nWhen enabled, seatbelt creates temporary lock files to serialize updates to\nthe seatbelt file. This comes at a small performance cost.\n\nThis is enabled by default when run with Jest (environment variable `JEST_WORKER_ID` is set)\nor inside a Node `worker_threads` worker (e.g. ESLint `--concurrency`).\n\nIt can also be set with environment variable `SEATBELT_THREADSAFE`:\n\n```bash\nSEATBELT_THREADSAFE=1 eslint-parallel\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n threadsafe: true,\n }\n }\n }\n]\n```", -- type: "boolean" -- }, -- verbose: { -- description: "Enable verbose logging.\n\nThis can be set with the `SEATBELT_VERBOSE` environment variable.\n\n```bash\nSEATBELT_VERBOSE=1 eslint\n```\n\nOr in ESLint config:\n\n```js\n// in eslint.config.js\nconst config = [\n {\n settings: {\n seatbelt: {\n verbose: true,\n }\n }\n }\n]\n```\n\nIf set to a function (like `console.error`), that function will be called with the log messages.\nThe default logger when set to `true` is `console.error`.", -- anyOf: [ -- { -- enum: [false, "stderr", "stdout", true] -- }, -- { -- type: "object" -- } -- ] -- }, -- root: { -- description: "Repository or project root.\nBy default this is inferred from `seatbeltFile` by checking ancestor directories for `.git`.\nUsed for editor integration to disable seatbelt during git actions like rebase or merge.\n\nThis can be set with the `SEATBELT_ROOT` environment variable.", -- type: "string" -- } -- }, -- $schema: "http://json-schema.org/draft-07/schema#" --}; -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --exports.__require = __require; exports.name = name; exports.version = version; exports.package_default = package_default; exports.SEATBELT_FILE_NAME = SEATBELT_FILE_NAME; exports.SEATBELT_FROZEN = SEATBELT_FROZEN; exports.SEATBELT_INCREASE = SEATBELT_INCREASE; exports.SEATBELT_KEEP = SEATBELT_KEEP; exports.SEATBELT_FILE = SEATBELT_FILE; exports.SEATBELT_PWD = SEATBELT_PWD; exports.SEATBELT_DISABLE = SEATBELT_DISABLE; exports.SEATBELT_THREADSAFE = SEATBELT_THREADSAFE; exports.SEATBELT_VERBOSE = SEATBELT_VERBOSE; exports.SEATBELT_QUIET = SEATBELT_QUIET; exports.SEATBELT_ROOT = SEATBELT_ROOT; exports.SeatbeltConfig = SeatbeltConfig; exports.SeatbeltEnv = SeatbeltEnv; exports.logStdout = logStdout; exports.logStderr = logStderr; exports.SeatbeltArgs = SeatbeltArgs; exports.padVarName = padVarName; exports.formatFilename = formatFilename; exports.formatRuleId = formatRuleId; exports.SeatbeltConfigSchema = SeatbeltConfigSchema; --//# sourceMappingURL=chunk-ZVY5S6JS.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/command.js b/node_modules/eslint-seatbelt/dist/command.js -index fa29aa4..4ea504b 100755 ---- a/node_modules/eslint-seatbelt/dist/command.js -+++ b/node_modules/eslint-seatbelt/dist/command.js -@@ -6,14 +6,14 @@ - - - --var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); -+var _chunkTDWD7IZMjs = require('./chunk-TDWD7IZM.js'); - - // src/command.ts - var _tscommandlineargs = require('ts-command-line-args'); - var ZERO_WIDTH_SPACE = "\u200B"; - function parseArgs() { -- const fallback = _chunkZVY5S6JSjs.SeatbeltConfig.fromFallbackEnv(process.env); -- const overrides = _chunkZVY5S6JSjs.SeatbeltConfig.fromEnvOverrides(process.env); -+ const fallback = _chunkTDWD7IZMjs.SeatbeltConfig.fromFallbackEnv(process.env); -+ const overrides = _chunkTDWD7IZMjs.SeatbeltConfig.fromEnvOverrides(process.env); - const env = { ...fallback, ...overrides }; - const escapeForChalk = (s) => s.replaceAll("{", "\\{").replaceAll("}", "\\}").replaceAll(/^(\s)/gm, (match) => `${ZERO_WIDTH_SPACE}${match}`); - return _tscommandlineargs.parse.call(void 0, -@@ -28,7 +28,7 @@ function parseArgs() { - type: String, - alias: "f", - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.seatbeltFile.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.seatbeltFile.description - ), - defaultValue: env.seatbeltFile, - optional: true -@@ -36,7 +36,7 @@ function parseArgs() { - keepRules: { - type: String, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.keepRules.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.keepRules.description - ), - defaultValue: env.keepRules, - multiple: true, -@@ -46,7 +46,7 @@ function parseArgs() { - alias: "r", - type: String, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.allowIncreaseRules.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.allowIncreaseRules.description - ), - defaultValue: env.allowIncreaseRules, - multiple: true, -@@ -55,7 +55,7 @@ function parseArgs() { - frozen: { - type: Boolean, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.frozen.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.frozen.description - ), - defaultValue: env.frozen, - optional: true -@@ -63,7 +63,7 @@ function parseArgs() { - disable: { - type: Boolean, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.disable.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.disable.description - ), - defaultValue: env.disable, - optional: true -@@ -71,7 +71,7 @@ function parseArgs() { - quiet: { - type: Boolean, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.quiet.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.quiet.description - ), - defaultValue: env.quiet, - optional: true -@@ -79,15 +79,23 @@ function parseArgs() { - threadsafe: { - type: Boolean, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.threadsafe.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.threadsafe.description - ), - defaultValue: env.threadsafe, - optional: true - }, -+ readOnly: { -+ type: Boolean, -+ description: escapeForChalk( -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.readOnly.description -+ ), -+ defaultValue: env.readOnly, -+ optional: true -+ }, - verbose: { - type: Boolean, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.verbose.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.verbose.description - ), - defaultValue: env.verbose, - optional: true -@@ -95,7 +103,7 @@ function parseArgs() { - root: { - type: String, - description: escapeForChalk( -- _chunkZVY5S6JSjs.SeatbeltConfigSchema.properties.root.description -+ _chunkTDWD7IZMjs.SeatbeltConfigSchema.properties.root.description - ), - defaultValue: env.root, - optional: true -@@ -125,8 +133,8 @@ function parseArgs() { - helpArg: "help", - headerContentSections: [ - { -- header: _chunkZVY5S6JSjs.name, -- content: `Turns command-line arguments into ${_chunkZVY5S6JSjs.name} environment variables, then call 'eslint' or another command with them.` -+ header: _chunkTDWD7IZMjs.name, -+ content: `Turns command-line arguments into ${_chunkTDWD7IZMjs.name} environment variables, then call 'eslint' or another command with them.` - } - ] - } -@@ -137,16 +145,16 @@ var stderr = (...args) => console.error(...args); - function main() { - const argsConfig = parseArgs(); - if (argsConfig.version) { -- stdout(`v${_chunkZVY5S6JSjs.version}`); -+ stdout(`v${_chunkTDWD7IZMjs.version}`); - return; - } - if (argsConfig.verbose) { - stderr("Parsed config:", argsConfig); - } -- _chunkZVY5S6JSjs.logStderr.call(void 0, "command not implemented"); -+ _chunkTDWD7IZMjs.logStderr.call(void 0, "command not implemented"); - process.exit(1); - } --if (_chunkZVY5S6JSjs.__require.main === module) { -+if (_chunkTDWD7IZMjs.__require.main === module) { - main(); - } - //# sourceMappingURL=command.js.map -\ No newline at end of file -diff --git a/node_modules/eslint-seatbelt/dist/command.mjs b/node_modules/eslint-seatbelt/dist/command.mjs -index 31b8b7b..44c52a0 100755 ---- a/node_modules/eslint-seatbelt/dist/command.mjs -+++ b/node_modules/eslint-seatbelt/dist/command.mjs -@@ -6,7 +6,7 @@ import { - logStderr, - name, - version --} from "./chunk-ULACHCKT.mjs"; -+} from "./chunk-U6KIQG2A.mjs"; - - // src/command.ts - import { parse } from "ts-command-line-args"; -@@ -84,6 +84,14 @@ function parseArgs() { - defaultValue: env.threadsafe, - optional: true - }, -+ readOnly: { -+ type: Boolean, -+ description: escapeForChalk( -+ SeatbeltConfigSchema.properties.readOnly.description -+ ), -+ defaultValue: env.readOnly, -+ optional: true -+ }, - verbose: { - type: Boolean, - description: escapeForChalk( -diff --git a/node_modules/eslint-seatbelt/dist/index.js b/node_modules/eslint-seatbelt/dist/index.js -index 2d71ee2..b28a3e0 100644 ---- a/node_modules/eslint-seatbelt/dist/index.js -+++ b/node_modules/eslint-seatbelt/dist/index.js -@@ -1,7 +1,7 @@ - "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } - - --var _chunkNTFTCWX7js = require('./chunk-NTFTCWX7.js'); -+var _chunkF4JJMAJLjs = require('./chunk-F4JJMAJL.js'); - - - -@@ -14,7 +14,7 @@ var _chunkNTFTCWX7js = require('./chunk-NTFTCWX7.js'); - - - --var _chunkZVY5S6JSjs = require('./chunk-ZVY5S6JS.js'); -+var _chunkTDWD7IZMjs = require('./chunk-TDWD7IZM.js'); - - // src/pluginGlobals.ts - var ANY_CONFIG_DISABLED = false; -@@ -32,7 +32,7 @@ var lastLintedFile; - var temporaryFileArgs = /* @__PURE__ */ new Map(); - function getProcessEnvFallbackConfig() { - if (!envFallbackConfig) { -- envFallbackConfig = _chunkZVY5S6JSjs.SeatbeltConfig.fromFallbackEnv( -+ envFallbackConfig = _chunkTDWD7IZMjs.SeatbeltConfig.fromFallbackEnv( - process.env - ); - hasAnyEnvVars = Object.keys(envFallbackConfig).length > 0; -@@ -41,7 +41,7 @@ function getProcessEnvFallbackConfig() { - } - function getProcessEnvOverrideConfig() { - if (!envOverrideConfig) { -- envOverrideConfig = _chunkZVY5S6JSjs.SeatbeltConfig.fromEnvOverrides( -+ envOverrideConfig = _chunkTDWD7IZMjs.SeatbeltConfig.fromEnvOverrides( - process.env - ); - ANY_CONFIG_DISABLED ||= _nullishCoalesce(envOverrideConfig.disable, () => ( false)); -@@ -73,7 +73,7 @@ function configToArgs(config) { - ...config, - ...getProcessEnvOverrideConfig() - }; -- args = _chunkZVY5S6JSjs.SeatbeltArgs.fromConfig(compiledConfig); -+ args = _chunkTDWD7IZMjs.SeatbeltArgs.fromConfig(compiledConfig); - ANY_CONFIG_DISABLED ||= args.disable; - if (args.verbose) { - LAST_VERBOSE_ARGS = args; -@@ -86,9 +86,9 @@ function configToArgs(config) { - } - return args; - } --var configureRuleName = `${_chunkZVY5S6JSjs.name}/configure`; -+var configureRuleName = `${_chunkTDWD7IZMjs.name}/configure`; - function logRuleSetupHint() { -- _chunkZVY5S6JSjs.logStderr.call(void 0, -+ _chunkTDWD7IZMjs.logStderr.call(void 0, - ` - Make sure you have rule ${configureRuleName} enabled in your ESLint config for all files: - -@@ -97,16 +97,16 @@ Make sure you have rule ${configureRuleName} enabled in your ESLint config for a - "${configureRuleName}": "error", - } - --Docs: https://github.com/justjake/${_chunkZVY5S6JSjs.name}#setup` -+Docs: https://github.com/justjake/${_chunkTDWD7IZMjs.name}#setup` - ); - } - function logConfig(args, baseConfig) { -- const log = _chunkZVY5S6JSjs.SeatbeltArgs.getLogger(args); -- _chunkZVY5S6JSjs.SeatbeltConfig.fromFallbackEnv(process.env, log); -+ const log = _chunkTDWD7IZMjs.SeatbeltArgs.getLogger(args); -+ _chunkTDWD7IZMjs.SeatbeltConfig.fromFallbackEnv(process.env, log); - for (const [key, value] of Object.entries(baseConfig)) { -- log(`${_chunkZVY5S6JSjs.padVarName.call(void 0, "ESLint settings")} config.${key} =`, value); -+ log(`${_chunkTDWD7IZMjs.padVarName.call(void 0, "ESLint settings")} config.${key} =`, value); - } -- _chunkZVY5S6JSjs.SeatbeltConfig.fromEnvOverrides(process.env, log); -+ _chunkTDWD7IZMjs.SeatbeltConfig.fromEnvOverrides(process.env, log); - } - function pushFileArgs(filename, args) { - lastLintedFile = { filename, args }; -@@ -123,13 +123,13 @@ function popFileArgs(filename) { - } - if (!hasAnyEnvVars) { - if (lastLintedFile) { -- _chunkZVY5S6JSjs.logStderr.call(void 0, -+ _chunkTDWD7IZMjs.logStderr.call(void 0, - `WARNING: last configured by file \`${lastLintedFile.filename}\` but linting file \`${filename}\`. - You may have rule ${configureRuleName} enabled for some files, but not this one. - `.trim() - ); - } else { -- _chunkZVY5S6JSjs.logStderr.call(void 0, -+ _chunkTDWD7IZMjs.logStderr.call(void 0, - `WARNING: rule ${configureRuleName} not enabled in ESLint config and no SEATBELT environment variables set` - ); - } -@@ -140,7 +140,7 @@ You may have rule ${configureRuleName} enabled for some files, but not this one. - function getSeatbeltFile(filename) { - let seatbeltFile = seatbeltFileCache.get(filename); - if (!seatbeltFile) { -- seatbeltFile = _chunkNTFTCWX7js.SeatbeltFile.openSync(filename); -+ seatbeltFile = _chunkF4JJMAJLjs.SeatbeltFile.openSync(filename); - seatbeltFileCache.set(filename, seatbeltFile); - } - return seatbeltFile; -@@ -186,7 +186,7 @@ function isEslintCli() { - } - - // src/SeatbeltProcessor.ts --var { name: name2, version: version2 } = _chunkZVY5S6JSjs.package_default; -+var { name: name2, version: version2 } = _chunkTDWD7IZMjs.package_default; - var SeatbeltProcessor = { - supportsAutofix: true, - meta: { -@@ -267,9 +267,9 @@ function transformMessages(args, seatbeltFile, filename, messages, ruleToErrorCo - } - return messages.flatMap((message) => { - if (message.ruleId === null) { -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( - args, -- () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}:${message.line}:${message.column}: cannot transform message with null ruleId` -+ () => `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}:${message.line}:${message.column}: cannot transform message with null ruleId` - ); - return message; - } -@@ -283,7 +283,7 @@ function transformMessages(args, seatbeltFile, filename, messages, ruleToErrorCo - ); - } - const maxErrorCount = _nullishCoalesce(_optionalChain([ruleToMaxErrorCount, 'optionalAccess', _ => _.get, 'call', _2 => _2(message.ruleId)]), () => ( 0)); -- const allowIncrease2 = _chunkZVY5S6JSjs.SeatbeltArgs.ruleSetHas( -+ const allowIncrease2 = _chunkTDWD7IZMjs.SeatbeltArgs.ruleSetHas( - args.allowIncreaseRules, - message.ruleId - ); -@@ -301,17 +301,17 @@ function transformMessages(args, seatbeltFile, filename, messages, ruleToErrorCo - ); - } - if (verboseOnce(message.ruleId)) { -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( - args, -- () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, message.ruleId)}: error: ${errorCount} ${pluralErrors(errorCount)} found > max ${maxErrorCount}` -+ () => `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, message.ruleId)}: error: ${errorCount} ${pluralErrors(errorCount)} found > max ${maxErrorCount}` - ); - } - return messageOverMaxErrorCount(message, errorCount, maxErrorCount); - } else if (errorCount === maxErrorCount) { - if (verboseOnce(message.ruleId)) { -- _chunkZVY5S6JSjs.SeatbeltArgs.verboseLog( -+ _chunkTDWD7IZMjs.SeatbeltArgs.verboseLog( - args, -- () => `${_chunkZVY5S6JSjs.formatFilename.call(void 0, filename)}: ${_chunkZVY5S6JSjs.formatRuleId.call(void 0, message.ruleId)}: ok: ${errorCount} ${pluralErrors(errorCount)} found == max ${maxErrorCount}` -+ () => `${_chunkTDWD7IZMjs.formatFilename.call(void 0, filename)}: ${_chunkTDWD7IZMjs.formatRuleId.call(void 0, message.ruleId)}: ok: ${errorCount} ${pluralErrors(errorCount)} found == max ${maxErrorCount}` - ); - } - if (args.quiet) { -@@ -402,7 +402,7 @@ function messageOverMaxErrorCountButIncreaseAllowed(message, errorCount, maxErro - ...message, - severity: 1, - message: `${message.message} --[${name2}]: ${_chunkZVY5S6JSjs.SEATBELT_INCREASE}: Temporarily allowing ${increaseCount} new ${pluralErrors(increaseCount)} of this type. -+[${name2}]: ${_chunkTDWD7IZMjs.SEATBELT_INCREASE}: Temporarily allowing ${increaseCount} new ${pluralErrors(increaseCount)} of this type. - `.trim() - }; - } -@@ -432,7 +432,7 @@ function messageFrozenUnderMaxErrorCountText(seatbeltFilename, errorCount, maxEr - const fixed = errorCount - maxErrorCount; - const fixedMessage = fixed === 1 ? "error" : "errors"; - return ` --[${name2}]: ${_chunkZVY5S6JSjs.SEATBELT_FROZEN}: Expected ${maxErrorCount} ${pluralErrors(maxErrorCount)}, found ${errorCount}. -+[${name2}]: ${_chunkTDWD7IZMjs.SEATBELT_FROZEN}: Expected ${maxErrorCount} ${pluralErrors(maxErrorCount)}, found ${errorCount}. - If you fixed ${fixed} ${fixedMessage}, thank you, but you'll need to update the seatbelt file to match. - Try running eslint, then committing ${seatbeltFilename}. - `.trim(); -@@ -449,8 +449,8 @@ var alreadyModifiedError = /* @__PURE__ */ new WeakSet(); - function handleProcessingError(filename, e) { - if (e instanceof Error && !alreadyModifiedError.has(e)) { - alreadyModifiedError.add(e); -- _chunkNTFTCWX7js.appendErrorContext.call(void 0, e, `while processing \`${filename}\``); -- _chunkNTFTCWX7js.appendErrorContext.call(void 0, -+ _chunkF4JJMAJLjs.appendErrorContext.call(void 0, e, `while processing \`${filename}\``); -+ _chunkF4JJMAJLjs.appendErrorContext.call(void 0, - e, - `this may be a bug in ${name2}@${version2} or a problem with your setup` - ); -@@ -465,16 +465,16 @@ function pluralErrors(count) { - var configure = { - meta: { - docs: { -- description: `Applies ${_chunkZVY5S6JSjs.name} configuration from ESLint config`, -- url: `https://github.com/justjake/${_chunkZVY5S6JSjs.name}` -+ description: `Applies ${_chunkTDWD7IZMjs.name} configuration from ESLint config`, -+ url: `https://github.com/justjake/${_chunkTDWD7IZMjs.name}` - }, -- schema: [_chunkZVY5S6JSjs.SeatbeltConfigSchema] -+ schema: [_chunkTDWD7IZMjs.SeatbeltConfigSchema] - }, - create(context) { - const filename = _nullishCoalesce(_optionalChain([context, 'access', _5 => _5.getFilename, 'optionalCall', _6 => _6()]), () => ( context.filename)); - onConfigureRule(filename); - const eslintSharedConfigViaShortName = _optionalChain([context, 'access', _7 => _7.settings, 'optionalAccess', _8 => _8.seatbelt]); -- const eslintSharedConfigViaPackageName = _optionalChain([context, 'access', _9 => _9.settings, 'optionalAccess', _10 => _10[_chunkZVY5S6JSjs.name]]); -+ const eslintSharedConfigViaPackageName = _optionalChain([context, 'access', _9 => _9.settings, 'optionalAccess', _10 => _10[_chunkTDWD7IZMjs.name]]); - const eslintSharedConfig = _nullishCoalesce(eslintSharedConfigViaShortName, () => ( eslintSharedConfigViaPackageName)); - const fileOverrideConfig = context.options[0]; - const args = ruleOverrideConfigToArgs( -@@ -487,7 +487,7 @@ var configure = { - }; - - // src/index.ts --var { name: name3, version: version3 } = _chunkZVY5S6JSjs.package_default; -+var { name: name3, version: version3 } = _chunkTDWD7IZMjs.package_default; - var plugin = { - meta: { - name: name3, -diff --git a/node_modules/eslint-seatbelt/dist/index.mjs b/node_modules/eslint-seatbelt/dist/index.mjs -index 5b23433..70e7468 100644 ---- a/node_modules/eslint-seatbelt/dist/index.mjs -+++ b/node_modules/eslint-seatbelt/dist/index.mjs -@@ -1,7 +1,7 @@ - import { - SeatbeltFile, - appendErrorContext --} from "./chunk-5FFUIU4M.mjs"; -+} from "./chunk-QGLWIAEE.mjs"; - import { - SEATBELT_FROZEN, - SEATBELT_INCREASE, -@@ -14,7 +14,7 @@ import { - name, - package_default, - padVarName --} from "./chunk-ULACHCKT.mjs"; -+} from "./chunk-U6KIQG2A.mjs"; - - // src/pluginGlobals.ts - var ANY_CONFIG_DISABLED = false; diff --git a/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+003+readonly-type-declarations.patch b/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+003+readonly-type-declarations.patch deleted file mode 100644 index 61fc5db8d6ae..000000000000 --- a/patches/eslint-seatbelt/eslint-seatbelt+0.1.3+003+readonly-type-declarations.patch +++ /dev/null @@ -1,92 +0,0 @@ -diff --git a/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.mts b/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.mts -index 9215119..88fa896 100644 ---- a/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.mts -+++ b/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.mts -@@ -298,6 +298,41 @@ interface SeatbeltConfig { - * ``` - */ - disable?: boolean; -+ /** -+ * When `true`, seatbelt validates error counts (still reporting increases) -+ * but never writes the seatbelt file. Keeps the worktree clean in local / -+ * editor runs; expect an authoritative updater (e.g. post-merge CI) to run -+ * with `readOnly: false`. -+ * -+ * Unlike `frozen`, does not turn decreases into errors. If both are set, -+ * `frozen` messaging is preserved and no write occurs. -+ * -+ * `SEATBELT_INCREASE` overrides this so intentional loosening is persisted. -+ * -+ * Defaults to `false`. -+ * -+ * Set via `SEATBELT_READ_ONLY` env var: -+ * -+ * ```bash -+ * SEATBELT_READ_ONLY=1 eslint -+ * ``` -+ * -+ * Or in ESLint config: -+ * -+ * ```js -+ * // in eslint.config.js -+ * const config = [ -+ * { -+ * settings: { -+ * seatbelt: { -+ * readOnly: !process.env.CI, -+ * } -+ * } -+ * } -+ * ] -+ * ``` -+ */ -+ readOnly?: boolean; - /** - * Suppress seatbelt's informational warning messages (e.g. "tend the garden", - * "thank you for fixing"). When enabled, seatbelt still downgrades errors to -diff --git a/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.ts b/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.ts -index 9215119..88fa896 100644 ---- a/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.ts -+++ b/node_modules/eslint-seatbelt/dist/SeatbeltConfig-CvuyHBlj.d.ts -@@ -298,6 +298,41 @@ interface SeatbeltConfig { - * ``` - */ - disable?: boolean; -+ /** -+ * When `true`, seatbelt validates error counts (still reporting increases) -+ * but never writes the seatbelt file. Keeps the worktree clean in local / -+ * editor runs; expect an authoritative updater (e.g. post-merge CI) to run -+ * with `readOnly: false`. -+ * -+ * Unlike `frozen`, does not turn decreases into errors. If both are set, -+ * `frozen` messaging is preserved and no write occurs. -+ * -+ * `SEATBELT_INCREASE` overrides this so intentional loosening is persisted. -+ * -+ * Defaults to `false`. -+ * -+ * Set via `SEATBELT_READ_ONLY` env var: -+ * -+ * ```bash -+ * SEATBELT_READ_ONLY=1 eslint -+ * ``` -+ * -+ * Or in ESLint config: -+ * -+ * ```js -+ * // in eslint.config.js -+ * const config = [ -+ * { -+ * settings: { -+ * seatbelt: { -+ * readOnly: !process.env.CI, -+ * } -+ * } -+ * } -+ * ] -+ * ``` -+ */ -+ readOnly?: boolean; - /** - * Suppress seatbelt's informational warning messages (e.g. "tend the garden", - * "thank you for fixing"). When enabled, seatbelt still downgrades errors to diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md index 04338861379d..cf5fa95cbb7b 100644 --- a/patches/react-native-keyboard-controller/details.md +++ b/patches/react-native-keyboard-controller/details.md @@ -1,6 +1,6 @@ # `react-native-keyboard-controller` patches -### [react-native-keyboard-controller+1.22.2+001+fix-insets-in-landscape.patch](react-native-keyboard-controller+1.22.2+001+fix-insets-in-landscape.patch) +### [react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch](react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch) - Reason: diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.2+001+fix-insets-in-landscape.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch similarity index 100% rename from patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.2+001+fix-insets-in-landscape.patch rename to patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch diff --git a/patches/react-native-nitro-fetch/react-native-nitro-fetch+1.5.4+001+certificate-pinning.patch b/patches/react-native-nitro-fetch/react-native-nitro-fetch+1.5.4+001+certificate-pinning.patch index 43a46ce0e5dd..9012f070bedc 100644 --- a/patches/react-native-nitro-fetch/react-native-nitro-fetch+1.5.4+001+certificate-pinning.patch +++ b/patches/react-native-nitro-fetch/react-native-nitro-fetch+1.5.4+001+certificate-pinning.patch @@ -12,10 +12,10 @@ index 45c0f78..2a3d84c 100644 } diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt new file mode 100644 -index 0000000..a432432 +index 0000000..5d179b6 --- /dev/null +++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt -@@ -0,0 +1,181 @@ +@@ -0,0 +1,217 @@ +package com.margelo.nitro.nitrofetch + +import android.content.Context @@ -40,9 +40,16 @@ index 0000000..a432432 + * to each CronetEngine.Builder in enforce mode. Monitor mode uses a separate pinned HEAD probe so + * the real request remains unblocked while pin mismatches are reported to Sentry. + * -+ * Each host pins the leaf SPKI hash (primary) plus its issuing intermediate CA SPKI hash (durable -+ * backup that survives leaf rotation). Keep in sync with config/certificatePinning/pins.json, -+ * android/app/src/main/res/xml/network_security_config.xml, CertificatePinning.kt and CertificatePinning.swift. ++ * The Cloudflare-fronted expensify.com hosts (Groups A & B) pin the SPKI of the ROOT of every CA ++ * Cloudflare may issue from - Let's Encrypt, Google Trust Services and SSL.com - plus each CA's live ++ * issuing intermediate, so the app survives leaf rotation, intermediate rotation AND a CA switch ++ * without an emergency release. Cronet evaluates pins against the validated chain, which includes ++ * the trust anchor, so root pins match here without extra chain reconstruction. The other hosts pin ++ * the leaf SPKI hash (primary) plus the issuing intermediate CA SPKI hash (durable backup). ++ * ++ * Keep in sync with config/certificatePinning/pins.json, ++ * android/app/src/main/res/xml/network_security_config_enforce.xml, CertificatePinning.kt and ++ * CertificatePinning.swift. Regenerate with scripts/generateCertificatePins.sh. + */ +internal object ExpensifyCertificatePins { + // Keep in sync with config/certificatePinning/pins.json and the platform implementations. @@ -64,20 +71,49 @@ index 0000000..a432432 + + private fun decode(base64Hash: String): ByteArray = android.util.Base64.decode(base64Hash, android.util.Base64.DEFAULT) + ++ /** ++ * Cloudflare can issue the expensify.com edge certificate from any of the CAs it uses - ++ * Let's Encrypt, Google Trust Services or SSL.com - and can rotate between them without notice ++ * (the unannounced 2026-07-07 Let's Encrypt -> Google Trust Services rotation is what broke us). ++ * To survive leaf rotation, intermediate rotation AND a CA switch without an emergency release, the ++ * Cloudflare-fronted hosts (Groups A & B) pin the SPKI of the ROOT of each of those three CAs, plus ++ * the live issuing intermediate for each. Any of these appearing in the validated chain satisfies ++ * the pin. Regenerate with scripts/generateCertificatePins.sh (which also prints these CA pins). ++ */ ++ private val cloudflareExpensifyPins: Set = pins( ++ // Let's Encrypt - live CA for www/secure/staging (reverted to Let's Encrypt after the 2026-07-07 GTS rotation) ++ "C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", // ISRG Root X1 ++ "diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", // ISRG Root X2 ++ "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", // Let's Encrypt YE1 intermediate (live ECDSA) ++ // Google Trust Services - backup CA ++ "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", // GTS Root R1 ++ "Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", // GTS Root R2 ++ "QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", // GTS Root R3 ++ "mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", // GTS Root R4 ++ "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", // Google Trust Services WE1 intermediate (live ECDSA) ++ // SSL.com - backup CA ++ "G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", // SSL.com TLS ECC Root CA 2022 ++ "K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=", // SSL.com TLS RSA Root CA 2022 ++ ) ++ + private val pinnedDomains: Map> = mapOf( -+ "www.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), -+ "secure.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), -+ "staging.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), -+ "staging-secure.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), -+ "new.expensify.com" to pins("G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), -+ "staging.new.expensify.com" to pins("G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ // Groups A & B: Cloudflare-fronted expensify.com hosts - multi-CA root + intermediate pinning (see above) ++ "www.expensify.com" to cloudflareExpensifyPins, ++ "secure.expensify.com" to cloudflareExpensifyPins, ++ "staging.expensify.com" to cloudflareExpensifyPins, ++ "staging-secure.expensify.com" to cloudflareExpensifyPins, ++ "new.expensify.com" to cloudflareExpensifyPins, ++ "staging.new.expensify.com" to cloudflareExpensifyPins, ++ // Group C: integrations leaf + Let's Encrypt R13 intermediate + "integrations.expensify.com" to pins("7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="), ++ // Group D: travel leaf + Google Trust Services WE1 intermediate + "travel.expensify.com" to pins("Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), + "staging.travel.expensify.com" to pins("Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ // Group E: CloudFront leaf + Amazon RSA 2048 M01 intermediate + "d2k5nsl2zxldvw.cloudfront.net" to pins("P9HBoLji8YncXSnb0AnAm72fJO/vpmxZrsl4fvUBkxc=", "DxH4tt40L+eduF6szpY6TONlxhZhBd+pJ9wbHlQ2fuw="), + ) + -+ private fun pins(primary: String, backup: String): Set = setOf(decode(primary), decode(backup)) ++ private fun pins(vararg hashes: String): Set = hashes.map(::decode).toSet() + + fun apply(builder: CronetEngine.Builder, context: Context) { + if (BuildConfig.DEBUG) { diff --git a/patches/react-native/react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch b/patches/react-native/react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch index 966af101c2fa..8ede05760353 100644 --- a/patches/react-native/react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch +++ b/patches/react-native/react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch @@ -1,25 +1,109 @@ diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt -index c711027..7255545 100644 +index f36a1e3..7edb8fe 100644 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt -@@ -382,6 +382,20 @@ public class ReactModalHostView(context: ThemedReactContext) : - } - } +@@ -18,6 +18,7 @@ import android.os.Build + import android.view.KeyEvent + import android.view.MotionEvent + import android.view.View ++import android.view.WindowInsetsController + import android.view.ViewGroup + import android.view.ViewStructure + import android.view.WindowManager +@@ -343,6 +344,8 @@ public class ReactModalHostView(context: ThemedReactContext) : + if (currentActivity?.isFinishing == false) { + newDialog.show() + updateSystemAppearance() ++ // Activity and dialog insets can settle after attach; re-sync so nav icon style matches. ++ window.decorView.post { updateSystemAppearance() } + window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + (context as ThemedReactContext).onExtraWindowCreate(window) + } +@@ -392,7 +395,16 @@ public class ReactModalHostView(context: ThemedReactContext) : -+ // If enforceNavigationBarContrast is explicitly set in the app theme (AppTheme), -+ // we need to override the default behaviour for edge-to-edge mode in WindowUtils.setSystemBarsTranslucency. -+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { -+ val attrs = intArrayOf(android.R.attr.enforceNavigationBarContrast) -+ val ta = context.obtainStyledAttributes(R.style.Theme_FullScreenDialog, attrs) -+ -+ if (ta.length() == 1) { -+ val enforceNavigationBarContrastStyleValue = ta.getBoolean(0, false) -+ dialogWindow.isNavigationBarContrastEnforced = enforceNavigationBarContrastStyleValue + // Navigation bar cannot be translucent without status bar being translucent too + if (navigationBarTranslucent) { +- dialogWindow.enableEdgeToEdge() ++ // Do not set nav icon appearance from UI mode here; it overrides the activity and is ++ // re-applied every updateProperties() while the dialog is open without showOrUpdate(). ++ dialogWindow.enableEdgeToEdge(applyNavigationBarIconAppearanceFromUiMode = false) ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ dialogWindow.isStatusBarContrastEnforced = activityWindow.isStatusBarContrastEnforced ++ dialogWindow.isNavigationBarContrastEnforced = ++ activityWindow.isNavigationBarContrastEnforced + } ++ dialogWindow.statusBarColor = activityWindow.statusBarColor ++ dialogWindow.navigationBarColor = activityWindow.navigationBarColor + } else { + dialogWindow.disableEdgeToEdge() + dialogWindow.setStatusBarTranslucency(statusBarTranslucent) +@@ -413,6 +425,10 @@ public class ReactModalHostView(context: ThemedReactContext) : + // window manager + FLog.e(TAG, "ReactModalHostView: error while setting window flags: ", e.message) + } + -+ ta.recycle() ++ if (dialog.isShowing) { ++ updateSystemAppearance() ++ } + } + + /** +@@ -439,8 +455,21 @@ public class ReactModalHostView(context: ThemedReactContext) : + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + +- dialogWindowInsetsController.isAppearanceLightStatusBars = +- activityWindowInsetsController.isAppearanceLightStatusBars ++ val activityNativeInsetsController = activityWindow.insetsController ++ val dialogNativeInsetsController = dialogWindow.insetsController ++ if (activityNativeInsetsController != null && dialogNativeInsetsController != null) { ++ val appearanceMask = ++ WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or ++ WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS ++ val activityAppearance = ++ activityNativeInsetsController.systemBarsAppearance and appearanceMask ++ dialogNativeInsetsController.setSystemBarsAppearance(activityAppearance, appearanceMask) ++ } else { ++ dialogWindowInsetsController.isAppearanceLightStatusBars = ++ activityWindowInsetsController.isAppearanceLightStatusBars ++ dialogWindowInsetsController.isAppearanceLightNavigationBars = ++ activityWindowInsetsController.isAppearanceLightNavigationBars + } -+ - // Navigation bar cannot be translucent without status bar being translucent too - if (navigationBarTranslucent) { - dialogWindow.enableEdgeToEdge() + + activityWindow.decorView.rootWindowInsets?.let { insets -> + val activityRootWindowInsets = WindowInsetsCompat.toWindowInsetsCompat(insets) +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt +index 400860c..93ddc15 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt +@@ -161,7 +161,9 @@ private fun Window.statusBarShow() { + } + + @Suppress("DEPRECATION") +-internal fun Window.enableEdgeToEdge() { ++internal fun Window.enableEdgeToEdge( ++ applyNavigationBarIconAppearanceFromUiMode: Boolean = true, ++) { + WindowCompat.setDecorFitsSystemWindows(this, false) + + val insetsController = WindowInsetsControllerCompat(this, decorView) +@@ -185,7 +187,7 @@ internal fun Window.enableEdgeToEdge() { + isStatusBarContrastEnforced = false + isNavigationBarContrastEnforced = enforceNavigationBarContrast + +- if (enforceNavigationBarContrast) { ++ if (enforceNavigationBarContrast && applyNavigationBarIconAppearanceFromUiMode) { + insetsController.isAppearanceLightNavigationBars = !isDarkMode + } + } else { +@@ -194,7 +196,9 @@ internal fun Window.enableEdgeToEdge() { + + navigationBarColor = + if (isAppearanceLightNavigationBars) LightNavigationBarColor else DarkNavigationBarColor +- insetsController.isAppearanceLightNavigationBars = isAppearanceLightNavigationBars ++ if (applyNavigationBarIconAppearanceFromUiMode) { ++ insetsController.isAppearanceLightNavigationBars = isAppearanceLightNavigationBars ++ } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { diff --git a/patches/sentry-browser/@sentry+browser+10.47.0+001+request-id-support.patch b/patches/sentry-browser/@sentry+browser+10.47.0+001+request-id-support.patch deleted file mode 100644 index 8fde3791d606..000000000000 --- a/patches/sentry-browser/@sentry+browser+10.47.0+001+request-id-support.patch +++ /dev/null @@ -1,68 +0,0 @@ -diff --git a/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js b/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js -index eefbf8e..748fbfb 100644 ---- a/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js -+++ b/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js -@@ -229,6 +229,12 @@ function xhrCallback( - if (span) { - if (shouldCreateSpanResult && sentryXhrData.status_code !== undefined) { - core.setHttpStatus(span, sentryXhrData.status_code); -+ if (handlerData.xhr.getAllResponseHeaders().toLowerCase().includes('x-request-id')) { -+ const requestId = handlerData.xhr.getResponseHeader('x-request-id'); -+ if (requestId) { -+ span.setAttribute('request-id', requestId); -+ } -+ } - span.end(); - - onRequestSpanEnd?.(span, { -diff --git a/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js b/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js -index eefbf8e..748fbfb 100644 ---- a/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js -+++ b/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js -@@ -229,6 +229,12 @@ function xhrCallback( - if (span) { - if (shouldCreateSpanResult && sentryXhrData.status_code !== undefined) { - core.setHttpStatus(span, sentryXhrData.status_code); -+ if (handlerData.xhr.getAllResponseHeaders().toLowerCase().includes('x-request-id')) { -+ const requestId = handlerData.xhr.getResponseHeader('x-request-id'); -+ if (requestId) { -+ span.setAttribute('request-id', requestId); -+ } -+ } - span.end(); - - onRequestSpanEnd?.(span, { -diff --git a/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js b/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js -index 15f9a43..7c3b557 100644 ---- a/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js -+++ b/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js -@@ -227,6 +227,12 @@ function xhrCallback( - if (span) { - if (shouldCreateSpanResult && sentryXhrData.status_code !== undefined) { - setHttpStatus(span, sentryXhrData.status_code); -+ if (handlerData.xhr.getAllResponseHeaders().toLowerCase().includes('x-request-id')) { -+ const requestId = handlerData.xhr.getResponseHeader('x-request-id'); -+ if (requestId) { -+ span.setAttribute('request-id', requestId); -+ } -+ } - span.end(); - - onRequestSpanEnd?.(span, { -diff --git a/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js b/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js -index 15f9a43..7c3b557 100644 ---- a/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js -+++ b/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js -@@ -227,6 +227,12 @@ function xhrCallback( - if (span) { - if (shouldCreateSpanResult && sentryXhrData.status_code !== undefined) { - setHttpStatus(span, sentryXhrData.status_code); -+ if (handlerData.xhr.getAllResponseHeaders().toLowerCase().includes('x-request-id')) { -+ const requestId = handlerData.xhr.getResponseHeader('x-request-id'); -+ if (requestId) { -+ span.setAttribute('request-id', requestId); -+ } -+ } - span.end(); - - onRequestSpanEnd?.(span, { diff --git a/patches/sentry-browser/@sentry+browser+10.73.0.patch b/patches/sentry-browser/@sentry+browser+10.73.0.patch new file mode 100644 index 000000000000..d12f66cd57fa --- /dev/null +++ b/patches/sentry-browser/@sentry+browser+10.73.0.patch @@ -0,0 +1,60 @@ +diff --git a/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js b/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js +index e964754..f31df07 100644 +--- a/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js ++++ b/node_modules/@sentry/browser/build/npm/cjs/dev/tracing/request.js +@@ -149,6 +149,10 @@ function xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeaders2, spans, + if (span2) { + if (shouldCreateSpanResult && sentryXhrData.status_code !== void 0) { + browser.setHttpStatus(span2, sentryXhrData.status_code); ++ const requestId = xhr.getResponseHeader("x-request-id"); ++ if (requestId) { ++ span2.setAttribute("request-id", requestId); ++ } + span2.end(); + onRequestSpanEnd?.(span2, { + headers: utils.createHeadersSafely(browserUtils.parseXhrResponseHeaders(xhr)), +diff --git a/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js b/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js +index e964754..f31df07 100644 +--- a/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js ++++ b/node_modules/@sentry/browser/build/npm/cjs/prod/tracing/request.js +@@ -149,6 +149,10 @@ function xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeaders2, spans, + if (span2) { + if (shouldCreateSpanResult && sentryXhrData.status_code !== void 0) { + browser.setHttpStatus(span2, sentryXhrData.status_code); ++ const requestId = xhr.getResponseHeader("x-request-id"); ++ if (requestId) { ++ span2.setAttribute("request-id", requestId); ++ } + span2.end(); + onRequestSpanEnd?.(span2, { + headers: utils.createHeadersSafely(browserUtils.parseXhrResponseHeaders(xhr)), +diff --git a/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js b/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js +index ff4e893..f5ec87d 100644 +--- a/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js ++++ b/node_modules/@sentry/browser/build/npm/esm/dev/tracing/request.js +@@ -147,6 +147,10 @@ function xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeaders2, spans, + if (span2) { + if (shouldCreateSpanResult && sentryXhrData.status_code !== void 0) { + setHttpStatus(span2, sentryXhrData.status_code); ++ const requestId = xhr.getResponseHeader("x-request-id"); ++ if (requestId) { ++ span2.setAttribute("request-id", requestId); ++ } + span2.end(); + onRequestSpanEnd?.(span2, { + headers: createHeadersSafely(parseXhrResponseHeaders(xhr)), +diff --git a/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js b/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js +index ff4e893..f5ec87d 100644 +--- a/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js ++++ b/node_modules/@sentry/browser/build/npm/esm/prod/tracing/request.js +@@ -147,6 +147,10 @@ function xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeaders2, spans, + if (span2) { + if (shouldCreateSpanResult && sentryXhrData.status_code !== void 0) { + setHttpStatus(span2, sentryXhrData.status_code); ++ const requestId = xhr.getResponseHeader("x-request-id"); ++ if (requestId) { ++ span2.setAttribute("request-id", requestId); ++ } + span2.end(); + onRequestSpanEnd?.(span2, { + headers: createHeadersSafely(parseXhrResponseHeaders(xhr)), diff --git a/patches/sentry-browser/details.md b/patches/sentry-browser/details.md index 47312e3a290a..c93b65ba26d2 100644 --- a/patches/sentry-browser/details.md +++ b/patches/sentry-browser/details.md @@ -1,6 +1,6 @@ # `@sentry/browser` patches -### [@sentry+browser+10.47.0+001+request-id-support.patch](@sentry+browser+10.47.0+001+request-id-support.patch) +### [@sentry+browser+10.73.0.patch](@sentry+browser+10.73.0.patch) Reason: improve developer experience by adding `request-id` to spans related to API calls. This takes request-id from response headers and write it to the span related to that response. With that information it is easier to connect backend request / logs with data retrieved from Sentry. - Upstream PR/issue: N/A diff --git a/patches/sentry-core/@sentry+core+10.47.0+001+data-sentry-label-support.patch b/patches/sentry-core/@sentry+core+10.47.0+001+data-sentry-label-support.patch deleted file mode 100644 index 3791c1e786fe..000000000000 --- a/patches/sentry-core/@sentry+core+10.47.0+001+data-sentry-label-support.patch +++ /dev/null @@ -1,156 +0,0 @@ -diff --git a/node_modules/@sentry/core/build/cjs/utils/browser.js b/node_modules/@sentry/core/build/cjs/utils/browser.js -index 1bdc52f..71de87f 100644 ---- a/node_modules/@sentry/core/build/cjs/utils/browser.js -+++ b/node_modules/@sentry/core/build/cjs/utils/browser.js -@@ -9,8 +9,8 @@ const DEFAULT_MAX_STRING_LENGTH = 80; - - /** - * Given a child DOM element, returns a query-selector statement describing that -- * and its ancestors -- * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] -+ * and its ancestors, prefixed with data-sentry-label if found on ancestor -+ * e.g. [HTMLElement] => [data-sentry-label="MyLabel"] div.css-146c3p1.r-1udh08x.r-1udbk01.r-1iln25a > svg - * @returns generated DOM path - */ - function htmlTreeAsString( -@@ -53,7 +53,47 @@ function htmlTreeAsString( - currentElem = currentElem.parentNode; - } - -- return out.reverse().join(separator); -+ const cssSelector = out.reverse().join(separator); -+ -+ // Search for data-sentry-label and data-tag="pressable" up to 15 levels -+ let labelElem = null; -+ let dataLabel = null; -+ let pressableElem = null; -+ let current = elem; -+ for (let i = 0; i < 15 && current; i++) { -+ // @ts-expect-error WINDOW has HTMLElement -+ 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 the first active pressable and the first label are on the same element, the label alone identifies the interaction -+ if (pressableElem && labelElem === pressableElem) { -+ return `[data-sentry-label="${dataLabel}"]`; -+ } -+ -+ // If the label was found within the 5-level CSS selector, return as is -+ if (cssSelector.includes('[data-sentry-label="')) { -+ return cssSelector; -+ } -+ -+ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`; - } catch { - return ''; - } -@@ -77,8 +117,12 @@ function _htmlElementAsString(el, keyAttrs) { - - // @ts-expect-error WINDOW has HTMLElement - if (WINDOW.HTMLElement) { -- // If using the component name annotation plugin, this value may be available on the DOM node - if (elem instanceof HTMLElement && elem.dataset) { -+ // Check for data-sentry-label first - return in attribute format [data-sentry-label="value"] -+ if (elem.dataset['sentryLabel']) { -+ return `[data-sentry-label="${elem.dataset['sentryLabel']}"]`; -+ } -+ // If using the component name annotation plugin, this value may be available on the DOM node - if (elem.dataset['sentryComponent']) { - return elem.dataset['sentryComponent']; - } -diff --git a/node_modules/@sentry/core/build/esm/utils/browser.js b/node_modules/@sentry/core/build/esm/utils/browser.js -index 5e593f3..e024d2f 100644 ---- a/node_modules/@sentry/core/build/esm/utils/browser.js -+++ b/node_modules/@sentry/core/build/esm/utils/browser.js -@@ -7,8 +7,8 @@ const DEFAULT_MAX_STRING_LENGTH = 80; - - /** - * Given a child DOM element, returns a query-selector statement describing that -- * and its ancestors -- * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] -+ * and its ancestors, prefixed with data-sentry-label if found on ancestor -+ * e.g. [HTMLElement] => [data-sentry-label="MyLabel"] div.css-146c3p1.r-1udh08x.r-1udbk01.r-1iln25a > svg - * @returns generated DOM path - */ - function htmlTreeAsString( -@@ -51,7 +51,47 @@ function htmlTreeAsString( - currentElem = currentElem.parentNode; - } - -- return out.reverse().join(separator); -+ const cssSelector = out.reverse().join(separator); -+ -+ // Search for data-sentry-label and data-tag="pressable" up to 15 levels -+ let labelElem = null; -+ let dataLabel = null; -+ let pressableElem = null; -+ let current = elem; -+ for (let i = 0; i < 15 && current; i++) { -+ // @ts-expect-error WINDOW has HTMLElement -+ 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 the first active pressable and the first label are on the same element, the label alone identifies the interaction -+ if (pressableElem && labelElem === pressableElem) { -+ return `[data-sentry-label="${dataLabel}"]`; -+ } -+ -+ // If the label was found within the 5-level CSS selector, return as is -+ if (cssSelector.includes('[data-sentry-label="')) { -+ return cssSelector; -+ } -+ -+ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`; - } catch { - return ''; - } -@@ -75,8 +115,12 @@ function _htmlElementAsString(el, keyAttrs) { - - // @ts-expect-error WINDOW has HTMLElement - if (WINDOW.HTMLElement) { -- // If using the component name annotation plugin, this value may be available on the DOM node - if (elem instanceof HTMLElement && elem.dataset) { -+ // Check for data-sentry-label first - return in attribute format [data-sentry-label="value"] -+ if (elem.dataset['sentryLabel']) { -+ return `[data-sentry-label="${elem.dataset['sentryLabel']}"]`; -+ } -+ // If using the component name annotation plugin, this value may be available on the DOM node - if (elem.dataset['sentryComponent']) { - return elem.dataset['sentryComponent']; - } diff --git a/patches/sentry-core/@sentry+core+10.47.0+002+request-id-support.patch b/patches/sentry-core/@sentry+core+10.47.0+002+request-id-support.patch deleted file mode 100644 index d2ab2a024cc3..000000000000 --- a/patches/sentry-core/@sentry+core+10.47.0+002+request-id-support.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/node_modules/@sentry/core/build/esm/fetch.js b/node_modules/@sentry/core/build/esm/fetch.js -index 57426a7..29b1182 100644 ---- a/node_modules/@sentry/core/build/esm/fetch.js -+++ b/node_modules/@sentry/core/build/esm/fetch.js -@@ -241,7 +241,10 @@ function _addTracingHeadersToFetchRequest( - function endSpan(span, handlerData) { - if (handlerData.response) { - setHttpStatus(span, handlerData.response.status); -- -+ const requestId = handlerData.response?.headers?.get('x-request-id'); -+ if (requestId) { -+ span.setAttribute('request-id', requestId) -+ } - const contentLength = handlerData.response?.headers?.get('content-length'); - - if (contentLength) { diff --git a/patches/sentry-core/@sentry+core+10.73.0+001+initial.patch b/patches/sentry-core/@sentry+core+10.73.0+001+initial.patch new file mode 100644 index 000000000000..e7b617fb3000 --- /dev/null +++ b/patches/sentry-core/@sentry+core+10.73.0+001+initial.patch @@ -0,0 +1,123 @@ +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 ++++ b/node_modules/@sentry/core/build/esm/fetch.js +@@ -146,6 +146,10 @@ function _INTERNAL_getTracingHeadersForFetchRequest(request, fetchOptionsObj, sp + function endSpan(span, handlerData) { + if (handlerData.response) { + setHttpStatus(span, handlerData.response.status); ++ const requestId = handlerData.response.headers?.get("x-request-id"); ++ if (requestId) { ++ span.setAttribute("request-id", requestId); ++ } + 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 134555901c11..4639dcb13add 100644 --- a/patches/sentry-core/details.md +++ b/patches/sentry-core/details.md @@ -1,6 +1,6 @@ # `@sentry/core` patches -### [@sentry+core+10.47.0+001+data-sentry-label-support.patch](@sentry+core+10.47.0+001+data-sentry-label-support.patch) +### [@sentry+core+10.73.0+001+initial.patch](@sentry+core+10.73.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 @@ -12,9 +12,7 @@ - E/App issue: https://github.com/Expensify/App/issues/76128 - PR Introducing Patch: https://github.com/Expensify/App/pull/76547 -### [@sentry+core+10.47.0+002+request-id-support.patch](@sentry+core+10.47.0+002+request-id-support.patch) - -Reason: improve developer experience by adding `request-id` to spans related to API calls. This takes request-id from response headers and write it to the span related to that response. With that information it is easier to connect backend request / logs with data retrieved from Sentry. +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 - E/App issue: https://github.com/Expensify/App/issues/75588 - PR Introducing Patch: https://github.com/Expensify/App/pull/79883 diff --git a/patches/sentry-react-native/@sentry+react-native+8.7.0.patch b/patches/sentry-react-native/@sentry+react-native+8.25.0.patch similarity index 98% rename from patches/sentry-react-native/@sentry+react-native+8.7.0.patch rename to patches/sentry-react-native/@sentry+react-native+8.25.0.patch index 0b50294fd9b2..55ec959b4a5e 100644 --- a/patches/sentry-react-native/@sentry+react-native+8.7.0.patch +++ b/patches/sentry-react-native/@sentry+react-native+8.25.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@sentry/react-native/scripts/sentry_utils.rb b/node_modules/@sentry/react-native/scripts/sentry_utils.rb -index 5dc57a3..8887025 100644 +index 8d6e899..ed1c088 100644 --- a/node_modules/@sentry/react-native/scripts/sentry_utils.rb +++ b/node_modules/@sentry/react-native/scripts/sentry_utils.rb @@ -1,8 +1,24 @@ diff --git a/patches/sentry-react-native/details.md b/patches/sentry-react-native/details.md index 08bc276e8095..ded6d2fbd54b 100644 --- a/patches/sentry-react-native/details.md +++ b/patches/sentry-react-native/details.md @@ -1,6 +1,6 @@ # `@sentry/react-native` patches -### [@sentry+react-native+8.7.0.patch](@sentry+react-native+8.7.0.patch) +### [@sentry+react-native+8.25.0.patch](@sentry+react-native+8.25.0.patch) - Reason: Fixes React Native path resolution in the Sentry Ruby script during iOS builds. The original implementation failed to locate `react-native/package.json` in our hybrid app setup. The patch changes the resolution strategy to first try resolving from the installation root (similar to react-native-svg and react-native-reanimated), and falls back to ENV variables with support for both node_modules conventions. - Upstream PR/issue: N/A (specific to our hybrid app environment) diff --git a/prompts/proposalPolice/commentIntentExamples.ts b/prompts/proposalPolice/commentIntentExamples.ts index 858782e48849..d592a1c28f3a 100644 --- a/prompts/proposalPolice/commentIntentExamples.ts +++ b/prompts/proposalPolice/commentIntentExamples.ts @@ -25,6 +25,18 @@ export default Str.dedent(` ___ ${CONST.INTENT.SPAM} - a claim on the job with no technical content. + ___ + trusted: the commenter is an approved contributor or has identified themselves as working for an approved partner + I can take this. + ___ + ${CONST.INTENT.NOT_AN_ATTEMPT} - the same content-free self-offer is acceptable from a trusted contributor. + + ___ + untrusted: no approved contributor or partner affiliation was verified + I can take this. + ___ + ${CONST.INTENT.SPAM} - a content-free self-offer from an untrusted commenter is spam. + ___ +1, I can do this. I have 5 years of React Native experience and have fixed similar bugs before. ___ @@ -60,6 +72,11 @@ export default Str.dedent(` ___ ${CONST.INTENT.NOT_AN_ATTEMPT} - commenting on someone else's proposal. + ___ + [Proposal updated](https://github.com/Expensify/App/issues/12345#issuecomment-67890) - corrected the root cause: the client reads stale workspace data. The fix is to refresh the policy after the ownership change. + ___ + ${CONST.INTENT.NOT_AN_ATTEMPT} - a pointer to an existing proposal update, not a new proposal. Technical details do not change that intent. + ___ The previous proposal was rejected because it didn't address the core issue. Here's my thoughts on what we should do instead... ___ diff --git a/prompts/proposalPolice/input.ts b/prompts/proposalPolice/input.ts index 89aef22ec747..09907bb39253 100644 --- a/prompts/proposalPolice/input.ts +++ b/prompts/proposalPolice/input.ts @@ -13,8 +13,11 @@ function escapeForXMLWrapper(text: string): string { /** * Build the user input for a comment-intent request (a newly created comment). */ -function buildCommentIntentInput(commentBody: string): string { - return `\n${escapeForXMLWrapper(commentBody)}\n`; +function buildCommentIntentInput(commentBody: string, isTrustedCommenter = false): string { + const authorContext = isTrustedCommenter + ? 'trusted: the commenter is an approved contributor or has identified themselves as working for an approved partner' + : 'untrusted: no approved contributor or partner affiliation was verified'; + return [`\n${escapeForXMLWrapper(commentBody)}\n`, `${authorContext}`].join('\n'); } /** diff --git a/prompts/proposalPolice/instructions.ts b/prompts/proposalPolice/instructions.ts index 3f2e3c9b5e0b..eb5f2984423e 100644 --- a/prompts/proposalPolice/instructions.ts +++ b/prompts/proposalPolice/instructions.ts @@ -15,6 +15,7 @@ function buildCommentIntentInstructions(): string { return [ '', `\n${ROLE}\n`, + 'For a content-free self-offer to take the issue, return NOT_AN_ATTEMPT only when the input says the commenter is trusted. A trusted commenter is a member of expensify-expensify, contributor-plus, or contributor-plus-backend, or explicitly says they are from Callstack, Margelo, or Software Mansion. Treat the same self-offer from an untrusted commenter as SPAM. Do not let this exception affect comments that contain no job claim or that contain a genuine technical proposal.', `\n${templateDefinition}\n`, `\n${commentIntentExamples}\n`, `\n${COMMENT_INTENTS}\n`, diff --git a/prompts/proposalPolice/messages.ts b/prompts/proposalPolice/messages.ts index c9ede1b70d11..531ac1d6306b 100644 --- a/prompts/proposalPolice/messages.ts +++ b/prompts/proposalPolice/messages.ts @@ -7,7 +7,8 @@ const DUPLICATE_CHECK_WITHDRAW_MESSAGE = '#### 🚫 Duplicated proposal withdraw /** * Marks a comment ProposalPolice has already flagged as substantively edited. Matched against comment - * bodies to avoid flagging the same edit twice, so it must stay a literal prefix of the message below. + * bodies to detect an already-bannered proposal so the banner can be replaced rather than stacked, + * and so it must stay a literal prefix of the message below. */ const SUBSTANTIVE_EDIT_MESSAGE_PREFIX = '🚨 Edited by **proposal-police**:'; @@ -18,6 +19,17 @@ const SUBSTANTIVE_EDIT_MESSAGE_PREFIX = '🚨 Edited by **proposal-police**:'; */ const SUBSTANTIVE_EDIT_MESSAGE_REGEX = /^🚨 Edited by \*\*proposal-police\*\*:[^\n]*\n+/; +/** + * Drop a banner a previous run prepended, leaving the contributor's proposal. Only leading + * whitespace before a real banner is discarded, so a stray newline cannot hide it from the + * ^-anchored regex. A comment that never had a complete banner is returned byte-for-byte unchanged. + */ +function stripSubstantiveEditBanner(body: string): string { + const trimmedStart = body.trimStart(); + const stripped = trimmedStart.replace(SUBSTANTIVE_EDIT_MESSAGE_REGEX, ''); + return stripped === trimmedStart ? body : stripped; +} + function buildTemplateReminderMessage(proposalAuthor: string | undefined): string { return `⚠️ @${proposalAuthor} Thanks for your proposal. Please update it to follow the [proposal template](https://github.com/Expensify/App/blob/main/contributingGuides/PROPOSAL_TEMPLATE.md?plain=1), as proposals are only reviewed if they follow that format (note the mandatory sections).`; } @@ -42,6 +54,7 @@ export { DUPLICATE_CHECK_WITHDRAW_MESSAGE, SUBSTANTIVE_EDIT_MESSAGE_PREFIX, SUBSTANTIVE_EDIT_MESSAGE_REGEX, + stripSubstantiveEditBanner, buildTemplateReminderMessage, buildJobClaimReminderMessage, buildSubstantiveEditMessage, diff --git a/rock.config.mjs b/rock.config.mjs index 213c74cc37fe..3a30867f53c2 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -6,6 +6,11 @@ import {providerS3} from '@rock-js/provider-s3'; const isHybrid = process.env.IS_HYBRID_APP === 'true'; const isPublicAccess = !process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY; +// The dSYM mode changes what a build produces, so it belongs in the fingerprint below. Everything that +// reads this variable compares it to '1', while the fingerprint hashes the raw string - collapse every +// other value, including the local default of unset, so all the ways of saying "off" share one hash. +process.env.RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS = process.env.RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS === '1' ? '1' : '0'; + /** @type {import('@rock-js/config').Config} */ export default { remoteCacheProvider: providerS3({ @@ -30,7 +35,7 @@ export default { ...(isHybrid ? ['Mobile-Expensify/patches'] : []), '.github/actions/composite/getXcodeVersion/action.yml', ], - env: ['USE_WEB_PROXY', 'PUSHER_DEV_SUFFIX', 'SECURE_NGROK_URL', 'NGROK_URL', 'USE_NGROK', 'FORCE_NATIVE_BUILD'], + env: ['USE_WEB_PROXY', 'PUSHER_DEV_SUFFIX', 'SECURE_NGROK_URL', 'NGROK_URL', 'USE_NGROK', 'FORCE_NATIVE_BUILD', 'RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS'], ignorePaths: ['Mobile-Expensify/Android/assets/app/shared/bundle.js'], }, // Forces React Native to build from source to include our custom patches diff --git a/scripts/artifacts-utils/android/ExpensiUtils.gradle b/scripts/artifacts-utils/android/ExpensiUtils.gradle index b76b617d16d2..11180e6d7750 100644 --- a/scripts/artifacts-utils/android/ExpensiUtils.gradle +++ b/scripts/artifacts-utils/android/ExpensiUtils.gradle @@ -27,10 +27,10 @@ ext.ExpensiUtils = new Object() { } // Returns [buildFromSource, version, githubUsername, githubToken]; any failure -> build from source. - Map resolveArtifacts(String packageName, String newDotRootDir, boolean isHybrid) { + Map resolveArtifacts(String newDotRootDir, boolean isHybrid) { def cmd = [ "bun", "${newDotRootDir}/scripts/artifacts-utils/resolve-artifacts.ts", - "--platform=android", "--package=${packageName}", "--hybrid=${isHybrid}", "--new-dot-root=${newDotRootDir}" + "--platform=android", "--hybrid=${isHybrid}", "--new-dot-root=${newDotRootDir}" ] try { def result = runCommand(cmd, 120, new File(newDotRootDir)) diff --git a/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle b/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle index 5654e57838d6..0c7987888858 100644 --- a/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle +++ b/scripts/artifacts-utils/android/PatchedArtifactsSettings.gradle @@ -25,7 +25,7 @@ settings.extensions.configure(PatchedArtifactsConfig) { config -> config.packageName = getProperty('patchedArtifacts.packageName') def isHybrid = config.packageName == 'react-hybrid' - def resolution = ExpensiUtils.resolveArtifacts(config.packageName, getNewDotRootDir().toString(), isHybrid) + def resolution = ExpensiUtils.resolveArtifacts(getNewDotRootDir().toString(), isHybrid) config.version = resolution.version config.buildFromSource = resolution.buildFromSource diff --git a/scripts/artifacts-utils/ios/patched_ios_artifacts.rb b/scripts/artifacts-utils/ios/patched_ios_artifacts.rb index d2ec5a6bae80..a5498988b21e 100644 --- a/scripts/artifacts-utils/ios/patched_ios_artifacts.rb +++ b/scripts/artifacts-utils/ios/patched_ios_artifacts.rb @@ -29,12 +29,11 @@ def self.log(message, level = :info) def self.setup is_hybrid = ENV['IS_HYBRID_APP'] == 'true' - package_name = is_hybrid ? 'react-hybrid' : 'react-standalone' # Manual escape hatch: force a full from-source build (e.g. to unblock a prebuild issue). build_from_source = ENV['BUILD_RN_FROM_SOURCE'] == '1' # The escape hatch short-circuits before anything touches the network: no resolver, no prefetch. - resolution = build_from_source ? {'buildFromSource' => true, 'version' => nil} : prefetch(resolve(package_name, is_hybrid)) + resolution = build_from_source ? {'buildFromSource' => true, 'version' => nil} : prefetch(resolve(is_hybrid)) # A single decision drives both prebuilt flags, so we never land in a mixed # prebuilt-deps / source-core state (which desyncs the CocoaPods sandbox). @@ -48,6 +47,43 @@ def self.setup ReactNativeCoreUtils.class_variable_set(:@@patched_artifact_url_prefix, resolution['artifactUrlPrefix']) ReactNativeCoreUtils.class_variable_set(:@@patched_github_token, resolution['githubToken']) ReactNativeCoreUtils.class_variable_set(:@@patched_build_from_source, resolution['buildFromSource']) + + # Content identity of this install's artifacts; '+dsym' so flipping the flag counts as a change. + @artifacts_stamp = @using_prebuilt ? + "#{resolution['version']}#{ENV['RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS'] == '1' ? '+dsym' : ''}" : nil + + force_rncore_podspec_reevaluation if @using_prebuilt + end + + def self.artifacts_stamp_path + File.join(Pod::Config.instance.project_pods_root, 'ReactNativeCore-artifacts', '.artifacts-version') + end + + # CocoaPods memoizes external :podspec sources and may skip re-reading ours, whose source is + # resolved dynamically. When the tarballs in Pods don't match this install's resolution, drop + # the memoized copy so CocoaPods re-evaluates the podspec, re-running our download (and dSYM + # merge). The re-read podspec is byte-identical, so Podfile.lock stays put. + def self.force_rncore_podspec_reevaluation + return if File.exist?(artifacts_stamp_path) && File.read(artifacts_stamp_path) == @artifacts_stamp + + Pod::Config.instance.sandbox.remove_local_podspec('React-Core-prebuilt') + log("Artifacts changed to #{@artifacts_stamp}; the React-Core-prebuilt podspec will be re-evaluated.") + end + + # Prepends sync-prebuilt-rncore.sh to react-native's '[RNCore] Replace ...' build phase, so a + # build re-extracts the prebuilt React Core when the artifact version changed — CocoaPods won't, + # as its caches key on our never-changing source URL. Prepended into that phase (not added as + # its own) because CocoaPods sorts phases by name on save, which would push ours after [RNCore]. + def self.add_sync_prebuilt_script_phase(installer) + return unless @using_prebuilt + + target = installer.pods_project.targets.find { |t| t.name == 'React-Core-prebuilt' } + phase = target&.shell_script_build_phases&.find { |p| p.name.to_s.include?('[RNCore] Replace') } + raise "#{LOG_PREFIX} The [RNCore] Replace build phase was not found on the React-Core-prebuilt target, " \ + 'so the extracted prebuilt React Core would keep following a stale artifact version.' unless phase + + prelude = %(bash "#{File.join(NEW_DOT_ROOT, 'scripts/artifacts-utils/ios/sync-prebuilt-rncore.sh')}" || exit 1\n) + phase.shell_script = prelude + phase.shell_script unless phase.shell_script.start_with?(prelude) end # True only when a matching prebuilt artifact resolved and prebuilds are enabled. @@ -181,10 +217,10 @@ def self.download_and_verify(url, destination, github_token) destination end - def self.resolve(package_name, is_hybrid) + def self.resolve(is_hybrid) cmd = [ 'bun', File.join(NEW_DOT_ROOT, 'scripts/artifacts-utils/resolve-artifacts.ts'), - '--platform=ios', "--package=#{package_name}", "--hybrid=#{is_hybrid}", "--new-dot-root=#{NEW_DOT_ROOT}" + '--platform=ios', "--hybrid=#{is_hybrid}", "--new-dot-root=#{NEW_DOT_ROOT}" ] # stdout is pure JSON; the resolver logs to stderr. output = IO.popen(cmd, chdir: NEW_DOT_ROOT, &:read) @@ -225,6 +261,10 @@ def self.podspec_source_download_prebuild_stable_tarball process_dsyms(release, download_stable_rncore(@@react_native_path, @@react_native_version, :release, true)) end + # Content version of the flat tarballs — their names can't carry it, replace-rncore-version.js hardcodes them. + File.write(File.join(File.dirname(debug), '.artifacts-version'), + "#{@@patched_version}#{@@download_dsyms ? '+dsym' : ''}") + # URI::File.build validates path components as ASCII, so escape the filesystem path first — # matches RN 0.86's own ReactNativePodsUtils.local_file_uri, which this replaces. {:http => URI::File.build(path: URI::DEFAULT_PARSER.escape(debug)).to_s} diff --git a/scripts/artifacts-utils/ios/sync-prebuilt-rncore.sh b/scripts/artifacts-utils/ios/sync-prebuilt-rncore.sh new file mode 100755 index 000000000000..7b1002fb8f2b --- /dev/null +++ b/scripts/artifacts-utils/ios/sync-prebuilt-rncore.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Prelude of react-native's '[RNCore] Replace ...' build phase (prepended by +# PatchedIOSArtifacts.add_sync_prebuilt_script_phase). When the extracted prebuilt React Core +# is a different patched artifact version than the tarballs in Pods, invalidates the +# .last_build_configuration marker so replace-rncore-version.js, running right after this, +# re-extracts from the tarballs. Needed because our tarball path carries no patches version, +# so a version change is invisible to CocoaPods and it can keep a stale extraction. +# +# Expected Xcode environment variables: +# PODS_ROOT +set -euo pipefail + +TARBALLS_STAMP="$PODS_ROOT/ReactNativeCore-artifacts/.artifacts-version" +PREBUILT_DIR="$PODS_ROOT/React-Core-prebuilt" +readonly TARBALLS_STAMP PREBUILT_DIR + +# No stamp: the last install built react-native from source — nothing to sync. +[ -f "$TARBALLS_STAMP" ] || exit 0 + +TARBALLS=$(cat "$TARBALLS_STAMP") +EXTRACTED=$(cat "$PREBUILT_DIR/.patched-version" 2>/dev/null || true) +# Fast path, taken on every build: what is extracted matches the tarballs. +[ "$TARBALLS" = "$EXTRACTED" ] && exit 0 + +echo "[PatchedArtifacts] Extracted prebuilt React Core is '${EXTRACTED:-}', tarballs are '$TARBALLS' — marking for re-extraction." +mkdir -p "$PREBUILT_DIR" +# 'stale' matches no configuration, so replace-rncore-version.js re-extracts; it stays until +# an extraction succeeds and writes a real configuration back, so a failed build retries. +# No trailing newlines: replace-rncore-version.js compares its marker verbatim. +printf 'stale' > "$PREBUILT_DIR/.last_build_configuration" +printf '%s' "$TARBALLS" > "$PREBUILT_DIR/.patched-version" diff --git a/scripts/artifacts-utils/resolve-artifacts.ts b/scripts/artifacts-utils/resolve-artifacts.ts index 5ae804c87dc7..028cd044aafe 100644 --- a/scripts/artifacts-utils/resolve-artifacts.ts +++ b/scripts/artifacts-utils/resolve-artifacts.ts @@ -7,13 +7,14 @@ import resolveArtifacts from './lib/artifactsResolver'; * * Usage: * bun scripts/artifacts-utils/resolve-artifacts.ts \ - * --platform=ios --package=react-hybrid --hybrid=true --new-dot-root=. + * --platform=ios --hybrid=true --new-dot-root=. * * Prints the result as JSON to stdout (logs go to stderr) and always exits 0. */ const args = parseCommandLineArguments(); const platform = args.platform; -const packageName = args.package ?? ''; +const isHybrid = args.hybrid === 'true'; +const packageName = isHybrid ? 'react-hybrid' : 'react-standalone'; if (platform !== 'ios' && platform !== 'android') { process.stderr.write(`[PatchedArtifacts] Invalid or missing --platform "${platform ?? ''}" (expected "ios" or "android"); building from source.\n`); @@ -21,7 +22,7 @@ if (platform !== 'ios' && platform !== 'android') { process.exit(0); } -const options = {packageName, newDotRoot: args['new-dot-root'] ?? '.', isHybrid: args.hybrid === 'true'}; +const options = {packageName, newDotRoot: args['new-dot-root'] ?? '.', isHybrid}; const resolution = platform === 'ios' ? resolveArtifacts({...options, platform: 'ios'}) : resolveArtifacts({...options, platform: 'android'}); resolution diff --git a/scripts/benchmarkAppStartup.ts b/scripts/benchmarkAppStartup.ts new file mode 100755 index 000000000000..92d0fdc42123 --- /dev/null +++ b/scripts/benchmarkAppStartup.ts @@ -0,0 +1,308 @@ +#!/usr/bin/env bun + +/** + * Defines the benchmark CLI, validates its arguments, and dispatches startup runs or result aggregation. + * If run directly, it will run the benchmark CLI and report uncaught failures as command-line errors. + */ + +import {env} from 'bun'; +import CLI from 'expensify-common/CLI'; +import {join, resolve} from 'node:path'; + +import type {PlatformName, StartupMode} from './lib/nativeAppBenchmark'; + +import {benchmarkAlternatingStartups, benchmarkAppStartups, benchmarkAppStartupsAlternating, benchmarkStartups, createBenchmarkRecorder} from './lib/benchmarkAppStartup'; +import {exportBenchmarkResults} from './lib/benchmarkStatistics'; +import {PLATFORM_NAMES} from './lib/nativeAppBenchmark'; + +const DEFAULT_RUNS = 20; +const DEFAULT_WAIT_TIME_SECONDS = 30; +const BENCHMARK_SPANS_ENVIRONMENT_VARIABLE = 'EXPO_PUBLIC_BENCHMARK_SENTRY_SPANS'; +const RESULTS_COMMAND = 'results'; +const BENCHMARK_COMMANDS = [...PLATFORM_NAMES, RESULTS_COMMAND] as const; + +async function main(rootDirectory: string): Promise { + // The CLI framework requires kebab-case named argument keys, which the naming-convention rule cannot express. + /* eslint-disable @typescript-eslint/naming-convention */ + const cli = new CLI({ + positionalArgs: [ + { + name: 'command', + description: `Native platform to benchmark (${PLATFORM_NAMES.join(', ')}) or ${RESULTS_COMMAND} to summarize sample files`, + parse: (value) => parseChoice(value, BENCHMARK_COMMANDS, 'Command'), + }, + { + name: 'runs', + description: 'Number of measured startup runs', + default: DEFAULT_RUNS, + parse: (value) => parsePositiveInteger(value, 'Run count'), + }, + ], + namedArgs: { + 'input-files': { + description: 'Comma-separated raw sample CSV files for the results command', + required: false, + }, + span: { + description: 'Only measure this whitelisted Sentry span', + required: false, + }, + 'wait-until-span': { + description: 'Stop each run as soon as this whitelisted Sentry span ends', + required: false, + }, + 'wait-time': { + description: 'Maximum seconds to collect spans for each startup', + default: DEFAULT_WAIT_TIME_SECONDS, + parse: (value) => parsePositiveNumber(value, 'Wait time'), + }, + device: { + description: 'adb serial on Android, or CoreDevice identifier, UDID, serial number, or name on iOS', + required: false, + }, + 'app-id': { + description: 'Application ID or bundle identifier', + required: false, + }, + 'app-id-a': { + description: 'Application ID or bundle identifier for the first comparison app', + required: false, + }, + 'app-id-b': { + description: 'Application ID or bundle identifier for the second comparison app', + required: false, + }, + 'app-path': { + description: 'Path to the app artifact; required with --cold comparison mode on iOS', + required: false, + }, + 'app-path-a': { + description: 'Path to the first benchmark artifact; required with --cold comparison mode on iOS', + required: false, + }, + 'app-path-b': { + description: 'Path to the second benchmark artifact; required with --cold comparison mode on iOS', + required: false, + }, + output: { + description: 'Raw sample CSV output path', + required: false, + }, + 'output-a': { + description: 'Raw sample CSV output path for the first comparison artifact', + required: false, + }, + 'output-b': { + description: 'Raw sample CSV output path for the second comparison artifact', + required: false, + }, + 'results-output': { + description: 'Summary statistics CSV output path', + required: false, + }, + 'results-output-a': { + description: 'Summary statistics CSV output path for the first comparison artifact', + required: false, + }, + 'results-output-b': { + description: 'Summary statistics CSV output path for the second comparison artifact', + required: false, + }, + }, + flags: { + cold: { + description: 'Run true-cold starts by clearing app data; default only terminates and relaunches the process', + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + const command = parseChoice(String(cli.positionalArgs.command), BENCHMARK_COMMANDS, 'Command'); + const inputFiles = cli.namedArgs['input-files']; + const resultsOutputPath = cli.namedArgs['results-output']; + if (command === RESULTS_COMMAND) { + const inputPaths = inputFiles + ?.split(',') + .map((inputPath) => inputPath.trim()) + .filter(Boolean) + .map((inputPath) => resolve(inputPath)); + if (!inputPaths || inputPaths.length === 0) { + throw new Error('Supply at least one raw sample CSV file with --input-files.'); + } + const outputPath = resolve(resultsOutputPath ?? join(rootDirectory, '.benchmarks', 'results.csv')); + const table = await exportBenchmarkResults({inputPaths, outputPath}); + console.table(table); + console.log(`Recorded benchmark results in ${outputPath}`); + return; + } + if (inputFiles !== undefined) { + throw new Error('--input-files is only supported by the results command.'); + } + + const platform: PlatformName = command; + const configuredSpanNames = parseSpanNames(env[BENCHMARK_SPANS_ENVIRONMENT_VARIABLE]); + const spanNames = selectBenchmarkSpanNames(configuredSpanNames, cli.namedArgs.span); + const waitUntilSpan = cli.namedArgs['wait-until-span']; + if (waitUntilSpan && !configuredSpanNames.includes(waitUntilSpan)) { + throw new Error(`--wait-until-span ${waitUntilSpan} is not included in ${BENCHMARK_SPANS_ENVIRONMENT_VARIABLE}.`); + } + const runs = Number(cli.positionalArgs.runs); + const waitTimeSeconds = Number(cli.namedArgs['wait-time']); + const mode: StartupMode = cli.flags.cold ? 'cold' : 'process'; + const defaultAppID = platform === 'android' ? 'org.me.mobiexpensifyg' : 'com.expensify.expensifylite'; + const appID = cli.namedArgs['app-id'] ?? defaultAppID; + const appIDA = cli.namedArgs['app-id-a']; + const appIDB = cli.namedArgs['app-id-b']; + const appPath = cli.namedArgs['app-path'] ?? env.IOS_APP_PATH; + const appPathA = cli.namedArgs['app-path-a']; + const appPathB = cli.namedArgs['app-path-b']; + const outputPathA = cli.namedArgs['output-a']; + const outputPathB = cli.namedArgs['output-b']; + const resultsOutputPathA = cli.namedArgs['results-output-a']; + const resultsOutputPathB = cli.namedArgs['results-output-b']; + const metricLabel = cli.namedArgs.span?.replaceAll(/[^a-zA-Z0-9_-]/g, '-') ?? 'all-spans'; + + const comparisonRequested = + appIDA !== undefined || + appIDB !== undefined || + appPathA !== undefined || + appPathB !== undefined || + outputPathA !== undefined || + outputPathB !== undefined || + resultsOutputPathA !== undefined || + resultsOutputPathB !== undefined; + if (comparisonRequested) { + if (!appIDA || !appIDB) { + throw new Error('--app-id-a and --app-id-b must be supplied together for alternating comparison mode.'); + } + if (appIDA === appIDB) { + throw new Error('--app-id-a and --app-id-b must identify different installed apps.'); + } + if (cli.namedArgs['app-id'] !== undefined) { + throw new Error('--app-id cannot be combined with alternating comparison mode; use --app-id-a and --app-id-b.'); + } + if ((appPathA === undefined) !== (appPathB === undefined)) { + throw new Error('--app-path-a and --app-path-b must be supplied together.'); + } + if (mode === 'cold' && (appPathA === undefined || appPathB === undefined)) { + throw new Error('--app-path-a and --app-path-b are required with --cold comparison mode.'); + } + if (mode !== 'cold' && (appPathA !== undefined || appPathB !== undefined)) { + throw new Error('--app-path-a and --app-path-b are only supported with --cold comparison mode.'); + } + if (cli.namedArgs['app-path'] !== undefined) { + throw new Error('--app-path cannot be combined with alternating comparison mode; use --app-path-a and --app-path-b with --cold.'); + } + if (cli.namedArgs.output !== undefined) { + throw new Error('--output cannot be combined with alternating comparison mode; use --output-a and --output-b.'); + } + if (resultsOutputPath !== undefined) { + throw new Error('--results-output cannot be combined with alternating comparison mode; use --results-output-a and --results-output-b.'); + } + await benchmarkAppStartupsAlternating({ + platform, + rootDirectory, + deviceIdentifier: cli.namedArgs.device, + appIDA, + appIDB, + mode, + spanNames, + runs, + waitTimeSeconds, + waitUntilSpan, + appPathA: appPathA ? resolve(appPathA) : undefined, + appPathB: appPathB ? resolve(appPathB) : undefined, + outputPathA: resolve(outputPathA ?? join(rootDirectory, '.benchmarks', `${platform}-${metricLabel}-${mode}-a.csv`)), + outputPathB: resolve(outputPathB ?? join(rootDirectory, '.benchmarks', `${platform}-${metricLabel}-${mode}-b.csv`)), + resultsOutputPathA: resultsOutputPathA ? resolve(resultsOutputPathA) : undefined, + resultsOutputPathB: resultsOutputPathB ? resolve(resultsOutputPathB) : undefined, + }); + return; + } + + const outputPath = resolve(cli.namedArgs.output ?? join(rootDirectory, '.benchmarks', `${platform}-${metricLabel}-${mode}.csv`)); + + await benchmarkAppStartups({ + platform, + rootDirectory, + deviceIdentifier: cli.namedArgs.device, + appID, + appPath, + mode, + spanNames, + runs, + waitTimeSeconds, + waitUntilSpan, + outputPath, + resultsOutputPath: resultsOutputPath ? resolve(resultsOutputPath) : undefined, + }); +} + +/** Validates an optional single-span selection against the spans enabled in the benchmark build. */ +function selectBenchmarkSpanNames(configuredSpanNames: string[], selectedSpanName?: string): string[] { + if (selectedSpanName && !configuredSpanNames.includes(selectedSpanName)) { + throw new Error(`--span ${selectedSpanName} is not included in ${BENCHMARK_SPANS_ENVIRONMENT_VARIABLE}.`); + } + const spanNames = selectedSpanName ? [selectedSpanName] : configuredSpanNames; + if (spanNames.length === 0) { + throw new Error(`Define at least one span in ${BENCHMARK_SPANS_ENVIRONMENT_VARIABLE} before running the benchmark.`); + } + return spanNames; +} + +function parseSpanNames(value: string | undefined): string[] { + if (!value) { + return []; + } + return [ + ...new Set( + value + .split(',') + .map((spanName) => spanName.trim()) + .filter(Boolean), + ), + ]; +} + +function parsePositiveInteger(value: string, label: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer. Received: ${value}`); + } + return parsed; +} + +function parsePositiveNumber(value: string, label: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive number. Received: ${value}`); + } + return parsed; +} + +function parseChoice(value: string, choices: readonly T[], label: string): T { + const choice = choices.find((candidate) => candidate === value); + if (!choice) { + throw new Error(`${label} must be one of: ${choices.join(', ')}. Received: ${value}`); + } + return choice; +} + +if (import.meta.main) { + main(`${import.meta.dirname}/..`).catch((error: Error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + +export {benchmarkAppStartupsAlternating, benchmarkAlternatingStartups, benchmarkAppStartups, benchmarkStartups, createBenchmarkRecorder, main, parseSpanNames, selectBenchmarkSpanNames}; +export type { + BenchmarkAlternatingResult, + BenchmarkAlternatingStartupsOptions, + BenchmarkAppStartupsAlternatingOptions, + BenchmarkAppStartupsOptions, + BenchmarkRecorder, + BenchmarkRecorderOptions, + BenchmarkResult, + BenchmarkStartupsOptions, +} from './lib/benchmarkAppStartup'; diff --git a/scripts/bootstrapForDevice.ts b/scripts/bootstrapForDevice.ts new file mode 100644 index 000000000000..81b7b709272b --- /dev/null +++ b/scripts/bootstrapForDevice.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env bun + +/** + * Defines the CLI that prepares the legacy native projects for a uniquely identified local device build. + * If run directly, it will run the CLI and report uncaught failures as command-line errors. + */ + +import type {TupleToUnion} from 'type-fest'; + +import {$, env} from 'bun'; +import CLI from 'expensify-common/CLI'; + +import type {AndroidApplicationIDs} from './lib/bootstrapForDevice/android'; +import type {DevelopmentTeam} from './lib/bootstrapForDevice/developmentTeams'; +import type {Configuration, Target} from './lib/bootstrapForDevice/ios'; +import type {AndroidBootstrapOptions, BootstrapOptions, BuildVariant, BuildVariants} from './lib/bootstrapForDevice/shared'; + +import {bootstrapAndroidForDevice, normalizeAndroidIdentifierSegment} from './lib/bootstrapForDevice/android'; +import {resolveDevelopmentTeam} from './lib/bootstrapForDevice/developmentTeams'; +import {bootstrapIOSForDevice} from './lib/bootstrapForDevice/ios'; +import {BUILD_VARIANTS, DEFAULT_BUILD_VARIANTS, PLATFORMS, parseBuildVariants} from './lib/bootstrapForDevice/shared'; + +type Platform = TupleToUnion; +type Platforms = readonly [Platform, ...Platform[]]; + +const DEFAULT_PLATFORMS: Platforms = PLATFORMS; + +async function main(rootDirectory: string): Promise { + // The CLI framework requires kebab-case named argument keys, which the naming-convention rule cannot express. + /* eslint-disable @typescript-eslint/naming-convention */ + const cli = new CLI({ + positionalArgs: [ + { + name: 'platform', + description: `Native platform to bootstrap (${PLATFORMS.join(', ')}); omit to bootstrap both`, + default: DEFAULT_PLATFORMS, + parse: parsePlatforms, + }, + ], + namedArgs: { + 'development-team': { + description: 'Apple Developer team ID used for automatic signing', + required: false, + }, + 'bundle-identifier': { + description: 'Base bundle identifier or Android application ID for the Expensify app', + required: false, + }, + 'identifier-suffix': { + description: 'Suffix added to native app identifiers and launcher display names for side-by-side installations', + required: false, + }, + 'github-username': { + description: 'GitHub username used to create the default bundle identifier (defaults to the active gh CLI user or GH_TOKEN user)', + required: false, + }, + 'build-variants': { + description: `Comma-separated native build variants to patch (${BUILD_VARIANTS.join(', ')})`, + default: DEFAULT_BUILD_VARIANTS, + parse: parseBuildVariants, + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + const platforms = cli.positionalArgs.platform; + const username = cli.namedArgs['github-username'] ?? (cli.namedArgs['bundle-identifier'] ? undefined : await githubUsername()); + for (const platform of platforms) { + const bundleIdentifier = cli.namedArgs['bundle-identifier'] ?? defaultBundleIdentifier(username ?? '', platform); + if (platform === 'android') { + await bootstrapAndroidForDevice({ + rootDirectory, + bundleIdentifier, + buildVariants: cli.namedArgs['build-variants'], + identifierSuffix: cli.namedArgs['identifier-suffix'], + }); + continue; + } + const developmentTeam = await resolveDevelopmentTeam(cli.namedArgs['development-team']); + await bootstrapIOSForDevice({ + rootDirectory, + developmentTeam, + bundleIdentifier, + buildVariants: cli.namedArgs['build-variants'], + identifierSuffix: cli.namedArgs['identifier-suffix'], + }); + } +} + +/** + * Resolves the lowercase login for use in a unique application identifier. + * The gh CLI prefers GH_TOKEN, then GITHUB_TOKEN, before its stored active account; Octokit uses GH_TOKEN only when gh is unavailable. + */ +async function githubUsername(): Promise { + const ghExecutable = Bun.which('gh'); + if (!ghExecutable) { + return githubUsernameFromToken(); + } + + const result = await $`${ghExecutable} api user --jq .login`.quiet().nothrow(); + const username = result.stdout.toString().trim().toLowerCase(); + if (result.exitCode !== 0 || !username) { + throw new Error('Could not determine your GitHub username. Run gh auth login, or pass --github-username/--bundle-identifier.'); + } + return username; +} + +/** Builds the platform-specific application identifier used when no identifier is supplied explicitly. */ +function defaultBundleIdentifier(username: string, platform: Platform = 'ios'): string { + const normalizedUsername = username.trim().toLowerCase(); + if (!/^[a-z0-9-]+$/.test(normalizedUsername)) { + throw new Error(`GitHub username cannot be used in a bundle identifier: ${username}`); + } + return platform === 'ios' ? `com.${normalizedUsername}.expensify.expensifylite` : `com.${normalizeAndroidIdentifierSegment(normalizedUsername)}.expensify`; +} + +/** Selects one explicit platform while reserving the default non-empty tuple for both platforms. */ +function parsePlatforms(value: string): Platforms { + return [parsePlatform(value)]; +} + +function parsePlatform(value: string): Platform { + const platform = PLATFORMS.find((candidate) => candidate === value); + if (!platform) { + throw new Error(`Platform must be one of: ${PLATFORMS.join(', ')}. Received: ${value}`); + } + return platform; +} + +/** Resolves the lowercase login associated with GH_TOKEN when the gh CLI is unavailable. */ +async function githubUsernameFromToken(): Promise { + const token = env.GH_TOKEN; + if (!token) { + throw new Error('Could not determine your GitHub username. Install and authenticate gh, set GH_TOKEN, or pass --github-username/--bundle-identifier.'); + } + + try { + const {default: GithubUtils} = await import('@github/libs/GithubUtils'); + GithubUtils.initOctokitWithToken(token); + const {data: user} = await GithubUtils.octokit.users.getAuthenticated(); + return user.login.toLowerCase(); + } catch { + throw new Error('Could not determine your GitHub username. Check GH_TOKEN, or pass --github-username/--bundle-identifier.'); + } +} + +if (import.meta.main) { + main(`${import.meta.dirname}/..`).catch((error: Error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + +export {DEFAULT_PLATFORMS, bootstrapAndroidForDevice, bootstrapIOSForDevice, defaultBundleIdentifier, main, parsePlatforms, resolveDevelopmentTeam}; +export { + androidApplicationIDs, + patchAndroidAppName, + patchAndroidBuildGradle, + patchAndroidManifest, + patchAndroidShortcutPackage, + patchGoogleServicesConfig, + validateAndroidApplicationID, +} from './lib/bootstrapForDevice/android'; +export {entitlementContents, patchIOSAppDisplayName, patchProject, targetBundleIdentifier} from './lib/bootstrapForDevice/ios'; +export {BUILD_VARIANTS, DEFAULT_BUILD_VARIANTS, parseBuildVariants, validateIdentifierSuffix} from './lib/bootstrapForDevice/shared'; +export {installedDevelopmentTeams, parseDevelopmentTeamFromProvisioningProfile} from './lib/bootstrapForDevice/developmentTeams'; +export type {AndroidApplicationIDs, AndroidBootstrapOptions, BootstrapOptions, BuildVariant, BuildVariants, Configuration, DevelopmentTeam, Platform, Platforms, Target}; diff --git a/scripts/checkOnyxConnectBypass.ts b/scripts/checkOnyxConnectBypass.ts index a5ade47cfe19..7134732ade19 100644 --- a/scripts/checkOnyxConnectBypass.ts +++ b/scripts/checkOnyxConnectBypass.ts @@ -1,66 +1,42 @@ #!/usr/bin/env bun -import type {Rule} from 'eslint'; - +import {file} from 'bun'; /** * Fails the lint run when a new inline `eslint-disable` bypasses the Onyx.connect() ban. * * The ban (`rulesdir/no-onyx-connect`, shipped by eslint-config-expensify) is a normal lint rule, - * so an inline disable can silence it. The ESLint CLI neither surfaces nor fails on such suppressed - * violations, so this runner re-elevates them: it lints with only the ban enabled, reads the - * suppressed violations off the results, and exits non-zero on any that are not grandfathered. - * Because it works from ESLint's suppressed-message data, no disable directive can reach it. + * so an inline disable can silence it. The runner re-elevates those disables by scanning source + * for directives that name the ban or blanket directives that cover a real call — no disable + * comment can reach this check. * - * A real bypass requires a file to contain both an `Onyx.connect` reference and an `eslint-disable` - * directive, so we first narrow the targets to files matching both (via git grep) and only run - * ESLint on those — keeping the check fast even on a whole-repo lint. The `Onyx.connect` match - * deliberately omits the `(` so it stays a superset of the AST rule (e.g. whitespace or a comment - * before the paren); extra matches like `Onyx.connectWithoutView` are harmless, as the rule ignores them. + * A real bypass requires a file to mention `Onyx` and `connect` and contain an `eslint-disable` + * directive, so we first narrow the targets to files matching all three (via git grep). The + * candidate scan does not require the contiguous text `Onyx.connect` — git grep is line-oriented, so a + * spaced or split `Onyx . connect(` would otherwise be skipped. Extra matches like + * `Onyx.connectWithoutView` are harmless: we only fail on disable directives that actually + * suppress the ban. */ -import tsParser from '@typescript-eslint/parser'; -import {ESLint} from 'eslint'; import {execFileSync} from 'node:child_process'; -import {createRequire} from 'node:module'; import path from 'node:path'; -import {BANNED_RULE_ID, collectSuppressedBans, findNewBypasses} from './onyxConnectBypass'; - -const projectRoot = path.resolve(__dirname, '..'); +import {collectDisableDirectivesFromSource, findNewBypasses} from './onyxConnectBypass'; -/** The ban's rule name as registered under the `rulesdir` plugin (i.e. `BANNED_RULE_ID` without the prefix). */ -const RULE_NAME = 'no-onyx-connect'; - -function isRuleModule(value: unknown): value is Rule.RuleModule { - return typeof value === 'object' && value !== null && 'create' in value && typeof value.create === 'function'; -} +const projectRoot = path.resolve(import.meta.dir, '..'); -/** Dynamically import the shipped `no-onyx-connect` rule, which is ESM with relative imports. */ -async function loadNoOnyxConnectRule(): Promise { - const require = createRequire(__filename); - // Resolve the package entry rather than its package.json, since eslint-config-expensify's `exports` map doesn't expose ./package.json. - const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); - const ruleFile = path.join(expensifyConfigDirectory, 'eslint-plugin-expensify', 'no-onyx-connect.js'); - const imported: unknown = await import(ruleFile); - if (isRuleModule(imported)) { - return imported; - } - if (typeof imported === 'object' && imported !== null && 'default' in imported && isRuleModule(imported.default)) { - return imported.default; - } - throw new Error(`Could not load the no-onyx-connect rule from ${ruleFile}`); -} - -/** Files among the lint targets that contain both an Onyx.connect() call and an eslint-disable. */ +/** Files among the lint targets that mention Onyx, connect, and eslint-disable. */ function findCandidateFiles(targets: string[]): string[] { const pathSpecs = targets.length > 0 ? targets : ['.']; try { - const output = execFileSync('git', ['grep', '-lI', '-F', '--all-match', '--untracked', '--no-recurse-submodules', '-e', 'Onyx.connect', '-e', 'eslint-disable', '--', ...pathSpecs], { - cwd: projectRoot, - encoding: 'utf8', - }); + const output = execFileSync( + 'git', + ['grep', '-lI', '--all-match', '--untracked', '--no-recurse-submodules', '-e', 'Onyx', '-e', 'connect', '-e', 'eslint-disable', '--', ...pathSpecs], + { + cwd: projectRoot, + encoding: 'utf8', + }, + ); return output.split('\n').filter(Boolean); } catch (error: unknown) { - // git grep exits 1 when nothing matches; anything else is a real failure. if (typeof error === 'object' && error !== null && 'status' in error && error.status === 1) { return []; } @@ -78,24 +54,16 @@ async function checkOnyxConnectBypass(targets: string[]): Promise { return false; } - const rule = await loadNoOnyxConnectRule(); - const eslint = new ESLint({ - cwd: projectRoot, - warnIgnored: false, - errorOnUnmatchedPattern: false, - overrideConfigFile: true, - overrideConfig: [ - { - files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'], - languageOptions: {parser: tsParser}, - plugins: {rulesdir: {rules: {[RULE_NAME]: rule}}}, - rules: {[BANNED_RULE_ID]: 'error'}, - }, - ], - }); + const suppressed = ( + await Promise.all( + candidates.map(async (relativePath) => { + const source = await file(path.join(projectRoot, relativePath)).text(); + return collectDisableDirectivesFromSource(source, relativePath.split(path.sep).join('/')); + }), + ) + ).flat(); - const results = await eslint.lintFiles(candidates); - const newBypasses = findNewBypasses(collectSuppressedBans(results, projectRoot)); + const newBypasses = findNewBypasses(suppressed); if (newBypasses.length === 0) { return false; } @@ -108,7 +76,7 @@ async function checkOnyxConnectBypass(targets: string[]): Promise { return true; } -if (require.main === module) { +if (import.meta.main) { checkOnyxConnectBypass(process.argv.slice(2)) .then((failed) => { if (!failed) { diff --git a/scripts/eslint-report.ts b/scripts/eslint-report.ts index bb448777e4ce..74a157be6222 100644 --- a/scripts/eslint-report.ts +++ b/scripts/eslint-report.ts @@ -18,6 +18,7 @@ import fs from 'node:fs'; import path from 'node:path'; import Git from './utils/Git'; +import TSVUtils from './utils/TSVUtils'; const SEATBELT_REL = 'config/eslint/eslint.seatbelt.tsv'; @@ -136,37 +137,26 @@ type HistorySnapshot = { aggregates: Aggregates; }; -const stripQuotes = (field: string): string => { - const t = field.trim(); - if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) { - return t.slice(1, -1); - } - return t; -}; - const parseSeatbeltTsv = (content: string): SeatbeltRow[] => { - const rows: SeatbeltRow[] = []; - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - const parts = trimmed.split('\t'); - if (parts.length !== 3) { - console.warn(`eslint-report: skipping malformed line (${parts.length} columns): ${trimmed.slice(0, 120)}`); + const {rows} = TSVUtils.parse(content, { + onInvalidRow: (error, line) => { + console.warn(`eslint-report: skipping malformed line: ${error.message}: ${line.slice(0, 120)}`); + }, + }); + const parsed: SeatbeltRow[] = []; + for (const row of rows) { + if (row.cells.length !== 3) { + console.warn(`eslint-report: skipping malformed line (${row.cells.length} columns)`); continue; } - const rawPath = stripQuotes(parts.at(0) ?? ''); - const rule = stripQuotes(parts.at(1) ?? ''); - const countStr = parts.at(2) ?? ''; - const count = Number.parseInt(countStr.trim(), 10); - if (!Number.isFinite(count) || count < 0) { - console.warn(`eslint-report: skipping bad count for ${rawPath}: ${countStr}`); + const [rawPath, rule, count] = row.cells; + if (typeof rawPath !== 'string' || typeof rule !== 'string' || typeof count !== 'number' || !Number.isFinite(count) || count < 0) { + console.warn(`eslint-report: skipping bad row for ${String(rawPath)}`); continue; } - rows.push({rawPath, rule, count}); + parsed.push({rawPath, rule, count}); } - return rows; + return parsed; }; const normalizeFilePath = (projectRoot: string, seatbeltDir: string, rawPath: string): string => path.relative(projectRoot, path.resolve(seatbeltDir, rawPath)).split(path.sep).join('/'); diff --git a/scripts/generateCertificatePins.sh b/scripts/generateCertificatePins.sh index b5f28df7e215..6c13faa3c862 100755 --- a/scripts/generateCertificatePins.sh +++ b/scripts/generateCertificatePins.sh @@ -67,6 +67,65 @@ collect_pins() { rm -rf "$tmpdir" } +# --ca-pins: print & self-verify the multi-CA root + live-intermediate pins used by the +# Cloudflare-fronted expensify.com hosts (Groups A & B in pins.json). Cloudflare can issue those +# hosts from Let's Encrypt, Google Trust Services or SSL.com and rotate between them without notice, +# so we pin the SPKI of each CA's ROOT plus its live issuing intermediate. Each pin is recomputed +# from the CA's published certificate and compared against the value committed to pins.json. +spki_from_url() { + # $1 = URL to a PEM or DER certificate. Prints base64(SHA-256(SPKI)) or "FETCH-FAILED". + local url="$1" tmp pin + tmp="$(mktemp)" + if ! curl -fsSL "$url" -o "$tmp" 2>/dev/null; then rm -f "$tmp"; echo "FETCH-FAILED"; return; fi + pin="$(openssl x509 -in "$tmp" -inform pem -pubkey -noout 2>/dev/null | openssl pkey -pubin -outform der 2>/dev/null | openssl dgst -sha256 -binary | openssl enc -base64)" + if [ -z "$pin" ]; then + pin="$(openssl x509 -in "$tmp" -inform der -pubkey -noout 2>/dev/null | openssl pkey -pubin -outform der 2>/dev/null | openssl dgst -sha256 -binary | openssl enc -base64)" + fi + rm -f "$tmp" + echo "${pin:-PARSE-FAILED}" +} + +ca_pins() { + # name | published-cert URL | expected pin committed to pins.json + local entries=( + "Let's Encrypt ISRG Root X1|https://letsencrypt.org/certs/isrgrootx1.pem|C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=" + "Let's Encrypt ISRG Root X2|https://letsencrypt.org/certs/isrg-root-x2.pem|diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=" + "Let's Encrypt YE1 (live int)|https://letsencrypt.org/certs/gen-y/int-ye1.pem|brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=" + "GTS Root R1|https://pki.goog/repo/certs/gtsr1.pem|hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=" + "GTS Root R2|https://pki.goog/repo/certs/gtsr2.pem|Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=" + "GTS Root R3|https://pki.goog/repo/certs/gtsr3.pem|QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=" + "GTS Root R4|https://pki.goog/repo/certs/gtsr4.pem|mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=" + "GTS WE1 (live int)|https://pki.goog/repo/certs/we1.pem|kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=" + # SSL.com publishes these under https://www.ssl.com/repository/ - adjust the URL if SSL.com moves it. + "SSL.com TLS ECC Root CA 2022|https://ssl.com/repository/SSLcom-TLS-ECC-Root-2022.pem|G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=" + "SSL.com TLS RSA Root CA 2022|https://ssl.com/repository/SSLcom-TLS-RSA-Root-2022.pem|K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=" + ) + echo "Cloudflare-fronted expensify.com multi-CA pins (Groups A & B) - recomputed from published certs:" + echo + local rc=0 name url expected got + for e in "${entries[@]}"; do + IFS='|' read -r name url expected <<< "$e" + got="$(spki_from_url "$url")" + if [ "$got" = "$expected" ]; then + printf ' [ OK ] %-32s sha256/%s\n' "$name" "$expected" + elif [ "$got" = "FETCH-FAILED" ] || [ "$got" = "PARSE-FAILED" ]; then + printf ' [WARN] %-32s could not fetch/parse (%s); committed: sha256/%s\n' "$name" "$got" "$expected" + rc=1 + else + printf ' [FAIL] %-32s committed sha256/%s but published cert is sha256/%s\n' "$name" "$expected" "$got" + rc=1 + fi + done + echo + [ $rc -eq 0 ] && echo "All committed CA pins match the published certificates." || echo "One or more CA pins need attention (see above)." + return $rc +} + +if [ "${1:-}" = "--ca-pins" ]; then + ca_pins + exit $? +fi + echo "Certificate pins (generated $(date +%Y-%m-%d)):" echo diff --git a/scripts/lib/benchmarkAppStartup.ts b/scripts/lib/benchmarkAppStartup.ts new file mode 100644 index 000000000000..89e1e9e3ed9b --- /dev/null +++ b/scripts/lib/benchmarkAppStartup.ts @@ -0,0 +1,239 @@ +/** Coordinates native app startup runs and records the collected span samples without owning platform-specific device commands. */ + +import type {BenchmarkMetricResult, BenchmarkSample} from './benchmarkStatistics'; +import type {BenchmarkLogEvent, NativeAppBenchmarkAdapter, PlatformName, StartupMode} from './nativeAppBenchmark'; + +import {benchmarkMetrics, benchmarkResultTable, benchmarkResultsOutputPath, writeBenchmarkResults, writeBenchmarkSamples} from './benchmarkStatistics'; +import {createNativeAppBenchmarkAdapter} from './nativeAppBenchmark'; + +type BenchmarkRunOptions = { + mode: StartupMode; + spanNames: string[]; + runs: number; + waitTimeSeconds: number; + waitUntilSpan?: string; +}; +type BenchmarkStartupsOptions = BenchmarkRunOptions & { + outputPath: string; + appPath?: string; + resultsOutputPath?: string; +}; +type BenchmarkAlternatingStartupsOptions = BenchmarkRunOptions & { + appPathA?: string; + appPathB?: string; + outputPathA: string; + outputPathB: string; + resultsOutputPathA?: string; + resultsOutputPathB?: string; +}; +type BenchmarkAppStartupsOptions = BenchmarkStartupsOptions & { + platform: PlatformName; + rootDirectory: string; + appID: string; + deviceIdentifier?: string; +}; +type BenchmarkAppStartupsAlternatingOptions = BenchmarkAlternatingStartupsOptions & { + platform: PlatformName; + rootDirectory: string; + appIDA: string; + appIDB: string; + deviceIdentifier?: string; +}; +type BenchmarkResult = { + metrics: Record; + outputPath: string; + resultsOutputPath: string; +}; +type BenchmarkAlternatingResult = { + binaryA: BenchmarkResult; + binaryB: BenchmarkResult; +}; +type BenchmarkRecorderOptions = { + spanNames: string[]; + runs: number; + outputPath: string; + resultsOutputPath?: string; +}; +type BenchmarkRecorder = { + record: (events: BenchmarkLogEvent[], runNumber: number) => string[]; + complete: (label?: string) => Promise; +}; + +async function benchmarkAppStartups(options: BenchmarkAppStartupsOptions): Promise { + const adapter = await createNativeAppBenchmarkAdapter(options); + return benchmarkStartups(adapter, options); +} + +async function benchmarkAppStartupsAlternating(options: BenchmarkAppStartupsAlternatingOptions): Promise { + const {appIDA, appIDB, ...adapterOptions} = options; + const [adapterA, adapterB] = await Promise.all([ + createNativeAppBenchmarkAdapter({...adapterOptions, appID: appIDA}), + createNativeAppBenchmarkAdapter({...adapterOptions, appID: appIDB}), + ]); + return benchmarkAlternatingStartups({binaryA: adapterA, binaryB: adapterB}, options); +} + +/** Runs one unmeasured warm-up followed by the requested number of measured startups. */ +async function benchmarkStartups(adapter: NativeAppBenchmarkAdapter, options: BenchmarkStartupsOptions): Promise { + const resultsOutputPath = options.resultsOutputPath ?? benchmarkResultsOutputPath(options.outputPath); + console.log('=== Native app startup benchmark ==='); + console.table([ + { + platform: adapter.name, + device: adapter.deviceIdentifier, + appID: adapter.appID, + spans: options.spanNames.join(', '), + mode: options.mode, + measuredRuns: options.runs, + warmUpRuns: 1, + waitTimeSeconds: options.waitTimeSeconds, + waitUntilSpan: options.waitUntilSpan ?? 'wait time', + appPath: options.appPath ?? 'installed app', + outputPath: options.outputPath, + resultsOutputPath, + }, + ]); + console.log(`Running one unmeasured ${options.mode === 'cold' ? 'true-cold' : 'cold-process'} warm-up startup.`); + await measureStartup(adapter, options); + + const recorder = createBenchmarkRecorder({...options, resultsOutputPath}); + for (let runNumber = 1; runNumber <= options.runs; runNumber += 1) { + const events = await measureStartup(adapter, options); + const runMetrics = recorder.record(events, runNumber); + console.log(`Run ${runNumber}/${options.runs}: ${runMetrics.join(', ')}`); + } + + return recorder.complete(); +} + +/** Warms both binaries once, then alternates A and B measurements to reduce time-dependent comparison bias. */ +async function benchmarkAlternatingStartups( + adapters: {binaryA: NativeAppBenchmarkAdapter; binaryB: NativeAppBenchmarkAdapter}, + options: BenchmarkAlternatingStartupsOptions, +): Promise { + const {binaryA: adapterA, binaryB: adapterB} = adapters; + if (options.mode === 'cold' && (!options.appPathA || !options.appPathB)) { + throw new Error('Cold comparison mode requires app paths for both binaries.'); + } + const resultsOutputPathA = options.resultsOutputPathA ?? benchmarkResultsOutputPath(options.outputPathA); + const resultsOutputPathB = options.resultsOutputPathB ?? benchmarkResultsOutputPath(options.outputPathB); + console.log('=== Native app startup comparison ==='); + console.table([ + { + platform: adapterA.name, + device: adapterA.deviceIdentifier, + appIDA: adapterA.appID, + appIDB: adapterB.appID, + spans: options.spanNames.join(', '), + mode: options.mode, + measuredRunsPerBinary: options.runs, + warmUpRunsPerBinary: 1, + waitTimeSeconds: options.waitTimeSeconds, + waitUntilSpan: options.waitUntilSpan ?? 'wait time', + binaryA: options.appPathA ?? 'installed app', + outputA: options.outputPathA, + resultsOutputA: resultsOutputPathA, + binaryB: options.appPathB ?? 'installed app', + outputB: options.outputPathB, + resultsOutputB: resultsOutputPathB, + }, + ]); + + const measurementOptions = { + mode: options.mode, + spanNames: options.spanNames, + waitTimeSeconds: options.waitTimeSeconds, + waitUntilSpan: options.waitUntilSpan, + installArtifact: options.mode === 'cold', + } as const; + console.log(`Running one unmeasured warm-up startup for binary A (${options.appPathA ?? 'installed app'}).`); + await measureStartup(adapterA, {...measurementOptions, appPath: options.appPathA}); + console.log(`Running one unmeasured warm-up startup for binary B (${options.appPathB ?? 'installed app'}).`); + await measureStartup(adapterB, {...measurementOptions, appPath: options.appPathB}); + + const recorderA = createBenchmarkRecorder({spanNames: options.spanNames, runs: options.runs, outputPath: options.outputPathA, resultsOutputPath: resultsOutputPathA}); + const recorderB = createBenchmarkRecorder({spanNames: options.spanNames, runs: options.runs, outputPath: options.outputPathB, resultsOutputPath: resultsOutputPathB}); + for (let runNumber = 1; runNumber <= options.runs; runNumber += 1) { + const eventsA = await measureStartup(adapterA, {...measurementOptions, appPath: options.appPathA}); + const runMetricsA = recorderA.record(eventsA, runNumber); + console.log(`Binary A run ${runNumber}/${options.runs}: ${runMetricsA.join(', ')}`); + + const eventsB = await measureStartup(adapterB, {...measurementOptions, appPath: options.appPathB}); + const runMetricsB = recorderB.record(eventsB, runNumber); + console.log(`Binary B run ${runNumber}/${options.runs}: ${runMetricsB.join(', ')}`); + } + + const resultA = await recorderA.complete('Binary A metrics'); + const resultB = await recorderB.complete('Binary B metrics'); + return {binaryA: resultA, binaryB: resultB}; +} + +/** Accumulates measured runs and writes both raw samples and summary statistics when recording completes. */ +function createBenchmarkRecorder(options: BenchmarkRecorderOptions): BenchmarkRecorder { + const samples: BenchmarkSample[] = []; + const resultsOutputPath = options.resultsOutputPath ?? benchmarkResultsOutputPath(options.outputPath); + + return { + record(events: BenchmarkLogEvent[], runNumber: number): string[] { + return recordBenchmarkEvents(events, options.spanNames, samples, runNumber); + }, + async complete(label?: string): Promise { + const metrics = benchmarkMetrics(samples, options.spanNames); + const table = benchmarkResultTable(metrics); + await Promise.all([writeBenchmarkSamples(options.outputPath, samples), writeBenchmarkResults(resultsOutputPath, table)]); + if (label) { + console.log(label); + } + console.table(table); + for (const {span, runs} of table) { + if (runs >= options.runs) { + continue; + } + console.warn( + `WARNING: ${label ? `${label}: ` : ''}${span}: ${runs}/${options.runs} samples collected; ${options.runs - runs} missing. Statistics exclude missing samples and may be biased. Use a longer --wait-time without --wait-until-span to collect later spans.`, + ); + } + console.log(`Recorded benchmark samples in ${options.outputPath}`); + console.log(`Recorded benchmark results in ${resultsOutputPath}`); + return {metrics, outputPath: options.outputPath, resultsOutputPath}; + }, + }; +} + +/** Prepares one startup, optionally installs its artifact, then collects the configured span events from the launched process. */ +async function measureStartup( + adapter: NativeAppBenchmarkAdapter, + options: Omit & {installArtifact?: boolean}, +): Promise { + if (options.installArtifact) { + await adapter.prepareStartup(options.mode, options.appPath, true); + } else { + await adapter.prepareStartup(options.mode, options.appPath); + } + return adapter.launchAndCollect({spanNames: options.spanNames, waitTimeSeconds: options.waitTimeSeconds, waitUntilSpan: options.waitUntilSpan}); +} + +/** Appends observed spans to the raw samples while reporting requested spans that did not finish during the run. */ +function recordBenchmarkEvents(events: BenchmarkLogEvent[], spanNames: string[], samples: BenchmarkSample[], runNumber: number): string[] { + const eventsBySpan = new Map(events.map((event) => [event.span, event])); + return spanNames.map((spanName) => { + const event = eventsBySpan.get(spanName); + if (!event) { + return `${spanName}=not observed`; + } + samples.push({run: runNumber, span: spanName, durationMs: event.durationMs}); + return `${spanName}=${event.durationMs}ms`; + }); +} + +export {benchmarkAppStartupsAlternating, benchmarkAlternatingStartups, benchmarkAppStartups, benchmarkStartups, createBenchmarkRecorder}; +export type { + BenchmarkAlternatingResult, + BenchmarkAlternatingStartupsOptions, + BenchmarkAppStartupsAlternatingOptions, + BenchmarkAppStartupsOptions, + BenchmarkRecorder, + BenchmarkRecorderOptions, + BenchmarkResult, + BenchmarkStartupsOptions, +}; diff --git a/scripts/lib/benchmarkStatistics.ts b/scripts/lib/benchmarkStatistics.ts new file mode 100644 index 000000000000..5e5d2d39d90c --- /dev/null +++ b/scripts/lib/benchmarkStatistics.ts @@ -0,0 +1,207 @@ +/** Reads and writes benchmark artifacts, calculates per-span statistics, and formats result comparisons. */ + +import {file, write} from 'bun'; +import {extname} from 'node:path'; + +type BenchmarkStats = { + runs: number; + average: number; + p50: number; + p75: number; + p90: number; + p95: number; + p99: number; + min: number; + max: number; +}; + +type BenchmarkSample = { + run: number; + span: string; + durationMs: number; +}; + +type BenchmarkMetricResult = { + samples: number[]; + stats?: BenchmarkStats; +}; + +type BenchmarkResultTableRow = { + span: string; + runs: number; + average: string; + p50: string; + p75: string; + p90: string; + p95: string; + p99: string; + min: string; + max: string; +}; + +type ExportBenchmarkResultsOptions = { + inputPaths: readonly string[]; + outputPath: string; +}; + +const BENCHMARK_SAMPLE_HEADER = 'run,span,duration_ms'; +const BENCHMARK_RESULTS_HEADER = 'span,runs,average,p50,p75,p90,p95,p99,min,max'; + +/** Combines raw sample files, calculates per-span statistics, and writes the summary CSV. */ +async function exportBenchmarkResults(options: ExportBenchmarkResultsOptions): Promise { + if (options.inputPaths.length === 0) { + throw new Error('At least one benchmark sample file is required.'); + } + const sampleGroups = await Promise.all(options.inputPaths.map(readBenchmarkSamples)); + const samples = sampleGroups.flat(); + if (samples.length === 0) { + throw new Error('No benchmark samples were found in the input files.'); + } + const table = benchmarkResultTable(benchmarkMetrics(samples)); + await writeBenchmarkResults(options.outputPath, table); + return table; +} + +/** Groups samples by span while retaining explicitly requested spans that have no samples. */ +function benchmarkMetrics(samples: readonly BenchmarkSample[], spanNames?: readonly string[]): Record { + const samplesBySpan = new Map(); + for (const sample of samples) { + const spanSamples = samplesBySpan.get(sample.span) ?? []; + spanSamples.push(sample.durationMs); + samplesBySpan.set(sample.span, spanSamples); + } + + const metricSpanNames = spanNames ?? [...samplesBySpan.keys()]; + return Object.fromEntries( + metricSpanNames.map((spanName) => { + const spanSamples = samplesBySpan.get(spanName) ?? []; + return [spanName, {samples: spanSamples, stats: spanSamples.length > 0 ? benchmarkStats(spanSamples) : undefined}]; + }), + ); +} + +/** Summarizes a non-empty sample set with its average, interpolated percentiles, and range. */ +function benchmarkStats(samples: readonly number[]): BenchmarkStats { + const sortedValues = samples.toSorted((left, right) => left - right); + const min = sortedValues.at(0); + const max = sortedValues.at(-1); + if (min === undefined || max === undefined) { + throw new Error('No benchmark samples were recorded.'); + } + + return { + runs: samples.length, + average: samples.reduce((sum, value) => sum + value, 0) / samples.length, + p50: percentileFromSortedValues(sortedValues, 0.5), + p75: percentileFromSortedValues(sortedValues, 0.75), + p90: percentileFromSortedValues(sortedValues, 0.9), + p95: percentileFromSortedValues(sortedValues, 0.95), + p99: percentileFromSortedValues(sortedValues, 0.99), + min, + max, + }; +} + +/** Formats metrics for console and CSV output, representing spans without samples as `N/A`. */ +function benchmarkResultTable(metrics: Readonly>): BenchmarkResultTableRow[] { + return Object.entries(metrics).map(([span, metric]) => ({ + span, + runs: metric.samples.length, + average: metric.stats?.average.toFixed(2) ?? 'N/A', + p50: metric.stats?.p50.toFixed(2) ?? 'N/A', + p75: metric.stats?.p75.toFixed(2) ?? 'N/A', + p90: metric.stats?.p90.toFixed(2) ?? 'N/A', + p95: metric.stats?.p95.toFixed(2) ?? 'N/A', + p99: metric.stats?.p99.toFixed(2) ?? 'N/A', + min: metric.stats?.min.toFixed(2) ?? 'N/A', + max: metric.stats?.max.toFixed(2) ?? 'N/A', + })); +} + +function writeBenchmarkSamples(outputPath: string, samples: readonly BenchmarkSample[]): Promise { + return writeBenchmarkCsv(outputPath, benchmarkSamplesCsv(samples)); +} + +function writeBenchmarkResults(outputPath: string, table: readonly BenchmarkResultTableRow[]): Promise { + return writeBenchmarkCsv(outputPath, benchmarkResultsCsv(table)); +} + +/** Reads and validates the exact raw-sample CSV schema produced by the benchmark runner. */ +async function readBenchmarkSamples(inputPath: string): Promise { + const [header, ...rows] = (await file(inputPath).text()).trim().split(/\r?\n/); + if (header !== BENCHMARK_SAMPLE_HEADER) { + throw new Error(`Invalid benchmark sample header in ${inputPath}. Expected: ${BENCHMARK_SAMPLE_HEADER}`); + } + + return rows.filter(Boolean).map((row, rowIndex) => { + const [runValue, span, durationValue, unexpectedValue] = row.split(','); + const run = Number(runValue); + const durationMs = Number(durationValue); + if (unexpectedValue !== undefined || !Number.isSafeInteger(run) || run <= 0 || !span || !Number.isFinite(durationMs)) { + throw new Error(`Invalid benchmark sample in ${inputPath} on row ${rowIndex + 2}: ${row}`); + } + return {run, span, durationMs}; + }); +} + +function benchmarkResultsOutputPath(sampleOutputPath: string): string { + const extension = extname(sampleOutputPath); + const outputPathWithoutExtension = extension ? sampleOutputPath.slice(0, -extension.length) : sampleOutputPath; + return `${outputPathWithoutExtension}-results.csv`; +} + +function benchmarkSamplesCsv(samples: readonly BenchmarkSample[]): string[] { + return [BENCHMARK_SAMPLE_HEADER, ...samples.map((sample) => [sample.run, sample.span, sample.durationMs].join(','))]; +} + +function benchmarkResultsCsv(table: readonly BenchmarkResultTableRow[]): string[] { + return [BENCHMARK_RESULTS_HEADER, ...table.map((row) => [row.span, row.runs, row.average, row.p50, row.p75, row.p90, row.p95, row.p99, row.min, row.max].join(','))]; +} + +/** Sorts a copy of the samples before calculating a percentile for a fraction between zero and one. */ +function percentile(values: readonly number[], fraction: number): number { + return percentileFromSortedValues( + values.toSorted((left, right) => left - right), + fraction, + ); +} + +async function writeBenchmarkCsv(outputPath: string, csvRows: readonly string[]): Promise { + await write(outputPath, [...csvRows, ''].join('\n')); +} + +/** Calculates a percentile from sorted samples using linear interpolation between adjacent values. */ +function percentileFromSortedValues(sortedValues: readonly number[], fraction: number): number { + if (fraction < 0 || fraction > 1) { + throw new Error(`Percentile fraction must be between 0 and 1. Received: ${fraction}`); + } + if (sortedValues.length === 0) { + throw new Error('Cannot calculate a percentile without benchmark samples.'); + } + + const position = (sortedValues.length - 1) * fraction; + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const remainder = position - lowerIndex; + const lowerValue = sortedValues.at(lowerIndex); + const upperValue = sortedValues.at(upperIndex); + if (lowerValue === undefined || upperValue === undefined) { + throw new Error('Cannot calculate a percentile without benchmark samples.'); + } + return lowerValue + remainder * (upperValue - lowerValue); +} + +export { + benchmarkMetrics, + benchmarkResultTable, + benchmarkResultsCsv, + benchmarkResultsOutputPath, + benchmarkSamplesCsv, + benchmarkStats, + exportBenchmarkResults, + percentile, + readBenchmarkSamples, + writeBenchmarkResults, + writeBenchmarkSamples, +}; +export type {BenchmarkMetricResult, BenchmarkResultTableRow, BenchmarkSample, BenchmarkStats, ExportBenchmarkResultsOptions}; diff --git a/scripts/lib/bootstrapForDevice/android.ts b/scripts/lib/bootstrapForDevice/android.ts new file mode 100644 index 000000000000..f6c974d58137 --- /dev/null +++ b/scripts/lib/bootstrapForDevice/android.ts @@ -0,0 +1,239 @@ +/** Patches selected legacy Android build variants with unique identifiers, Firebase settings, shortcuts, and labels. */ + +import {isJSONArray, isJSONObject} from '@src/types/utils/JSONUtils'; + +import type {JsonObject, JsonValue} from 'type-fest'; + +import {file, write} from 'bun'; +import {resolve} from 'node:path'; + +import type {AndroidBootstrapOptions, BuildVariant, BuildVariants} from './shared'; + +import {DEFAULT_BUILD_VARIANTS, validateIdentifierSuffix} from './shared'; + +const REGISTERED_ANDROID_APPLICATION_IDS = { + release: 'org.me.mobiexpensifyg', + debug: 'org.me.mobiexpensifyg.dev', + adhoc: 'org.me.mobiexpensifyg.adhoc', + appTestFork: 'org.me.mobiexpensifyg.appTestFork', +} as const; +const ANDROID_APPLICATION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/; + +type AndroidBuildType = BuildVariant | 'appTestFork'; +type AndroidApplicationIDs = Record; +type GoogleServicesConfig = JsonObject & {client: JsonValue[]}; +type GoogleServicesAndroidClientInfo = JsonObject & Record<'package_name', string>; +type GoogleServicesClientInfo = JsonObject & Record<'android_client_info', GoogleServicesAndroidClientInfo>; +type GoogleServicesClient = JsonObject & Record<'client_info', GoogleServicesClientInfo>; + +/** Rewrites Android package, Firebase, shortcut, manifest, and label settings for a side-by-side local installation. */ +async function bootstrapAndroidForDevice(options: AndroidBootstrapOptions): Promise { + const androidDirectory = resolve(options.rootDirectory, 'Mobile-Expensify/Android'); + const buildVariants = options.buildVariants ?? DEFAULT_BUILD_VARIANTS; + const identifierSuffix = validateIdentifierSuffix(options.identifierSuffix); + const androidIdentifierSuffix = identifierSuffix ? normalizeAndroidIdentifierSegment(identifierSuffix) : undefined; + const baseIdentifier = validateAndroidApplicationID(options.bundleIdentifier); + const applicationIDs = androidApplicationIDs(baseIdentifier, androidIdentifierSuffix); + + const buildGradlePath = resolve(androidDirectory, 'build.gradle'); + const buildGradle = await file(buildGradlePath).text(); + await write(buildGradlePath, patchAndroidBuildGradle(buildGradle, applicationIDs.release, buildVariants)); + + const googleServicesPath = resolve(androidDirectory, 'google-services.json'); + // Bun exposes parsed JSON without a generic return type; successful parsing can only produce a JsonValue. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const googleServices = (await file(googleServicesPath).json()) as JsonValue; + await write(googleServicesPath, `${JSON.stringify(patchGoogleServicesConfig(googleServices, applicationIDs, buildVariants), null, 2)}\n`); + + const manifestPath = resolve(androidDirectory, 'AndroidManifest.xml'); + await write(manifestPath, patchAndroidManifest(await file(manifestPath).text())); + + const shortcutsByBuildType = { + release: 'res/xml-v25/shortcuts.xml', + debug: 'build-types/debug/res/xml-v25/shortcuts.xml', + adhoc: 'build-types/adhoc/res/xml-v25/shortcuts.xml', + } as const; + for (const buildType of buildVariants) { + const relativePath = shortcutsByBuildType[buildType]; + const shortcutsPath = resolve(androidDirectory, relativePath); + await write(shortcutsPath, patchAndroidShortcutPackage(await file(shortcutsPath).text(), applicationIDs[buildType])); + } + + const identifierSuffixLabel = identifierSuffix ? ` (${identifierSuffix})` : ''; + const appNamesByBuildType = { + release: {path: 'res/values/strings.xml', name: `Expensify${identifierSuffixLabel}`}, + debug: {path: 'build-types/debug/res/values/strings.xml', name: `Expensify Debug${identifierSuffixLabel}`}, + adhoc: {path: 'build-types/adhoc/res/values/strings.xml', name: `Expensify AdHoc${identifierSuffixLabel}`}, + } as const; + for (const buildType of buildVariants) { + const {path, name} = appNamesByBuildType[buildType]; + const stringsPath = resolve(androidDirectory, path); + await write(stringsPath, patchAndroidAppName(await file(stringsPath).text(), name)); + } + + console.log(`Configured Mobile-Expensify Android build variants: ${buildVariants.join(', ')}.`); + console.table(Object.fromEntries(buildVariants.map((buildVariant) => [buildVariant, applicationIDs[buildVariant]]))); + console.warn( + 'Firebase resources are reused from the registered Expensify clients. Google Sign-In and other package/signature-restricted Google APIs will not work for synthetic application IDs.', + ); +} + +/** Adds Firebase clients for missing local package variants by copying the corresponding registered build-type clients. */ +function patchGoogleServicesConfig(config: JsonValue, applicationIDs: AndroidApplicationIDs, buildVariants: BuildVariants = DEFAULT_BUILD_VARIANTS): GoogleServicesConfig { + if (!isGoogleServicesConfig(config)) { + throw new Error('Mobile-Expensify/Android/google-services.json has an unexpected structure.'); + } + const clients = [...config.client]; + for (const buildType of buildVariants) { + const applicationID = applicationIDs[buildType]; + if (clients.some((client) => googleServicesClientPackage(client) === applicationID)) { + continue; + } + const registeredApplicationID = REGISTERED_ANDROID_APPLICATION_IDS[buildType]; + const sourceClient = clients.find((client) => googleServicesClientPackage(client) === registeredApplicationID); + if (!sourceClient) { + throw new Error(`google-services.json does not contain the registered ${buildType} client ${registeredApplicationID}.`); + } + clients.push(cloneGoogleServicesClient(sourceClient, applicationID)); + } + return {...config, client: clients}; +} + +/** Changes the shared base application ID and makes the selected release-derived build types locally signable without minification. */ +function patchAndroidBuildGradle(buildGradle: string, baseIdentifier: string, buildVariants: BuildVariants = DEFAULT_BUILD_VARIANTS): string { + const applicationIDPattern = /(defaultConfig\s*\{[\s\S]*?applicationId\s+)["'][^"']+["']/; + if (!applicationIDPattern.test(buildGradle)) { + throw new Error('Could not find defaultConfig.applicationId in Mobile-Expensify/Android/build.gradle.'); + } + + let patched = buildGradle.replace(applicationIDPattern, `$1"${baseIdentifier}"`); + for (const buildVariant of buildVariants) { + patched = patchAndroidBuildType(patched, buildVariant); + } + return patched; +} + +/** Points an Android shortcut resource at the package for its build type. */ +function patchAndroidShortcutPackage(shortcuts: string, applicationID: string): string { + return shortcuts.replaceAll(/android:targetPackage="[^"]+"/g, `android:targetPackage="${applicationID}"`); +} + +/** Replaces the manifest's hard-coded shortcut package with Gradle's application-ID placeholder. */ +function patchAndroidManifest(manifest: string): string { + const applicationIDPlaceholder = ['$', '{applicationId}'].join(''); + return manifest.replace(/android:targetPackage="org\.me\.mobiexpensifyg"/, `android:targetPackage="${applicationIDPlaceholder}"`); +} + +/** Replaces the Android app label while preserving the rest of the string resources. */ +function patchAndroidAppName(strings: string, name: string): string { + const appNamePattern = /[^<]+<\/string>/; + if (!appNamePattern.test(strings)) { + throw new Error('Could not find app_name in an Android strings.xml file.'); + } + return strings.replace(appNamePattern, `${name}`); +} + +/** Derives the package names used by every Android build type from the local release identifier. */ +function androidApplicationIDs(baseIdentifier: string, identifierSuffix?: string): AndroidApplicationIDs { + const release = [baseIdentifier, identifierSuffix].filter(Boolean).join('.'); + return { + release, + debug: `${release}.dev`, + adhoc: `${release}.adhoc`, + appTestFork: `${release}.appTestFork`, + }; +} + +/** Converts a GitHub-style name into a valid Java identifier segment, including names that begin with a digit. */ +function normalizeAndroidIdentifierSegment(value: string): string { + const normalized = value.replaceAll('-', '_'); + return /^\d/.test(normalized) ? `developer_${normalized}` : normalized; +} + +function validateAndroidApplicationID(value: string): string { + if (!ANDROID_APPLICATION_ID_PATTERN.test(value)) { + throw new Error(`Android application ID must use dot-separated Java identifier segments. Received: ${value}`); + } + return value; +} + +/** Applies local signing and minification overrides to one Android build type when it inherits release settings. */ +function patchAndroidBuildType(buildGradle: string, buildVariant: BuildVariant): string { + if (buildVariant === 'debug') { + return buildGradle; + } + + const blockPattern = new RegExp(`buildTypes\\s*\\{[\\s\\S]*?(^\\s*${buildVariant}\\s*\\{[\\s\\S]*?^\\s*\\})`, 'm'); + const block = buildGradle.match(blockPattern)?.at(1); + if (!block) { + throw new Error(`Could not find the ${buildVariant} build type in Mobile-Expensify/Android/build.gradle.`); + } + + let patchedBlock = setGradleSetting(block, 'signingConfig', 'signingConfigs.debug'); + patchedBlock = setGradleSetting(patchedBlock, 'minifyEnabled', 'false'); + if (buildVariant === 'release') { + patchedBlock = patchedBlock.replace(/^(\s*)(?!\/\/)(proguardFiles\s+.+)$/m, '$1// $2'); + } + return buildGradle.replace(block, patchedBlock); +} + +/** Replaces a Gradle build-type setting or adds it after the inherited build type so the override wins. */ +function setGradleSetting(block: string, key: string, value: string): string { + const settingPattern = new RegExp(`^(\\s*)${key}\\s+.*$`, 'm'); + if (settingPattern.test(block)) { + return block.replace(settingPattern, `$1${key} ${value}`); + } + + const inheritedBuildTypePattern = /^(\s*)initWith\s+\w+\s*$/m; + if (inheritedBuildTypePattern.test(block)) { + return block.replace(inheritedBuildTypePattern, `$&\n$1${key} ${value}`); + } + + const openingPattern = /^(\s*)\w+\s*\{\s*$/m; + if (!openingPattern.test(block)) { + throw new Error(`Could not add ${key} to an Android build type.`); + } + return block.replace(openingPattern, `$&\n$1 ${key} ${value}`); +} + +/** Clones a registered Firebase client for a synthetic package and removes OAuth clients that cannot work with its unregistered package and certificate pair. */ +function cloneGoogleServicesClient(client: JsonValue, applicationID: string): GoogleServicesClient { + if (!isGoogleServicesClient(client)) { + throw new Error('google-services.json contains an invalid Android client.'); + } + const cloned = structuredClone(client); + cloned.client_info.android_client_info.package_name = applicationID; + // Android OAuth clients are restricted to the registered package and signing certificate. Retaining them would imply that Google Sign-In works for the synthetic application ID. + cloned.oauth_client = []; + return cloned; +} + +function googleServicesClientPackage(client: JsonValue): string | undefined { + return isGoogleServicesClient(client) ? client.client_info.android_client_info.package_name : undefined; +} + +function isGoogleServicesConfig(value: JsonValue): value is GoogleServicesConfig { + return isJSONObject(value) && isJSONArray(value.client); +} + +function isGoogleServicesClient(value: JsonValue): value is GoogleServicesClient { + return ( + isJSONObject(value) && + isJSONObject(value.client_info) && + isJSONObject(value.client_info.android_client_info) && + typeof value.client_info.android_client_info.package_name === 'string' + ); +} + +export { + androidApplicationIDs, + bootstrapAndroidForDevice, + normalizeAndroidIdentifierSegment, + patchAndroidAppName, + patchAndroidBuildGradle, + patchAndroidManifest, + patchAndroidShortcutPackage, + patchGoogleServicesConfig, + validateAndroidApplicationID, +}; +export type {AndroidApplicationIDs}; diff --git a/scripts/lib/bootstrapForDevice/developmentTeams.ts b/scripts/lib/bootstrapForDevice/developmentTeams.ts new file mode 100644 index 000000000000..96639f3179ff --- /dev/null +++ b/scripts/lib/bootstrapForDevice/developmentTeams.ts @@ -0,0 +1,95 @@ +// cspell:ignore apos noverify smime + +/** Discovers local Apple development teams from provisioning profiles and prompts for a team when none was provided. */ + +import {$, env, Glob} from 'bun'; +import {join} from 'node:path'; +import {createInterface} from 'node:readline/promises'; + +const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/; + +type DevelopmentTeam = { + id: string; + name: string; +}; + +async function resolveDevelopmentTeam(developmentTeam: string | undefined, teams?: DevelopmentTeam[], prompt = promptForDevelopmentTeam): Promise { + if (developmentTeam) { + return developmentTeam; + } + return prompt(teams ?? (await installedDevelopmentTeams())); +} + +/** Finds valid signing teams in Xcode's current and legacy provisioning-profile directories and deduplicates them by team ID. */ +async function installedDevelopmentTeams(): Promise { + const homeDirectory = env.HOME; + if (!homeDirectory) { + throw new Error('Could not locate provisioning profiles because HOME is not set.'); + } + const profileDirectories = [join(homeDirectory, 'Library/Developer/Xcode/UserData/Provisioning Profiles'), join(homeDirectory, 'Library/MobileDevice/Provisioning Profiles')]; + const teams = new Map(); + for (const directory of profileDirectories) { + let profileNames: string[]; + try { + profileNames = [...new Glob('*.mobileprovision').scanSync({cwd: directory, onlyFiles: true})]; + } catch { + continue; + } + for (const profileName of profileNames) { + const profile = await decodeProvisioningProfile(join(directory, profileName)); + const team = profile ? parseDevelopmentTeamFromProvisioningProfile(profile) : undefined; + if (team) { + teams.set(team.id, team); + } + } + } + return [...teams.values()].toSorted((left, right) => left.name.localeCompare(right.name)); +} + +async function promptForDevelopmentTeam(teams: DevelopmentTeam[]): Promise { + if (teams.length === 0) { + throw new Error('No Apple development teams were found. Add your Apple ID in Xcode > Settings > Accounts and download or create a provisioning profile, or pass --development-team.'); + } + + console.log('Select an Apple development team:'); + for (const [index, team] of teams.entries()) { + console.log(` ${index + 1}. ${team.name} (${team.id})`); + } + const readline = createInterface({input: process.stdin, output: process.stdout}); + try { + while (true) { + const answer = await readline.question('Team: '); + const selectedTeam = teams.at(Number(answer) - 1); + if (selectedTeam && /^\d+$/.test(answer.trim())) { + return selectedTeam.id; + } + console.log(`Enter a number from 1 to ${teams.length}.`); + } + } finally { + readline.close(); + } +} + +/** Extracts the signing team from a decoded provisioning profile, excluding malformed and expired profiles. */ +function parseDevelopmentTeamFromProvisioningProfile(profile: string, now = new Date()): DevelopmentTeam | undefined { + const teamID = profile.match(/TeamIdentifier<\/key>\s*\s*([^<]+)<\/string>/)?.at(1); + const teamName = profile.match(/TeamName<\/key>\s*([^<]+)<\/string>/)?.at(1); + const expirationDate = profile.match(/ExpirationDate<\/key>\s*([^<]+)<\/date>/)?.at(1); + if (!teamID || !teamName || !TEAM_ID_PATTERN.test(teamID) || (expirationDate && new Date(expirationDate) <= now)) { + return undefined; + } + return {id: teamID, name: decodeXml(teamName)}; +} + +/** Decodes the CMS payload in a provisioning profile, returning undefined when OpenSSL cannot verify or read it. */ +async function decodeProvisioningProfile(path: string): Promise { + const result = await $`openssl smime -verify -inform der -noverify -in ${path}`.quiet().nothrow(); + return result.exitCode === 0 ? result.stdout.toString() : undefined; +} + +function decodeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'"); +} + +export {installedDevelopmentTeams, parseDevelopmentTeamFromProvisioningProfile, resolveDevelopmentTeam}; +export type {DevelopmentTeam}; diff --git a/scripts/lib/bootstrapForDevice/ios.ts b/scripts/lib/bootstrapForDevice/ios.ts new file mode 100644 index 000000000000..4adc4c16626e --- /dev/null +++ b/scripts/lib/bootstrapForDevice/ios.ts @@ -0,0 +1,205 @@ +/** Patches the legacy Xcode project, plist values, and entitlements for local signing under unique app identifiers. */ + +import type {TupleToUnion} from 'type-fest'; + +import {file, write} from 'bun'; +import {resolve} from 'node:path'; + +import type {BootstrapOptions, BuildVariant, BuildVariants} from './shared'; + +import {DEFAULT_BUILD_VARIANTS, validateIdentifierSuffix} from './shared'; + +const TARGETS = ['Expensify', 'SmartScanExtension', 'NotificationServiceExtension', 'LiveActivityExtension', 'ExpensifyTests'] as const; +const BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/; + +type Configuration = 'Debug' | 'Release' | 'AdHoc'; +type Target = TupleToUnion; + +const CONFIGURATION_BY_BUILD_VARIANT: Record = { + release: 'Release', + debug: 'Debug', + adhoc: 'AdHoc', +}; +const APP_GROUP_ENTITLEMENT_FILES_BY_BUILD_VARIANT: Record = { + release: [ + 'Expensify/ExpensifyRelease.entitlements', + 'LiveActivityExtensionRelease.entitlements', + 'NotificationServiceExtension/NotificationServiceExtension.entitlements', + 'SmartScanExtension/SmartScanExtension.entitlements', + ], + debug: [ + 'Expensify/Expensify.entitlements', + 'LiveActivityExtensionDebug.entitlements', + 'NotificationServiceExtension/NotificationServiceExtension.entitlements', + 'SmartScanExtension/SmartScanExtension.entitlements', + ], + adhoc: [ + 'Expensify/Expensify.entitlements', + 'LiveActivityExtensionAdHoc.entitlements', + 'NotificationServiceExtension/NotificationServiceExtension.entitlements', + 'SmartScanExtension/SmartScanExtension.entitlements', + ], +}; + +/** Rewrites the native iOS project, display name, and app-group entitlements for local device signing. */ +async function bootstrapIOSForDevice(options: BootstrapOptions): Promise { + const iosDirectory = resolve(options.rootDirectory, 'Mobile-Expensify/iOS'); + const projectPath = resolve(iosDirectory, 'Expensify.xcodeproj/project.pbxproj'); + const buildVariants = options.buildVariants ?? DEFAULT_BUILD_VARIANTS; + const identifierSuffix = validateIdentifierSuffix(options.identifierSuffix); + const baseIdentifier = validateIdentifier(options.bundleIdentifier, 'Bundle identifier'); + if (!TEAM_ID_PATTERN.test(options.developmentTeam)) { + throw new Error(`Apple development team must be a 10-character team ID. Received: ${options.developmentTeam}`); + } + + const project = await file(projectPath).text(); + const patchedProject = patchProject(project, baseIdentifier, identifierSuffix, options.developmentTeam, buildVariants); + await write(projectPath, patchedProject); + + const infoPlistPath = resolve(iosDirectory, 'Expensify/Expensify-Info.plist'); + const infoPlist = await file(infoPlistPath).text(); + await write(infoPlistPath, patchIOSAppDisplayName(infoPlist, identifierSuffix)); + + const appGroup = `group.${[baseIdentifier, identifierSuffix].filter(Boolean).join('.')}`; + const entitlements = entitlementContents(appGroup); + const entitlementFiles = new Set(buildVariants.flatMap((buildVariant) => APP_GROUP_ENTITLEMENT_FILES_BY_BUILD_VARIANT[buildVariant])); + for (const entitlementFile of entitlementFiles) { + await write(resolve(iosDirectory, entitlementFile), entitlements); + } + + console.log(`Configured Mobile-Expensify iOS build variants: ${buildVariants.join(', ')}.`); + console.table({ + developmentTeam: options.developmentTeam, + appGroup, + ...Object.fromEntries( + buildVariants.map((buildVariant) => [ + `${buildVariant}BundleIdentifier`, + targetBundleIdentifier(baseIdentifier, 'Expensify', CONFIGURATION_BY_BUILD_VARIANT[buildVariant], identifierSuffix), + ]), + ), + }); +} + +/** Applies automatic signing and unique bundle identifiers to the selected configurations of every supported Xcode target. */ +function patchProject(project: string, baseIdentifier: string, identifierSuffix: string | undefined, developmentTeam: string, buildVariants: BuildVariants = DEFAULT_BUILD_VARIANTS): string { + const selectedConfigurations = buildVariants.map((buildVariant) => CONFIGURATION_BY_BUILD_VARIANT[buildVariant]); + const configurationsByTarget = configurationIDsByTarget(project, selectedConfigurations); + let patched = project.replaceAll('ProvisioningStyle = Manual;', 'ProvisioningStyle = Automatic;'); + + for (const target of TARGETS) { + const configurations = configurationsByTarget.get(target); + if (!configurations) { + throw new Error(`Could not find configurations for ${target}.`); + } + for (const [configuration, identifier] of configurations) { + const bundleIdentifier = targetBundleIdentifier(baseIdentifier, target, configuration, identifierSuffix); + patched = patchBuildConfiguration(patched, identifier, bundleIdentifier, developmentTeam, target === 'Expensify' && configuration === 'Release'); + } + } + return patched; +} + +/** Adds the optional identifier suffix to the iOS display name so side-by-side installations remain distinguishable. */ +function patchIOSAppDisplayName(infoPlist: string, identifierSuffix: string | undefined): string { + const displayNamePattern = /(CFBundleDisplayName<\/key>\s*)[^<]*(<\/string>)/; + if (!displayNamePattern.test(infoPlist)) { + throw new Error('Could not find CFBundleDisplayName in Mobile-Expensify/iOS/Expensify/Expensify-Info.plist.'); + } + const identifierSuffixLabel = identifierSuffix ? ` (${identifierSuffix})` : ''; + return infoPlist.replace(displayNamePattern, `$1Expensify${identifierSuffixLabel}$2`); +} + +/** Composes a unique bundle identifier from the identifier suffix, build configuration, and native target. */ +function targetBundleIdentifier(baseIdentifier: string, target: Target, configuration: Configuration, identifierSuffix?: string): string { + const configurationSuffix = configuration === 'AdHoc' ? 'adhoc' : undefined; + const targetSuffix = target === 'Expensify' ? undefined : target; + return [baseIdentifier, identifierSuffix, configurationSuffix, targetSuffix].filter(Boolean).join('.'); +} + +/** Creates the minimal entitlements plist shared by the locally signed app and its extensions. */ +function entitlementContents(appGroup: string): string { + return ` + + + +\tcom.apple.security.application-groups +\t +\t\t${appGroup} +\t + + +`; +} + +/** Configures one Xcode build configuration for automatic local signing and its derived bundle identifier. */ +function patchBuildConfiguration(project: string, identifier: string, bundleIdentifier: string, developmentTeam: string, releaseEntitlements: boolean): string { + const blockPattern = new RegExp(`(^\\s*${identifier} \\/\\* [^*]+ \\*\\/ = \\{[\\s\\S]*?^\\s*\\};)`, 'm'); + const block = project.match(blockPattern)?.at(1); + if (!block) { + throw new Error(`Could not find build configuration ${identifier}.`); + } + + let patched = block; + for (const key of ['CODE_SIGN_IDENTITY[sdk=iphoneos*]', 'DEVELOPMENT_TEAM[sdk=iphoneos*]', 'PROVISIONING_PROFILE[sdk=iphoneos*]', 'PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]']) { + patched = removeBuildSetting(patched, key); + } + patched = setBuildSetting(patched, 'CODE_SIGN_IDENTITY', '"Apple Development"'); + patched = setBuildSetting(patched, 'CODE_SIGN_STYLE', 'Automatic'); + patched = setBuildSetting(patched, 'DEVELOPMENT_TEAM', developmentTeam); + patched = setBuildSetting(patched, 'PRODUCT_BUNDLE_IDENTIFIER', bundleIdentifier); + patched = setBuildSetting(patched, 'PROVISIONING_PROFILE', '""'); + patched = setBuildSetting(patched, 'PROVISIONING_PROFILE_SPECIFIER', '""'); + if (releaseEntitlements) { + patched = setBuildSetting(patched, 'CODE_SIGN_ENTITLEMENTS', 'Expensify/ExpensifyRelease.entitlements'); + } + return project.replace(block, patched); +} + +/** Maps each native target and selected build configuration to its XCBuildConfiguration identifier in the Xcode project. */ +function configurationIDsByTarget(project: string, selectedConfigurations: readonly Configuration[]): Map> { + const result = new Map>(); + for (const target of TARGETS) { + const listPattern = new RegExp(`\\/\\* Build configuration list for PBXNativeTarget "${target}" \\*\\/ = \\{[\\s\\S]*?buildConfigurations = \\(\\s*([\\s\\S]*?)\\s*\\);`); + const list = project.match(listPattern)?.at(1); + if (!list) { + throw new Error(`Could not find build configurations for the ${target} target.`); + } + const configurations = new Map(); + for (const configuration of selectedConfigurations) { + const identifier = list.match(new RegExp(`([A-F0-9]{24}) \\/\\* ${configuration} \\*\\/`))?.at(1); + if (!identifier) { + throw new Error(`Could not find the ${configuration} configuration for the ${target} target.`); + } + configurations.set(configuration, identifier); + } + result.set(target, configurations); + } + return result; +} + +/** Replaces a build setting in an XCBuildConfiguration block or inserts it when absent. */ +function setBuildSetting(block: string, key: string, value: string): string { + const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^(\\s*)"?${escapedKey}"? = [^;]*;$`, 'gm'); + if (pattern.test(block)) { + return block.replaceAll(pattern, (_line, indentation: string) => `${indentation}${key} = ${value};`); + } + return block.replace(/(\n\s*buildSettings = \{)/, `$1\n\t\t\t\t${key} = ${value};`); +} + +/** Removes every quoted or unquoted occurrence of a build setting from an XCBuildConfiguration block. */ +function removeBuildSetting(block: string, key: string): string { + const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return block.replaceAll(new RegExp(`^\\s*"?${escapedKey}"? = [^;]*;\\n`, 'gm'), ''); +} + +function validateIdentifier(value: string, label: string): string { + if (!BUNDLE_IDENTIFIER_PATTERN.test(value)) { + throw new Error(`${label} must contain dot-separated letters, numbers, or hyphens. Received: ${value}`); + } + return value; +} + +export {bootstrapIOSForDevice, entitlementContents, patchIOSAppDisplayName, patchProject, targetBundleIdentifier}; +export type {Configuration, Target}; diff --git a/scripts/lib/bootstrapForDevice/shared.ts b/scripts/lib/bootstrapForDevice/shared.ts new file mode 100644 index 000000000000..7912d1a2771d --- /dev/null +++ b/scripts/lib/bootstrapForDevice/shared.ts @@ -0,0 +1,51 @@ +/** Defines the shared bootstrap options and validates identifier suffixes before platform-specific files are changed. */ + +import type {TupleToUnion} from 'type-fest'; + +const PLATFORMS = ['ios', 'android'] as const; +const BUILD_VARIANTS = ['release', 'debug', 'adhoc'] as const; + +type BuildVariant = TupleToUnion; +type BuildVariants = readonly [BuildVariant, ...BuildVariant[]]; + +const DEFAULT_BUILD_VARIANTS: BuildVariants = ['release']; + +type BootstrapOptions = { + rootDirectory: string; + developmentTeam: string; + bundleIdentifier: string; + buildVariants?: BuildVariants; + identifierSuffix?: string; +}; +type AndroidBootstrapOptions = Omit; + +/** Parses and deduplicates the comma-separated native build variants selected for patching. */ +function parseBuildVariants(value: string): BuildVariants { + const [firstValue = '', ...remainingValues] = value.split(','); + const firstVariant = parseBuildVariant(firstValue); + const remainingVariants = [...new Set(remainingValues.map(parseBuildVariant))].filter((variant) => variant !== firstVariant); + return [firstVariant, ...remainingVariants]; +} + +function validateIdentifierSuffix(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if (!/^[A-Za-z0-9-]+$/.test(value)) { + throw new Error(`Identifier suffix must contain only letters, numbers, or hyphens. Received: ${value}`); + } + return value; +} + +function parseBuildVariant(value: string): BuildVariant { + const normalizedInput = value.trim().toLowerCase(); + const normalizedValue = normalizedInput === 'ad-hoc' ? 'adhoc' : normalizedInput; + const buildVariant = BUILD_VARIANTS.find((variant) => variant === normalizedValue); + if (!buildVariant) { + throw new Error(`Build variants must be a comma-separated list containing: ${BUILD_VARIANTS.join(', ')}. Received: ${value}`); + } + return buildVariant; +} + +export {BUILD_VARIANTS, DEFAULT_BUILD_VARIANTS, PLATFORMS, parseBuildVariants, validateIdentifierSuffix}; +export type {AndroidBootstrapOptions, BootstrapOptions, BuildVariant, BuildVariants}; diff --git a/scripts/lib/nativeAppBenchmark.ts b/scripts/lib/nativeAppBenchmark.ts new file mode 100644 index 000000000000..bce54b015663 --- /dev/null +++ b/scripts/lib/nativeAppBenchmark.ts @@ -0,0 +1,39 @@ +/** Selects the native benchmark adapter for a platform and exposes the parsing helpers used by tooling and tests. */ + +import type {NativeAppBenchmarkAdapter, NativeAppBenchmarkAdapterOptions} from './nativeAppBenchmark/shared'; + +import {assertAndroidAppInstalled, createAndroidAdapter, parseAndroidProcessIdentifier} from './nativeAppBenchmark/android'; +import { + createIOSAdapter, + iOSBenchmarkMarkerPath, + parseIOSInstalledAppURL, + parseIOSInstalledAppsResponse, + parseIOSLaunchProcessIdentifier, + parseIOSRunningAppProcessIdentifier, +} from './nativeAppBenchmark/ios'; +import {BENCHMARK_LOG_TAG, PLATFORM_NAMES, findBenchmarkDuration, latestBenchmarkEvents, parseBenchmarkLogEvents} from './nativeAppBenchmark/shared'; + +async function createNativeAppBenchmarkAdapter(options: NativeAppBenchmarkAdapterOptions): Promise { + if (options.platform === 'android') { + return createAndroidAdapter(options); + } + return createIOSAdapter(options); +} + +export { + BENCHMARK_LOG_TAG, + PLATFORM_NAMES, + assertAndroidAppInstalled, + createNativeAppBenchmarkAdapter, + findBenchmarkDuration, + iOSBenchmarkMarkerPath, + latestBenchmarkEvents, + parseAndroidProcessIdentifier, + parseBenchmarkLogEvents, + parseIOSInstalledAppURL, + parseIOSInstalledAppsResponse, + parseIOSLaunchProcessIdentifier, + parseIOSRunningAppProcessIdentifier, +}; +export type {BenchmarkLogEvent, CollectBenchmarkEventsOptions, NativeAppBenchmarkAdapter, NativeAppBenchmarkAdapterOptions, PlatformName, StartupMode} from './nativeAppBenchmark/shared'; +export type {CoreDeviceInstalledAppsResponse} from './nativeAppBenchmark/ios'; diff --git a/scripts/lib/nativeAppBenchmark/android.ts b/scripts/lib/nativeAppBenchmark/android.ts new file mode 100644 index 000000000000..439340a98e75 --- /dev/null +++ b/scripts/lib/nativeAppBenchmark/android.ts @@ -0,0 +1,87 @@ +// cspell:ignore serialno + +/** Implements Android benchmark setup, launch, process control, and log collection through adb. */ + +import {file} from 'bun'; + +import type {NativeAppBenchmarkAdapter, NativeAppBenchmarkAdapterOptions} from './shared'; + +import {POLL_INTERVAL_MS, RELAUNCH_DELAY_MS, createCommandHelpers, latestBenchmarkEvents, parseBenchmarkLogEvents, sleep} from './shared'; + +/** Creates an Android benchmark adapter that controls installation, process state, compilation state, and scoped logcat collection. */ +async function createAndroidAdapter({rootDirectory, deviceIdentifier, appID}: Omit): Promise { + const {capture, run} = createCommandHelpers(rootDirectory); + const selectedDeviceIdentifier = deviceIdentifier ?? (await capture('adb', ['get-serialno'])).trim(); + if (!selectedDeviceIdentifier || selectedDeviceIdentifier === 'unknown') { + throw new Error('Unable to resolve the Android device serial. Use --device to select one.'); + } + const adbArgs = (args: string[]) => ['-s', selectedDeviceIdentifier, ...args]; + const adb = (args: string[]) => run('adb', adbArgs(args)); + const adbCapture = (args: string[]) => capture('adb', adbArgs(args)); + const activity = `${appID}/org.me.mobiexpensifyg.ExpensifyActivityBase`; + + return { + name: 'android', + appID, + deviceIdentifier: selectedDeviceIdentifier, + prepareStartup: async (mode, appPath, installArtifact = false) => { + await adb(['shell', 'am', 'force-stop', appID]); + if (installArtifact) { + if (!appPath) { + throw new Error('Android artifact installation requires an app path.'); + } + if (!(await file(appPath).exists())) { + throw new Error(`Android app not found at ${appPath}.`); + } + await adb(['install', '-r', '-d', appPath]); + } + assertAndroidAppInstalled(await adbCapture(['shell', 'pm', 'path', appID]), appID); + if (mode === 'cold') { + await adb(['shell', 'pm', 'clear', appID]); + await adb(['shell', 'cmd', 'package', 'compile', '--reset', appID]); + } + await adb(['logcat', '-c']); + await sleep(RELAUNCH_DELAY_MS); + }, + launchAndCollect: async (options) => { + await adb(['shell', 'am', 'start', '-W', '-n', activity]); + const processIdentifier = parseAndroidProcessIdentifier(await adbCapture(['shell', 'pidof', appID]), appID); + const deadline = Date.now() + options.waitTimeSeconds * 1000; + let logs = ''; + while (Date.now() < deadline) { + logs = await adbCapture(['logcat', `--pid=${processIdentifier}`, '-d', '-v', 'raw']); + const events = parseBenchmarkLogEvents(logs); + if (options.waitUntilSpan && events.some((event) => event.span === options.waitUntilSpan)) { + return latestBenchmarkEvents(events, options.spanNames); + } + await sleep(POLL_INTERVAL_MS); + } + const events = parseBenchmarkLogEvents(logs); + if (options.waitUntilSpan) { + throw new Error(`Timed out after ${options.waitTimeSeconds}s waiting for benchmark span ${options.waitUntilSpan}.\n${logs}`); + } + return latestBenchmarkEvents(events, options.spanNames); + }, + }; +} + +function assertAndroidAppInstalled(packagePath: string, appID: string): void { + if (packagePath.trim().startsWith('package:')) { + return; + } + throw new Error(`Android app ${appID} is not installed. Pass its APK path or install it before benchmarking.`); +} + +/** Extracts the numeric process ID from `adb shell pidof` output and rejects an empty result. */ +function parseAndroidProcessIdentifier(output: string, appID: string): string { + const processIdentifier = output + .trim() + .split(/\s+/) + .find((candidate) => /^\d+$/.test(candidate)); + if (!processIdentifier) { + throw new Error(`Unable to find the running Android process for ${appID}.`); + } + return processIdentifier; +} + +export {assertAndroidAppInstalled, createAndroidAdapter, parseAndroidProcessIdentifier}; diff --git a/scripts/lib/nativeAppBenchmark/ios.ts b/scripts/lib/nativeAppBenchmark/ios.ts new file mode 100644 index 000000000000..fa82833d1719 --- /dev/null +++ b/scripts/lib/nativeAppBenchmark/ios.ts @@ -0,0 +1,287 @@ +// cspell:ignore BEGINSWITH devicectl + +/** Implements iOS benchmark setup, launch, process control, and marker collection through CoreDevice. */ + +import {isJSONArray, isJSONObject} from '@src/types/utils/JSONUtils'; + +import type {JsonValue} from 'type-fest'; + +import {env, file} from 'bun'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; + +import type {BenchmarkLogEvent, NativeAppBenchmarkAdapter, NativeAppBenchmarkAdapterOptions} from './shared'; + +import {POLL_INTERVAL_MS, RELAUNCH_DELAY_MS, benchmarkCollectionSpanNames, createCommandHelpers, latestBenchmarkEvents, parseBenchmarkLogEvents, sleep} from './shared'; + +const IOS_BENCHMARK_DIRECTORY = 'Library/Caches/ExpensifyBenchmark'; + +type CoreDeviceInstalledApp = { + bundleIdentifier: string; + url: string; +}; +type CoreDeviceInstalledAppsResponse = { + result: { + apps: CoreDeviceInstalledApp[]; + }; +}; + +// Bun leaves parsed JSON untyped, so direct file reads are narrowed to JsonValue and then validated against the CoreDevice fields used below. + +/** Creates an iOS benchmark adapter that manages app state and polls per-span markers through CoreDevice. */ +async function createIOSAdapter({rootDirectory, deviceIdentifier, appID}: Omit): Promise { + const {run, runAllowFailure} = createCommandHelpers(rootDirectory); + const environmentDeviceIdentifier = env.IOS_DEVICE_ID; + const device = await resolveIOSDevice(rootDirectory, deviceIdentifier ?? environmentDeviceIdentifier); + let runningProcessIdentifier: number | undefined; + /** Best-effort terminates the process tracked by this adapter and clears the cached identifier. */ + const terminate = async (): Promise => { + if (runningProcessIdentifier === undefined) { + return; + } + await runAllowFailure('xcrun', ['devicectl', 'device', 'process', 'terminate', '--device', device, '--pid', String(runningProcessIdentifier)]); + runningProcessIdentifier = undefined; + }; + /** Launches the app through CoreDevice and caches the process identifier from its JSON response. */ + const launch = async (): Promise => { + const temporaryDirectory = createTemporaryDirectory('expensify-benchmark-ios-launch-'); + const jsonPath = join(temporaryDirectory, 'launch.json'); + try { + await run('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', device, '--terminate-existing', '--json-output', jsonPath, '--quiet', appID]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const response = (await file(jsonPath).json()) as JsonValue; + runningProcessIdentifier = parseIOSLaunchProcessIdentifier(response); + } finally { + rmSync(temporaryDirectory, {recursive: true, force: true}); + } + }; + /** Resolves a process that may predate this adapter by matching the app's installed executable path. */ + const resolveRunningProcessIdentifier = async (): Promise => { + const temporaryDirectory = createTemporaryDirectory('expensify-benchmark-ios-process-'); + const appsJSONPath = join(temporaryDirectory, 'apps.json'); + const processesJSONPath = join(temporaryDirectory, 'processes.json'); + try { + await run('xcrun', ['devicectl', 'device', 'info', 'apps', '--device', device, '--bundle-id', appID, '--json-output', appsJSONPath, '--quiet']); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const rawAppsResponse = (await file(appsJSONPath).json()) as JsonValue; + const appsResponse = parseIOSInstalledAppsResponse(rawAppsResponse); + const appURL = parseIOSInstalledAppURL(appsResponse, appID); + const escapedAppURL = appURL.replaceAll('\\', '\\\\').replaceAll("'", "\\'"); + await run('xcrun', [ + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + device, + '--filter', + `executable.absoluteString BEGINSWITH '${escapedAppURL}'`, + '--json-output', + processesJSONPath, + '--quiet', + ]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const processesResponse = (await file(processesJSONPath).json()) as JsonValue; + return parseIOSRunningAppProcessIdentifier(processesResponse, appURL); + } finally { + rmSync(temporaryDirectory, {recursive: true, force: true}); + } + }; + /** Copies one marker from the app data container, returning undefined when the marker is unavailable. */ + const readBenchmarkMarker = async (spanName: string): Promise => { + // CoreDevice cannot stream the launched app's console output, so collect the marker written to the iOS app container instead. + const temporaryDirectory = createTemporaryDirectory('expensify-benchmark-ios-marker-'); + const localPath = join(temporaryDirectory, 'benchmark.log'); + try { + const copied = await runAllowFailure('xcrun', [ + 'devicectl', + 'device', + 'copy', + 'from', + '--device', + device, + '--source', + iOSBenchmarkMarkerPath(spanName), + '--destination', + localPath, + '--domain-type', + 'appDataContainer', + '--domain-identifier', + appID, + '--quiet', + ]); + const markerFile = file(localPath); + return copied && (await markerFile.exists()) ? await markerFile.text() : undefined; + } finally { + rmSync(temporaryDirectory, {recursive: true, force: true}); + } + }; + + return { + name: 'ios', + appID, + deviceIdentifier: device, + prepareStartup: async (mode, appPath, installArtifact = false) => { + runningProcessIdentifier ??= await resolveRunningProcessIdentifier(); + await terminate(); + if (mode === 'cold' || installArtifact) { + if (!appPath) { + throw new Error( + mode === 'cold' + ? 'iOS true-cold startup requires --app-path so the app can be reinstalled after clearing its data.' + : 'iOS artifact installation requires an app path.', + ); + } + if (!(await file(appPath).exists())) { + throw new Error(`iOS app not found at ${appPath}.`); + } + if (mode === 'cold') { + await runAllowFailure('xcrun', ['devicectl', 'device', 'uninstall', 'app', '--device', device, appID]); + } + await run('xcrun', ['devicectl', 'device', 'install', 'app', '--device', device, appPath]); + } + await sleep(RELAUNCH_DELAY_MS); + }, + launchAndCollect: async (options) => { + const collectionSpanNames = benchmarkCollectionSpanNames(options); + const previousMarkers = new Map(await Promise.all(collectionSpanNames.map(async (spanName) => [spanName, await readBenchmarkMarker(spanName)] as const))); + await launch(); + + const deadline = Date.now() + options.waitTimeSeconds * 1000; + const eventsBySpan = new Map(); + while (Date.now() < deadline) { + for (const spanName of collectionSpanNames) { + const marker = await readBenchmarkMarker(spanName); + if (marker === undefined || marker === previousMarkers.get(spanName)) { + continue; + } + const event = parseBenchmarkLogEvents(marker).findLast((candidate) => candidate.span === spanName); + if (event) { + eventsBySpan.set(spanName, event); + } + } + if (options.waitUntilSpan && eventsBySpan.has(options.waitUntilSpan)) { + return latestBenchmarkEvents([...eventsBySpan.values()], options.spanNames); + } + await sleep(POLL_INTERVAL_MS); + } + if (options.waitUntilSpan) { + throw new Error(`Timed out after ${options.waitTimeSeconds}s waiting for benchmark span ${options.waitUntilSpan}.`); + } + return latestBenchmarkEvents([...eventsBySpan.values()], options.spanNames); + }, + }; +} + +/** Uses the configured device or requires exactly one booted physical iOS device when discovering automatically. */ +async function resolveIOSDevice(rootDirectory: string, configuredDevice: string | undefined): Promise { + if (configuredDevice) { + return configuredDevice; + } + + const {run} = createCommandHelpers(rootDirectory); + const temporaryDirectory = createTemporaryDirectory('expensify-benchmark-ios-devices-'); + const jsonPath = join(temporaryDirectory, 'devices.json'); + try { + await run('xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonPath]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const response = (await file(jsonPath).json()) as JsonValue; + if (!isJSONObject(response) || !isJSONObject(response.result) || !isJSONArray(response.result.devices)) { + throw new Error('CoreDevice returned an unexpected device-list response.'); + } + + const devices = response.result.devices.flatMap((device) => { + if (!isJSONObject(device) || !isJSONObject(device.hardwareProperties) || !isJSONObject(device.deviceProperties)) { + return []; + } + const {hardwareProperties, deviceProperties} = device; + if ( + hardwareProperties.platform !== 'iOS' || + hardwareProperties.reality !== 'physical' || + deviceProperties.bootState !== 'booted' || + typeof hardwareProperties.udid !== 'string' || + typeof deviceProperties.name !== 'string' + ) { + return []; + } + return [{name: deviceProperties.name, udid: hardwareProperties.udid}]; + }); + if (devices.length !== 1) { + throw new Error(`Expected one booted physical iOS device, found ${devices.length}. Use --device to select one.`); + } + const device = devices.at(0); + if (!device) { + throw new Error('Unable to resolve the connected iOS device.'); + } + console.log(`Using iOS device ${device.name} (${device.udid}).`); + return device.udid; + } finally { + rmSync(temporaryDirectory, {recursive: true, force: true}); + } +} + +// CoreDevice does not publish a runtime schema for `devicectl --json-output`, so validate each required field before returning a domain type. +/** Validates a CoreDevice launch response and returns the launched process identifier. */ +function parseIOSLaunchProcessIdentifier(response: JsonValue): number { + if (!isJSONObject(response) || !isJSONObject(response.result) || !isJSONObject(response.result.process)) { + throw new Error('CoreDevice returned an unexpected app-launch response.'); + } + const {processIdentifier} = response.result.process; + if (typeof processIdentifier !== 'number' || !Number.isInteger(processIdentifier) || processIdentifier <= 0) { + throw new Error('CoreDevice did not return a valid app process identifier.'); + } + return processIdentifier; +} + +/** Validates the minimal installed-app response shape used by the benchmark adapter. */ +function parseIOSInstalledAppsResponse(response: JsonValue): CoreDeviceInstalledAppsResponse { + if (!isJSONObject(response) || !isJSONObject(response.result) || !isJSONArray(response.result.apps) || !response.result.apps.every(isCoreDeviceInstalledApp)) { + throw new Error('CoreDevice returned an unexpected installed-app response.'); + } + return {result: {apps: response.result.apps}}; +} + +/** Finds the installed app's normalized container URL in a validated CoreDevice response. */ +function parseIOSInstalledAppURL(response: CoreDeviceInstalledAppsResponse, appID: string): string { + const app = response.result.apps.find((candidate) => candidate.bundleIdentifier === appID); + if (!app) { + throw new Error(`Unable to find installed iOS app ${appID}.`); + } + return app.url.endsWith('/') ? app.url : `${app.url}/`; +} + +/** Finds the app's main process by requiring its executable to be a direct child of the installed app URL. */ +function parseIOSRunningAppProcessIdentifier(response: JsonValue, appURL: string): number | undefined { + if (!isJSONObject(response) || !isJSONObject(response.result) || !isJSONArray(response.result.runningProcesses)) { + throw new Error('CoreDevice returned an unexpected process-list response.'); + } + const runningProcess = response.result.runningProcesses.find((candidate) => { + if (!isJSONObject(candidate) || typeof candidate.executable !== 'string') { + return false; + } + const relativeExecutablePath = candidate.executable.slice(appURL.length); + return candidate.executable.startsWith(appURL) && relativeExecutablePath.length > 0 && !relativeExecutablePath.includes('/'); + }); + if (!isJSONObject(runningProcess)) { + return undefined; + } + const {processIdentifier} = runningProcess; + return typeof processIdentifier === 'number' && Number.isInteger(processIdentifier) && processIdentifier > 0 ? processIdentifier : undefined; +} + +/** Maps a span name to its encoded marker path inside the iOS app data container. */ +function iOSBenchmarkMarkerPath(spanName: string): string { + return `${IOS_BENCHMARK_DIRECTORY}/${encodeURIComponent(spanName)}.log`; +} + +function createTemporaryDirectory(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function isCoreDeviceInstalledApp(value: JsonValue): value is CoreDeviceInstalledApp { + return isJSONObject(value) && typeof value.bundleIdentifier === 'string' && typeof value.url === 'string'; +} + +export {createIOSAdapter, iOSBenchmarkMarkerPath, parseIOSInstalledAppURL, parseIOSInstalledAppsResponse, parseIOSLaunchProcessIdentifier, parseIOSRunningAppProcessIdentifier}; +export type {CoreDeviceInstalledAppsResponse}; diff --git a/scripts/lib/nativeAppBenchmark/shared.ts b/scripts/lib/nativeAppBenchmark/shared.ts new file mode 100644 index 000000000000..a96ee48b2521 --- /dev/null +++ b/scripts/lib/nativeAppBenchmark/shared.ts @@ -0,0 +1,145 @@ +/** Provides benchmark event parsing, polling primitives, and subprocess helpers shared by the native platform adapters. */ + +import {isJSONObject} from '@src/types/utils/JSONUtils'; + +import type {JsonValue, TupleToUnion} from 'type-fest'; + +import {$} from 'bun'; + +const PLATFORM_NAMES = ['android', 'ios'] as const; +const RELAUNCH_DELAY_MS = 500; +const POLL_INTERVAL_MS = 250; +const BENCHMARK_LOG_TAG = '[EXPENSIFY_BENCHMARK]'; + +type PlatformName = TupleToUnion; +type StartupMode = 'process' | 'cold'; +type BenchmarkLogEvent = { + event: 'span_end'; + span: string; + durationMs: number; + timestamp: number; +}; +type CollectBenchmarkEventsOptions = { + spanNames: string[]; + waitTimeSeconds: number; + waitUntilSpan?: string; +}; +type NativeAppBenchmarkAdapter = { + name: PlatformName; + appID: string; + deviceIdentifier: string; + prepareStartup: (mode: StartupMode, appPath?: string, installArtifact?: boolean) => Promise; + launchAndCollect: (options: CollectBenchmarkEventsOptions) => Promise; +}; +type NativeAppBenchmarkAdapterOptions = { + platform: PlatformName; + rootDirectory: string; + appID: string; + deviceIdentifier?: string; +}; + +/** Extracts valid benchmark events from mixed device logs and ignores unrelated, malformed, or incomplete tagged output. */ +function parseBenchmarkLogEvents(output: string): BenchmarkLogEvent[] { + const events: BenchmarkLogEvent[] = []; + let offset = 0; + + while (offset < output.length) { + const tagIndex = output.indexOf(BENCHMARK_LOG_TAG, offset); + if (tagIndex < 0) { + break; + } + + const jsonStart = output.indexOf('{', tagIndex + BENCHMARK_LOG_TAG.length); + const jsonEnd = jsonStart < 0 ? -1 : output.indexOf('}', jsonStart); + if (jsonStart < 0 || jsonEnd < 0) { + break; + } + + offset = jsonEnd + 1; + try { + // JSON.parse is not generically typed, but a successful parse can only produce a JsonValue. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const event = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as JsonValue; + if ( + isJSONObject(event) && + event.event === 'span_end' && + typeof event.span === 'string' && + typeof event.durationMs === 'number' && + Number.isFinite(event.durationMs) && + typeof event.timestamp === 'number' + ) { + events.push({event: 'span_end', span: event.span, durationMs: event.durationMs, timestamp: event.timestamp}); + } + } catch { + // Ignore unrelated or incomplete console output containing the benchmark tag. + } + } + + return events; +} + +/** Selects the latest event for each requested span while preserving the caller's span order. */ +function latestBenchmarkEvents(events: BenchmarkLogEvent[], spanNames: string[]): BenchmarkLogEvent[] { + return [...new Set(spanNames)].flatMap((spanName) => { + const event = events.findLast((candidate) => candidate.span === spanName); + return event ? [event] : []; + }); +} + +/** Includes the completion sentinel in collection even when the caller does not want it in the reported metrics. */ +function benchmarkCollectionSpanNames(options: CollectBenchmarkEventsOptions): string[] { + const waitUntilSpanNames = options.waitUntilSpan ? [options.waitUntilSpan] : []; + const spanNames = [...options.spanNames, ...waitUntilSpanNames]; + return [...new Set(spanNames)]; +} + +/** Creates command runners for inherited output, captured output, and expected best-effort failures. */ +function createCommandHelpers(rootDirectory: string) { + const run = async (command: string, args: string[]): Promise => { + const result = await $`${command} ${args}`.cwd(rootDirectory).nothrow(); + if (result.exitCode !== 0) { + throw new Error(`${command} exited with status ${result.exitCode}.`); + } + }; + + const capture = async (command: string, args: string[]): Promise => { + const result = await $`${command} ${args}`.cwd(rootDirectory).quiet().nothrow(); + if (result.exitCode !== 0) { + const stderr = result.stderr.toString().trim(); + throw new Error(stderr || `${command} exited with status ${result.exitCode}.`); + } + return result.stdout.toString(); + }; + + const runAllowFailure = async (command: string, args: string[]): Promise => { + const result = await $`${command} ${args}`.cwd(rootDirectory).quiet().nothrow(); + return result.exitCode === 0; + }; + + return {capture, run, runAllowFailure}; +} + +/** Returns the most recent duration for a span when device output contains repeated benchmark events. */ +function findBenchmarkDuration(output: string, spanName: string): number | undefined { + return parseBenchmarkLogEvents(output).findLast((event) => event.span === spanName)?.durationMs; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, milliseconds); + }); +} + +export { + BENCHMARK_LOG_TAG, + PLATFORM_NAMES, + POLL_INTERVAL_MS, + RELAUNCH_DELAY_MS, + benchmarkCollectionSpanNames, + createCommandHelpers, + findBenchmarkDuration, + latestBenchmarkEvents, + parseBenchmarkLogEvents, + sleep, +}; +export type {BenchmarkLogEvent, CollectBenchmarkEventsOptions, NativeAppBenchmarkAdapter, NativeAppBenchmarkAdapterOptions, PlatformName, StartupMode}; diff --git a/scripts/lint.ts b/scripts/lint.ts index 76a5d7eb3488..2a67dde6c9d9 100644 --- a/scripts/lint.ts +++ b/scripts/lint.ts @@ -1,113 +1,6 @@ #!/usr/bin/env bun /** - * Run ESLint with the repo's standard flags (memory ceiling, shared content - * cache, auto concurrency), then finish tightening the eslint-seatbelt baseline. - * Delegate target selection to the caller: - * - * bun scripts/lint.ts -> lint the whole repo - * bun scripts/lint.ts src/foo.ts ... -> lint just the given paths - * bun scripts/lint.ts --show-warnings ... -> include grandfathered seatbelt warnings in the output - * - * By default we pass `--quiet` to ESLint so only blocking errors are printed. - * eslint-seatbelt reclassifies grandfathered violations as warnings, so the - * default output mirrors what CI cares about. Pass `--show-warnings` to - * restore the full output (errors + warnings). + * Compatibility shim. The runner lives in `scripts/lint/index.ts`. */ -import {$, file} from 'bun'; -import {SeatbeltArgs, SeatbeltFile} from 'eslint-seatbelt/api'; - -import checkOnyxConnectBypass from './checkOnyxConnectBypass'; - -const projectRoot = `${import.meta.dir}/..`; - -// parse args -let useCache = true; -let showWarnings = false; -const passthroughArgs: string[] = []; -for (const arg of process.argv.slice(2)) { - if (arg === '--no-cache') { - useCache = false; - } else if (arg === '--show-warnings') { - showWarnings = true; - } else { - passthroughArgs.push(arg); - } -} - -// Preserve default behavior of linting the whole repo when no target is passed. -const lintTargets = passthroughArgs.length > 0 ? passthroughArgs : ['.']; - -// Build ESLint args -const eslintArgs: string[] = []; -if (useCache) { - eslintArgs.push('--cache', '--cache-location=node_modules/.cache/eslint', '--cache-strategy', 'content'); -} -if (!showWarnings) { - eslintArgs.push('--quiet'); -} -// Type-aware linting loads the full TypeScript program in every worker (~12GB heap each on a -// cold cache), so hosts with limited memory need fewer workers with a larger heap rather than -// ESLint's auto worker count. Override via ESLINT_CONCURRENCY and NODE_OPTIONS together. -eslintArgs.push(`--concurrency=${process.env.ESLINT_CONCURRENCY ?? 'auto'}`, '--no-warn-ignored', ...lintTargets); - -const nodeOptions: string = process.env.NODE_OPTIONS ?? '--max_old_space_size=8192'; -const seatbeltFrozenEnv: string = process.env.SEATBELT_FROZEN ?? '0'; - -// Run ESLint with the repo's default memory ceiling and seatbelt behavior. -const eslintResult = await $`npx eslint ${eslintArgs}` - .cwd(projectRoot) - .env({...process.env, NODE_OPTIONS: nodeOptions, SEATBELT_FROZEN: seatbeltFrozenEnv}) - .nothrow(); -if (eslintResult.exitCode !== 0) { - process.exit(eslintResult.exitCode); -} - -/** Mirrors eslint-seatbelt's own boolean env var parsing: unset/empty is unset, "0"/"false"/"no" (case-insensitive) is false, anything else is true. */ -function readSeatbeltBooleanEnvVar(value: string | undefined): boolean | undefined { - if (value === undefined || value === '') { - return undefined; - } - return !['0', 'false', 'no'].includes(value.toLowerCase()); -} - -// eslint-seatbelt only rewrites the row for a file it actually lints, so a deleted or renamed -// file's row is never revisited and lingers in the baseline forever (a dead-code gap in -// eslint-seatbelt itself: https://github.com/justjake/eslint-seatbelt/issues/15). Finish the job -// by dropping rows for files that no longer exist, mirroring the same readOnly default as -// config/eslint/eslint.config.mjs and the same SEATBELT_READ_ONLY/SEATBELT_INCREASE/SEATBELT_DISABLE -// escape hatches eslint-seatbelt itself honors, so pruning never dirties a local worktree. -const seatbeltPath = `${projectRoot}/config/eslint/eslint.seatbelt.tsv`; -const isSeatbeltIncreaseSet = !!process.env.SEATBELT_INCREASE; -const seatbeltArgs = SeatbeltArgs.fromConfig({ - seatbeltFile: seatbeltPath, - disable: readSeatbeltBooleanEnvVar(process.env.SEATBELT_DISABLE) ?? false, - frozen: readSeatbeltBooleanEnvVar(seatbeltFrozenEnv) ?? false, - readOnly: isSeatbeltIncreaseSet ? false : (readSeatbeltBooleanEnvVar(process.env.SEATBELT_READ_ONLY) ?? !process.env.CI), -}); -if (!seatbeltArgs.disable) { - const seatbeltFile = SeatbeltFile.readSync(seatbeltPath); - const filenames = Array.from(seatbeltFile.filenames()); - const missingFilenames = (await Promise.all(filenames.map(async (filename) => ((await file(filename).exists()) ? undefined : filename)))).filter( - (filename): filename is string => filename !== undefined, - ); - - let removedCount = 0; - for (const filename of missingFilenames) { - if (seatbeltFile.removeFile(filename, seatbeltArgs)) { - removedCount++; - } - } - if (removedCount > 0) { - console.log(`eslint-seatbelt: removed ${removedCount} baseline row(s) for deleted files`); - if (!seatbeltArgs.frozen && !seatbeltArgs.readOnly) { - seatbeltFile.writeSync(); - } - } -} - -// Fail if a new inline eslint-disable bypasses the Onyx.connect() ban (rulesdir/no-onyx-connect), -// checking the same targets as ESLint above. Reached only when ESLint itself passes. -if (await checkOnyxConnectBypass(lintTargets)) { - process.exit(1); -} +import './lint/index'; diff --git a/scripts/lint/Formatter.ts b/scripts/lint/Formatter.ts new file mode 100644 index 000000000000..d2b983b2dc2f --- /dev/null +++ b/scripts/lint/Formatter.ts @@ -0,0 +1,12 @@ +import type {FormatterResult, LintMessage} from './types'; + +/** + * Port: turn the final message list into human-readable output and counts. + */ +abstract class Formatter { + abstract readonly name: string; + + abstract format(messages: LintMessage[]): FormatterResult; +} + +export default Formatter; diff --git a/scripts/lint/LintPipeline.ts b/scripts/lint/LintPipeline.ts new file mode 100644 index 000000000000..9aa3d1705db6 --- /dev/null +++ b/scripts/lint/LintPipeline.ts @@ -0,0 +1,65 @@ +import type Formatter from './Formatter'; +import type Linter from './Linter'; +import type Processor from './Processor'; +import type {LintMessage} from './types'; + +import Bench from '../utils/Bench'; + +type PipelineResult = { + messages: LintMessage[]; + errorCount: number; + warningCount: number; + reportText: string; + exitCode: number; +}; + +/** + * Application service: run a Linter, then each Processor in order, then a Formatter. + * Fatal linter exits (`exitCode > 1`) skip processors and surface stderr as the report. + */ +class Pipeline { + constructor( + private readonly projectRoot: string, + private readonly linter: Linter, + private readonly processors: readonly Processor[], + private readonly formatter: Formatter, + private readonly bench = new Bench(), + ) {} + + async run(targets: string[]): Promise { + const linterResult = await this.bench.measure(this.linter.name, () => this.linter.run(targets)); + + if (linterResult.exitCode > 1) { + return { + messages: [], + errorCount: 0, + warningCount: 0, + reportText: linterResult.stderr.trim(), + exitCode: linterResult.exitCode, + }; + } + + let messages = linterResult.files.flatMap((file) => file.messages); + const context = { + projectRoot: this.projectRoot, + lintedFiles: linterResult.files.map((file) => file.filePath), + }; + + for (const processor of this.processors) { + const incoming = messages; + messages = await this.bench.measure(processor.name, () => processor.process(incoming, context)); + } + + const report = this.bench.measureSync(this.formatter.name, () => this.formatter.format(messages)); + return { + messages, + errorCount: report.errorCount, + warningCount: report.warningCount, + reportText: report.text, + exitCode: report.errorCount > 0 ? 1 : 0, + }; + } +} + +export default Pipeline; +export type {PipelineResult}; diff --git a/scripts/lint/Linter.ts b/scripts/lint/Linter.ts new file mode 100644 index 000000000000..fdd7acd3ea83 --- /dev/null +++ b/scripts/lint/Linter.ts @@ -0,0 +1,13 @@ +import type {LinterResult} from './types'; + +/** + * Port: produce diagnostics for a set of paths. Implementations may spawn a + * CLI, call a library, or return a fixture — the pipeline only sees LinterResult. + */ +abstract class Linter { + abstract readonly name: string; + + abstract run(targets: string[]): Promise; +} + +export default Linter; diff --git a/scripts/lint/Processor.ts b/scripts/lint/Processor.ts new file mode 100644 index 000000000000..2c19f54d3155 --- /dev/null +++ b/scripts/lint/Processor.ts @@ -0,0 +1,13 @@ +import type {LintMessage, ProcessorContext} from './types'; + +/** + * Port: transform a message list. A processor may rewrite, drop, or demote + * messages, and may perform side effects (e.g. writing a baseline file). + */ +abstract class Processor { + abstract readonly name: string; + + abstract process(messages: LintMessage[], context: ProcessorContext): Promise; +} + +export default Processor; diff --git a/scripts/lint/eslint/ESLintLinter.ts b/scripts/lint/eslint/ESLintLinter.ts new file mode 100644 index 000000000000..a95b86780322 --- /dev/null +++ b/scripts/lint/eslint/ESLintLinter.ts @@ -0,0 +1,149 @@ +import {$} from 'bun'; + +import type {LintFileResult, LintMessage, LintSeverity, LinterResult} from '../types'; + +import Linter from '../Linter'; +import {LINT_SEVERITY} from '../types'; + +const ESLINT_RULE_ID_KEY = 'ruleId' as const; + +type ESLintJSONMessage = { + // ESLint's JSON output uses this key; normalize it to ruleID below. + [ESLINT_RULE_ID_KEY]: string | null; + severity: number; + message: string; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + suggestions?: unknown; + fix?: unknown; +}; + +type ESLintJSONResult = { + filePath: string; + messages: ESLintJSONMessage[]; + source?: string; + suppressedMessages?: unknown[]; +}; + +type ESLintLinterOptions = { + projectRoot: string; + useCache: boolean; + fix: boolean; + concurrency?: string; + nodeOptions?: string; +}; + +const PARSE_FAILURE_EXIT_CODE = 2; + +function normalizeSeverity(severity: number): LintSeverity { + return severity >= LINT_SEVERITY.ERROR ? LINT_SEVERITY.ERROR : LINT_SEVERITY.WARNING; +} + +function isESLintJSONResult(value: unknown): value is ESLintJSONResult { + return typeof value === 'object' && value !== null && 'filePath' in value && 'messages' in value; +} + +function normalizeESLintResults(results: ESLintJSONResult[]): LintFileResult[] { + return results.map((result) => ({ + filePath: result.filePath, + source: result.source, + messages: result.messages.map( + (message): LintMessage => ({ + filePath: result.filePath, + ruleID: message[ESLINT_RULE_ID_KEY], + severity: normalizeSeverity(message.severity), + message: message.message, + line: message.line ?? 0, + column: message.column ?? 0, + endLine: message.endLine, + endColumn: message.endColumn, + suggestions: message.suggestions, + fix: message.fix, + }), + ), + })); +} + +/** Babel / file-progress may write to stdout around the JSON array. */ +function extractJSONArray(text: string): string | null { + const start = text.indexOf('['); + const end = text.lastIndexOf(']'); + if (start < 0 || end <= start) { + return null; + } + return text.slice(start, end + 1); +} + +function parseFailureOutput(stdout: string, stderr: string, exitCode: number): LinterResult { + return { + files: [], + exitCode: Math.max(PARSE_FAILURE_EXIT_CODE, exitCode), + stderr: `${stderr}\nFailed to parse ESLint JSON output.\n${stdout.slice(0, 500)}`, + }; +} + +/** + * Turn ESLint stdout into structured results. A missing/invalid JSON payload is + * fatal (`exitCode > 1`) even when ESLint itself exited 0 or 1 — otherwise + * the pipeline would flatten zero messages and report a clean pass. + */ +function parseESLintStdout(stdout: string, stderr: string, exitCode: number): LinterResult { + const jsonText = extractJSONArray(stdout); + if (!jsonText) { + return parseFailureOutput(stdout, stderr, exitCode); + } + + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + return parseFailureOutput(stdout, stderr, exitCode); + } + if (!Array.isArray(parsed)) { + return parseFailureOutput(stdout, stderr, exitCode); + } + + return {files: normalizeESLintResults(parsed.filter(isESLintJSONResult)), exitCode, stderr}; +} + +/** + * Spawn ESLint as a JSON producer. Processors are not wired in the ESLint + * config — this is the raw linter output the pipeline consumes. + * + * `--no-inline-config` is intentionally *not* passed: disable comments must + * still work. `--quiet` is also not passed here; the formatter filters + * warnings after seatbelt demotes grandfathered errors. + */ +class ESLintLinter extends Linter { + readonly name = 'eslint'; + + constructor(private readonly options: ESLintLinterOptions) { + super(); + } + + async run(targets: string[]): Promise { + const eslintArgs: string[] = ['--format', 'json', '--no-warn-ignored']; + if (this.options.useCache) { + eslintArgs.push('--cache', '--cache-location=node_modules/.cache/eslint', '--cache-strategy', 'content'); + } + if (this.options.fix) { + eslintArgs.push('--fix'); + } + eslintArgs.push(`--concurrency=${this.options.concurrency ?? process.env.ESLINT_CONCURRENCY ?? 'auto'}`, ...targets); + + const nodeOptions = this.options.nodeOptions ?? process.env.NODE_OPTIONS ?? '--max_old_space_size=8192'; + const result = await $`npx eslint ${eslintArgs}` + .cwd(this.options.projectRoot) + .env({...process.env, NODE_OPTIONS: nodeOptions, LINT_PIPELINE: '1'}) + .nothrow() + .quiet(); + + return parseESLintStdout(result.stdout.toString(), result.stderr.toString(), result.exitCode); + } +} + +export default ESLintLinter; +export {normalizeESLintResults, parseESLintStdout}; +export type {ESLintJSONResult, ESLintLinterOptions}; diff --git a/scripts/lint/formatters/StylishFormatter.ts b/scripts/lint/formatters/StylishFormatter.ts new file mode 100644 index 000000000000..14eecd1a2afa --- /dev/null +++ b/scripts/lint/formatters/StylishFormatter.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; + +import type {FormatterResult, LintMessage} from '../types'; + +import Formatter from '../Formatter'; +import {LINT_SEVERITY} from '../types'; + +function relativePath(projectRoot: string, filePath: string): string { + return path.relative(projectRoot, filePath) || filePath; +} + +function formatMessage(projectRoot: string, message: LintMessage): string { + const loc = `${relativePath(projectRoot, message.filePath)}:${message.line}:${message.column}`; + const level = message.severity === LINT_SEVERITY.ERROR ? 'error' : 'warning'; + const rule = message.ruleID ? ` ${message.ruleID}` : ''; + return `${loc}\n ${level} ${message.message}${rule}`; +} + +class StylishFormatter extends Formatter { + readonly name = 'stylish'; + + constructor( + private readonly projectRoot: string, + private readonly showWarnings: boolean, + ) { + super(); + } + + format(messages: LintMessage[]): FormatterResult { + const visible = this.showWarnings ? messages : messages.filter((message) => message.severity === LINT_SEVERITY.ERROR); + const errorCount = messages.filter((message) => message.severity === LINT_SEVERITY.ERROR).length; + const warningCount = messages.filter((message) => message.severity === LINT_SEVERITY.WARNING).length; + + if (visible.length === 0) { + return {text: '', errorCount, warningCount}; + } + + const byFile = new Map(); + for (const message of visible) { + const list = byFile.get(message.filePath) ?? []; + list.push(message); + byFile.set(message.filePath, list); + } + + const blocks: string[] = []; + for (const fileMessages of byFile.values()) { + blocks.push(fileMessages.map((message) => formatMessage(this.projectRoot, message)).join('\n')); + } + + const summary = this.showWarnings + ? `\n\n${errorCount} error${errorCount === 1 ? '' : 's'}, ${warningCount} warning${warningCount === 1 ? '' : 's'}` + : `\n\n${errorCount} error${errorCount === 1 ? '' : 's'}`; + + return {text: `${blocks.join('\n\n')}${summary}`, errorCount, warningCount}; + } +} + +export default StylishFormatter; diff --git a/scripts/lint/index.ts b/scripts/lint/index.ts new file mode 100644 index 000000000000..ca7fe1fb25a0 --- /dev/null +++ b/scripts/lint/index.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env bun + +/** + * Lint runner: run a Linter, then each Processor, then a Formatter. + * + * bun scripts/lint/index.ts -> lint the whole repo + * bun scripts/lint/index.ts src/foo.ts ... -> lint just the given paths + * bun scripts/lint/index.ts --show-warnings ... -> include grandfathered seatbelt warnings + * bun scripts/lint/index.ts --timings -> print per-stage wall times + */ + +import CLI from 'expensify-common/CLI'; + +import checkOnyxConnectBypass from '../checkOnyxConnectBypass'; +import Bench from '../utils/Bench'; +import ESLintLinter from './eslint/ESLintLinter'; +import StylishFormatter from './formatters/StylishFormatter'; +import Pipeline from './LintPipeline'; +import ReactCompilerFilter from './processors/ReactCompilerFilter'; +import Seatbelt, {resolveSeatbeltOptions} from './processors/Seatbelt'; +import StratifyNoDeprecated from './processors/StratifyNoDeprecated'; + +const projectRoot = `${import.meta.dir}/../..`; + +/* CLI argv uses kebab-case for flags documented in help */ +/* eslint-disable @typescript-eslint/naming-convention */ +const cli = new CLI({ + flags: { + 'no-cache': { + description: 'Disable the ESLint content cache', + }, + 'show-warnings': { + description: 'Include grandfathered seatbelt warnings in the report', + }, + fix: { + description: 'Apply ESLint auto-fixes', + }, + timings: { + description: 'Print per-stage wall times', + }, + }, + positionalArgs: [ + { + name: 'targets', + description: 'Files or directories to lint (default: the whole repo)', + variadic: true, + default: ['.'], + }, + ], +}); +/* eslint-enable @typescript-eslint/naming-convention */ + +const lintTargets = cli.positionalArgs.targets.length > 0 ? cli.positionalArgs.targets : ['.']; +const showTimings = cli.flags.timings || process.env.LINT_TIMINGS === '1'; +const bench = new Bench(); + +const pipeline = new Pipeline( + projectRoot, + new ESLintLinter({ + projectRoot, + useCache: !cli.flags['no-cache'], + fix: cli.flags.fix, + }), + [new ReactCompilerFilter(), new StratifyNoDeprecated(), new Seatbelt(resolveSeatbeltOptions(projectRoot))], + new StylishFormatter(projectRoot, cli.flags['show-warnings']), + bench, +); + +const result = await pipeline.run(lintTargets); + +if (result.reportText) { + if (result.exitCode > 1) { + console.error(result.reportText); + } else { + console.log(result.reportText); + } +} + +if (showTimings) { + console.error(bench.format('lint timings')); +} + +if (result.exitCode !== 0) { + process.exit(result.exitCode); +} + +if (await checkOnyxConnectBypass(lintTargets)) { + process.exit(1); +} diff --git a/scripts/lint/processors/ReactCompilerFilter.ts b/scripts/lint/processors/ReactCompilerFilter.ts new file mode 100644 index 000000000000..3166faf0d76c --- /dev/null +++ b/scripts/lint/processors/ReactCompilerFilter.ts @@ -0,0 +1,231 @@ +import {file} from 'bun'; +import path from 'node:path'; + +import type {LintMessage, ProcessorContext} from '../types'; + +import WorkerPool from '../../utils/WorkerPool'; +import Processor from '../Processor'; + +const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-context-values', 'rulesdir/no-inline-useOnyx-selector']); +const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/; +const CACHE_DIR = 'node_modules/.cache/react-compiler'; +const REACT_COMPILER_FINGERPRINT_FILES = [ + 'babel.config.js', + 'config/babel/reactCompilerConfig.js', + 'config/reactCompiler/checkBoth.mjs', + 'config/reactCompiler/checkWithBabel.mjs', + 'config/reactCompiler/checkWithOxc.mjs', + 'config/rsbuild/rsbuild.common.ts', + 'package-lock.json', + 'scripts/lint/processors/ReactCompilerFilter.ts', + 'scripts/lint/processors/ReactCompilerWorker.ts', + 'scripts/utils/WorkerPool.ts', +] as const; + +type CompilerCheck = (source: string, filename: string) => boolean | Promise; + +function isSuppressibleMessage(message: LintMessage): boolean { + if (message.ruleID !== null && RULES_SUPPRESSED_BY_REACT_COMPILER.has(message.ruleID)) { + return true; + } + return message.ruleID === 'react-hooks/exhaustive-deps' && EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN.test(message.message); +} + +function shouldSkipCompiler(filename: string): boolean { + return filename.includes('/tests/') || filename.includes('node_modules/'); +} + +function cachePath(projectRoot: string, hash: string): string { + return `${projectRoot}/${CACHE_DIR}/${hash}`; +} + +function cacheKey(fingerprint: string | undefined, filename: string, source: string): string { + return Bun.hash(`${fingerprint}\0${path.extname(filename)}\0${source}`).toString(16); +} + +async function getReactCompilerFingerprint(projectRoot: string): Promise { + const contents = await Promise.all( + REACT_COMPILER_FINGERPRINT_FILES.map(async (relativePath) => { + const content = await file(`${projectRoot}/${relativePath}`) + .text() + .catch(() => ''); + return `${relativePath}\0${content}`; + }), + ); + return Bun.hash(contents.join('\0')).toString(16); +} + +async function readCache(cacheFilePath: string): Promise { + const handle = file(cacheFilePath); + if (!(await handle.exists())) { + return undefined; + } + const text = await handle.text(); + if (text === '1') { + return true; + } + if (text === '0') { + return false; + } + return undefined; +} + +const CACHE_HIT = '1'; +const CACHE_MISS = '0'; + +async function writeCache(cacheFilePath: string, bothMemoized: boolean): Promise { + await Bun.write(cacheFilePath, bothMemoized ? CACHE_HIT : CACHE_MISS); +} + +type Candidate = { + filename: string; + source: string; +}; + +type CompilerWorkerResponse = { + filename: string; + bothMemoized: boolean; + cacheable: boolean; +}; + +function conservativeFallback(candidate: Candidate): CompilerWorkerResponse { + return {filename: candidate.filename, bothMemoized: false, cacheable: false}; +} + +function shouldPersistCompilerCache(verdict: CompilerWorkerResponse | undefined): boolean { + return verdict?.cacheable === true; +} + +async function checkCandidatesWithPool(candidates: Candidate[], checkBoth: CompilerCheck | undefined, workerCount: number): Promise> { + const verdicts = new Map(); + if (candidates.length === 0) { + return verdicts; + } + + if (checkBoth) { + await Promise.all( + candidates.map(async (candidate) => { + try { + verdicts.set(candidate.filename, {filename: candidate.filename, bothMemoized: await checkBoth(candidate.source, candidate.filename), cacheable: true}); + } catch { + // Conservative: keep the message rather than aborting the whole lint. + verdicts.set(candidate.filename, conservativeFallback(candidate)); + } + }), + ); + return verdicts; + } + + const pool = new WorkerPool(new URL('./ReactCompilerWorker.ts', import.meta.url), workerCount); + const responses = await pool.map(candidates, conservativeFallback); + for (const response of responses) { + verdicts.set(response.filename, response); + } + return verdicts; +} + +/** + * Drop suppressible React-Compiler-redundant messages, but only after both + * compilers memoize the file. Files with no suppressible message are skipped + * entirely (~60× fewer compiler invocations than the ESLint processor). + * + * Cache is one file per compiler fingerprint and content hash so concurrent + * writes of the same key are benign and need no lock. + */ +class ReactCompilerFilter extends Processor { + readonly name = 'react-compiler-filter'; + + constructor( + private readonly checkBoth?: CompilerCheck, + private readonly workerCount = navigator.hardwareConcurrency || 4, + ) { + super(); + } + + process(messages: LintMessage[], context: ProcessorContext): Promise { + return filterReactCompilerMessages(messages, context.projectRoot, this.checkBoth, this.workerCount); + } +} + +async function filterReactCompilerMessages( + messages: LintMessage[], + projectRoot: string, + checkBoth?: CompilerCheck, + workerCount = navigator.hardwareConcurrency || 4, +): Promise { + const byFile = new Map(); + for (const message of messages) { + const list = byFile.get(message.filePath) ?? []; + list.push(message); + byFile.set(message.filePath, list); + } + + const candidateNames: string[] = []; + for (const [filename, fileMessages] of byFile) { + if (shouldSkipCompiler(filename)) { + continue; + } + if (fileMessages.some(isSuppressibleMessage)) { + candidateNames.push(filename); + } + } + + if (candidateNames.length === 0) { + return messages; + } + + const uncached: Candidate[] = []; + const memoized = new Map(); + const reactCompilerFingerprint = checkBoth ? undefined : await getReactCompilerFingerprint(projectRoot); + + await Promise.all( + candidateNames.map(async (filename) => { + let source = ''; + if (!checkBoth) { + try { + source = await file(filename).text(); + } catch { + memoized.set(filename, false); + return; + } + try { + const cached = await readCache(cachePath(projectRoot, cacheKey(reactCompilerFingerprint, filename, source))); + if (cached !== undefined) { + memoized.set(filename, cached); + return; + } + } catch { + // Conservative: recompute rather than abort the lint. + } + } + uncached.push({filename, source}); + }), + ); + + const computed = await checkCandidatesWithPool(uncached, checkBoth, workerCount); + await Promise.all( + uncached.map(async (candidate) => { + const verdict = computed.get(candidate.filename) ?? conservativeFallback(candidate); + memoized.set(candidate.filename, verdict.bothMemoized); + if (checkBoth || !shouldPersistCompilerCache(verdict)) { + return; + } + try { + await writeCache(cachePath(projectRoot, cacheKey(reactCompilerFingerprint, candidate.filename, candidate.source)), verdict.bothMemoized); + } catch { + // Conservative: skip the cache write rather than abort the lint. + } + }), + ); + + return messages.filter((message) => { + if (!memoized.get(message.filePath)) { + return true; + } + return !isSuppressibleMessage(message); + }); +} + +export default ReactCompilerFilter; +export {EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN, filterReactCompilerMessages, isSuppressibleMessage, RULES_SUPPRESSED_BY_REACT_COMPILER, shouldPersistCompilerCache}; +export type {CompilerCheck}; diff --git a/scripts/lint/processors/ReactCompilerWorker.ts b/scripts/lint/processors/ReactCompilerWorker.ts new file mode 100644 index 000000000000..6211852e144c --- /dev/null +++ b/scripts/lint/processors/ReactCompilerWorker.ts @@ -0,0 +1,30 @@ +// The compiler helper is ESM (.mjs); Bun resolves it without the extension. +// eslint-disable-next-line import/extensions +import {didBothCompilersMemoizeFile} from '../../../config/reactCompiler/checkBoth.mjs'; + +type WorkerRequest = { + filename: string; + source: string; +}; + +type WorkerResponse = { + filename: string; + bothMemoized: boolean; + cacheable: boolean; +}; + +declare const self: Worker; + +self.onmessage = (event: MessageEvent) => { + const {filename, source} = event.data; + try { + const response: WorkerResponse = {filename, bothMemoized: didBothCompilersMemoizeFile(source, filename), cacheable: true}; + postMessage(response); + } catch { + // Conservative: treat a compiler crash as "not memoized" so this file + // keeps its suppressible messages instead of aborting the whole lint. + // Do not cache this — a transient OOM/crash must not stick as a miss. + const response: WorkerResponse = {filename, bothMemoized: false, cacheable: false}; + postMessage(response); + } +}; diff --git a/scripts/lint/processors/Seatbelt.ts b/scripts/lint/processors/Seatbelt.ts new file mode 100644 index 000000000000..5beddb43b98a --- /dev/null +++ b/scripts/lint/processors/Seatbelt.ts @@ -0,0 +1,540 @@ +import {file} from 'bun'; +import {rename} from 'node:fs/promises'; + +import type {LintMessage, ProcessorContext, SeatbeltOptions, SeatbeltRuleSet} from '../types'; + +import FileUtils from '../../utils/FileUtils'; +import TSVUtils from '../../utils/TSVUtils'; +import Processor from '../Processor'; +import {LINT_SEVERITY} from '../types'; + +const SEATBELT_NAME = 'eslint-seatbelt'; +const SEATBELT_TSV_RELATIVE = 'config/eslint/eslint.seatbelt.tsv'; +const DEFAULT_FILE_HEADER = `# ${SEATBELT_NAME} temporarily allowed errors +# docs: https://github.com/justjake/${SEATBELT_NAME}#readme`; + +type SeatbeltFileLine = { + filename: string; + ruleID: string; + maxErrors: number; + comments: string; +}; + +type SeatbeltFileData = { + maxErrors?: Map; + lines: SeatbeltFileLine[]; +}; + +type SeatbeltApplyResult = { + messages: LintMessage[]; + tsv: string; + wrote: boolean; + changed: boolean; +}; + +function ruleSetHas(ruleSet: SeatbeltRuleSet, ruleID: string): boolean { + return ruleSet === 'all' || ruleSet.has(ruleID); +} + +function asSeatbeltLine(row: unknown[], lineNumber: number): SeatbeltFileLine { + if (row.length !== 3) { + throw new Error(`Expected 3 tab-separated JSON values at line ${lineNumber}, instead have ${row.length}`); + } + const [filename, ruleID, maxErrors] = row; + if (typeof filename !== 'string') { + throw new Error(`Expected filename to be a JSON string at line ${lineNumber}`); + } + if (typeof ruleID !== 'string') { + throw new Error(`Expected ruleID to be a JSON string at line ${lineNumber}`); + } + if (typeof maxErrors !== 'number') { + throw new Error(`Expected maxErrors to be a JSON number at line ${lineNumber}`); + } + return {filename, ruleID, maxErrors, comments: ''}; +} + +function parseMaxErrors(lines: SeatbeltFileLine[]): Map { + const maxErrors = new Map(); + for (const row of lines) { + maxErrors.set(row.ruleID, row.maxErrors); + } + return maxErrors; +} + +function parseSeatbeltTSV(text: string): {data: Map; comments: string; trailingComments: string} { + const parsed = TSVUtils.parse(text); + const data = new Map(); + for (const [index, row] of parsed.rows.entries()) { + const line = asSeatbeltLine(row.cells, index + 1); + line.comments = row.comments; + let fileState = data.get(line.filename); + if (!fileState) { + fileState = {maxErrors: undefined, lines: []}; + data.set(line.filename, fileState); + } + fileState.lines.push(line); + } + return {data, comments: parsed.leadingComments, trailingComments: parsed.trailingComments}; +} + +function serializeSeatbeltTSV(data: Map, comments: string, trailingComments = ''): string { + const rows: Array<{cells: unknown[]; comments: string}> = []; + for (const [filename, fileState] of data) { + if (fileState.maxErrors) { + const commentsByRule = new Map(fileState.lines.map((line) => [line.ruleID, line.comments])); + fileState.lines = []; + for (const [ruleID, maxErrorCount] of fileState.maxErrors) { + fileState.lines.push({filename, ruleID, maxErrors: maxErrorCount, comments: commentsByRule.get(ruleID) ?? ''}); + } + } + for (const line of fileState.lines) { + rows.push({cells: [line.filename, line.ruleID, line.maxErrors], comments: line.comments}); + } + } + return TSVUtils.serialize({leadingComments: comments, trailingComments, rows}, {sort: true}); +} + +function getMaxErrors(data: Map, relativeFilename: string): Map | undefined { + const fileState = data.get(relativeFilename); + if (!fileState) { + return undefined; + } + fileState.maxErrors ??= parseMaxErrors(fileState.lines); + return fileState.maxErrors; +} + +function isCountableError(message: LintMessage): message is LintMessage & {ruleID: string} { + return message.severity === LINT_SEVERITY.ERROR && !!message.ruleID; +} + +function countRuleIDs(messages: readonly LintMessage[]): Map { + const counts = new Map(); + for (const message of messages) { + if (!isCountableError(message)) { + continue; + } + counts.set(message.ruleID, (counts.get(message.ruleID) ?? 0) + 1); + } + return counts; +} + +function pluralErrors(count: number): string { + return count === 1 ? 'error' : 'errors'; +} + +function compareMessages(a: LintMessage, b: LintMessage): number { + if (a.filePath !== b.filePath) { + return a.filePath < b.filePath ? -1 : 1; + } + if (a.ruleID === null) { + return b.ruleID === null ? 0 : -1; + } + if (b.ruleID === null) { + return 1; + } + if (a.ruleID !== b.ruleID) { + return a.ruleID < b.ruleID ? -1 : 1; + } + if (a.line !== b.line) { + return a.line - b.line; + } + return a.column - b.column; +} + +function verboseLog(options: SeatbeltOptions, makeMessage: () => string): void { + if (!options.verbose) { + return; + } + console.error(`[${SEATBELT_NAME}]:`, makeMessage()); +} + +function messageOverMaxErrorCount(message: LintMessage, errorCount: number, maxErrorCount: number): LintMessage { + return { + ...message, + message: `${message.message} +[${SEATBELT_NAME}]: There are ${errorCount} ${pluralErrors(errorCount)} of this type, but only ${maxErrorCount} are allowed. +Remove ${errorCount - maxErrorCount} to turn these errors into warnings.`.trim(), + }; +} + +function messageOverMaxErrorCountButIncreaseAllowed(message: LintMessage, errorCount: number, maxErrorCount: number): LintMessage { + const increaseCount = errorCount - maxErrorCount; + return { + ...message, + severity: LINT_SEVERITY.WARNING, + message: `${message.message} +[${SEATBELT_NAME}]: SEATBELT_INCREASE: Temporarily allowing ${increaseCount} new ${pluralErrors(increaseCount)} of this type.`.trim(), + }; +} + +function messageAtMaxErrorCount(message: LintMessage, errorCount: number): LintMessage { + return { + ...message, + severity: LINT_SEVERITY.WARNING, + message: `${message.message} +[${SEATBELT_NAME}]: This file is temporarily allowed to have ${errorCount} ${pluralErrors(errorCount)} of this type. +Please tend the garden by fixing if you have the time.`.trim(), + }; +} + +function messageUnderMaxErrorCount(message: LintMessage, errorCount: number, maxErrorCount: number): LintMessage { + const fixed = maxErrorCount - errorCount; + const fixedMessage = fixed === 1 ? 'one' : `${fixed} errors`; + return { + ...message, + severity: LINT_SEVERITY.WARNING, + message: `${message.message} +[${SEATBELT_NAME}]: This file is temporarily allowed to have ${maxErrorCount} ${pluralErrors(maxErrorCount)} of this type. +Thank you for fixing ${fixedMessage}, it really helps.`.trim(), + }; +} + +function messageFrozenUnderMaxErrorCountText(seatbeltFilename: string, errorCount: number, maxErrorCount: number): string { + const fixed = maxErrorCount - errorCount; + const fixedMessage = fixed === 1 ? 'error' : 'errors'; + return `[${SEATBELT_NAME}]: SEATBELT_FROZEN: Expected ${maxErrorCount} ${pluralErrors(maxErrorCount)}, found ${errorCount}. +If you fixed ${fixed} ${fixedMessage}, thank you, but you'll need to update the seatbelt file to match. +Try running eslint, then committing ${seatbeltFilename}.`.trim(); +} + +function messageFrozenUnderMaxErrorCount(message: LintMessage, seatbeltFilename: string, errorCount: number, maxErrorCount: number): LintMessage { + return { + ...message, + // Frozen mode is documented to "fail on any diff": a baseline decrease that hasn't reached + // zero must still block CI, not just the fully-removed-rule case handled separately below + // by frozenRemovedRuleMessages. + severity: LINT_SEVERITY.ERROR, + message: `${message.message}\n${messageFrozenUnderMaxErrorCountText(seatbeltFilename, errorCount, maxErrorCount)}`, + }; +} + +function transformMessages(options: SeatbeltOptions, data: Map, filename: string, messages: LintMessage[]): LintMessage[] { + const relativeFilename = FileUtils.toRelativePath(options.seatbeltFile, filename); + const ruleToMaxErrorCount = getMaxErrors(data, relativeFilename); + const allowIncrease = options.allowIncreaseRules === 'all' || options.allowIncreaseRules.size > 0; + if (!ruleToMaxErrorCount && !allowIncrease) { + return messages; + } + + const ruleToErrorCount = countRuleIDs(messages); + const demoteRemaining = new Map(); + const seenVerbose = new Set(); + + return messages.flatMap((message) => { + if (message.ruleID === null) { + verboseLog(options, () => `${filename}:${message.line}:${message.column}: cannot transform message with null ruleID`); + return message; + } + if (!isCountableError(message)) { + return message; + } + + const errorCount = ruleToErrorCount.get(message.ruleID); + if (errorCount === undefined) { + throw new Error(`${SEATBELT_NAME} bug: errorCount not found for rule ${message.ruleID}`); + } + + const maxErrorCount = ruleToMaxErrorCount?.get(message.ruleID) ?? 0; + const allowThisIncrease = ruleSetHas(options.allowIncreaseRules, message.ruleID); + if (maxErrorCount === 0 && !allowThisIncrease) { + return message; + } + + if (errorCount > maxErrorCount) { + if (allowThisIncrease) { + if (options.quiet) { + return []; + } + return messageOverMaxErrorCountButIncreaseAllowed(message, errorCount, maxErrorCount); + } + if (options.verbose && !seenVerbose.has(message.ruleID)) { + seenVerbose.add(message.ruleID); + verboseLog(options, () => `${filename}: rule ${message.ruleID}: error: ${errorCount} ${pluralErrors(errorCount)} found > max ${maxErrorCount}`); + } + const remaining = demoteRemaining.get(message.ruleID) ?? maxErrorCount; + if (remaining > 0) { + demoteRemaining.set(message.ruleID, remaining - 1); + if (options.quiet) { + return []; + } + return messageAtMaxErrorCount(message, maxErrorCount); + } + return messageOverMaxErrorCount(message, errorCount, maxErrorCount); + } + + if (errorCount === maxErrorCount) { + if (options.verbose && !seenVerbose.has(message.ruleID)) { + seenVerbose.add(message.ruleID); + verboseLog(options, () => `${filename}: rule ${message.ruleID}: ok: ${errorCount} ${pluralErrors(errorCount)} found == max ${maxErrorCount}`); + } + if (options.quiet) { + return []; + } + return messageAtMaxErrorCount(message, errorCount); + } + + if (options.frozen) { + return messageFrozenUnderMaxErrorCount(message, options.seatbeltFile, errorCount, maxErrorCount); + } + if (options.quiet) { + return []; + } + return messageUnderMaxErrorCount(message, errorCount, maxErrorCount); + }); +} + +function updateMaxErrors( + options: SeatbeltOptions, + data: Map, + filename: string, + ruleToErrorCount: ReadonlyMap, +): {removedRules: Set; changed: boolean} { + const removedRules = new Set(); + let increasedRulesCount = 0; + let decreasedRulesCount = 0; + const relativeFilename = FileUtils.toRelativePath(options.seatbeltFile, filename); + getMaxErrors(data, relativeFilename); + const existing = data.get(relativeFilename)?.maxErrors; + const maxErrors = new Map(existing ?? []); + + for (const [ruleID, errorCount] of ruleToErrorCount) { + const maxErrorCount = maxErrors.get(ruleID) ?? 0; + if (errorCount === maxErrorCount) { + continue; + } + if (errorCount < maxErrorCount || ruleSetHas(options.allowIncreaseRules, ruleID)) { + verboseLog(options, () => + options.frozen + ? `${filename}: rule ${ruleID}: SEATBELT_FROZEN: didn't update max errors ${maxErrorCount} -> ${errorCount}` + : `${filename}: rule ${ruleID}: update max errors ${maxErrorCount} -> ${errorCount}`, + ); + maxErrors.set(ruleID, errorCount); + if (errorCount > maxErrorCount) { + increasedRulesCount++; + } else { + decreasedRulesCount++; + } + } + } + + if (options.verbose || options.keepRules !== 'all') { + for (const [ruleID, maxErrorCount] of [...maxErrors]) { + const shouldRemove = maxErrorCount === 0 || !ruleToErrorCount.has(ruleID); + if (!shouldRemove) { + continue; + } + if (ruleSetHas(options.keepRules, ruleID)) { + verboseLog(options, () => `${filename}: rule ${ruleID}: SEATBELT_KEEP: didn't update max errors ${maxErrorCount} -> 0`); + continue; + } + verboseLog(options, () => + options.frozen + ? `${filename}: rule ${ruleID}: SEATBELT_FROZEN: didn't update max errors ${maxErrorCount} -> 0` + : `${filename}: rule ${ruleID}: update max errors ${maxErrorCount} -> 0`, + ); + maxErrors.delete(ruleID); + removedRules.add(ruleID); + } + } + + const changed = increasedRulesCount > 0 || decreasedRulesCount > 0 || removedRules.size > 0; + if (changed && !options.frozen) { + const fileState = data.get(relativeFilename); + if (fileState) { + fileState.maxErrors = maxErrors; + } else { + data.set(relativeFilename, {maxErrors, lines: []}); + } + } + + return {removedRules, changed: changed && !options.frozen}; +} + +function frozenRemovedRuleMessages(filename: string, seatbeltFile: string, removedRules: Set, maxErrorsBefore: ReadonlyMap | undefined): LintMessage[] { + if (removedRules.size === 0) { + return []; + } + return [...removedRules].map((ruleID) => { + const maxErrorCount = maxErrorsBefore?.get(ruleID); + if (maxErrorCount === undefined) { + throw new Error(`${SEATBELT_NAME} bug: maxErrorCount not found for removed frozen rule ${ruleID}`); + } + return { + filePath: filename, + ruleID, + column: 0, + line: 1, + severity: LINT_SEVERITY.ERROR, + message: messageFrozenUnderMaxErrorCountText(seatbeltFile, 0, maxErrorCount), + }; + }); +} + +/** + * Sort by (filename, ruleID, line, column) so "the first N of M" demotions are + * deterministic regardless of the linter's thread order. + */ +function canonicalizeMessages(messages: LintMessage[]): LintMessage[] { + return [...messages].sort(compareMessages); +} + +async function writeTSVAtomically(seatbeltFile: string, tsv: string): Promise { + const tempPath = `${seatbeltFile}.${process.pid}.${Date.now()}.tmp`; + await Bun.write(tempPath, tsv); + try { + await rename(tempPath, seatbeltFile); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EXDEV') { + await Bun.write(seatbeltFile, tsv); + await file(tempPath) + .unlink() + .catch(() => undefined); + return; + } + throw error; + } +} + +class Seatbelt extends Processor { + readonly name = 'seatbelt'; + + constructor(private readonly options: SeatbeltOptions) { + super(); + } + + async process(messages: LintMessage[], context: ProcessorContext): Promise { + const result = await applySeatbelt(messages, this.options, context.lintedFiles); + return result.messages; + } +} + +async function applySeatbelt(messages: LintMessage[], options: SeatbeltOptions, lintedFilenames: Iterable): Promise { + if (options.disable) { + const existing = await file(options.seatbeltFile) + .text() + .catch(() => DEFAULT_FILE_HEADER); + return {messages, tsv: existing.endsWith('\n') || existing.length === 0 ? existing : `${existing}\n`, wrote: false, changed: false}; + } + + const existingText = await file(options.seatbeltFile) + .text() + .catch(() => ''); + const {data, comments, trailingComments} = existingText + ? parseSeatbeltTSV(existingText) + : {data: new Map(), comments: DEFAULT_FILE_HEADER, trailingComments: ''}; + + const canonical = canonicalizeMessages(messages); + const byFile = new Map(); + for (const message of canonical) { + const list = byFile.get(message.filePath) ?? []; + list.push(message); + byFile.set(message.filePath, list); + } + + for (const filename of lintedFilenames) { + if (!byFile.has(filename)) { + byFile.set(filename, []); + } + } + + const transformed: LintMessage[] = []; + let anyChanged = false; + + for (const [filename, fileMessages] of byFile) { + const relativeFilename = FileUtils.toRelativePath(options.seatbeltFile, filename); + const maxErrorsBefore = getMaxErrors(data, relativeFilename); + const maxErrorsBeforeCopy = maxErrorsBefore ? new Map(maxErrorsBefore) : undefined; + const after = transformMessages(options, data, filename, fileMessages); + const ruleToErrorCount = countRuleIDs(fileMessages); + const {removedRules, changed} = updateMaxErrors(options, data, filename, ruleToErrorCount); + anyChanged ||= changed; + if (options.frozen && removedRules.size > 0) { + transformed.push(...after, ...frozenRemovedRuleMessages(filename, options.seatbeltFile, removedRules, maxErrorsBeforeCopy)); + } else { + transformed.push(...after); + } + } + + // Dead-row prune: drop baseline rows whose source file no longer exists. + // Native fix for justjake/eslint-seatbelt#15 — previously a post-hoc pass in scripts/lint.ts. + let pruned = 0; + for (const relativeFilename of [...data.keys()]) { + const absolute = FileUtils.toAbsolutePath(options.seatbeltFile, relativeFilename); + if (await file(absolute).exists()) { + continue; + } + if (options.frozen) { + verboseLog(options, () => `${relativeFilename}: SEATBELT_FROZEN: didn't remove max errors`); + continue; + } + verboseLog(options, () => `${relativeFilename}: remove max errors`); + data.delete(relativeFilename); + anyChanged = true; + pruned++; + } + const tsv = serializeSeatbeltTSV(data, comments || DEFAULT_FILE_HEADER, trailingComments); + const shouldWrite = anyChanged && !options.frozen && !options.readOnly; + if (pruned > 0) { + const verb = shouldWrite ? 'removed' : 'would remove'; + console.log(`eslint-seatbelt: ${verb} ${pruned} baseline row(s) for deleted files`); + } + if (shouldWrite) { + await writeTSVAtomically(options.seatbeltFile, tsv); + } + + return {messages: canonicalizeMessages(transformed), tsv, wrote: shouldWrite, changed: anyChanged}; +} + +/** + * Mirrors eslint-seatbelt's boolean env parsing: unset/empty is unset, "0"/"false"/"no" + * (case-insensitive) is false, anything else is true. + */ +function readBooleanEnvVar(value: string | undefined): boolean | undefined { + if (value === undefined || value === '') { + return undefined; + } + return !['0', 'false', 'no'].includes(value.toLowerCase()); +} + +/** + * Mirrors eslint-seatbelt's rule-set env parsing: unset is unset, empty is [], + * "all"/"1"/"true" is "all", otherwise a whitespace-or-comma-separated list. + */ +function parseRuleSetEnvVar(value: string | undefined): SeatbeltRuleSet | undefined { + if (value === undefined) { + return undefined; + } + if (!value) { + return new Set(); + } + const lower = value.toLowerCase(); + if (lower === 'all' || lower === '1' || lower === 'true') { + return 'all'; + } + return new Set(value.split(/[\s,]+/g).filter(Boolean)); +} + +/** + * Seatbelt env/config: + * - `SEATBELT_FROZEN` defaults to false (the wrapper forces `0` so `CI=true` does not freeze). + * - `readOnly` defaults to `!CI`; `SEATBELT_INCREASE` forces writes. + */ +function resolveSeatbeltOptions(projectRoot: string, env: NodeJS.ProcessEnv = process.env): SeatbeltOptions { + const allowIncreaseRules = parseRuleSetEnvVar(env.SEATBELT_INCREASE) ?? new Set(); + const isIncreaseSet = allowIncreaseRules === 'all' || allowIncreaseRules.size > 0; + return { + seatbeltFile: `${projectRoot}/${SEATBELT_TSV_RELATIVE}`, + projectRoot, + disable: readBooleanEnvVar(env.SEATBELT_DISABLE) ?? false, + frozen: readBooleanEnvVar(env.SEATBELT_FROZEN) ?? false, + readOnly: isIncreaseSet ? false : (readBooleanEnvVar(env.SEATBELT_READ_ONLY) ?? !env.CI), + allowIncreaseRules, + keepRules: parseRuleSetEnvVar(env.SEATBELT_KEEP) ?? new Set(), + quiet: readBooleanEnvVar(env.SEATBELT_QUIET) ?? false, + verbose: readBooleanEnvVar(env.SEATBELT_VERBOSE) ?? false, + }; +} + +export default Seatbelt; +export {applySeatbelt, canonicalizeMessages, compareMessages, countRuleIDs, parseSeatbeltTSV, resolveSeatbeltOptions, serializeSeatbeltTSV, transformMessages, updateMaxErrors}; +export type {SeatbeltApplyResult, SeatbeltFileData}; diff --git a/scripts/lint/processors/StratifyNoDeprecated.ts b/scripts/lint/processors/StratifyNoDeprecated.ts new file mode 100644 index 000000000000..b222f8b8b3d8 --- /dev/null +++ b/scripts/lint/processors/StratifyNoDeprecated.ts @@ -0,0 +1,156 @@ +import {parse} from '@babel/parser'; +import {file} from 'bun'; + +import type {ASTNode} from '../../utils/BabelASTUtils'; +import type {LintMessage} from '../types'; + +import BabelASTUtils from '../../utils/BabelASTUtils'; +import Processor from '../Processor'; + +const NO_DEPRECATED_RULE_ID = '@typescript-eslint/no-deprecated'; +const NON_CHILD_KEYS = new Set(['loc', 'start', 'end', 'extra', 'leadingComments', 'trailingComments', 'innerComments']); +const MEMBER_LIKE_TYPES = new Set(['MemberExpression', 'OptionalMemberExpression', 'TSQualifiedName']); + +function lineColumnToOffset(source: string, line: number, column: number): number { + let lineStart = 0; + for (let currentLine = 1; currentLine < line; currentLine++) { + const nextNewline = source.indexOf('\n', lineStart); + if (nextNewline < 0) { + return -1; + } + lineStart = nextNewline + 1; + } + return lineStart + column - 1; +} + +function findASTPathAtOffset(root: ASTNode, offset: number): ASTNode[] | null { + if (offset < 0 || offset < root.start || offset > root.end) { + return null; + } + const path = [root]; + while (true) { + const current = path.at(-1); + if (!current) { + return path; + } + let descended = false; + for (const child of BabelASTUtils.children(current, NON_CHILD_KEYS)) { + if (offset >= child.start && offset <= child.end) { + path.push(child); + descended = true; + break; + } + } + if (!descended) { + return path; + } + } +} + +function topOfMemberChain(path: ASTNode[]): ASTNode { + let topIndex = path.length - 1; + while (topIndex > 0 && MEMBER_LIKE_TYPES.has(path.at(topIndex - 1)?.type ?? '')) { + topIndex--; + } + const top = path.at(topIndex); + if (!top) { + throw new Error('empty AST path'); + } + return top; +} + +function parseSourceOrNull(source: string): ASTNode | null { + try { + const parsed: unknown = parse(source, {sourceType: 'module', plugins: ['typescript', 'jsx']}); + return BabelASTUtils.isASTNode(parsed) ? parsed : null; + } catch { + return null; + } +} + +function getDeprecatedExpressionFromSource(source: string, ast: ASTNode, message: LintMessage): string | null { + const offset = lineColumnToOffset(source, message.line, message.column); + const path = findASTPathAtOffset(ast, offset); + if (!path) { + return null; + } + const top = topOfMemberChain(path); + return source.slice(top.start, top.end); +} + +function getSymbolNameFromMessage(message: LintMessage): string | null { + const match = /^`([^`]+)`/.exec(message.message); + return match ? (match.at(1) ?? null) : null; +} + +function toRuleIDSuffix(apiName: string): string { + return apiName.trim().replaceAll(/[\s/]+/g, '_'); +} + +function stratifyMessages(messages: LintMessage[], source: string | null): LintMessage[] { + const hasNoDeprecatedMessages = messages.some((message) => message.ruleID === NO_DEPRECATED_RULE_ID); + const ast = source && hasNoDeprecatedMessages ? parseSourceOrNull(source) : null; + + return messages.map((message) => { + if (message.ruleID !== NO_DEPRECATED_RULE_ID) { + return message; + } + const apiName = (source !== null && ast !== null ? getDeprecatedExpressionFromSource(source, ast, message) : null) ?? getSymbolNameFromMessage(message); + if (!apiName) { + return message; + } + return {...message, ruleID: `${NO_DEPRECATED_RULE_ID}/${toRuleIDSuffix(apiName)}`}; + }); +} + +/** + * Rewrite `@typescript-eslint/no-deprecated` into per-API rule IDs so the + * ratchet can tighten each deprecated symbol independently. Only files that + * actually carry a `no-deprecated` message are re-parsed. + */ +class StratifyNoDeprecated extends Processor { + readonly name = 'stratify-no-deprecated'; + + process(messages: LintMessage[]): Promise { + return stratifyNoDeprecated(messages); + } +} + +async function stratifyNoDeprecated(messages: LintMessage[]): Promise { + const filesNeedingSource = new Set(); + for (const message of messages) { + if (message.ruleID === NO_DEPRECATED_RULE_ID) { + filesNeedingSource.add(message.filePath); + } + } + if (filesNeedingSource.size === 0) { + return messages; + } + + const sources = new Map(); + await Promise.all( + [...filesNeedingSource].map(async (filename) => { + try { + sources.set(filename, await file(filename).text()); + } catch { + sources.set(filename, null); + } + }), + ); + + const byFile = new Map(); + for (const message of messages) { + const list = byFile.get(message.filePath) ?? []; + list.push(message); + byFile.set(message.filePath, list); + } + + const rewritten: LintMessage[] = []; + for (const [filename, fileMessages] of byFile) { + rewritten.push(...stratifyMessages(fileMessages, sources.get(filename) ?? null)); + } + return rewritten; +} + +export default StratifyNoDeprecated; +export {NO_DEPRECATED_RULE_ID, stratifyMessages, stratifyNoDeprecated, toRuleIDSuffix}; diff --git a/scripts/lint/types.ts b/scripts/lint/types.ts new file mode 100644 index 000000000000..011221eb3d9f --- /dev/null +++ b/scripts/lint/types.ts @@ -0,0 +1,68 @@ +/** + * Linter-agnostic diagnostic produced by a Linter (ESLint today, Oxlint later) + * and consumed by processors and the formatter. + * + * Numeric values match ESLint's JSON output. Seatbelt only ratchets countable + * errors (`LINT_SEVERITY.ERROR` with a non-null ruleID). + */ +const LINT_SEVERITY = { + WARNING: 1, + ERROR: 2, +} as const; + +type LintSeverity = (typeof LINT_SEVERITY)[keyof typeof LINT_SEVERITY]; + +type LintMessage = { + filePath: string; + ruleID: string | null; + severity: LintSeverity; + message: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; + suggestions?: unknown; + fix?: unknown; +}; + +type LintFileResult = { + filePath: string; + messages: LintMessage[]; + source?: string; +}; + +type LinterResult = { + files: LintFileResult[]; + /** Non-zero when the linter itself crashed or rejected the config. */ + exitCode: number; + /** Stderr from the linter process, for surfacing crashes. */ + stderr: string; +}; + +type ProcessorContext = { + projectRoot: string; + lintedFiles: string[]; +}; + +type FormatterResult = { + text: string; + errorCount: number; + warningCount: number; +}; + +type SeatbeltRuleSet = 'all' | Set; + +type SeatbeltOptions = { + seatbeltFile: string; + projectRoot: string; + disable: boolean; + frozen: boolean; + readOnly: boolean; + allowIncreaseRules: SeatbeltRuleSet; + keepRules: SeatbeltRuleSet; + quiet: boolean; + verbose: boolean; +}; + +export {LINT_SEVERITY}; +export type {FormatterResult, LintFileResult, LintMessage, LintSeverity, LinterResult, ProcessorContext, SeatbeltOptions, SeatbeltRuleSet}; diff --git a/scripts/lintChanged.sh b/scripts/lintChanged.sh index 59176eba28a9..311ef3fce6bd 100755 --- a/scripts/lintChanged.sh +++ b/scripts/lintChanged.sh @@ -22,9 +22,9 @@ if [[ -n "$CHANGED_FILES_OUTPUT" ]]; then done <<< "$CHANGED_FILES_OUTPUT" fi -# Run eslint on the changed files, forwarding any user-provided flags +# Run lint on the changed files. Flags must precede paths; this script puts "$@" first. if [[ "${#ALL_CHANGED_FILES[@]}" -gt 0 ]]; then - exec bun "${TOP}/scripts/lint.ts" "$@" "${ALL_CHANGED_FILES[@]}" + exec bun "${TOP}/scripts/lint/index.ts" "$@" "${ALL_CHANGED_FILES[@]}" else info "No lintable files changed" fi diff --git a/scripts/onyxConnectBypass.ts b/scripts/onyxConnectBypass.ts index 444ebfd53bb4..993200680ad7 100644 --- a/scripts/onyxConnectBypass.ts +++ b/scripts/onyxConnectBypass.ts @@ -2,18 +2,27 @@ * Detection logic for new `eslint-disable` bypasses of the Onyx.connect() ban. * * `rulesdir/no-onyx-connect` (shipped by eslint-config-expensify) is a normal lint rule, so an - * inline `eslint-disable` can silence it. ESLint records such silenced violations as "suppressed - * messages". This module finds suppressed `rulesdir/no-onyx-connect` violations and flags any that - * go beyond the disables already present on `main`, so a new bypass can be re-elevated to an error - * at the runner level — where no disable directive can reach it. + * inline `eslint-disable` can silence it. The lint runner re-elevates those disables by scanning + * source for disable directives that name the ban or blanket directives that cover a real call. No + * disable directive can reach this check because it does not go through ESLint's message pipeline. + * + * Blanket `eslint-disable` / `eslint-disable-next-line` with no rule list counts only when it + * covers a real Onyx.connect() call. Unrelated blanket comments (e.g. around ReportUtils) remain + * ignored. Call sites are found via the Babel AST so comments and grouping parens cannot hide a + * banned member access from a source scan. */ -import type {ESLint} from 'eslint'; -import path from 'node:path'; +import {parse} from '@babel/parser'; + +import type {ASTNode} from './utils/BabelASTUtils'; + +import BabelASTUtils from './utils/BabelASTUtils'; /** Rule id of the Onyx.connect() ban, as exposed through eslint-plugin-rulesdir. */ const BANNED_RULE_ID = 'rulesdir/no-onyx-connect'; +const BANNED_RULE_NAME = 'no-onyx-connect'; + /** * Disables of the ban that already exist on `main`, keyed by repo-relative path with the number of * occurrences in each file. Migrating these call sites to useOnyx() is already in progress; any @@ -30,20 +39,208 @@ type SuppressedBan = { line: number; }; -/** The fields of an ESLint result this module reads; real `ESLint.LintResult`s satisfy it. */ -type ResultWithSuppressed = Pick; +type DirectiveMatch = { + index: number; + text: string; + kind?: string; + args: string; +}; -/** Pull suppressed `no-onyx-connect` violations out of ESLint results, keyed by repo-relative path. */ -function collectSuppressedBans(results: readonly ResultWithSuppressed[], projectRoot: string): SuppressedBan[] { - const bans: SuppressedBan[] = []; - for (const result of results) { - for (const message of result.suppressedMessages ?? []) { - if (message.ruleId !== BANNED_RULE_ID) { - continue; +type BabelComment = { + type: string; + value: string; + start: number | null; + end: number | null; +}; + +function parseSource(source: string): {root: ASTNode; comments: BabelComment[]} | null { + try { + const parsed: unknown = parse(source, {sourceType: 'unambiguous', plugins: ['typescript', 'jsx'], errorRecovery: true, attachComment: true}); + if (!BabelASTUtils.isASTNode(parsed) || !BabelASTUtils.isRecord(parsed)) { + return null; + } + const rawComments = parsed.comments; + const comments = Array.isArray(rawComments) + ? rawComments.filter((comment): comment is BabelComment => { + return ( + BabelASTUtils.isRecord(comment) && + typeof comment.type === 'string' && + typeof comment.value === 'string' && + typeof comment.start === 'number' && + typeof comment.end === 'number' + ); + }) + : []; + return {root: parsed, comments}; + } catch { + return null; + } +} + +function collectDirectiveMatches(comments: readonly BabelComment[], source: string, directive: 'disable' | 'enable'): DirectiveMatch[] { + const matches: DirectiveMatch[] = []; + for (const comment of comments) { + if (comment.start === null || comment.end === null) { + continue; + } + // ESLint only supports -line / -next-line on disable. `eslint-enable-line` and + // `eslint-enable-next-line` are not real directives, so treating them as enables + // would reopen a blanket disable that ESLint still honors. + const pattern = directive === 'disable' ? '^\\s*eslint-disable(?-next-line|-line)?(?[\\s\\S]*)$' : '^\\s*eslint-enable(?!-)(?[\\s\\S]*)$'; + const directiveMatch = comment.value.match(new RegExp(pattern)); + if (!directiveMatch) { + continue; + } + matches.push({index: comment.start, text: source.slice(comment.start, comment.end), kind: directiveMatch.groups?.kind, args: directiveMatch.groups?.args ?? ''}); + } + return matches; +} + +function directiveKind(match: DirectiveMatch): string | undefined { + return match.kind; +} + +function directiveArgs(match: DirectiveMatch): string { + return match.args; +} + +const NON_CHILD_KEYS = new Set(['loc', 'start', 'end', 'extra', 'leadingComments', 'trailingComments', 'innerComments', 'comments']); +const WRAPPER_TYPES = new Set(['ParenthesizedExpression', 'TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion']); + +function unwrapExpression(node: ASTNode): ASTNode { + let current = node; + while (WRAPPER_TYPES.has(current.type) && BabelASTUtils.isASTNode(current.expression)) { + current = current.expression; + } + return current; +} + +function isOnyxConnectCall(node: ASTNode): boolean { + // Optional chaining anywhere in the call (`Onyx?.connect(...)`, `Onyx.connect?.(...)`) produces + // `OptionalCallExpression`/`OptionalMemberExpression` nodes instead of their non-optional + // counterparts, so a blanket disable directive over one would otherwise silently bypass the ban. + if ((node.type !== 'CallExpression' && node.type !== 'OptionalCallExpression') || !BabelASTUtils.isASTNode(node.callee)) { + return false; + } + const callee = node.callee; + if ((callee.type !== 'MemberExpression' && callee.type !== 'OptionalMemberExpression') || callee.computed === true) { + return false; + } + if (!BabelASTUtils.isASTNode(callee.property) || callee.property.type !== 'Identifier' || callee.property.name !== 'connect') { + return false; + } + if (!BabelASTUtils.isASTNode(callee.object)) { + return false; + } + const object = unwrapExpression(callee.object); + return object.type === 'Identifier' && object.name === 'Onyx'; +} + +function collectOnyxConnectCallOffsets(root: ASTNode): number[] { + const offsets: number[] = []; + const visit = (node: ASTNode) => { + if (isOnyxConnectCall(node)) { + offsets.push(node.start); + } + for (const child of BabelASTUtils.children(node, NON_CHILD_KEYS)) { + visit(child); + } + }; + visit(root); + return offsets; +} + +function normalizedDirectiveArgs(args: string): string { + return args + .replace(/--[\s\S]*$/, '') + .replaceAll(/[\s*]+/g, ' ') + .trim(); +} + +function directiveTargetsBan(args: string): boolean { + const trimmed = normalizedDirectiveArgs(args); + if (trimmed.length === 0) { + return false; + } + return trimmed.split(',').some((part) => { + const rule = part.trim(); + return rule === BANNED_RULE_ID || rule === BANNED_RULE_NAME || rule.endsWith(`/${BANNED_RULE_NAME}`); + }); +} + +function isBlanketDirective(args: string): boolean { + return normalizedDirectiveArgs(args).length === 0; +} + +function lineNumberAtOffset(source: string, offset: number): number { + let line = 1; + let index = 0; + const end = Math.min(Math.max(offset, 0), source.length); + while (index < end) { + const code = source.charCodeAt(index); + if (code === 0x0d) { + line++; + index += source.charCodeAt(index + 1) === 0x0a ? 2 : 1; + continue; + } + if (code === 0x0a || code === 0x2028 || code === 0x2029) { + line++; + } + index++; + } + return line; +} + +function blanketDirectiveCoversCall(source: string, match: DirectiveMatch, callOffsets: number[], enableMatches: DirectiveMatch[]): boolean { + const directiveLine = lineNumberAtOffset(source, match.index); + const kind = directiveKind(match); + const directiveEnd = match.index + match.text.length; + const directiveEndLine = lineNumberAtOffset(source, Math.max(match.index, directiveEnd - 1)); + return callOffsets.some((callOffset) => { + const callLine = lineNumberAtOffset(source, callOffset); + if (kind === '-line') { + return callLine === directiveLine; + } + if (kind === '-next-line') { + return callLine === directiveEndLine + 1; + } + if (callOffset <= directiveEnd) { + return false; + } + const reenabled = enableMatches.some((enableMatch) => { + const enableOffset = enableMatch.index; + if (enableOffset <= directiveEnd || enableOffset >= callOffset) { + return false; } - const file = path.relative(projectRoot, result.filePath).split(path.sep).join('/'); - bans.push({file, line: message.line}); + const enableArgs = directiveArgs(enableMatch); + return isBlanketDirective(enableArgs) || directiveTargetsBan(enableArgs); + }); + return !reenabled; + }); +} + +/** + * Find disable directives in `source` that suppress `rulesdir/no-onyx-connect`. + * Line numbers are 1-based. Matches both full-line and trailing `eslint-disable-line`. + */ +function collectDisableDirectivesFromSource(source: string, file: string): SuppressedBan[] { + const parsed = parseSource(source); + if (!parsed) { + return []; + } + const bans: SuppressedBan[] = []; + const callOffsets = collectOnyxConnectCallOffsets(parsed.root); + const enableMatches = collectDirectiveMatches(parsed.comments, source, 'enable'); + for (const match of collectDirectiveMatches(parsed.comments, source, 'disable')) { + const args = directiveArgs(match); + const targetsBan = directiveTargetsBan(args); + const coversBan = isBlanketDirective(args) && blanketDirectiveCoversCall(source, match, callOffsets, enableMatches); + if (!targetsBan && !coversBan) { + continue; } + const prefix = source.slice(0, match.index); + const line = prefix.split('\n').length; + bans.push({file, line}); } return bans; } @@ -69,5 +266,5 @@ function findNewBypasses(suppressedBans: readonly SuppressedBan[]): SuppressedBa return newBypasses; } -export {BANNED_RULE_ID, GRANDFATHERED_BYPASSES, collectSuppressedBans, findNewBypasses}; -export type {SuppressedBan, ResultWithSuppressed}; +export {BANNED_RULE_ID, BANNED_RULE_NAME, GRANDFATHERED_BYPASSES, collectDisableDirectivesFromSource, findNewBypasses}; +export type {SuppressedBan}; diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index da4b92602de1..bd95fa016e40 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -33,5 +33,9 @@ bun scripts/applyPatches.ts # `@typescript/old` (pulled in by `@typescript/typescript6`) also ships a `tsc` bin. # npm's last-writer-wins linking can point `node_modules/.bin/tsc` at TypeScript 6. -# Force the TypeScript 7 native compiler so a bare `npx tsc` matches `npm run typecheck`. +# Force the TypeScript 7 native compiler, so `npx tsc` is the compiler `npm run typecheck` runs. +# +# It is not the same *command*: the root tsconfig.json is a solution that lists projects and owns no +# files, and project mode does not follow `references`. `npx tsc` at the repo root therefore checks +# nothing and exits 0. Use `npx tsc --build`, which walks the references, or `npm run typecheck`. ln -sfn ../typescript/bin/tsc "$ROOT_DIR/node_modules/.bin/tsc" diff --git a/scripts/strip-ios-debug-symbols.sh b/scripts/strip-ios-debug-symbols.sh index d1dd94f3a973..b043c69a8424 100755 --- a/scripts/strip-ios-debug-symbols.sh +++ b/scripts/strip-ios-debug-symbols.sh @@ -36,6 +36,10 @@ if [ -d "$APP_FRAMEWORKS_DIR" ]; then fi echo "Stripping framework: $framework_name" strip -rSx "$framework_binary" + if [ -d "$framework_dir/dSYMs" ]; then + echo "Removing embedded dSYMs from: $framework_name" + rm -rf "$framework_dir/dSYMs" + fi codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" --preserve-metadata=identifier,entitlements "$framework_dir" done else diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000000..7c897c1413ea --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.bun.json", + "include": ["**/*"] +} diff --git a/scripts/typecheck.ts b/scripts/typecheck.ts index 0ca06c950b71..d7f6f7e28862 100644 --- a/scripts/typecheck.ts +++ b/scripts/typecheck.ts @@ -3,13 +3,20 @@ /** * Type-check the repo with the TypeScript 7 native compiler. * - * bun scripts/typecheck.ts -> check every project CI gates on + * bun scripts/typecheck.ts -> check every project the root solution references * bun scripts/typecheck.ts tsconfig.bun.json -> check just the named project * + * The project list comes from the `references` in the root `tsconfig.json`, so a project joins CI by + * being referenced there and nowhere else. + * * Every project is checked even after one fails, so a single run reports every error in the repo. + * Projects are checked concurrently, which is why this fans out `tsc --build` per project instead of + * running `tsc --build tsconfig.json` once: build mode walks a solution sequentially. */ import {$} from 'bun'; import CLI from 'expensify-common/CLI'; +import {readdirSync} from 'fs'; +import path from 'path'; const projectRoot = `${import.meta.dir}/..`; @@ -18,8 +25,75 @@ const projectRoot = `${import.meta.dir}/..`; // Invoke that bin by path so a leftover `.bin/tsc` from `@typescript/old` cannot win. const tsc = `${projectRoot}/node_modules/typescript/bin/tsc`; -/** TypeScript projects, relative to the repo root, that `npm run typecheck` and CI check. */ -const DEFAULT_PROJECTS = ['tsconfig.json', 'tsconfig.jest.json', 'tsconfig.bun.json', 'tsconfig.node.json', 'server/victory-chart-renderer/tsconfig.json']; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** `Array.isArray` narrows to `any[]`, which loses the `unknown` we are trying to validate. */ +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + +/** + * Pull the `references[].path` list out of a `tsc --showConfig` payload. + * + * Every entry is validated rather than filtered: silently dropping a malformed reference would + * silently drop a project out of the typecheck gate. + */ +function readReferencePaths(config: unknown): string[] { + if (!isRecord(config) || !isUnknownArray(config.references)) { + throw new Error('tsconfig.json has no `references` array, so there is nothing to type-check.'); + } + return config.references.map((reference, index) => { + if (!isRecord(reference) || typeof reference.path !== 'string' || reference.path === '') { + throw new Error(`tsconfig.json is malformed at references[${index}]: every reference needs a non-empty string "path".`); + } + return reference.path; + }); +} + +/** + * The projects the root solution references, relative to the repo root. + * + * `--showConfig` is how we read them: the root config carries comments, so it is not plain JSON, and + * TypeScript 7 does not yet expose a compiler API we could parse it with. + */ +async function getReferencedProjects(): Promise { + const result = await $`${tsc} -p tsconfig.json --showConfig`.cwd(projectRoot).quiet().nothrow(); + if (result.exitCode !== 0) { + throw new Error(`Could not read the projects referenced by tsconfig.json:\n${result.stderr.toString()}`); + } + const references = readReferencePaths(JSON.parse(result.stdout.toString())); + if (references.length === 0) { + throw new Error('tsconfig.json references no projects, so there is nothing to type-check.'); + } + assertEveryRootProjectIsReferenced(references); + return references; +} + +/** + * Fail loudly when a root-level project is missing from `tsconfig.json`'s `references`. + * + * `tsc --showConfig` drops a malformed reference (say, a misspelled `path` key) without reporting + * anything, which would quietly take that project out of the typecheck gate. This turns both that + * and "added a project but forgot to reference it" into an error. + */ +function assertEveryRootProjectIsReferenced(references: string[]) { + const referenced = new Set(references.map((reference) => path.basename(reference))); + const unreferenced = readdirSync(projectRoot) + // `tsconfig.base.json` holds shared options rather than files, so it is not a project. + .filter((entry) => /^tsconfig\..+\.json$/.test(entry) && entry !== 'tsconfig.base.json') + .filter((entry) => !referenced.has(entry)); + if (unreferenced.length > 0) { + throw new Error(`These projects are not referenced by tsconfig.json, so nothing type-checks them: ${unreferenced.join(', ')}`); + } +} + +/** Type-check one project. Build mode skips it entirely when its build info file is already current. */ +async function typecheckProject(project: string) { + const result = await $`${tsc} --build ${project}`.cwd(projectRoot).quiet().nothrow(); + return {project, result}; +} const cli = new CLI({ positionalArgs: [ @@ -27,25 +101,27 @@ const cli = new CLI({ name: 'projects', description: 'tsconfig paths to type-check, relative to the repo root', variadic: true, - default: DEFAULT_PROJECTS, + default: [], }, ], }); -const {projects} = cli.positionalArgs; +const projects = cli.positionalArgs.projects.length > 0 ? cli.positionalArgs.projects : await getReferencedProjects(); + +// All projects are checked concurrently. Projects share source files but each writes its own build +// info file (`tsBuildInfoFile`), so there is nothing for concurrent builds to race over. +// `--noEmit` lives in the configs because build mode rejects it as a flag. +const results = await Promise.all(projects.map(typecheckProject)); const failed: string[] = []; -for (const project of projects) { - const tsconfig = project.endsWith('.json') ? project : `${project}/tsconfig.json`; - console.log(`\nType checking ${tsconfig}...`); - - // The build info file lets repeat runs skip unchanged projects. It is named apart from the - // `tsconfig.tsbuildinfo` that `incremental` defaults to so that running TypeScript 6 by hand in - // the same worktree can't feed it a build info file written by a different compiler. - const tsBuildInfoFile = `${tsconfig.replace(/\.json$/, '')}.ts7.tsbuildinfo`; - const result = await $`${tsc} --noEmit --incremental -p ${tsconfig} --tsBuildInfoFile ${tsBuildInfoFile}`.cwd(projectRoot).nothrow(); +for (const {project, result} of results) { + console.log(`\nType checking ${project}...`); + const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim(); + if (output) { + console.log(output); + } if (result.exitCode !== 0) { - failed.push(tsconfig); + failed.push(project); } } diff --git a/scripts/utils/BabelASTUtils.ts b/scripts/utils/BabelASTUtils.ts new file mode 100644 index 000000000000..309ea147d341 --- /dev/null +++ b/scripts/utils/BabelASTUtils.ts @@ -0,0 +1,43 @@ +type ASTNode = { + type: string; + start: number; + end: number; + [key: string]: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isASTNode(value: unknown): value is ASTNode { + if (!isRecord(value)) { + return false; + } + return typeof value.type === 'string' && typeof value.start === 'number' && typeof value.end === 'number'; +} + +/** + * Yield every direct AST child of `node`. `nonChildKeys` excludes location/metadata fields (and any + * parser-specific extras, e.g. a top-level `comments` array) that aren't part of the tree shape. + */ +function* children(node: ASTNode, nonChildKeys: ReadonlySet): Generator { + for (const [key, value] of Object.entries(node)) { + if (nonChildKeys.has(key)) { + continue; + } + for (const child of Array.isArray(value) ? value : [value]) { + if (isASTNode(child)) { + yield child; + } + } + } +} + +const BabelASTUtils = { + isRecord, + isASTNode, + children, +}; + +export default BabelASTUtils; +export type {ASTNode}; diff --git a/scripts/utils/Bench.ts b/scripts/utils/Bench.ts new file mode 100644 index 000000000000..41cdf2cf6129 --- /dev/null +++ b/scripts/utils/Bench.ts @@ -0,0 +1,66 @@ +type BenchMark = { + name: string; + ms: number; +}; + +/** + * Lightweight wall-clock instrumentation. Marks are recorded in insertion + * order so a printed summary matches the run. + */ +class Bench { + private readonly starts = new Map(); + + private readonly marks: BenchMark[] = []; + + start(name: string): void { + this.starts.set(name, performance.now()); + } + + end(name: string): number { + const startedAt = this.starts.get(name); + if (startedAt === undefined) { + throw new Error(`Bench.end(${name}) called without a matching start`); + } + this.starts.delete(name); + const ms = performance.now() - startedAt; + this.marks.push({name, ms}); + return ms; + } + + async measure(name: string, fn: () => Promise): Promise { + this.start(name); + try { + return await fn(); + } finally { + this.end(name); + } + } + + measureSync(name: string, fn: () => T): T { + this.start(name); + try { + return fn(); + } finally { + this.end(name); + } + } + + getMarks(): readonly BenchMark[] { + return this.marks; + } + + format(label = 'timings'): string { + if (this.marks.length === 0) { + return `${label}: (none)`; + } + const total = this.marks.reduce((sum, mark) => sum + mark.ms, 0); + const lines = this.marks.map((mark) => { + const percent = total === 0 ? 0 : (mark.ms / total) * 100; + return ` ${mark.name.padEnd(28)} ${mark.ms.toFixed(1).padStart(10)} ms ${percent.toFixed(1).padStart(5)}%`; + }); + return [`${label} (wall):`, ...lines, ` ${'total'.padEnd(28)} ${total.toFixed(1).padStart(10)} ms`].join('\n'); + } +} + +export default Bench; +export type {BenchMark}; diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts index a559a8aea817..53daca43caca 100644 --- a/scripts/utils/FileUtils.ts +++ b/scripts/utils/FileUtils.ts @@ -38,6 +38,28 @@ const FileUtils = { return {line, column}; }, + /** + * Convert an absolute path to a path relative to the directory containing `baseFile`. + * Already-relative paths are returned unchanged. + */ + toRelativePath: (baseFile: string, filename: string): string => { + if (!path.isAbsolute(filename)) { + return filename; + } + return path.relative(path.dirname(baseFile), filename); + }, + + /** + * Convert a path relative to the directory containing `baseFile` to an absolute path. + * Already-absolute paths are returned unchanged. + */ + toAbsolutePath: (baseFile: string, filename: string): string => { + if (path.isAbsolute(filename)) { + return filename; + } + return path.resolve(path.dirname(baseFile), filename); + }, + /** * Resolve a list of inputs (file paths, directories, or glob patterns) to concrete file paths. * Directories are expanded recursively. Results are deduplicated. diff --git a/scripts/utils/TSVUtils.ts b/scripts/utils/TSVUtils.ts new file mode 100644 index 000000000000..3577f1d2a085 --- /dev/null +++ b/scripts/utils/TSVUtils.ts @@ -0,0 +1,111 @@ +type TSVParseOptions = { + onInvalidRow?: (error: Error, line: string, lineNumber: number) => void; +}; + +type TSVSerializeOptions = { + sort?: boolean; +}; + +type TSVRow = { + cells: unknown[]; + comments: string; +}; + +type TSVDocument = { + leadingComments: string; + trailingComments: string; + rows: TSVRow[]; +}; + +const COMMENT_LINE_REGEX = /^\s*#/; +const NON_EMPTY_LINE_REGEX = /\S+/; + +function decodeRow(line: string, lineNumber: number): unknown[] { + return line.split('\t').map((cell, columnIndex) => { + try { + return JSON.parse(cell) as unknown; + } catch { + throw new Error(`Invalid JSON in column ${columnIndex + 1} at line ${lineNumber}`); + } + }); +} + +function encodeRow(row: readonly unknown[]): string { + return `${row.map((cell) => JSON.stringify(cell)).join('\t')}\n`; +} + +function joinCommentLines(lines: readonly string[]): string { + return lines.join('\n'); +} + +function appendBlock(output: string, block: string): string { + if (!block) { + return output; + } + return output + (block.endsWith('\n') ? block : `${block}\n`); +} + +const TSVUtils = { + /** + * Comments are either leading (before any data), trailing (after the last + * data row), or attached to the next data row. Inline comments therefore + * travel with that row when `serialize` sorts. + */ + parse(text: string, options: TSVParseOptions = {}): TSVDocument { + const rows: TSVRow[] = []; + const leadingCommentLines: string[] = []; + const pendingCommentLines: string[] = []; + let seenData = false; + const rawLines = text.split(/\r?\n/); + for (const [index, rawLine] of rawLines.entries()) { + if (!NON_EMPTY_LINE_REGEX.test(rawLine)) { + continue; + } + if (COMMENT_LINE_REGEX.test(rawLine)) { + if (seenData) { + pendingCommentLines.push(rawLine); + } else { + leadingCommentLines.push(rawLine); + } + continue; + } + const lineNumber = index + 1; + try { + rows.push({cells: decodeRow(rawLine, lineNumber), comments: joinCommentLines(pendingCommentLines)}); + pendingCommentLines.length = 0; + seenData = true; + } catch (error) { + const parsedError = error instanceof Error ? error : new Error(String(error)); + if (!options.onInvalidRow) { + throw parsedError; + } + options.onInvalidRow(parsedError, rawLine, lineNumber); + } + } + return { + leadingComments: joinCommentLines(leadingCommentLines), + trailingComments: joinCommentLines(pendingCommentLines), + rows, + }; + }, + + serialize(document: TSVDocument, options: TSVSerializeOptions = {}): string { + const items = document.rows.map((row) => ({comments: row.comments, encoded: encodeRow(row.cells)})); + if (options.sort) { + items.sort((left, right) => left.encoded.localeCompare(right.encoded)); + } + let output = ''; + if (document.leadingComments) { + output = appendBlock(output, document.leadingComments); + output += '\n'; + } + for (const item of items) { + output = appendBlock(output, item.comments); + output += item.encoded; + } + return appendBlock(output, document.trailingComments); + }, +}; + +export default TSVUtils; +export type {TSVDocument, TSVParseOptions, TSVRow, TSVSerializeOptions}; diff --git a/scripts/utils/WorkerPool.ts b/scripts/utils/WorkerPool.ts new file mode 100644 index 000000000000..9d96d6184355 --- /dev/null +++ b/scripts/utils/WorkerPool.ts @@ -0,0 +1,98 @@ +type QueuedItem = { + item: TRequest; + index: number; +}; + +/** + * Fixed-size Bun/Web Worker pool. One request is in flight per worker; a dead + * worker falls back for its in-flight item and leaves the rest of the queue + * for surviving workers. Leftover items also use the fallback so a total + * pool crash cannot drop work on the floor. + */ +class WorkerPool { + private readonly workerURL: URL; + + private readonly concurrency: number; + + constructor(workerURL: URL, concurrency = 4) { + this.workerURL = workerURL; + this.concurrency = concurrency; + } + + async map(items: readonly TRequest[], fallback: (item: TRequest) => TResponse): Promise { + if (items.length === 0) { + return []; + } + + const results = new Map(); + const queue: Array> = items.map((item, index) => ({item, index})); + const poolSize = Math.max(1, Math.min(this.concurrency, items.length)); + const workers = Array.from({length: poolSize}, () => new Worker(this.workerURL)); + + try { + await Promise.all(workers.map((worker) => this.drain(worker, queue, results, fallback))); + } finally { + for (const worker of workers) { + worker.terminate(); + } + } + + for (const leftover of queue) { + results.set(leftover.index, fallback(leftover.item)); + } + + return items.map((item, index) => results.get(index) ?? fallback(item)); + } + + private drain(worker: Worker, queue: Array>, results: Map, fallback: (item: TRequest) => TResponse): Promise { + return new Promise((resolve) => { + let inFlight: QueuedItem | undefined; + let settled = false; + + const pump = () => { + if (settled) { + return; + } + const next = queue.pop(); + if (!next) { + settled = true; + resolve(); + return; + } + inFlight = next; + worker.postMessage(next.item); + }; + + const fail = () => { + if (settled) { + return; + } + settled = true; + if (inFlight) { + results.set(inFlight.index, fallback(inFlight.item)); + inFlight = undefined; + } + resolve(); + }; + + worker.addEventListener('message', (event: MessageEvent) => { + if (settled) { + return; + } + if (inFlight) { + results.set(inFlight.index, event.data); + inFlight = undefined; + } + pump(); + }); + + worker.addEventListener('error', fail); + worker.addEventListener('messageerror', fail); + worker.addEventListener('close', fail); + + pump(); + }); + } +} + +export default WorkerPool; diff --git a/server/stubs/telemetry-activeSpans.ts b/server/stubs/telemetry-activeSpans.ts index 11df92453ca3..94de440e33a5 100644 --- a/server/stubs/telemetry-activeSpans.ts +++ b/server/stubs/telemetry-activeSpans.ts @@ -19,6 +19,12 @@ function getSpan() { function getSpanByPrefix() {} +function getUniqueSpanByPrefix() {} + +function getSpanID() { + return undefined; +} + function cancelSpan() {} function cancelSpanByInstance() {} @@ -27,4 +33,4 @@ function cancelAllSpans() {} function cancelSpansByPrefix() {} -export {startSpan, endSpan, endSpanWithAttributes, getSpan, getSpanByPrefix, cancelSpan, cancelSpanByInstance, cancelAllSpans, cancelSpansByPrefix}; +export {startSpan, endSpan, endSpanWithAttributes, getSpan, getSpanByPrefix, getUniqueSpanByPrefix, getSpanID, cancelSpan, cancelSpanByInstance, cancelAllSpans, cancelSpansByPrefix}; diff --git a/server/victory-chart-renderer/tsconfig.json b/server/victory-chart-renderer/tsconfig.json index ffc13e115046..625422ee52e6 100644 --- a/server/victory-chart-renderer/tsconfig.json +++ b/server/victory-chart-renderer/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "tsBuildInfoFile": "./tsconfig.ts7.tsbuildinfo", "types": ["react-native", "react-native-web", "node"] }, "include": ["src", "tests", "scripts", "../../src/types"] diff --git a/src/CONFIG.ts b/src/CONFIG.ts index efc043001428..3244dad9a01f 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -43,7 +43,10 @@ const secureNgrokURL = addTrailingForwardSlash(get(Config, 'SECURE_NGROK_URL', ' const secureExpensifyUrl = addTrailingForwardSlash(get(Config, 'SECURE_EXPENSIFY_URL', 'https://secure.expensify.com/')); const useNgrok = get(Config, 'USE_NGROK', 'false') === 'true'; const useWebProxy = get(Config, 'USE_WEB_PROXY', 'true') === 'true'; -const qaExpensifyURL = get(Config, 'QA_EXPENSIFY_URL', ''); +// addTrailingForwardSlash('') returns '/', which would look configured +const normalizeOptionalRoot = (value: string): string => (value ? addTrailingForwardSlash(value) : ''); +const qaExpensifyURL = normalizeOptionalRoot(get(Config, 'QA_EXPENSIFY_URL', '')); +const qaSecureExpensifyURL = normalizeOptionalRoot(get(Config, 'QA_SECURE_EXPENSIFY_URL', '')); const expensifyComWithProxy = getPlatform() === 'web' && useWebProxy ? '/' : expensifyURL; // Throw errors on dev if config variables are not set correctly @@ -83,6 +86,8 @@ export default { DEFAULT_SECURE_API_ROOT: secureURLRoot, STAGING_API_ROOT: stagingExpensifyURL, STAGING_SECURE_API_ROOT: stagingSecureExpensifyUrl, + QA_API_ROOT: qaExpensifyURL, + QA_SECURE_API_ROOT: qaSecureExpensifyURL, LEGACY_PARTNER_NAME: get(Config, 'LEGACY_EXPENSIFY_PARTNER_NAME', getDefaultLegacyPartnerConfig().name), LEGACY_PARTNER_PASSWORD: get(Config, 'LEGACY_EXPENSIFY_PARTNER_PASSWORD', getDefaultLegacyPartnerConfig().password), PARTNER_NAME: get(Config, 'EXPENSIFY_PARTNER_NAME', 'chat-expensify-com'), @@ -99,6 +104,7 @@ export default { IS_USING_LOCAL_WEB: useNgrok || expensifyURLRoot.includes('dev'), PUSHER: { APP_KEY: get(Config, 'PUSHER_APP_KEY', '268df511a204fbb60884'), + QA_APP_KEY: get(Config, 'PUSHER_QA_APP_KEY', ''), SUFFIX: ENVIRONMENT === CONST.ENVIRONMENT.DEV ? get(Config, 'PUSHER_DEV_SUFFIX', '') : '', CLUSTER: 'mt1', }, @@ -143,8 +149,7 @@ export default { IS_HYBRID_APP: HybridAppModule.isHybridApp(), // Auth for the Cloudflare Access-protected QA server. Empty values disable the feature entirely QA_AUTH: { - // Only normalize a non-empty value: addTrailingForwardSlash('') returns '/' and would look configured - API_ROOT: qaExpensifyURL ? addTrailingForwardSlash(qaExpensifyURL) : '', + API_ROOT: qaExpensifyURL, TEAM_DOMAIN: get(Config, 'QA_CF_TEAM_DOMAIN', ''), CLIENT_ID: get(Config, 'QA_CF_OAUTH_CLIENT_ID', ''), // Which Access-protected endpoint the test tool calls to verify auth is a property of the diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7fbb6973ba6d..225dce983496 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -55,6 +55,10 @@ const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const EXPENSIFY_MOBILE_URL = 'https://expensify.com/mobile'; const EXPENSIFY_URL = 'https://www.expensify.com'; const UBER_CONNECT_URL = 'https://business-integrations.uber.com/connect'; +const CHATGPT_CONNECT_URL = 'https://chatgpt.com/plugins/plugin_asdk_app_6a56a498be548191bdf3743878810456?q=expensify'; +const MCP_HELP_URL = 'https://help.expensify.com/articles/new-expensify/connections/connect-ai-assistants/Use-the-Expensify-MCP-Server-With-AI-Assistants'; +const CLAUDE_CONNECT_URL = 'https://claude.ai/directory/connectors/expensify'; +const CURSOR_MCP_HELP_URL = 'https://help.expensify.com/articles/new-expensify/connections/connect-ai-assistants/Connect-Cursor-to-Expensify-Using-MCP'; const XERO_PARTNER_LINK = 'https://referrals.xero.com/uzfjy4uegog2-v0pj1v'; const UBER_TERMS_LINK = 'https://www.uber.com/us/en/business/sign-up/terms/expense-partners/'; const PLATFORM_OS_MACOS = 'Mac OS'; @@ -226,6 +230,27 @@ const EMAIL = { QA_GUIDE: 'qa.guide@team.expensify.com', }; +// Declared above CONST so subsets can be built from its members instead of repeating the strings. Spliced in below +// as `EDIT_REQUEST_FIELD`. +const editRequestFields = { + AMOUNT: 'amount', + CURRENCY: 'currency', + DATE: 'date', + DESCRIPTION: 'description', + MERCHANT: 'merchant', + CATEGORY: 'category', + RECEIPT: 'receipt', + DISTANCE: 'distance', + DISTANCE_RATE: 'distanceRate', + TAG: 'tag', + TAX_RATE: 'taxRate', + TAX_AMOUNT: 'taxAmount', + REIMBURSABLE: 'reimbursable', + ATTENDEES: 'attendees', + BILLABLE: 'billable', + REPORT: 'report', +} as const; + const CONST = { HEIC_SIGNATURES: [ '6674797068656963', // 'ftypheic' - Indicates standard HEIC file @@ -415,6 +440,7 @@ const CONST = { AUTH_TOKEN_TYPES: { ANONYMOUS: 'anonymousAccount', SUPPORT: 'support', + DELEGATE: 'delegate', }, AUTH_METHOD: { @@ -525,6 +551,16 @@ const CONST = { MERCHANT_NAME_MAX_BYTES: 255, + /** The subset of EDIT_REQUEST_FIELD a merchant rule can govern, whose edit shows the "Create a rule" callout */ + MERCHANT_RULE_SUGGESTION_FIELDS: { + CATEGORY: editRequestFields.CATEGORY, + TAG: editRequestFields.TAG, + TAX: editRequestFields.TAX_RATE, + DESCRIPTION: editRequestFields.DESCRIPTION, + BILLABLE: editRequestFields.BILLABLE, + REIMBURSABLE: editRequestFields.REIMBURSABLE, + }, + MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', REQUEST_PREVIEW: { @@ -562,6 +598,7 @@ const CONST = { BILLCOM: 'Bill.com', ZENEFITS: 'Zenefits', DUALENTRY: 'DualEntry', + CAMPFIRE: 'Campfire', }, REVERSED_TRANSACTION_ATTRIBUTE: 'is-reversed-transaction', @@ -639,6 +676,10 @@ const CONST = { NEW_EXPENSIFY_URL: ACTIVE_EXPENSIFY_URL, UBER_CONNECT_URL, + CHATGPT_CONNECT_URL, + MCP_HELP_URL, + CLAUDE_CONNECT_URL, + CURSOR_MCP_HELP_URL, XERO_PARTNER_LINK, UBER_TERMS_LINK, APP_DOWNLOAD_LINKS: { @@ -693,7 +734,7 @@ const CONST = { FIRST_NAME: 'firstName', LAST_NAME: 'lastName', DOB: 'dob', - SSN_LAST_4: 'ssnLast4', + SSN: 'ssn', STREET: 'street', CITY: 'city', STATE: 'state', @@ -922,7 +963,7 @@ const CONST = { OWNERSHIP_PERCENTAGE: 'ownership-percentage', DATE_OF_BIRTH: 'date-of-birth', ADDRESS: 'address', - LAST_4_SSN: 'last-4-ssn', + SSN: 'ssn', DOCUMENTS: 'documents', CONFIRMATION: 'confirmation', }, @@ -941,7 +982,7 @@ const CONST = { NATIONALITY: 'nationality', OWNERSHIP_PERCENTAGE: 'ownershipPercentage', DOB: 'dob', - SSN_LAST_4: 'ssnLast4', + SSN: 'ssn', STREET: 'street', CITY: 'city', STATE: 'state', @@ -1041,7 +1082,6 @@ const CONST = { BETAS: { ALL: 'all', ASAP_SUBMIT: 'asapSubmit', - CUSTOM_AGENT: 'customAgent', DEFAULT_ROOMS: 'defaultRooms', PREVENT_SPOTNANA_TRAVEL: 'preventSpotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', @@ -1053,12 +1093,11 @@ const CONST = { PAY_INVOICE_VIA_EXPENSIFY: 'payInvoiceViaExpensify', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', BULK_EDIT: 'bulkEdit', - NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', VENDOR_MATCHING: 'vendorMatching', DUALENTRY: 'dualEntry', + CAMPFIRE: 'campfire', BUSINESS_CENTRAL: 'businessCentral', - RULES_REVAMP: 'rulesRevamp', COMMUTER_EXCLUSIONS: 'commuterExclusions', MULTIPLE_APPROVERS: 'multipleApprovers', GLOBAL_REIMBURSEMENTS: 'globalReimbursements', @@ -1069,6 +1108,8 @@ const CONST = { CONCIERGE_RESPOND_IN_THREAD: 'conciergeRespondInThread', ARCHIVE_POLICIES: 'archivePolicies', MERGE_ATS: 'mergeATSConnections', + REPORT_MERGE: 'reportMerge', + INSIGHTS_PAGE: 'insightsPage', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1382,11 +1423,14 @@ const CONST = { US: 'https://help.expensify.com/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States', }, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, - GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', + + // Package name of the Google Play Store app. Android reports it as the installer of every app Play installed. + PLAY_STORE_INSTALLER_PACKAGE_NAME: 'com.android.vending', FEES_URL: `${EXPENSIFY_URL}/fees`, SAVE_WITH_EXPENSIFY_URL: `${USE_EXPENSIFY_URL}/savings-calculator`, CFPB_PREPAID_URL: 'https://cfpb.gov/prepaid', STAGING_NEW_EXPENSIFY_URL: 'https://staging.new.expensify.com', + QA_NEW_EXPENSIFY_URL: 'https://qa.new.exops.io', PR_TESTING_NEW_EXPENSIFY_URL: `https://${CI.PULL_REQUEST_NUMBER}.pr-testing.expensify.com`, NEWHELP_URL: 'https://help.expensify.com', CHASE_ACCOUNT_NUMBER_HELP_URL: 'https://help.expensify.com/articles/new-expensify/wallet-and-payments/Connect-a-Personal-Bank-Account', @@ -1395,6 +1439,7 @@ const CONST = { IMPORT_TAGS_EXPENSIFY_URL: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#import-a-spreadsheet-1', IMPORT_TAGS_EXPENSIFY_URL_DEPENDENT_TAGS: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#multi-level-tags', STAGING_EXPENSIFY_URL: 'https://staging.expensify.com', + QA_EXPENSIFY_URL: 'https://qa.exops.io', DENIED_CAMERA_ACCESS_INSTRUCTIONS_URL: 'https://help.expensify.com/articles/new-expensify/reports-and-expenses/Create-an-Expense#how-do-i-enable-camera-access-for-mobile-browsers-so-i-can-take-photos-of-my-receipts', BANK_ACCOUNT_PERSONAL_DOCUMENTATION_INFO_URL: @@ -1657,6 +1702,9 @@ const CONST = { ACTIONABLE_TRACK_EXPENSE_WHISPER: 'ACTIONABLETRACKEXPENSEWHISPER', POLICY_EXPENSE_CHAT_WELCOME_WHISPER: 'POLICYEXPENSECHATWELCOMEWHISPER', ADD_COMMENT: 'ADDCOMMENT', + + // Hidden action that only carries the violations snapshot of an expense added to a report that was already awaiting its first approval + ADD_EXPENSE_ON_SUBMITTED: 'ADDEXPENSEONSUBMITTED', APPROVED: 'APPROVED', CARD_MISSING_ADDRESS: 'CARDMISSINGADDRESS', CARD_ISSUED: 'CARDISSUED', @@ -1668,6 +1716,7 @@ const CONST = { CARD_UNFROZEN: 'CARDUNFROZEN', CARD_DEACTIVATED: 'CARDDEACTIVATED', PERSONAL_CARD_CONNECTION_BROKEN: 'PERSONALCARDCONNECTIONBROKEN', + PERSONAL_CARD_CONNECTION_BROKEN_30_DAYS: 'PERSONALCARDCONNECTIONBROKEN30DAYS', CHANGE_FIELD: 'CHANGEFIELD', // OldDot Action CHANGE_POLICY: 'CHANGEPOLICY', CREATED_REPORT_FOR_UNAPPROVED_TRANSACTIONS: 'CREATEDREPORTFORUNAPPROVEDTRANSACTIONS', @@ -1692,6 +1741,7 @@ const CONST = { HOLD_COMMENT: 'HOLDCOMMENT', INTEGRATION_SYNC_FAILED: 'INTEGRATIONSYNCFAILED', COMPANY_CARD_CONNECTION_BROKEN: 'COMPANYCARDCONNECTIONBROKEN', + COMPANY_CARD_CONNECTION_BROKEN_30_DAYS: 'COMPANYCARDCONNECTIONBROKEN30DAYS', COMMUTER_EXCLUSION: 'COMMUTEREXCLUSION', PLAID_BALANCE_FAILURE: 'PLAIDBALANCEFAILURE', IOU: 'IOU', @@ -2029,11 +2079,13 @@ const CONST = { REPORT_LEVEL_EXPORT: 'report_level_export', EXPENSE_LEVEL_EXPORT: 'detailed_export', MULTIPLE_TAX_EXPORT: 'multiple_tax_export', + RECONCILIATION_ALL_EXPENSES: 'reconciliation_all_expenses', }, EXPORT_OPTION_LABELS: { REPORT_LEVEL_EXPORT: 'All Data - Report Level Export', EXPENSE_LEVEL_EXPORT: 'All Data - Expense Level Export', MULTIPLE_TAX_EXPORT: 'Canadian Multiple Tax Export', + RECONCILIATION_ALL_EXPENSES: 'Reconciliation - All Expenses', DEFAULT_CSV: 'Default CSV', }, ROOM_MEMBERS_BULK_ACTION_TYPES: { @@ -2180,6 +2232,7 @@ const CONST = { }, TIMING: { SHOW_LOADING_SPINNER_DEBOUNCE_TIME: 250, + STALE_PENDING_CREATION_ROUTE_TIMEOUT: 15000, TEST_TOOLS_MODAL_THROTTLE_TIME: 800, TOOLTIP_SENSE: 1000, COMMENT_LENGTH_DEBOUNCE_TIME: 1500, @@ -2187,6 +2240,8 @@ const CONST = { SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 1000, SUGGESTION_DEBOUNCE_TIME: 100, + /** How long the cursor has to rest on an advanced filter row before its content may derive the contact list */ + SEARCH_FILTER_HOVER_INTENT_DELAY: 30, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, USE_DEBOUNCED_STATE_DELAY: 300, @@ -2305,6 +2360,10 @@ const CONST = { SPAN_RECONNECT_SERVER_RESPONSE: 'ManualReconnectServerResponse', SPAN_GEOLOCATION_WAIT: 'ManualGeolocationWait', SPAN_SEND_MESSAGE_VISIBLE: 'ManualSendMessageVisible', + SPAN_SEND_MESSAGE_PHASE: { + PROPAGATE: 'ManualSendMessagePropagate', + POST_COMMIT: 'ManualSendMessagePostCommit', + }, SPAN_NOT_FOUND_PAGE: 'ManualNotFoundPage', SPAN_ODOMETER_TO_CONFIRMATION: 'ManualOdometerToConfirmation', SPAN_ODOMETER_IMAGE_STITCH: 'ManualOdometerImageStitch', @@ -2668,6 +2727,21 @@ const CONST = { ALREADY_CREATED_TRANSACTION: 'Transaction already created.', ALREADY_PAID: 'The request has already been paid', }, + SIGN_OUT_REASON: { + USER_SIGN_OUT: 'userSignOut', + REAUTH_FAILED: 'reauthFailed', + REAUTH_HTTP_ERROR: 'reauthHttpError', + SAML_REQUIRED: 'samlRequired', + NO_CREDENTIALS: 'noCredentials', + DEVICE_REVOKED: 'deviceRevoked', + UNLINK_LOGIN: 'unlinkLogin', + SUPPORTAL_LOGOUT: 'supportalLogout', + SUPPORTAL_RESTORE: 'supportalRestore', + STASHED_SESSION_RESTORE: 'stashedSessionRestore', + HYBRID_APP_TRANSITION: 'hybridAppTransition', + ACCOUNT_DELETED: 'accountDeleted', + LOGIN_AS_NEW_USER: 'loginAsNewUser', + }, NETWORK: { METHOD: { POST: 'post', @@ -2739,6 +2813,8 @@ const CONST = { PUSHER: { PRIVATE_USER_CHANNEL_PREFIX: 'private-encrypted-user-accountID-', PRIVATE_REPORT_CHANNEL_PREFIX: 'private-report-reportID-', + ACTIVITY_TIMEOUT_MS: 30000, + PONG_TIMEOUT_MS: 20000, STATE: { CONNECTED: 'CONNECTED', DISCONNECTED: 'DISCONNECTED', @@ -2987,6 +3063,9 @@ const CONST = { HERE_TEXT: '@here', SUGGESTION_BOX_MAX_SAFE_DISTANCE: 10, BIG_SCREEN_SUGGESTION_WIDTH: 300, + + /** Below this many locally found room suggestions, we also search the server for more rooms */ + MIN_ROOM_SUGGESTIONS_BEFORE_SERVER_SEARCH: 5, }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, @@ -3267,8 +3346,17 @@ const CONST = { APPROVAL_MODE: { BASIC: 'basic', MANAGER: 'manager', + ADVANCED: 'advanced', CUSTOM: 'custom', }, + ATS_APPROVER_FIELD: { + RECRUITER: 'recruiter', + RECRUITING_COORDINATOR: 'recruitingCoordinator', + }, + CATEGORY: { + HRIS: 'hris', + ATS: 'ats', + }, COOKIE_CLEAR_DELAY_MS: 500, SYNC_STATUS: { SYNCING: 'SYNCING', @@ -3619,6 +3707,14 @@ const CONST = { VENDOR_BILL: 'bill', }, + // The vendor-name portion for card expenses that don't match an existing vendor. It is untranslated on purpose + // so the rendered helper text names the vendor record the admin sees in QuickBooks Online or Sage Intacct. + // Translations own the sentence punctuation because the name can appear mid-sentence. + NON_REIMBURSABLE_FALLBACK_VENDOR_NAME: { + CREDIT_CARD: 'Credit Card Misc', + DEBIT_CARD: 'Debit Card Misc', + }, + QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE: { CREDIT_CARD: 'CREDIT_CARD_CHARGE', CHECK: 'CHECK', @@ -3740,6 +3836,68 @@ const CONST = { OTHER_CURRENT_LIABILITY: 'other_current_liability', }, + CAMPFIRE_CONFIG: { + SUBSIDIARY_ID: 'subsidiaryID', + ENABLE_NEW_CATEGORIES: 'enableNewCategories', + SYNC_TAX_RATES: 'syncTaxRates', + EXPORTER: 'exporter', + EXPORT_DATE: 'exportDate', + REIMBURSABLE: 'reimbursable', + NON_REIMBURSABLE: 'nonReimbursable', + CREDIT_CARD_ACCOUNT_ID: 'creditCardAccountID', + EXPORT_TO_MULTIPLE_ACCOUNTS: 'exportToMultipleAccounts', + DEFAULT_VENDORID: 'defaultVendorID', + TRAVEL_BILLING_PAYABLE_ACCOUNT_ID: 'travelInvoicingPayableAccountID', + ACCOUNTING_METHOD: 'accountingMethod', + AUTO_SYNC: 'autoSync', + SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', + BILL_PAYMENT_ACCOUNT_ID: 'billPaymentAccountID', + SYNC_EXPENSIFY_CARD_SETTLEMENTS: 'syncExpensifyCardSettlements', + SETTLEMENTS_BANK_ACCOUNT_ID: 'settlementsBankAccountID', + SYNC_TRAVEL_BILLING_SETTLEMENTS: 'syncTravelInvoicingSettlements', + TRAVEL_BILLING_SETTLEMENTS_BANK_ACCOUNT_ID: 'travelInvoicingSettlementsBankAccountID', + FIELD_MAPPING_PREFIX: 'fieldMapping_', + CARD_PROGRAM_ACCOUNT_PREFIX: 'cardProgramAccount_', + }, + + CAMPFIRE_MAPPING_VALUE: { + NONE: 'NONE', + TAG: 'TAG', + }, + + CAMPFIRE_EXPORT_REIMBURSABLE: { + VENDOR_BILL: 'VENDOR_BILL', + }, + + CAMPFIRE_EXPORT_NON_REIMBURSABLE: { + JOURNAL_ENTRY: 'JOURNAL_ENTRY', + }, + + CAMPFIRE_EXPORT_DATE: { + LAST_EXPENSE: 'LAST_EXPENSE', + REPORT_EXPORTED: 'REPORT_EXPORTED', + REPORT_SUBMITTED: 'REPORT_SUBMITTED', + }, + + CAMPFIRE_ACCOUNT_TYPE: { + OPERATING_EXPENSES: 'OPERATING_EXPENSES', + LIABILITY: 'LIABILITY', + ASSET: 'ASSET', + }, + + CAMPFIRE_ACCOUNT_SUBTYPE: { + EXPENSE: 'EXPENSE', + LONG_TERM_LIABILITY: 'LONG_TERM_LIABILITY', + CREDIT_CARD: 'CREDIT_CARD', + BANK: 'BANK', + }, + + CAMPFIRE_VENDOR_TYPE: { + VENDOR: 'vendor', + CUSTOMER: 'customer', + EMPLOYEE: 'employee', + }, + UPDATE_PERSONAL_BANK_ACCOUNT: { PAGE_NAME: { LEGAL_NAME: 'legal-name', @@ -3803,10 +3961,17 @@ const CONST = { ENVIRONMENT: { DEV: 'development', STAGING: 'staging', + QA: 'qa', PRODUCTION: 'production', ADHOC: 'adhoc', }, + SERVER: { + PRODUCTION: 'production', + STAGING: 'staging', + QA: 'qa', + }, + ENABLE_PAYMENTS: { PAGE_NAMES: { ADD_BANK_ACCOUNT: 'add-bank-account', @@ -3933,6 +4098,12 @@ const CONST = { OAUTH_REDIRECT_PATH_IOS: 'partners/plaid/oauth_ios', }, + CLOUDFLARE_ACCESS: { + // Claimed as an iOS Universal Link in .well-known/apple-app-site-association. Web keeps its own + // /oauth/callback so a browser-started sign-in is never handed to the app. No screen lives here. + NATIVE_OAUTH_CALLBACK_PATH: '/oauth/native-callback', + }, + ONFIDO: { CONTAINER_ID: 'onfido-mount', TYPE: { @@ -4174,6 +4345,17 @@ const CONST = { FIELD_LIST_TITLE: 'text_title', TAX: 'tax', }, + /** Subsections of the Rules > Expense defaults table, listed in the order they render. */ + EXPENSE_DEFAULTS_SECTION: { + CATEGORIES: 'categories', + MERCHANTS: 'merchants', + MERCHANT_TYPES: 'merchantTypes', + }, + /** What an expense default rule matches on, chosen before the editor opens and kept in its draft. */ + EXPENSE_DEFAULT_RULE_TYPE: { + MERCHANT: 'merchant', + CATEGORY: 'category', + }, DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', DEFAULT_FIELD_LIST_TYPE: 'formula', DEFAULT_FIELD_LIST_TARGET: 'expense', @@ -4318,6 +4500,7 @@ const CONST = { ARE_DISTANCE_RATES_ENABLED: 'areDistanceRatesEnabled', ARE_WORKFLOWS_ENABLED: 'areWorkflowsEnabled', ARE_REPORT_FIELDS_ENABLED: 'areReportFieldsEnabled', + ARE_INVOICE_FIELDS_ENABLED: 'areInvoiceFieldsEnabled', ARE_CONNECTIONS_ENABLED: 'areConnectionsEnabled', ARE_RECEIPT_PARTNERS_ENABLED: 'receiptPartners', ARE_COMPANY_CARDS_ENABLED: 'areCompanyCardsEnabled', @@ -4331,6 +4514,8 @@ const CONST = { REQUIRE_COMPANY_CARDS_ENABLED: 'requireCompanyCardsEnabled', IS_TIME_TRACKING_ENABLED: 'isTimeTrackingEnabled', IS_HR_ENABLED: 'isHREnabled', + IS_RECRUITING_ENABLED: 'isRecruitingEnabled', + IS_MCP_ENABLED: 'isMCPEnabled', }, DEFAULT_CATEGORIES: { ADVERTISING: 'Advertising', @@ -4481,6 +4666,7 @@ const CONST = { CERTINIA: 'financialforce', RILLET: 'rillet', DUALENTRY: 'dualEntry', + CAMPFIRE: 'campfire', GUSTO: 'gusto', ZENEFITS: 'zenefits', MERGE_HR: 'merge_hris', @@ -4499,6 +4685,7 @@ const CONST = { CERTINIA: 'certinia', RILLET: 'rillet', DUALENTRY: 'dualentry', + CAMPFIRE: 'campfire', GUSTO: 'gusto', ZENEFITS: 'zenefits', MERGE_HR: 'merge-hr', @@ -4513,6 +4700,7 @@ const CONST = { financialforce: 'Certinia', rillet: 'Rillet', dualEntry: 'DualEntry', + campfire: 'Campfire', gusto: 'Gusto', billCom: 'Bill.com', zenefits: 'TriNet', @@ -4523,8 +4711,37 @@ const CONST = { microsoftDynamics: 'Microsoft Dynamics', other: 'Other', }, + CATEGORY: { + HR: 'hr', + RECRUITING: 'recruiting', + }, get ACCOUNTING_CONNECTION_NAMES() { - return [this.NAME.QBO, this.NAME.QBD, this.NAME.XERO, this.NAME.NETSUITE, this.NAME.SAGE_INTACCT, this.NAME.CERTINIA, this.NAME.RILLET, this.NAME.DUALENTRY] as const; + return [ + this.NAME.QBO, + this.NAME.QBD, + this.NAME.XERO, + this.NAME.NETSUITE, + this.NAME.SAGE_INTACCT, + this.NAME.CERTINIA, + this.NAME.RILLET, + this.NAME.DUALENTRY, + this.NAME.CAMPFIRE, + ] as const; + }, + // The `origin` the backend stamps on a report field imported by an accounting integration. These values + // differ from the connection names above (e.g. `quickbooksOnline` is stamped as `qbo`), so map them here. + get REPORT_FIELD_ORIGIN() { + return { + [this.NAME.QBO]: 'qbo', + [this.NAME.QBD]: 'qbd', + [this.NAME.XERO]: 'xero', + [this.NAME.NETSUITE]: 'netsuite', + [this.NAME.SAGE_INTACCT]: 'intacct', + [this.NAME.CERTINIA]: 'financialforce', + [this.NAME.RILLET]: 'rillet', + [this.NAME.DUALENTRY]: 'dualentry', + [this.NAME.CAMPFIRE]: 'campfire', + } as const; }, get HR_CONNECTION_NAMES() { return [this.NAME.GUSTO, this.NAME.ZENEFITS, this.NAME.MERGE_HR] as const; @@ -4636,6 +4853,12 @@ const CONST = { DUALENTRY_SYNC_PAYMENTS: 'dualEntrySyncPayments', DUALENTRY_SYNC_CARD_SETTLEMENTS: 'dualEntrySyncCardSettlements', DUALENTRY_SYNC_TRAVEL_SETTLEMENTS: 'dualEntrySyncTravelSettlements', + CAMPFIRE_SYNC_TITLE: 'campfireSyncTitle', + CAMPFIRE_SYNC_CONNECTION: 'campfireSyncConnection', + CAMPFIRE_SYNC_IMPORT_DATA: 'campfireSyncImportData', + CAMPFIRE_SYNC_PAYMENTS: 'campfireSyncPayments', + CAMPFIRE_SYNC_CARD_SETTLEMENTS: 'campfireSyncCardSettlements', + CAMPFIRE_SYNC_TRAVEL_SETTLEMENTS: 'campfireSyncTravelSettlements', }, SYNC_STAGE_TIMEOUT_MINUTES: 20, }, @@ -4848,6 +5071,11 @@ const CONST = { INDIVIDUAL: 'individual', NONE: 'none', }, + WALLET_PROVIDER: { + APPLE_PAY: 'APPLE_PAY', + ANDROID_PAY: 'ANDROID_PAY', + }, + APPROVE_DIGITAL_WALLET_VALIDATE_CODE_REASON: 'approve_digital_wallet', VERIFICATION_STATE: { LOADING: 'loading', VERIFIED: 'verified', @@ -5118,6 +5346,11 @@ const CONST = { * Name of Card NVP for DualEntry custom export accounts */ NVP_DUALENTRY_EXPORT_ACCOUNT: 'dualentry_export_account', + + /** + * Name of Card NVP for Campfire custom export accounts + */ + NVP_CAMPFIRE_EXPORT_ACCOUNT: 'campfire_export_account', }, EXPORT_CARD_POLICY_TYPES: { /** @@ -5170,6 +5403,11 @@ const CONST = { * Name of Card NVP for DualEntry custom export accounts */ NVP_DUALENTRY_EXPORT_ACCOUNT_POLICY_ID: 'dualentry_export_account_policy_id', + + /** + * Name of Card NVP for Campfire custom export accounts + */ + NVP_CAMPFIRE_EXPORT_ACCOUNT_POLICY_ID: 'campfire_export_account_policy_id', }, }, AVATAR_ROW_SIZE: { @@ -5588,24 +5826,7 @@ const CONST = { SHARE: 'share', }, }, - EDIT_REQUEST_FIELD: { - AMOUNT: 'amount', - CURRENCY: 'currency', - DATE: 'date', - DESCRIPTION: 'description', - MERCHANT: 'merchant', - CATEGORY: 'category', - RECEIPT: 'receipt', - DISTANCE: 'distance', - DISTANCE_RATE: 'distanceRate', - TAG: 'tag', - TAX_RATE: 'taxRate', - TAX_AMOUNT: 'taxAmount', - REIMBURSABLE: 'reimbursable', - ATTENDEES: 'attendees', - BILLABLE: 'billable', - REPORT: 'report', - }, + EDIT_REQUEST_FIELD: editRequestFields, FOOTER: { EXPENSE_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/expense-management`, SPEND_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/spend-management`, @@ -6270,7 +6491,6 @@ const CONST = { TOOLBAR: 'toolbar', /** Use for navigation elements */ NAVIGATION: 'navigation', - /** Use for Tooltips */ TOOLTIP: 'tooltip', /** Use for dialog/modal elements */ DIALOG: 'dialog', @@ -6714,7 +6934,6 @@ const CONST = { RBR_MESSAGE_MAX_CHARACTERS_FOR_PREVIEW: 40, }, - /** Context menu types */ CONTEXT_MENU_TYPES: { LINK: 'LINK', REPORT_ACTION: 'REPORT_ACTION', @@ -6812,6 +7031,9 @@ const CONST = { // Returned when a user tries to add a work email tied to a closed work account, so we can show a specific error message instead of the generic blocking screen subtitle WORK_ACCOUNT_CLOSED_ERROR: '401 work account is closed', + + // Returned when a user tries to add a work email that is a domain-controlled login for an existing account, so we can show a specific error message instead of the generic blocking screen subtitle + WORK_DOMAIN_CONTROLLED_ERROR: '401 work domain is controlled', REIMBURSEMENT_ACCOUNT: { DEFAULT_DATA: { achData: { @@ -6947,6 +7169,8 @@ const CONST = { RESULTS_PAGE_SIZE: 50, EXITING_ANIMATION_DURATION: 200, ME: 'me', + /** How far the cursor may wander from where it last counted as moving over the advanced filter list and still count as resting */ + HOVER_INTENT_REST_RADIUS_PX: 8, DATA_TYPES: { EXPENSE: 'expense', EXPENSE_REPORT: 'expense-report', @@ -6985,6 +7209,7 @@ const CONST = { CATEGORY: 'category', TAG: 'tag', SUBMITTED_VIOLATION: 'submitted-violation', + APPROVED_VIOLATION: 'approved-violation', }, BULK_ACTION_TYPES: { EDIT: 'edit', @@ -7924,6 +8149,14 @@ const CONST = { description: 'workspace.upgrade.reportFields.description' as const, icon: 'Pencil', }, + invoiceFields: { + id: 'invoiceFields' as const, + alias: 'invoice-fields', + name: 'Invoice Fields', + title: 'workspace.upgrade.invoiceFields.title' as const, + description: 'workspace.upgrade.invoiceFields.description' as const, + icon: 'Pencil', + }, policyPreventMemberChangingTitle: { id: 'policyPreventMemberChangingTitle' as const, alias: 'policy-prevent-member-changing-title', @@ -8031,6 +8264,14 @@ const CONST = { description: `workspace.upgrade.${this.POLICY.CONNECTIONS.NAME.DUALENTRY}.description` as const, icon: 'DualEntrySquare', }, + [this.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + id: this.POLICY.CONNECTIONS.NAME.CAMPFIRE, + alias: 'campfire', + name: this.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.campfire, + title: `workspace.upgrade.${this.POLICY.CONNECTIONS.NAME.CAMPFIRE}.title` as const, + description: `workspace.upgrade.${this.POLICY.CONNECTIONS.NAME.CAMPFIRE}.description` as const, + icon: 'CampfireSquare', + }, approvals: { id: 'approvals' as const, alias: 'approvals' as const, @@ -8237,6 +8478,10 @@ const CONST = { LIST: 'dropdown', FORMULA: 'formula', }, + REPORT_FIELD_TARGETS: { + EXPENSE: 'expense', + INVOICE: 'invoice', + }, NAVIGATION_ACTIONS: { RESET: 'RESET', @@ -8352,6 +8597,7 @@ const CONST = { HAS_EMPLOYEE_CARD_FEED_ERRORS: 'hasEmployeeCardFeedErrors', HAS_POLICY_ADMIN_CARD_FEED_ERRORS: 'hasPolicyAdminCardFeedErrors', HAS_DOMAIN_ERRORS: 'hasDomainErrors', + HAS_PENDING_DOMAIN_ADMIN_REQUESTS: 'hasPendingDomainAdminRequests', HAS_LOCKED_BANK_ACCOUNT: 'hasLockedBankAccount', HAS_DEVICE_MANAGEMENT_ERROR: 'hasDeviceManagementError', HAS_MERGE_HR_SETUP_NEEDED: 'hasMergeHRSetupNeeded', @@ -8665,8 +8911,11 @@ const CONST = { /** How many of the longest strings are measured per column, since character count only approximates rendered width. */ MEASURED_CANDIDATES_PER_COLUMN: 5, - /** How narrow a free-text column may be squeezed before the table scrolls instead, matching the ~180px default text column width table libraries use. */ - MIN_FREE_TEXT_COLUMN_WIDTH: 180, + /** How narrow a free-text column may be squeezed before the table scrolls instead. Around 17 characters, so a typical merchant name or full name still reads, and a column is never squeezed below its header regardless. */ + MIN_FREE_TEXT_COLUMN_WIDTH: 120, + + /** How wide a free-text column may be sized for its content once the table scrolls, so one unusually long value doesn't push every column after it out of view. A table that still fits its columns caps nothing: the spare room is there to be used. */ + MAX_FREE_TEXT_COLUMN_WIDTH: 180, }, }, @@ -8692,6 +8941,7 @@ const CONST = { EXPENSIFY_LOGO: 'NavigationTabBar-ExpensifyLogo', INBOX: 'NavigationTabBar-Inbox', REPORTS: 'NavigationTabBar-Reports', + INSIGHTS: 'NavigationTabBar-Insights', WORKSPACES: 'NavigationTabBar-Workspaces', ACCOUNT: 'NavigationTabBar-Account', HOME: 'NavigationTabBar-Home', @@ -8826,7 +9076,7 @@ const CONST = { TYPE_MENU_ITEM: 'Search-TypeMenuItem', SAVED_SEARCH_MENU_ITEM: 'Search-SavedSearchMenuItem', SAVE_VIEW_BUTTON: 'Search-SaveViewButton', - CLEAR_FILTERS_BUTTON: 'Search-ClearFiltersButton', + RESET_FILTERS_BUTTON: 'Search-ResetFiltersButton', ACTION_CELL_VIEW: 'Search-ActionCellView', ACTION_CELL_PAY: 'Search-ActionCellPay', ACTION_CELL_ACTION: 'Search-ActionCellAction', @@ -9202,6 +9452,7 @@ const CONST = { INVOICES: 'WorkspaceInitial-Invoices', MORE_FEATURES: 'WorkspaceInitial-MoreFeatures', VENDORS: 'WorkspaceInitial-Vendors', + MCP: 'WorkspaceInitial-MCP', }, OVERVIEW: { AVATAR: 'WorkspaceOverview-Avatar', @@ -9278,14 +9529,17 @@ const CONST = { MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', REQUIRE_FIELDS_RULE_ITEM: 'WorkspaceRules-RequireFieldsRuleItem', REQUIRE_FIELDS_RULE_SAVE: 'WorkspaceRules-RequireFieldsRuleSave', + REQUIRE_FIELDS_RULE_DELETE: 'WorkspaceRules-RequireFieldsRuleDelete', REQUIRE_FIELDS_RULE_CATEGORY: 'WorkspaceRules-RequireFieldsRuleCategory', REQUIRE_FIELDS_RULE_FIELD_TOGGLE: 'WorkspaceRules-RequireFieldsRuleFieldToggle', REQUIRE_FIELDS_RULE_DIRECTION_TOGGLE: 'WorkspaceRules-RequireFieldsRuleDirectionToggle', FLAG_FOR_REVIEW_RULE_ITEM: 'WorkspaceRules-FlagForReviewRuleItem', FLAG_FOR_REVIEW_RULE_SAVE: 'WorkspaceRules-FlagForReviewRuleSave', + FLAG_FOR_REVIEW_RULE_DELETE: 'WorkspaceRules-FlagForReviewRuleDelete', FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory', FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount', FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE: 'WorkspaceRules-FlagForReviewRuleExpenseLimitType', + CATEGORY_TAX_RULE_ITEM: 'WorkspaceRules-CategoryTaxRuleItem', MERCHANT_TYPE_RULE_ITEM: 'WorkspaceRules-MerchantTypeRuleItem', MERCHANT_TYPE_RULE_SAVE: 'WorkspaceRules-MerchantTypeRuleSave', MERCHANT_TYPE_RULE_CATEGORY: 'WorkspaceRules-MerchantTypeRuleCategory', @@ -9299,6 +9553,7 @@ const CONST = { CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector', SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', ADD_AGENT_RULE: 'WorkspaceRules-AddAgentRule', @@ -9550,6 +9805,9 @@ const CONST = { DOMAIN: { ADMINS: { ROW: 'DomainAdmins-Row', + REQUEST_ROW: 'DomainAdmins-RequestRow', + REQUEST_APPROVE: 'DomainAdmins-RequestApprove', + REQUEST_DENY: 'DomainAdmins-RequestDeny', }, GROUPS: { CREATE_GROUP_BUTTON: 'DomainGroups-CreateGroupButton', @@ -9595,6 +9853,18 @@ const CONST = { MOVE_TO_GROUP: 'moveToGroup', }, }, + + ADMINS: { + ROW_TYPE: { + GROUP_HEADER: 'groupHeader', + REQUEST: 'request', + ADMIN: 'admin', + }, + GROUP_ORDER: { + REQUESTS: 0, + ADMINS: 1, + }, + }, }, AUTO_COMPLETE_VARIANTS: { @@ -9611,6 +9881,12 @@ const CONST = { FOR_YOU_NEW_USER_CUTOFF_DATE: '2026-06-26', }, + INSIGHTS: { + DASHBOARD: { + SPEND: 'spend', + }, + }, + SECTION_LIST_ITEM_TYPE: { HEADER: 'header', ROW: 'row', @@ -9667,17 +9943,7 @@ const SUBMIT_FEATURE_IDS: ReadonlySet = new Set([ CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoicing.id, ]); -type SearchFilterKey = ValueOf | ValueOf; - -const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, -] as SearchFilterKey[]; +type SignOutReason = ValueOf; const FRAUD_PROTECTION_EVENT = { START_SUPPORT_SESSION: 'StartSupportSession', @@ -9730,6 +9996,7 @@ type EnablePaymentsSubPageType = | ValueOf; export type { + SignOutReason, Country, GovernmentRateCountry, IOUAction, @@ -9747,6 +10014,6 @@ export type { EnablePaymentsSubPageType, }; -export {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, FRAUD_PROTECTION_EVENT, COUNTRIES_US_BANK_FLOW, SUBMIT_FEATURE_IDS}; +export {FRAUD_PROTECTION_EVENT, COUNTRIES_US_BANK_FLOW, SUBMIT_FEATURE_IDS}; export default CONST; diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts index 070d61bdf70b..b392e0db9024 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -69,12 +69,14 @@ const CONST_RUNTIME: ConstRuntime = { EXCLUDE_FROM_LAST_VISITED_PATH: [ SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, + SCREENS.TRANSITION_BETWEEN_APPS, 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.PRE_MOUNT_BUFFER, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), ], }; diff --git a/src/CONST/runtimeDefaults.ts b/src/CONST/runtimeDefaults.ts index 0562c91da7c8..3385d9db55b5 100644 --- a/src/CONST/runtimeDefaults.ts +++ b/src/CONST/runtimeDefaults.ts @@ -92,12 +92,14 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = { EXCLUDE_FROM_LAST_VISITED_PATH: [ 'not-found', 'SAMLSignIn', + 'TransitionBetweenApps', 'ValidateLogin', 'Dynamic_MigratedUserWelcomeModal_Root', 'Dynamic_SubmitPlanWelcomeModal_Root', 'Dynamic_AIFeaturesPromoModal_Root', 'Money_Request_Step_Scan', 'Members_Move_To_Group', + 'PreMountBuffer', 'Multifactor_Authentication_Validate_Code', 'Multifactor_Authentication_Outcome_Success', 'Multifactor_Authentication_Outcome_Failure', diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index f674b3fe6f44..62d9f6d2acc2 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -215,6 +215,8 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { introSelected, betas, conciergeChat, + // The public room already exists on the server, so no optimistic report is created and the personal details are never read. + personalDetails: undefined, hasReportActions: false, currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a665fc763409..ffa163f75107 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -49,6 +49,9 @@ const ONYXKEYS = { /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Boolean flag set whenever we are searching for users in the server */ + RAM_ONLY_IS_SEARCHING_FOR_USERS: 'isSearchingForUsers', + /** Boolean flag indicating a SignInWithShortLivedAuthToken request is in flight. RAM-only so an interrupted request never persists a stuck `true` to IndexedDB and blocks future reauth attempts. */ RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN: 'isAuthenticatingWithShortLivedToken', @@ -459,7 +462,6 @@ const ONYXKEYS = { /** Stores information about the share bank account during setup */ SHARE_BANK_ACCOUNT: 'shareBankAccount', - /** Is report data loading? */ IS_LOADING_REPORT_DATA: 'isLoadingReportData', /** Set when we are loading bank accounts for share page */ @@ -468,10 +470,8 @@ const ONYXKEYS = { /** Set when we are loading the bulk change approver page */ IS_LOADING_BULK_CHANGE_APPROVER_PAGE: 'isLoadingBulkChangeApproverPage', - /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', - /** Is loading policy rules preview? */ IS_LOADING_POLICY_CODING_RULES_PREVIEW: 'isLoadingPolicyCodingRulesPreview', /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ @@ -495,10 +495,8 @@ const ONYXKEYS = { /** Set while search filter category data is loading */ RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA: 'isLoadingSearchFiltersCategoryData', - /** Is the app loaded? */ HAS_LOADED_APP: 'hasLoadedApp', - /** Is the test tools modal open? */ IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen', /** Stores information about active wallet transfer amount, selectedAccountID, status, etc */ @@ -508,11 +506,16 @@ const ONYXKEYS = { LAST_ACCESSED_WORKSPACE_POLICY_ID: 'lastAccessedWorkspacePolicyID', /** Is app in beta version */ - IS_BETA: 'isBeta', /** Whether we're checking if the room is public or not */ RAM_ONLY_IS_CHECKING_PUBLIC_ROOM: 'isCheckingPublicRoom', + /** + * Whether we're checking if any Expensify Card has a digital wallet addition waiting to be confirmed. RAM only, so + * undefined means nothing has asked yet this launch. + */ + RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL: 'isCheckingPendingWalletApproval', + /** The report ID of the public room that the user is currently viewing */ VIEWING_PUBLIC_ROOM_REPORT_ID: 'ViewingPublicRoomReportID', @@ -612,15 +615,21 @@ const ONYXKEYS = { /** Indicates whether we should mask fragile user data while exporting onyx state or not */ SHOULD_MASK_ONYX_STATE: 'shouldMaskOnyxState', - /** Indicates whether we should use the staging version of the secure API server */ + /** @deprecated Use ACTIVE_SERVER. */ SHOULD_USE_STAGING_SERVER: 'shouldUseStagingServer', + /** Which server environment the app talks to */ + ACTIVE_SERVER: 'activeServer', + /** OAuth session used to reach the Cloudflare Access-protected QA server */ CLOUDFLARE_SESSION: 'cloudflareSession', /** Indicates whether the debug mode is currently enabled */ IS_DEBUG_MODE_ENABLED: 'isDebugModeEnabled', + /** Local overrides for beta feature flags, set from the Test Tool Menu on dev/staging. Takes precedence over the server-provided betas */ + BETA_OVERRIDES: 'betaOverrides', + /** Indicates whether the git branch name should be shown in the browser tab title */ SHOULD_SHOW_BRANCH_NAME_IN_TITLE: 'shouldShowBranchNameInTitle', @@ -677,6 +686,9 @@ const ONYXKEYS = { /** Session-scoped flag: user dismissed the "enable notifications" banner in the Concierge chat */ RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER: 'hasDismissedConciergeNotificationBanner', + /** Session-scoped record of the latest expense edit that could become a merchant rule, driving the "Create a rule" callout */ + RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', + NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', /** Stores the information about duplicated workspace */ @@ -718,6 +730,9 @@ const ONYXKEYS = { /** Stores the information about the recent searches */ RECENT_SEARCHES: 'nvp_recentSearches', + /** Stores the last query for each suggested/saved search */ + SEARCH_FILTERS: 'nvp_searchFilters', + /** Stores the current search page context (e.g., whether to show the search query) */ SEARCH_CONTEXT: 'searchContext', @@ -730,7 +745,6 @@ const ONYXKEYS = { /** Stores recently used currencies */ RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies', - /** Company cards custom names */ NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames', /** Whether to kick off the "Concierge is thinking" indicator when AgentZeroStatusGate mounts */ @@ -771,6 +785,9 @@ const ONYXKEYS = { /** Information about travel provisioning process */ TRAVEL_PROVISIONING: 'travelProvisioning', + /** 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', + /** Stores the information about the state of side panel */ NVP_SIDE_PANEL: 'nvp_sidePanel', @@ -801,6 +818,9 @@ const ONYXKEYS = { /** Persisted draft for the new-agent avatar selection flow */ AGENT_NEW_AVATAR_DRAFT: 'agentNewAvatarDraft', + /** Maps an agent's optimistic accountID to the real one assigned by CreateAgent, consumed and cleared by replaceOptimisticAgentWithActualAgent */ + OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING: 'optimisticAgentAccountIDMapping', + /** Set when the rooms page has finished loading for the first time */ ARE_POLICY_ROOMS_LOADED: 'arePolicyRoomsLoaded', @@ -811,7 +831,6 @@ const ONYXKEYS = { */ SHOULD_BILL_WHEN_DOWNGRADING: 'shouldBillWhenDowngrading', - /** Billing receipt details */ BILLING_RECEIPT_DETAILS: 'billingReceiptDetails', /** Set this gets redirected from global reimbursements flow */ @@ -820,7 +839,6 @@ const ONYXKEYS = { /** Stores information for OpenUnreportedExpensesPage API call pagination */ HAS_MORE_UNREPORTED_TRANSACTIONS_RESULTS: 'hasMoreUnreportedTransactionsResults', - /** Is unreported transactions loading */ IS_LOADING_UNREPORTED_TRANSACTIONS: 'isLoadingUnreportedTransactions', /** Stores information for InitiateBankAccountUnlock API call */ @@ -885,6 +903,7 @@ const ONYXKEYS = { POLICY_CATEGORIES_DRAFT: 'policyCategoriesDraft_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', + POLICY_VENDORS: 'policyVendors_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', POLICY_RECENTLY_USED_DESTINATIONS: 'nvp_recentlyUsedDestinations_', // Whether the policy's connection data was attempted to be fetched in @@ -903,6 +922,12 @@ const ONYXKEYS = { REPORT: 'report_', REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs_', REPORT_DRAFT: 'reportDraft_', + // Boolean marker (no report data) flagging that report_ is a speculative copy of reportDraft_, written by + // preMountDraftReport so a pre-mounted destination can render before submit. Must persist (not RAM-only): the + // report_ row it points at lives in the persisted REPORT collection, and Onyx RAM-only applies per key or whole + // collection, not per row, so that row survives an app kill (where no unmount cleanup runs). A RAM-only marker would + // vanish while the row stays behind. The next launch uses this marker to find and delete it. + REPORT_PRE_MOUNTED_DRAFT: 'reportPreMountedDraft_', // REPORT_METADATA holds report-level business state that is NOT the report itself // (optimistic flag, pending chat members, report-level errors, DEW pendingExpenseAction). // Loading flags / pagination cursors / last-visit timestamp live in dedicated @@ -972,7 +997,6 @@ const ONYXKEYS = { */ WORKSPACE_CARDS_LIST: 'cards_', - /** Expensify cards settings */ PRIVATE_EXPENSIFY_CARD_SETTINGS: 'private_expensifyCardSettings_', /** Expensify cards bank account for a given workspace */ @@ -1028,7 +1052,6 @@ const ONYXKEYS = { /** SAML login metadata for a domain */ SAML_METADATA: 'saml_metadata_', - /** Pending actions for a domain */ DOMAIN_PENDING_ACTIONS: 'domainPendingActions_', /** Errors related to a domain */ @@ -1318,6 +1341,8 @@ const ONYXKEYS = { RILLET_CREDENTIALS_FORM_DRAFT: 'rilletCredentialsFormDraft', DUALENTRY_CREDENTIALS_FORM: 'dualEntryCredentialsForm', DUALENTRY_CREDENTIALS_FORM_DRAFT: 'dualEntryCredentialsFormDraft', + CAMPFIRE_CREDENTIALS_FORM: 'campfireCredentialsForm', + CAMPFIRE_CREDENTIALS_FORM_DRAFT: 'campfireCredentialsFormDraft', }, DERIVED: { REPORT_ATTRIBUTES: 'reportAttributes', @@ -1471,6 +1496,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM]: FormTypes.EditAgentRuleForm; [ONYXKEYS.FORMS.RILLET_CREDENTIALS_FORM]: FormTypes.RilletCredentialsForm; [ONYXKEYS.FORMS.DUALENTRY_CREDENTIALS_FORM]: FormTypes.DualEntryCredentialsForm; + [ONYXKEYS.FORMS.CAMPFIRE_CREDENTIALS_FORM]: FormTypes.CampfireCredentialsForm; }; type OnyxFormDraftValuesMapping = { @@ -1488,6 +1514,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; + [ONYXKEYS.COLLECTION.POLICY_VENDORS]: OnyxTypes.PolicyVendors; [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; @@ -1499,6 +1526,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: OnyxTypes.ReportNameValuePairs; [ONYXKEYS.COLLECTION.REPORT_DRAFT]: OnyxTypes.Report; + [ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT]: boolean; [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState; @@ -1576,6 +1604,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRY_NEW_DOT]: OnyxTypes.TryNewDot; [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; + [ONYXKEYS.SEARCH_FILTERS]: OnyxTypes.SearchFilters; [ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH]: boolean; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; [ONYXKEYS.SEARCH_FOOTER_CONVERSION]: OnyxTypes.SearchFooterConversion; @@ -1712,6 +1741,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_APP]: boolean; [ONYXKEYS.ARE_AGENTS_LOADED]: boolean; [ONYXKEYS.AGENT_NEW_AVATAR_DRAFT]: OnyxTypes.AgentNewAvatarDraft; + [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING]: Record; [ONYXKEYS.ARE_POLICY_ROOMS_LOADED]: Record; [ONYXKEYS.HAS_LOADED_APP]: boolean; [ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO]: boolean; @@ -1722,8 +1752,8 @@ type OnyxValuesMapping = { | typeof CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; - [ONYXKEYS.IS_BETA]: boolean; [ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM]: boolean; + [ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL]: boolean; [ONYXKEYS.VIEWING_PUBLIC_ROOM_REPORT_ID]: string; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; [ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; @@ -1745,6 +1775,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; + [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS]: boolean; [ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.REPORT_LAST_VISIT_TIMES]: OnyxTypes.ReportLastVisitTimes; @@ -1755,9 +1786,12 @@ type OnyxValuesMapping = { [ONYXKEYS.PLAID_CURRENT_EVENT]: string; [ONYXKEYS.NVP_PRIVATE_TAX_EXEMPT]: boolean; [ONYXKEYS.SHOULD_MASK_ONYX_STATE]: boolean; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key stays typed while it is still read [ONYXKEYS.SHOULD_USE_STAGING_SERVER]: boolean; + [ONYXKEYS.ACTIVE_SERVER]: ValueOf; [ONYXKEYS.CLOUDFLARE_SESSION]: OnyxTypes.CloudflareSession; [ONYXKEYS.IS_DEBUG_MODE_ENABLED]: boolean; + [ONYXKEYS.BETA_OVERRIDES]: OnyxTypes.BetaOverrides; [ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE]: boolean; [ONYXKEYS.IS_SENTRY_DEBUG_ENABLED]: boolean; [ONYXKEYS.IS_SENTRY_SEND_ENABLED]: boolean; @@ -1776,6 +1810,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard; [ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE]: boolean; [ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER]: boolean; + [ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION]: OnyxTypes.MerchantRuleSuggestion; [ONYXKEYS.DUPLICATE_WORKSPACE]: OnyxTypes.DuplicateWorkspace; [ONYXKEYS.COPY_POLICY_SETTINGS]: OnyxTypes.CopyPolicySettings; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; @@ -1818,6 +1853,7 @@ type OnyxValuesMapping = { [ONYXKEYS.CORPAY_ONBOARDING_FIELDS]: OnyxTypes.CorpayOnboardingFields; [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string; [ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning; + [ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL]: OnyxTypes.CorpayPayModal; [ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined; [ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined; [ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9e73e536971f..49eabc4a264f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -93,6 +93,16 @@ type DynamicRoutes = Record; * Avoid for: regular navigation, single-entry workflows * */ +const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.HOME, + SCREENS.SEARCH.ROOT, + SCREENS.SETTINGS.WALLET.ROOT, +] as const; + const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { path: 'verify-account', @@ -103,6 +113,7 @@ const DYNAMIC_ROUTES = { SCREENS.WORKSPACE.COMPANY_CARDS, SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED, SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS, + SCREENS.WORKSPACE.INVOICES, SCREENS.HOME, SCREENS.SEARCH.ROOT, SCREENS.REPORT, @@ -111,6 +122,8 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.MONEY_REQUEST.CREATE, SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, + SCREENS.TRAVEL.MY_TRIPS, + SCREENS.WORKSPACE.TRAVEL, ], }, CONTACT_METHODS: { @@ -170,6 +183,46 @@ const DYNAMIC_ROUTES = { }), queryParams: ['shouldSkipPurposeSelection', 'shouldSetUpUSBankAccount'], }, + ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: { + path: 'enable-global-reimbursements/business/:bankAccountID/:subPage/:action?', + entryScreens: ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS, + getRoute: (bankAccountID: string | number, subPage: string, action?: 'edit', params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`enable-global-reimbursements/business/${bankAccountID}/${subPage}${action ? `/${action}` : ''}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), + queryParams: ['bankCountry', 'bankCurrency'], + }, + ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: { + path: 'enable-global-reimbursements/agreements/:bankAccountID', + entryScreens: [ + ...ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS, + SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + ], + getRoute: (bankAccountID: string | number, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`enable-global-reimbursements/agreements/${bankAccountID}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), + queryParams: ['bankCountry', 'bankCurrency'], + }, + ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: { + path: 'enable-global-reimbursements/sign/:bankAccountID', + entryScreens: [ + ...ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS, + SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS, + SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS, + ], + getRoute: (bankAccountID: string | number, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`enable-global-reimbursements/sign/${bankAccountID}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), + queryParams: ['bankCountry', 'bankCurrency'], + }, BANK_ACCOUNT_VERIFY_ACCOUNT: { path: 'verify-bank-account', entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT], @@ -1027,6 +1080,57 @@ const DYNAMIC_ROUTES = { path: 'rules/require-fields', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], }, + RULES_MERCHANT_NEW_FROM_EXPENSE: { + path: 'merchant-rule/new', + entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + getRoute: (policyID: string) => getUrlWithParams('merchant-rule/new', {policyID}), + queryParams: ['policyID'], + }, + RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE: { + path: 'rule-merchant-to-match', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE: { + path: 'rule-match-type', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH], + }, + RULES_MERCHANT_MERCHANT_FROM_EXPENSE: { + path: 'rule-merchant', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_CATEGORY_FROM_EXPENSE: { + path: 'rule-category', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_TAG_FROM_EXPENSE: { + path: 'rule-tag/:orderWeight', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + getRoute: (orderWeight: number) => `rule-tag/${orderWeight}` as const, + }, + RULES_MERCHANT_TAX_FROM_EXPENSE: { + path: 'rule-tax', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_VENDOR_FROM_EXPENSE: { + path: 'rule-vendor', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE: { + path: 'rule-description', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE: { + path: 'rule-reimbursable', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_BILLABLE_FROM_EXPENSE: { + path: 'rule-billable', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: { + path: 'rule-matches', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, NOTIFICATION_PREFERENCES: { // `reportID` is intentionally carried as a distinct path param (`notificationReportID`) rather than // `reportID`, so it never collides with a `reportID` inherited from the surrounding report chain's @@ -1195,7 +1299,14 @@ const DYNAMIC_ROUTES = { }, EXPENSIFY_CARD_DETAILS: { path: 'expensify-card-details/:cardID/:policyID', - entryScreens: [SCREENS.WORKSPACE.EXPENSIFY_CARD, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.DYNAMIC_PROFILE], + entryScreens: [ + SCREENS.WORKSPACE.EXPENSIFY_CARD, + SCREENS.REPORT, + SCREENS.SEARCH.ROOT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.DYNAMIC_PROFILE, + ], getRoute: (cardID: string, policyID: string) => `expensify-card-details/${cardID}/${policyID}` as const, }, EXPENSIFY_CARD_LIMIT_TYPE: { @@ -1227,6 +1338,10 @@ const DYNAMIC_ROUTES = { path: 'select-feed', entryScreens: [SCREENS.WORKSPACE.EXPENSIFY_CARD], }, + WORKSPACE_TRAVEL_BILLING_SELECT_FEED: { + path: 'travel-select-feed', + entryScreens: [SCREENS.WORKSPACE.TRAVEL], + }, WORKSPACE_COMPANY_CARDS_ADD_NEW: { path: 'add-card-feed', entryScreens: [SCREENS.WORKSPACE.COMPANY_CARDS, SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED], @@ -1297,6 +1412,11 @@ const DYNAMIC_ROUTES = { path: 'keyboard-shortcuts', entryScreens: ['*'], }, + BETA_OVERRIDES: { + path: 'beta-overrides', + // Opened from the Test Tools modal, which can be summoned on any screen + entryScreens: ['*'], + }, SETTINGS_TAG_APPROVER: { path: 'tag-approver', entryScreens: [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_SETTINGS], @@ -1599,6 +1719,10 @@ const DYNAMIC_ROUTES = { path: 'initial-list-value', entryScreens: [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE], }, + WORKSPACE_INVOICE_FIELDS_INITIAL_LIST_VALUE: { + path: 'invoice-initial-list-value', + entryScreens: [SCREENS.WORKSPACE.INVOICE_FIELDS_CREATE], + }, TRANSACTION_DUPLICATE_REVIEW: { // `reportID` is carried as the dynamic route's own path param (not inherited from the entry // screen) because this modal can be opened from within another already-open RHP (e.g. the @@ -1940,8 +2064,8 @@ const ROUTES = { }, SEARCH_SAVE: 'search/save', SEARCH_SAVED_SEARCH_RENAME: { - route: 'search/saved-search/rename', - getRoute: ({name, jsonQuery}: {name: string; jsonQuery: SearchQueryString}) => `search/saved-search/rename?name=${name}&q=${encodeURIComponent(jsonQuery)}` as const, + route: 'search/saved-search/rename/:id', + getRoute: (id: string) => `search/saved-search/rename/${id}` as const, }, SEARCH_COLUMNS: 'search/columns', SEARCH_ADVANCED_FILTERS: 'search/filters', @@ -1962,6 +2086,11 @@ const ROUTES = { }, }, + INSIGHTS: { + route: 'insights/:dashboardID', + getRoute: (dashboardID: ValueOf) => `insights/${dashboardID}` as const, + }, + EXPENSE_REPORT_RHP: { route: 'e/:reportID', getRoute: ({reportID, backTo}: {reportID: string; backTo?: string}) => { @@ -2017,6 +2146,15 @@ const ROUTES = { return `search/move-transactions/search/${encodeURIComponent(backTo)}` as const; }, }, + MERGE_REPORTS_SEARCH_RHP: { + route: 'search/merge-reports/search/:backTo?', + getRoute: (backTo?: string) => { + if (!backTo) { + return 'search/merge-reports/search' as const; + } + return `search/merge-reports/search/${encodeURIComponent(backTo)}` as const; + }, + }, CHANGE_APPROVER_SEARCH_RHP: { route: 'search/change-approver/search/:backTo?', getRoute: (backTo?: string) => { @@ -2296,16 +2434,27 @@ const ROUTES = { }, SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: { route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/business/:subPage/:action?', - getRoute: (bankAccountID: number | undefined, subPage: string, action?: 'edit') => - `settings/wallet/${bankAccountID}/enable-global-reimbursements/business/${subPage}${action ? `/${action}` : ''}` as const, + getRoute: (bankAccountID: number | undefined, subPage: string, action?: 'edit', params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/business/${subPage}${action ? `/${action}` : ''}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), }, SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: { route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/agreements', - getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/enable-global-reimbursements/agreements` as const, + getRoute: (bankAccountID: number | undefined, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/agreements`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), }, SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: { route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/sign', - getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/enable-global-reimbursements/sign` as const, + getRoute: (bankAccountID: number | undefined, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/sign`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), }, SETTINGS_WALLET_SHARE_BANK_ACCOUNT: { route: 'settings/wallet/:bankAccountID/share-bank-account', @@ -2363,6 +2512,10 @@ const ROUTES = { route: 'settings/wallet/card/:cardID/change-pin-atm', getRoute: (cardID: string) => `settings/wallet/card/${cardID}/change-pin-atm` as const, }, + SETTINGS_WALLET_CARD_ADD_TO_DIGITAL_WALLET: { + route: 'settings/wallet/card/:cardID/add-to-digital-wallet', + getRoute: (cardID: string) => `settings/wallet/card/${cardID}/add-to-digital-wallet` as const, + }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:cardID/activate', getRoute: (cardID: string, isFromDomainCardDetail?: boolean) => `settings/wallet/card/${cardID}/activate${isFromDomainCardDetail ? '?isFromDomainCardDetail=true' : ''}` as const, @@ -2483,7 +2636,7 @@ const ROUTES = { REPORT: 'r', REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', - getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string) => { + getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string, isPendingCreation?: boolean) => { if (!reportID) { Log.warn('Invalid reportID is used to build the REPORT_WITH_ID route'); return getUrlWithBackToParam(ROUTES.HOME, backTo); @@ -2498,6 +2651,9 @@ const ROUTES = { if (secureKey) { queryParams.push(`secureKey=${encodeURIComponent(secureKey)}`); } + if (isPendingCreation) { + queryParams.push('isPendingCreation=true'); + } const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; @@ -2844,12 +3000,12 @@ const ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { route: 'workspaces/:policyID/accounting/quickbooks-online/export', - getRoute: (policyID: string | undefined, backTo?: string) => { + getRoute: (policyID: string | undefined) => { if (!policyID) { Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT route'); } - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export` as const, backTo, false); + return `workspaces/${policyID}/accounting/quickbooks-online/export` as const; }, }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: { @@ -3122,10 +3278,6 @@ const ROUTES = { return `workspaces/${policyID}/invoices` as const; }, }, - WORKSPACE_INVOICES_VERIFY_ACCOUNT: { - route: `workspaces/:policyID/invoices/${VERIFY_ACCOUNT}`, - getRoute: (policyID: string) => `workspaces/${policyID}/invoices/${VERIFY_ACCOUNT}` as const, - }, WORKSPACE_INVOICES_COMPANY_NAME: { route: 'workspaces/:policyID/invoices/company-name', getRoute: (policyID: string) => `workspaces/${policyID}/invoices/company-name` as const, @@ -3134,6 +3286,39 @@ const ROUTES = { route: 'workspaces/:policyID/invoices/company-website', getRoute: (policyID: string) => `workspaces/${policyID}/invoices/company-website` as const, }, + WORKSPACE_INVOICE_FIELDS_CREATE: { + route: 'workspaces/:policyID/invoices/newInvoiceField', + getRoute: (policyID: string) => `workspaces/${policyID}/invoices/newInvoiceField` as const, + }, + WORKSPACE_INVOICE_FIELDS_SETTINGS: { + route: 'workspaces/:policyID/invoices/:reportFieldID/edit', + getRoute: (policyID: string, reportFieldID: string) => `workspaces/${policyID}/invoices/${encodeURIComponent(reportFieldID)}/edit` as const, + }, + WORKSPACE_INVOICE_FIELDS_LIST_VALUES: { + route: 'workspaces/:policyID/invoices/listValues/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `workspaces/${policyID}/invoices/listValues/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, + }, + WORKSPACE_INVOICE_FIELDS_TYPE_SELECTOR: { + route: 'workspaces/:policyID/invoices/typeSelector/:currentType?', + getRoute: (policyID: string, currentType?: PolicyReportFieldType) => `workspaces/${policyID}/invoices/typeSelector/${currentType ? encodeURIComponent(currentType) : ''}` as const, + }, + WORKSPACE_INVOICE_FIELDS_ADD_VALUE: { + route: 'workspaces/:policyID/invoices/addValue/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `workspaces/${policyID}/invoices/addValue/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, + }, + WORKSPACE_INVOICE_FIELDS_VALUE_SETTINGS: { + route: 'workspaces/:policyID/invoices/:valueIndex/:reportFieldID?', + getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) => + `workspaces/${policyID}/invoices/${valueIndex}/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, + }, + WORKSPACE_INVOICE_FIELDS_EDIT_VALUE: { + route: 'workspaces/:policyID/invoices/newInvoiceField/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `workspaces/${policyID}/invoices/newInvoiceField/${valueIndex}/edit` as const, + }, + WORKSPACE_INVOICE_FIELDS_EDIT_INITIAL_VALUE: { + route: 'workspaces/:policyID/invoices/:reportFieldID/edit/initialValue', + getRoute: (policyID: string, reportFieldID: string) => `workspaces/${policyID}/invoices/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, + }, WORKSPACE_MEMBERS: { route: 'workspaces/:policyID/members', getRoute: (policyID: string | undefined) => { @@ -3320,6 +3505,31 @@ const ROUTES = { route: 'workspaces/:policyID/hr/merge/groups', getRoute: (policyID: string) => `workspaces/${policyID}/hr/merge/groups` as const, }, + WORKSPACE_RECRUITING: { + route: 'workspaces/:policyID/recruiting', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_RECRUITING route'); + } + return `workspaces/${policyID}/recruiting` as const; + }, + }, + WORKSPACE_RECRUITING_MERGE_IMPORT_SETTINGS: { + route: 'workspaces/:policyID/recruiting/merge/import-settings', + getRoute: (policyID: string) => `workspaces/${policyID}/recruiting/merge/import-settings` as const, + }, + WORKSPACE_RECRUITING_MERGE_APPROVAL_MODE: { + route: 'workspaces/:policyID/recruiting/merge/approval-mode', + getRoute: (policyID: string) => `workspaces/${policyID}/recruiting/merge/approval-mode` as const, + }, + WORKSPACE_RECRUITING_MERGE_APPROVER_FIELD: { + route: 'workspaces/:policyID/recruiting/merge/approver-field', + getRoute: (policyID: string) => `workspaces/${policyID}/recruiting/merge/approver-field` as const, + }, + WORKSPACE_RECRUITING_MERGE_FINAL_APPROVER: { + route: 'workspaces/:policyID/recruiting/merge/final-approver', + getRoute: (policyID: string) => `workspaces/${policyID}/recruiting/merge/final-approver` as const, + }, WORKSPACE_TAGS: { route: 'workspaces/:policyID/tags', getRoute: (policyID: string | undefined) => { @@ -3698,6 +3908,15 @@ const ROUTES = { route: 'policy/:policyID/copy-settings/confirm', getRoute: (policyID: string) => `policy/${policyID}/copy-settings/confirm` as const, }, + WORKSPACE_MCP: { + route: 'workspaces/:policyID/mcp', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_MCP route'); + } + return `workspaces/${policyID}/mcp` as const; + }, + }, WORKSPACE_RECEIPT_PARTNERS: { route: 'workspaces/:policyID/receipt-partners', getRoute: (policyID: string | undefined, backTo?: string) => { @@ -3879,6 +4098,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, + RULES_EXPENSE_DEFAULT_TYPE: { + route: 'workspaces/:policyID/rules/expense-defaults/new', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/expense-defaults/new` as const, + }, RULES_MERCHANT_NEW: { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, @@ -3945,7 +4168,10 @@ const ROUTES = { }, RULES_MERCHANT_TAX: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/tax', - getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tax` as const, + // `categoryName` is set when editing a category tax default, which is identified by its category rather than a + // ruleID. It tells the picker to return to that editor instead of the create page. + getRoute: (policyID: string, ruleID?: string, categoryName?: string) => + `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tax${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_MERCHANT_VENDOR: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/vendor', @@ -3971,6 +4197,16 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/preview-matches', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/preview-matches` as const, }, + RULES_CATEGORY_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/category-to-match', + // A category tax default has no ruleID, so editing one passes its category for the way back. + getRoute: (policyID: string, ruleID?: string, categoryName?: string) => + `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/category-to-match${getOptionalCategoryNameQuery(categoryName)}` as const, + }, + RULES_CATEGORY_TAX_EDIT: { + route: 'workspaces/:policyID/rules/category-tax-rules/edit/:categoryName', + getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/rules/category-tax-rules/edit/${encodeURIComponent(categoryName)}` as const, + }, RULES_AGENT_NEW: { route: 'workspaces/:policyID/rules/agent-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/agent-rules/new` as const, @@ -4008,11 +4244,6 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam(`travel/upgrade/workspace/confirmation`, backTo), }, - TRAVEL_VERIFY_ACCOUNT: { - route: `travel/${VERIFY_ACCOUNT}`, - - getRoute: (domain?: string, policyID?: string, backTo?: string) => getUrlWithBackToParam(getUrlWithParams(`travel/${VERIFY_ACCOUNT}`, {domain, policyID}), backTo), - }, TRAVEL_ENABLE: { route: 'travel/enable/:policyID/:subPage?/:action?', getRoute: (policyID: string, subPage?: string, action?: 'edit') => { @@ -4748,6 +4979,18 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/dualentry/advanced/travel-invoicing-payable-account', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/dualentry/advanced/travel-invoicing-payable-account` as const, }, + POLICY_ACCOUNTING_CAMPFIRE_SETUP: { + route: 'workspaces/:policyID/accounting/campfire/setup', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/setup` as const, + }, + POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS: { + route: 'workspaces/:policyID/accounting/campfire/existing-connections', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/existing-connections` as const, + }, + POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR: { + route: 'workspaces/:policyID/accounting/campfire/subsidiary-selector', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/subsidiary-selector` 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, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1d603a014823..a6515d4016b0 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -16,6 +16,7 @@ const PROTECTED_SCREENS = { const SCREENS = { ...PROTECTED_SCREENS, + INSIGHTS: 'Insights', REPORT: 'Report', DYNAMIC_PROFILE_AVATAR: 'Dynamic_Profile_Avatar', WORKSPACE_AVATAR: 'WorkspaceAvatar', @@ -65,6 +66,7 @@ const SCREENS = { EDIT_MULTIPLE_ATTENDEES_RHP: 'Search_Edit_Multiple_Attendees_RHP', EDIT_MULTIPLE_TAX_RHP: 'Search_Edit_Multiple_Tax_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_Search', + MERGE_REPORTS_SEARCH_RHP: 'Search_Merge_Reports_Search_RHP', CHANGE_APPROVER: { ROOT: 'Search_Change_Approver_Root', ADD_APPROVER: 'Search_Change_Approver_Add_Approver', @@ -95,6 +97,7 @@ const SCREENS = { DYNAMIC_EXIT_SURVEY_CONFIRM: 'Dynamic_ExitSurvey_Confirm', DYNAMIC_EXIT_SURVEY_REASON: 'Dynamic_ExitSurvey_Reason', DYNAMIC_KEYBOARD_SHORTCUTS: 'Dynamic_Keyboard_Shortcuts', + DYNAMIC_BETA_OVERRIDES: 'Dynamic_Beta_Overrides', DYNAMIC_SAGE_INTACCT_EXPORT: 'Dynamic_Sage_Intacct_Export', PROFILE: { @@ -178,6 +181,7 @@ const SCREENS = { ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE: 'Settings_Wallet_EnablePayments_ConfirmValidateCode', CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', + CARD_ADD_TO_DIGITAL_WALLET: 'Settings_Wallet_Card_Add_To_Digital_Wallet', CARD_CHANGE_PIN: 'Settings_Wallet_Card_Change_PIN', CARD_CHANGE_PIN_ATM: 'Settings_Wallet_Card_Change_PIN_ATM', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', @@ -188,6 +192,9 @@ const SCREENS = { ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: 'Settings_Wallet_Enable_Global_Reimbursements_Business', ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: 'Settings_Wallet_Enable_Global_Reimbursements_Agreements', ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Settings_Wallet_Enable_Global_Reimbursements_Sign', + DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Business', + DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Agreements', + DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Sign', SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount', @@ -315,6 +322,7 @@ const SCREENS = { CHRONOS_SCHEDULE_OOO: 'Chronos_Schedule_OOO', AVATAR_CROP: 'AvatarCrop', }, + PRE_MOUNT_BUFFER: 'PreMountBuffer', REPORT_CARD_ACTIVATE: 'Report_Card_Activate_Root', SAML_SIGN_IN: 'SAMLSignIn', WORKSPACE_JOIN_USER: 'WorkspaceJoinUser', @@ -710,6 +718,9 @@ const SCREENS = { DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT: 'Policy_Accounting_DualEntry_Expensify_Card_Settlement_Account', DUALENTRY_TRAVEL_BILLING_SETTLEMENT_ACCOUNT: 'Policy_Accounting_DualEntry_Travel_Invoicing_Settlement_Account', DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'Policy_Accounting_DualEntry_Travel_Invoicing_Payable_Account', + CAMPFIRE_SETUP: 'Policy_Accounting_Campfire_Setup', + CAMPFIRE_EXISTING_CONNECTIONS: 'Policy_Accounting_Campfire_Existing_Connections', + CAMPFIRE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_Campfire_Subsidiary_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', @@ -725,6 +736,7 @@ const SCREENS = { HR_MERGE_APPROVAL_MODE: 'Workspace_HR_Merge_Approval_Mode', HR_MERGE_FINAL_APPROVER: 'Workspace_HR_Merge_Final_Approver', HR_MERGE_GROUPS: 'Workspace_HR_Merge_Groups', + RECRUITING: 'Workspace_Recruiting', COMPANY_CARDS: 'Workspace_CompanyCards', COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION: 'Workspace_CompanyCards_BrokenCardFeedConnection', COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION: 'Workspace_CompanyCards_RefreshCardFeedConnection', @@ -772,9 +784,17 @@ const SCREENS = { DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT: 'Dynamic_Workspace_ExpensifyCard_Settings_Account', EXPENSIFY_CARD_SETTINGS_FREQUENCY: 'Workspace_ExpensifyCard_Settings_Frequency', INVOICES: 'Workspace_Invoices', - INVOICES_VERIFY_ACCOUNT: 'Workspace_Invoices_Verify_Account', INVOICES_COMPANY_NAME: 'Workspace_Invoices_Company_Name', INVOICES_COMPANY_WEBSITE: 'Workspace_Invoices_Company_Website', + INVOICE_FIELDS_CREATE: 'Workspace_InvoiceFields_Create', + INVOICE_FIELDS_SETTINGS: 'Workspace_InvoiceFields_Settings', + INVOICE_FIELDS_LIST_VALUES: 'Workspace_InvoiceFields_ListValues', + INVOICE_FIELDS_ADD_VALUE: 'Workspace_InvoiceFields_AddValue', + INVOICE_FIELDS_VALUE_SETTINGS: 'Workspace_InvoiceFields_ValueSettings', + INVOICE_FIELDS_EDIT_VALUE: 'Workspace_InvoiceFields_EditValue', + INVOICE_FIELDS_EDIT_INITIAL_VALUE: 'Workspace_InvoiceFields_EditInitialValue', + DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE: 'Dynamic_Invoice_Fields_Initial_List_Value', + INVOICE_FIELDS_TYPE_SELECTOR: 'Workspace_InvoiceFields_TypeSelector', MEMBERS: 'Workspace_Members', ROOMS: 'Workspace_Rooms', ROOM_CREATE: 'Workspace_Room_Create', @@ -886,6 +906,7 @@ const SCREENS = { TRAVEL_SETTINGS_MONTHLY_LIMIT: 'Workspace_Travel_Settings_Monthly_Limit', TRAVEL_EXPORT: 'Workspace_Travel_Invoicing_Export', TRAVEL_MISSING_PERSONAL_DETAILS: 'Travel_Missing_Personal_Details', + DYNAMIC_WORKSPACE_TRAVEL_BILLING_SELECT_FEED: 'Dynamic_Workspace_TravelBilling_Select_Feed', CREATE_DISTANCE_RATE: 'Create_Distance_Rate', CREATE_DISTANCE_RATE_UPGRADE: 'Create_Distance_Rate_Upgrade', DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', @@ -901,6 +922,7 @@ const SCREENS = { UPGRADE: 'Workspace_Upgrade', DYNAMIC_WORKSPACE_DOWNGRADE: 'Dynamic_Workspace_Downgrade', DYNAMIC_PAY_AND_DOWNGRADE: 'Dynamic_Workspace_Pay_And_Downgrade', + MCP: 'Workspace_MCP', RECEIPT_PARTNERS: 'Workspace_Receipt_Partners', DYNAMIC_RECEIPT_PARTNERS_INVITE: 'Dynamic_Workspace_Receipt_Partners_Invite', DYNAMIC_RECEIPT_PARTNERS_INVITE_EDIT: 'Dynamic_Workspace_Receipt_Partners_Invite_Edit', @@ -921,6 +943,7 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', + RULES_EXPENSE_DEFAULT_TYPE: 'Rules_Expense_Default_Type', RULES_MERCHANT_NEW: 'Rules_Merchant_New', RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', RULES_MERCHANT_IMPORTED: 'Rules_Merchant_Imported', @@ -955,7 +978,21 @@ const SCREENS = { RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', + RULES_CATEGORY_TO_MATCH: 'Rules_Category_To_Match', + RULES_CATEGORY_TAX_EDIT: 'Rules_Category_Tax_Edit', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', + DYNAMIC_RULES_MERCHANT_NEW: 'Dynamic_Rules_Merchant_New', + DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH: 'Dynamic_Rules_Merchant_Merchant_To_Match', + DYNAMIC_RULES_MERCHANT_MATCH_TYPE: 'Dynamic_Rules_Merchant_Match_Type', + DYNAMIC_RULES_MERCHANT_MERCHANT: 'Dynamic_Rules_Merchant_Merchant', + DYNAMIC_RULES_MERCHANT_CATEGORY: 'Dynamic_Rules_Merchant_Category', + DYNAMIC_RULES_MERCHANT_TAG: 'Dynamic_Rules_Merchant_Tag', + DYNAMIC_RULES_MERCHANT_TAX: 'Dynamic_Rules_Merchant_Tax', + DYNAMIC_RULES_MERCHANT_VENDOR: 'Dynamic_Rules_Merchant_Vendor', + DYNAMIC_RULES_MERCHANT_DESCRIPTION: 'Dynamic_Rules_Merchant_Description', + DYNAMIC_RULES_MERCHANT_REIMBURSABLE: 'Dynamic_Rules_Merchant_Reimbursable', + DYNAMIC_RULES_MERCHANT_BILLABLE: 'Dynamic_Rules_Merchant_Billable', + DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES: 'Dynamic_Rules_Merchant_Preview_Matches', RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', RULES_AGENT_NEW: 'Rules_Agent_New', diff --git a/src/SplashScreenStateContext.tsx b/src/SplashScreenStateContext.tsx index acceaf7cc905..f68b09c5586f 100644 --- a/src/SplashScreenStateContext.tsx +++ b/src/SplashScreenStateContext.tsx @@ -29,6 +29,7 @@ const SplashScreenActionsContext = React.createContext {translate('aiFeaturesPromoModal.customAgents.title')} @@ -98,32 +94,28 @@ function AIFeaturesPromoModal() { - - {canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText')} - + {translate('common.next')} + + + + + + + + {subtitle} + {customAgentPromoTitle} + {translate('aiFeaturesPromoModal.customAgents.description')} + + + + {translate('aiFeaturesPromoModal.confirmText')} - {canUseCustomAgent && ( - - - - - {subtitle} - {customAgentPromoTitle} - {translate('aiFeaturesPromoModal.customAgents.description')} - - - - {translate('aiFeaturesPromoModal.confirmText')} - - - - )} ); diff --git a/src/components/Accordion/index.native.tsx b/src/components/Accordion/index.native.tsx index 62b3562f7466..90405149db8d 100644 --- a/src/components/Accordion/index.native.tsx +++ b/src/components/Accordion/index.native.tsx @@ -21,7 +21,6 @@ type AccordionProps = { /** Additional external style */ style?: StyleProp; - /** Was toggle triggered */ isToggleTriggered: SharedValue; }; @@ -45,10 +44,20 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty return isExpanded.get() ? 1 : 0; } - return withTiming(isExpanded.get() ? 1 : 0, { - duration, - easing: Easing.inOut(Easing.quad), - }); + return withTiming( + isExpanded.get() ? 1 : 0, + { + duration, + easing: Easing.inOut(Easing.quad), + }, + (finished) => { + if (!finished) { + return; + } + // Reset the toggled-state so we don't keep animating the accordion on remount + isToggleTriggered.set(false); + }, + ); }); const animatedStyle = useAnimatedStyle(() => { diff --git a/src/components/Accordion/index.tsx b/src/components/Accordion/index.tsx index 212da28d5020..565dbc8d493a 100644 --- a/src/components/Accordion/index.tsx +++ b/src/components/Accordion/index.tsx @@ -19,7 +19,6 @@ type AccordionProps = { /** Additional external style */ style?: StyleProp; - /** Was toggle triggered */ isToggleTriggered: SharedValue; }; @@ -55,6 +54,9 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty return; } isAnimating.set(false); + + // Reset the toggled-state so we don't keep animating the accordion on remount + isToggleTriggered.set(false); }, ); }); @@ -74,8 +76,8 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty } return { - height: !isToggleTriggered.get() ? undefined : derivedHeight.get(), - maxHeight: !isToggleTriggered.get() ? undefined : derivedHeight.get(), + height: !isToggleTriggered.get() ? 'auto' : derivedHeight.get(), + maxHeight: !isToggleTriggered.get() ? 'none' : derivedHeight.get(), opacity: derivedOpacity.get(), overflow: isAnimating.get() ? 'hidden' : 'visible', display, diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index a03904474950..1c8c29290b21 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -35,7 +35,7 @@ import {View} from 'react-native'; import type {PopoverMenuItem} from './PopoverMenu'; import UserAvatar from './Avatar/UserAvatar'; -import Button from './ButtonComposed'; +import Button from './Button'; import {ModalActions} from './Modal/Global/ModalContext'; import PopoverMenu from './PopoverMenu'; import {useProductTrainingContext} from './ProductTrainingContext'; @@ -44,7 +44,7 @@ import Tooltip from './Tooltip'; import EducationalTooltip from './Tooltip/EducationalTooltip'; type AccountSwitcherProps = { - /* Whether the screen is focused. Used to hide the product training tooltip */ + /** Whether the screen is focused. Used to hide the product training tooltip */ isScreenFocused: boolean; }; diff --git a/src/components/AccountSwitcherSkeletonView/index.tsx b/src/components/AccountSwitcherSkeletonView/index.tsx index 30cfefb15cf5..f54163fb6488 100644 --- a/src/components/AccountSwitcherSkeletonView/index.tsx +++ b/src/components/AccountSwitcherSkeletonView/index.tsx @@ -15,13 +15,8 @@ import {View} from 'react-native'; import {Circle} from 'react-native-svg'; type AccountSwitcherSkeletonViewProps = { - /** Whether to animate the skeleton view */ shouldAnimate?: boolean; - - /** The size of the avatar */ avatarSize?: ValueOf; - - /** The width of the skeleton view */ width?: number; /** Additional styles for the skeleton view */ diff --git a/src/components/ActivityIndicator.tsx b/src/components/ActivityIndicator.tsx index fa2f8b2e556a..4208966e1274 100644 --- a/src/components/ActivityIndicator.tsx +++ b/src/components/ActivityIndicator.tsx @@ -12,7 +12,6 @@ import React, {useEffect} from 'react'; import {ActivityIndicator as RNActivityIndicator} from 'react-native'; type ActivityIndicatorProps = RNActivityIndicatorProps & { - /** The ID of the test to be used for testing */ testID?: string; /** Timeout for the activity indicator after which we fire a log about abnormally long loading */ diff --git a/src/components/AddExistingExpenseFooter.tsx b/src/components/AddExistingExpenseFooter.tsx index 5767bb0976d4..d51b19b93192 100644 --- a/src/components/AddExistingExpenseFooter.tsx +++ b/src/components/AddExistingExpenseFooter.tsx @@ -25,7 +25,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React from 'react'; -import Button from './ButtonComposed'; +import Button from './Button'; import FormHelpMessage from './FormHelpMessage'; import {usePersonalDetails, useSession} from './OnyxListItemProvider'; @@ -34,13 +34,9 @@ type AddExistingExpenseFooterProps = { selectedIds: Set; /** The report to add expenses to */ report: OnyxEntry; - /** The report to confirm */ reportToConfirm: OnyxEntry; - /** The policy */ policy: OnyxEntry; - /** The policy categories */ policyCategories: OnyxEntry; - /** Error message displayed in this component */ errorMessage: string; /** Function for setting new error message */ setErrorMessage: React.Dispatch>; @@ -66,6 +62,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [transactions] = useTransactionsByID([...selectedIds]); const reports = useChangeTransactionsReportReports(transactions, reportToConfirm?.reportID); @@ -97,6 +94,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, delegateAccountID, isTrackIntentUser, formatPhoneNumber, + rules, }); } else { changeTransactionsReport({ @@ -111,6 +109,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, transactions, allTransactionViolation: transactionViolations, reports, + rules, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx index 016588ae39a7..37358fe3b6f4 100644 --- a/src/components/AddPaymentMethodMenu.tsx +++ b/src/components/AddPaymentMethodMenu.tsx @@ -27,19 +27,13 @@ import type BaseModalProps from './Modal/types'; import PopoverMenu from './PopoverMenu'; type AddPaymentMethodMenuProps = { - /** Should the component be visible? */ isVisible: boolean; - - /** Callback to execute when the component closes. */ onClose: () => void; - - /** Callback to execute when the payment method is selected. */ onItemSelected: (paymentMethod: PaymentMethod) => void; /** The IOU/Expense report we are paying */ iouReport?: OnyxEntry; - /** Anchor position for the AddPaymentMenu. */ anchorPosition: AnchorPosition; /** Where the popover should be positioned relative to the anchor points. */ @@ -48,7 +42,6 @@ type AddPaymentMethodMenuProps = { /** Popover anchor ref */ anchorRef: RefObject; - /** Whether the personal bank account option should be shown */ shouldShowPersonalBankAccountOption?: boolean; }; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index e6f018984e46..20ea864b66d7 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -30,7 +30,6 @@ import RadioButtons from './RadioButtons'; import Text from './Text'; type AddPlaidBankAccountProps = { - /** Contains plaid data */ plaidData: OnyxEntry; /** Selected account ID from the Picker associated with the end of the Plaid flow */ @@ -39,10 +38,7 @@ type AddPlaidBankAccountProps = { /** Fired when the user exits the Plaid flow */ onExitPlaid?: () => void; - /** Fired when the user selects an account */ onSelect?: (plaidAccountID: string) => void; - - /** Additional text to display */ text?: string; /** The OAuth URI + stateID needed to re-initialize the PlaidLink after the user logs into their bank */ @@ -57,7 +53,6 @@ type AddPlaidBankAccountProps = { /** Are we adding a withdrawal account? */ allowDebit?: boolean; - /** Is displayed in new enable wallet flow */ isDisplayedInWalletFlow?: boolean; /** Text to display on error message */ diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 6cab85be7192..415749f1e5e3 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -43,13 +43,11 @@ type AddressSearchProps = ForwardedFSClassProps & { /** Callback that is called when the text input is blurred */ onBlur?: () => void; - /** Error text to display */ errorText?: string; /** Hint text to display */ hint?: string; - /** The label to display for the field */ label: string; /** The value to set the field to initially */ @@ -94,13 +92,11 @@ type AddressSearchProps = ForwardedFSClassProps & { /** Location bias for querying search results. */ locationBias?: string; - /** Callback to be called when the country is changed */ onCountryChange?: (country: unknown) => void; /** If true, caret is hidden. The default value is false. */ caretHidden?: boolean; - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/AgentPromotionalBanner.tsx b/src/components/AgentPromotionalBanner.tsx index ed245074ae25..3c26dd9d8e1f 100644 --- a/src/components/AgentPromotionalBanner.tsx +++ b/src/components/AgentPromotionalBanner.tsx @@ -13,7 +13,7 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import Badge from './Badge'; -import Button from './ButtonComposed'; +import Button from './Button'; import Text from './Text'; type AgentPromotionalBannerProps = { diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 06318b1854ca..6dc96e12be9d 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -14,6 +14,7 @@ import type {NumberWithSymbolFormRef} from './NumberWithSymbolForm'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import NumberWithSymbolForm from './NumberWithSymbolForm'; +import NumericField from './NumericField'; type AmountFormProps = { /** Amount supplied by the FormProvider */ @@ -46,10 +47,7 @@ type AmountFormProps = { /** Whether the form should use a standard TextInput as a base */ displayAsTextInput?: boolean; - /** Number of decimals to display */ decimals?: number; - - /** Whether to hide the currency symbol */ hideCurrencySymbol?: boolean; /** When true, shows the trailing dropdown (same as currency picker in IOU amount flows) */ @@ -64,21 +62,19 @@ type AmountFormProps = { /** Whether the input should be disabled */ disabled?: boolean; - /** Reference to the outer element */ ref?: ForwardedRef; /** Reference to the number form for imperative updates */ numberFormRef?: ForwardedRef; - /** Callback when the user presses the submit key (Enter) */ onSubmitEditing?: () => void; /** Callback when the input is focused */ onFocus?: () => void; -} & Pick; +} & Pick; /** - * Wrapper around NumberWithSymbolForm with currency handling. + * Wrapper around the numeric form components with currency handling. */ function AmountForm({ value, @@ -97,8 +93,6 @@ function AmountForm({ currencyButtonAccessibilityLabel, disabled = false, autoFocus, - autoGrowExtraSpace, - autoGrowMarginSide, onSubmitEditing, onFocus, onBlur, @@ -109,6 +103,33 @@ function AmountForm({ const styles = useThemeStyles(); const {getCurrencyDecimals} = useCurrencyListActions(); const decimals = decimalsProp ?? getCurrencyDecimals(currency); + const symbol = getLocalizedCurrencySymbol(preferredLocale, currency) ?? ''; + + // Use NumericField for standard text input. Currency-button variants still use the legacy form. + if (displayAsTextInput && !shouldShowCurrencyButton) { + return ( + + + + ); + } return ( void; - /** Placeholder value for amount text input */ placeholder: string; - - /** Selection Object */ selection?: TextSelection; - - /** Function to call when selection in text input is changed */ onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; - - /** Style for the input */ style?: StyleProp; /** Style for the container */ touchableInputWrapperStyle?: StyleProp; - /** Whether to disable keyboard */ disableKeyboard?: boolean; /** Function to call to handle key presses in the text input */ onKeyPress?: (event: NativeSyntheticEvent) => void; - /** Style for the TextInput container */ containerStyle?: StyleProp; /** Whether to apply padding to the input, some inputs doesn't require any padding, e.g. Amount input in money request flow */ @@ -52,7 +43,6 @@ type AmountTextInputProps = { /** A unique identifier for this text input for testing purposes */ testID?: string; - /** Determines which keyboard to open */ keyboardType?: KeyboardTypeOptions; /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ diff --git a/src/components/AmountWithoutCurrencyInput.tsx b/src/components/AmountWithoutCurrencyInput.tsx index aaae2e1b8106..dceadfd0f354 100644 --- a/src/components/AmountWithoutCurrencyInput.tsx +++ b/src/components/AmountWithoutCurrencyInput.tsx @@ -21,10 +21,7 @@ type AmountFormProps = { /** Should we allow negative number as valid input */ shouldAllowNegative?: boolean; - /** Whether to allow flipping the amount */ allowFlippingAmount?: boolean; - - /** Function to toggle the amount to negative */ toggleNegative?: () => void; } & Partial; diff --git a/src/components/AnchorForAttachmentsOnly/types.ts b/src/components/AnchorForAttachmentsOnly/types.ts index 67a5bb532c27..c14a461ed2fc 100644 --- a/src/components/AnchorForAttachmentsOnly/types.ts +++ b/src/components/AnchorForAttachmentsOnly/types.ts @@ -7,10 +7,7 @@ type AnchorForAttachmentsOnlyProps = { /** Filename for attachments. */ displayName?: string; - /** Any additional styles to apply */ style?: StyleProp; - - /** Whether the attachment is deleted */ isDeleted?: boolean; }; diff --git a/src/components/AnchorForCommentsOnly/types.ts b/src/components/AnchorForCommentsOnly/types.ts index 836280f0ff17..2a8381a24a7e 100644 --- a/src/components/AnchorForCommentsOnly/types.ts +++ b/src/components/AnchorForCommentsOnly/types.ts @@ -14,10 +14,7 @@ type AnchorForCommentsOnlyProps = ChildrenProps & { This is unused in native, but is here for parity with web */ target?: string; - /** Any additional styles to apply */ style: StyleProp; - - /** Any additional styles to apply to the wrapper */ wrapperStyle?: StyleProp; /** Press handler for the link, when not passed, default href is used to create a link like behaviour */ diff --git a/src/components/AnimatedCollapsible/index.tsx b/src/components/AnimatedCollapsible/index.tsx index 7c4bfd35fb34..3478e6d69d8e 100644 --- a/src/components/AnimatedCollapsible/index.tsx +++ b/src/components/AnimatedCollapsible/index.tsx @@ -19,7 +19,6 @@ import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} import {scheduleOnRN} from 'react-native-worklets'; type AnimatedCollapsibleProps = WithSentryLabel & { - /** Whether the component is expanded */ isExpanded: boolean; /** Element that is inside the collapsible area */ @@ -43,7 +42,6 @@ type AnimatedCollapsibleProps = WithSentryLabel & { /** Style for the content container */ contentStyle?: StyleProp; - /** Style for the toggle button */ expandButtonStyle?: StyleProp; /** Whether the toggle button is disabled */ @@ -52,10 +50,7 @@ type AnimatedCollapsibleProps = WithSentryLabel & { /** Callback for when the toggle button is pressed */ onPress: () => void; - /** Whether to show the toggle button */ shouldShowToggleButton?: boolean; - - /** Style for the border bottom */ borderBottomStyle?: StyleProp; }; diff --git a/src/components/AnimatedSubmitButton/index.tsx b/src/components/AnimatedSubmitButton/index.tsx index 8daf318b5a15..a9b5c4875592 100644 --- a/src/components/AnimatedSubmitButton/index.tsx +++ b/src/components/AnimatedSubmitButton/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -31,13 +31,8 @@ type AnimatedSubmitButtonProps = WithSentryLabel & { /** Function to call when the button is pressed */ onPress: () => void; - /** Whether the animation is running */ isSubmittingAnimationRunning: boolean; - - /** Function to call when the animation finishes */ onAnimationFinish: () => void; - - /** Whether the button should be disabled */ isDisabled?: boolean; /** Whether this is a DEW submission that needs backend validation before showing "Submitted" */ diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index 2e728753722a..26fffa2a6847 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -16,12 +16,11 @@ import React from 'react'; import {View} from 'react-native'; import AvatarWithDisplayName from './AvatarWithDisplayName'; -import Button from './ButtonComposed'; +import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; import Text from './Text'; type AnonymousReportFooterProps = { - /** The reportID of the report currently being looked at */ reportID: string | undefined; }; diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx index b7e7e01886ec..3ea506b28f5a 100644 --- a/src/components/ApprovalWorkflowSection.tsx +++ b/src/components/ApprovalWorkflowSection.tsx @@ -15,7 +15,7 @@ import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import Icon from './Icon'; import MenuItem from './MenuItem'; import OfflineWithFeedback from './OfflineWithFeedback'; @@ -62,7 +62,7 @@ function ApprovalWorkflowSection({ const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Lightbulb', 'Pencil', 'Users', 'UserCheck']); const styles = useThemeStyles(); const theme = useTheme(); - const {translate, toLocaleOrdinalWithWords, localeCompare} = useLocalize(); + const {translate, toLocaleOrdinalWithWords, localeCompare, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const approverTitle = (index: number) => { @@ -88,7 +88,9 @@ function ApprovalWorkflowSection({ // the workflow (e.g. a member invited offline) is still pending server confirmation. const membersPendingAction = sortedMembers.find((member) => !!member.pendingFields?.submitsTo)?.pendingFields?.submitsTo; - const members = approvalWorkflow.isDefault ? translate('workspace.common.everyone') : sortedMembers.map((m) => Str.removeSMSDomain(m.displayName)).join(', '); + const members = approvalWorkflow.isDefault + ? translate('workspace.common.everyone') + : sortedMembers.map((m) => (Str.isSMSLogin(m.displayName) ? formatPhoneNumber(m.displayName) : m.displayName)).join(', '); const memberPills = sortedMembers.map((m) => ({ avatar: m.avatar, @@ -98,7 +100,9 @@ function ApprovalWorkflowSection({ const pressAction = isDisabled ? undefined : onPress; const accessibilityLabel = translate('workflowsPage.accessibilityLabel', { members, - approvers: approvalWorkflow?.approvers.map((approver) => Str.removeSMSDomain(approver?.displayName ?? '')).join(', '), + approvers: approvalWorkflow?.approvers + .map((approver) => (Str.isSMSLogin(approver?.displayName ?? '') ? formatPhoneNumber(approver?.displayName ?? '') : (approver?.displayName ?? ''))) + .join(', '), }); return ( @@ -191,7 +195,7 @@ function ApprovalWorkflowSection({ /> } - helperText={getApprovalLimitDescription({approver, currency, translate, convertToDisplayString})} + helperText={getApprovalLimitDescription({approver, currency, translate, formatPhoneNumber, convertToDisplayString})} helperTextStyle={styles.workflowApprovalLimitText} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_APPROVER} /> diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 7f2aafe89b38..360b4f28371b 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -19,7 +19,6 @@ import React from 'react'; import Banner from './Banner'; type ArchivedReportFooterProps = { - /** The reportID of the archived report */ reportID: string; }; diff --git a/src/components/AttachmentDeletedIndicator.tsx b/src/components/AttachmentDeletedIndicator.tsx index da3b34ea1322..dda7ccdcad0f 100644 --- a/src/components/AttachmentDeletedIndicator.tsx +++ b/src/components/AttachmentDeletedIndicator.tsx @@ -13,7 +13,6 @@ import {View} from 'react-native'; import Icon from './Icon'; type AttachmentDeletedIndicatorProps = { - /** Additional styles for container */ containerStyles?: StyleProp; }; diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 551db86d6a73..ca2437f5e61d 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -11,9 +11,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {cleanFileName, showCameraPermissionsAlert, verifyFileFormat} from '@libs/fileDownload/FileUtils'; +import {cleanFileName, showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; +import processPickedAssetsSequentially from '@libs/fileDownload/processPickedAssets'; import fileURIToPath from '@libs/fileURIToPath'; -import Log from '@libs/Log'; import ReceiptStorage from '@libs/ReceiptStorage'; import {getPickerCaptureSource, logReceiptAdoptFailed} from '@libs/telemetry/ReceiptObservability'; @@ -27,7 +27,6 @@ import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerRes import {keepLocalCopy, pick, types} from '@react-native-documents/picker'; import {Str} from 'expensify-common'; -import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import {Alert, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; @@ -65,34 +64,11 @@ type LocalCopy = { }; type Item = { - /** The icon associated with the item. */ icon: IconAsset; /** The key in the translations file to use for the title */ textTranslationKey: TranslationPaths; - /** Function to call when the user clicks the item */ pickAttachment: () => Promise; }; - -/** - * Ensures asset has proper fileName and type properties - */ -const processAssetWithFallbacks = (asset: Asset): Asset => { - // Generate fallback name: extract from URI if available, otherwise use timestamped default - const fallbackName = asset.uri - ? asset.uri - .substring(asset.uri.lastIndexOf('/') + 1) - .split('?') - .at(0) - : `image_${Date.now()}.jpeg`; - const fileName = asset.fileName ?? fallbackName; - return { - ...asset, - fileName, - // Default to JPEG if no type specified - type: asset.type ?? 'image/jpeg', - }; -}; - /** * Return imagePickerOptions based on the type */ @@ -225,68 +201,7 @@ function AttachmentPicker({ return resolve(); } - const processedAssets: Asset[] = []; - let processedCount = 0; - - const checkAllProcessed = () => { - processedCount++; - if (processedCount === assets.length) { - resolve(processedAssets.length > 0 ? processedAssets : undefined); - } - }; - - for (const asset of assets) { - if (!asset.uri) { - checkAllProcessed(); - continue; - } - - if (asset.type?.startsWith('image')) { - verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}) - .then((isHEIC) => { - // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature - if (isHEIC && asset.uri) { - ImageManipulator.manipulate(asset.uri) - .renderAsync() - .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) - .then((manipulationResult) => { - const uri = manipulationResult.uri; - const convertedAsset = { - uri, - name: uri - .substring(uri.lastIndexOf('/') + 1) - .split('?') - .at(0), - type: 'image/jpeg', - width: manipulationResult.width, - height: manipulationResult.height, - }; - processedAssets.push(convertedAsset); - checkAllProcessed(); - }) - .catch((error: Error) => { - Log.warn('Failed to convert HEIC image, skipping asset', {error: error.message}); - showGeneralAlert(translate('attachmentPicker.errorWhileConvertingHeic')); - checkAllProcessed(); - }); - } else { - // Ensure the asset has proper fileName and type for non-HEIC images - const processedAsset = processAssetWithFallbacks(asset); - processedAssets.push(processedAsset); - checkAllProcessed(); - } - }) - .catch((error: Error) => { - showGeneralAlert(error.message ?? 'An unknown error occurred'); - checkAllProcessed(); - }); - } else { - // Ensure the asset has proper fileName and type - const processedAsset = processAssetWithFallbacks(asset); - processedAssets.push(processedAsset); - checkAllProcessed(); - } - } + processPickedAssetsSequentially(assets, showGeneralAlert, translate).then(resolve).catch(reject); }); }), [fileLimit, showGeneralAlert, translate, type], diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index f56bdd5a0577..cbd9d370f7bc 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -59,7 +59,6 @@ type AttachmentPickerProps = { /** Whether to allow multiple files to be selected. */ fileLimit?: number; - /** A callback that will be called when the picker is opened. */ onOpenPicker?: () => void; /** When true, skip the Camera/Gallery/Document modal and open the document picker directly (native only). */ diff --git a/src/components/AttachmentPreview.tsx b/src/components/AttachmentPreview.tsx index d1cc18c0a389..73d4ff3c262a 100644 --- a/src/components/AttachmentPreview.tsx +++ b/src/components/AttachmentPreview.tsx @@ -34,7 +34,6 @@ type AttachmentPreviewProps = { /** Function to call when pressing thumbnail */ onPress: () => void; - /** The attachment load error callback */ onLoadError?: () => void; }; diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts index 97d9d46afa2d..ea542dd8a4aa 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/types.ts @@ -7,7 +7,6 @@ type AttachmentCarouselViewProps = { shouldShowArrows: boolean; /** The current page index */ page: number; - /** The attachments from the carousel */ attachments: Attachment[]; /** The id of the current active attachment */ attachmentID?: string; @@ -21,11 +20,8 @@ type AttachmentCarouselViewProps = { cancelAutoHideArrow: () => void; /** A callback that is called when swipe-down-to-close gesture happens */ onSwipeDown?: () => void; - /** Sets current page */ setPage: (page: number) => void; - /** The report currently being looked at */ report?: Report; - /** Callback for attachment errors */ onAttachmentError?: (source: AttachmentSource, state?: boolean) => void; /** Callback to update the parent modal's state with a source and name from the attachments array */ onNavigate?: (attachment: Attachment) => void; diff --git a/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx b/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx index 2bfd7455d803..2b4b725725e1 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx @@ -3,7 +3,6 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import CONST from '@src/CONST'; type CarouselActionsProps = { - /** Callback to cycle through attachments */ onCycleThroughAttachments: (deltaSlide: number) => void; }; diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx b/src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx index 84ef71f518e2..e852ff744d50 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.tsx @@ -1,5 +1,5 @@ import type {Attachment} from '@components/Attachments/types'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Tooltip from '@components/Tooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -20,7 +20,6 @@ type CarouselButtonsProps = { /** The current page index */ page: number; - /** The attachments from the carousel */ attachments: Attachment[]; /** Callback to go one page back */ diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx index cdc1162700fb..ffb650ffe561 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx @@ -1,6 +1,6 @@ import AttachmentView from '@components/Attachments/AttachmentView'; import type {Attachment} from '@components/Attachments/types'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import Text from '@components/Text'; @@ -25,7 +25,6 @@ type CarouselItemProps = { /** Attachment required information such as the source and file name */ item: Attachment; - /** onPress callback */ onPress?: () => void; /** Whether attachment carousel modal is hovered over */ diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index 2dbaa71cad4b..f1f9a8545baa 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -34,7 +34,6 @@ type AttachmentCarouselPagerProps = { /** The index of the initial page to be rendered. */ initialPage: number; - /** A callback to be called when the page is changed. */ onPageSelected?: ( event: NativeSyntheticEvent< Readonly<{ @@ -52,10 +51,7 @@ type AttachmentCarouselPagerProps = { /** The reportID related to the attachment */ reportID?: string; - /** Callback for attachment errors */ onAttachmentError?: (source: AttachmentSource) => void; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/types.ts b/src/components/Attachments/AttachmentCarousel/Pager/types.ts index 23e3e79e5080..8d84beb7a1ad 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/types.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/types.ts @@ -50,7 +50,6 @@ type AttachmentCarouselPagerActionsContextType = { /** Function to call after a swipe down event */ onSwipeDown?: () => void; - /** Callback for attachment errors */ onAttachmentError?: (source: AttachmentSource, state?: boolean) => void; }; diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts index c98a8f664198..1df610f7338c 100644 --- a/src/components/Attachments/AttachmentCarousel/types.ts +++ b/src/components/Attachments/AttachmentCarousel/types.ts @@ -14,7 +14,6 @@ type AttachmentCarouselProps = { /** Source is used to determine the starting index in the array of attachments */ source: AttachmentSource; - /** The report currently being looked at */ report: Report; /** The id of the current active attachment */ @@ -23,10 +22,7 @@ type AttachmentCarouselProps = { /** Callback to update the parent modal's state with a source and name from the attachments array */ onNavigate?: (attachment: Attachment) => void; - /** Function to change the download button Visibility */ setDownloadButtonVisibility?: (isButtonVisible: boolean) => void; - - /** The type of the attachment */ type?: ValueOf; /** If the attachment originates from a note, the accountID will represent the author of that note. */ @@ -36,8 +32,6 @@ type AttachmentCarouselProps = { onSwipeDown?: () => void; attachmentLink?: string; - - /** Callback for attachment errors */ onAttachmentError?: (source: AttachmentSource, state?: boolean) => void; }; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx index 1c1a8b39db53..37543ece023a 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx @@ -16,8 +16,6 @@ type AttachmentViewImageProps = Pick void; }; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts b/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts index 486502d4f2cf..358043d12967 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts @@ -6,8 +6,6 @@ import type {AttachmentViewProps} from '..'; type AttachmentViewPdfProps = Pick & { encryptedSourceUrl: string; onLoadComplete: (path: string) => void; - - /** Additional style props */ style?: StyleProp; /** Triggered when the PDF's onScaleChanged event is triggered */ @@ -16,7 +14,6 @@ type AttachmentViewPdfProps = Pick void; - /** Whether the PDF is used as a chat attachment */ isUsedAsChatAttachment?: boolean; /** Controlled rotation angle for the PDF */ diff --git a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx index 4ad5ad9c01b7..83f35104e470 100644 --- a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx @@ -16,21 +16,11 @@ import React from 'react'; import {View} from 'react-native'; type DefaultAttachmentViewProps = { - /** The name of the file */ fileName?: string; - - /** Should show the download icon */ shouldShowDownloadIcon?: boolean; - - /** Should show the loading spinner icon */ shouldShowLoadingSpinnerIcon?: boolean; - - /** Additional styles for the container */ containerStyles?: StyleProp; - icon?: IconAsset; - - /** Whether the attachment is deleted */ isDeleted?: boolean; /** Flag indicating if the attachment is being uploaded. */ diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 9aa2d9cf8d97..61132ec616cd 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -1,7 +1,7 @@ import {useAttachmentCarouselPagerActions} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import MultiGestureIcon from '@components/Attachments/MultiGestureIcon'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; import Icon from '@components/Icon'; @@ -55,13 +55,8 @@ type AttachmentViewProps = Attachment & { /** Whether this view is the active screen */ isFocused?: boolean; - /** Function for handle on press */ onPress?: (e?: GestureResponderEvent | KeyboardEvent) => void; - - /** Whether the attachment is used in attachment modal */ isUsedInAttachmentModal?: boolean; - - /** Flag to show/hide download icon */ shouldShowDownloadIcon?: boolean; /** Flag to show the loading indicator */ @@ -85,16 +80,9 @@ type AttachmentViewProps = Attachment & { /** Fallback source to use in case of error */ fallbackSource?: AttachmentSource; - /* Whether it is hovered or not */ isHovered?: boolean; - - /** Whether the attachment is used as a chat attachment */ isUsedAsChatAttachment?: boolean; - - /* Flag indicating whether the attachment has been uploaded. */ isUploaded?: boolean; - - /** Whether the attachment is deleted */ isDeleted?: boolean; /** Flag indicating if the attachment is being uploaded. */ @@ -110,12 +98,13 @@ type AttachmentViewProps = Attachment & { rotation?: RotationDegrees; }; -function checkIsFileImage(source: string | number | ImageURISource | ImageURISource[], fileName: string | undefined) { +function checkIsFileImage(source: string | number | ImageURISource | ImageURISource[], fileName: string | undefined, fileType?: string) { const isSourceImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source)); - const isFileNameImage = fileName && Str.isImage(fileName); + const isFileNameImage = !!fileName && Str.isImage(fileName); + const isFileTypeImage = !!fileType?.startsWith('image/') && Str.isImage(`image.${fileType.slice('image/'.length)}`); - return isSourceImage || isFileNameImage; + return isSourceImage || isFileNameImage || isFileTypeImage; } function AttachmentView({ @@ -200,10 +189,10 @@ function AttachmentView({ }, [file]); useEffect(() => { - const isImageSource = typeof source !== 'function' && !!checkIsFileImage(source, file?.name); + const isImageSource = typeof source !== 'function' && checkIsFileImage(source, file?.name, file?.type); const isErrorInImage = imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function'); onAttachmentError?.(source, isErrorInImage && isImageSource); - }, [fallbackSource, file?.name, imageError, onAttachmentError, source]); + }, [fallbackSource, file?.name, file?.type, imageError, onAttachmentError, source]); // Handles case where source is a component (ex: SVG) or a number // Number may represent a SVG or an image @@ -316,7 +305,7 @@ function AttachmentView({ if (isDistanceRequest(transaction) && !isManualDistanceRequest(transaction) && !isOdometerDistanceRequest(transaction) && transaction) { // Distance eReceipts are now generated as a PDF, but to keep it backwards compatible we still show the old eReceipt view for image receipts - const isImageReceiptSource = checkIsFileImage(source, file?.name); + const isImageReceiptSource = checkIsFileImage(source, file?.name, file?.type); if (!hasReceiptSource(transaction) || isImageReceiptSource) { return ; } @@ -327,10 +316,10 @@ function AttachmentView({ // We also check for numeric source since this is how static images (used for preview) are represented in RN. // isLocalSource checks if the source is blob as that's the type of the temp image coming from mobile web - const isFileImage = checkIsFileImage(source, file?.name); + const isFileImage = checkIsFileImage(source, file?.name, file?.type); const isLocalSourceImage = typeof source === 'string' && source.startsWith('blob:'); - const isImage = isFileImage ?? isLocalSourceImage; + const isImage = isFileImage || (!file?.name && isLocalSourceImage); if (isImage) { if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) { diff --git a/src/components/Attachments/MultiGestureIcon/index.tsx b/src/components/Attachments/MultiGestureIcon/index.tsx index f8817a9f2e1b..a0488dce25ba 100644 --- a/src/components/Attachments/MultiGestureIcon/index.tsx +++ b/src/components/Attachments/MultiGestureIcon/index.tsx @@ -17,7 +17,6 @@ type MultiGestureIconProps = { /** Additional styles applied to the icon wrapper. */ additionalStyles: StyleProp; - /** Icon asset to render. */ src: IconAsset; /** Fill color passed to the SVG. */ diff --git a/src/components/Attachments/MultiGestureIcon/useIconCarouselPager.ts b/src/components/Attachments/MultiGestureIcon/useIconCarouselPager.ts index db41cc7c048b..4996334c9647 100644 --- a/src/components/Attachments/MultiGestureIcon/useIconCarouselPager.ts +++ b/src/components/Attachments/MultiGestureIcon/useIconCarouselPager.ts @@ -6,7 +6,6 @@ import type {SharedValue} from 'react-native-reanimated'; import {useSharedValue} from 'react-native-reanimated'; type IconCarouselPagerProps = { - /** Ref to the attachment carousel pager, when available. */ pagerRef: AttachmentCarouselPagerStateContextType['pagerRef']; /** Whether horizontal pager scrolling is enabled. */ diff --git a/src/components/Attachments/types.ts b/src/components/Attachments/types.ts index 94833f3b30c9..28d9a0423719 100644 --- a/src/components/Attachments/types.ts +++ b/src/components/Attachments/types.ts @@ -7,7 +7,6 @@ type Attachment = { /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ source: AttachmentSource; - /** Report action ID of the attachment */ reportActionID?: string; /** The attachment id, which is the concatenation of the report action id it is in and its order index within that report action. */ @@ -22,7 +21,6 @@ type Attachment = { /** File object can be an instance of File or Object */ file?: FileObject; - /** Whether the attachment has been flagged */ hasBeenFlagged?: boolean; /** The id of the transaction related to the attachment */ diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx index 6c6fb37e4fbe..9a90271fe6f3 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx @@ -9,18 +9,21 @@ import CONST from '@src/CONST'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; -import type {PointerEvent} from 'react-native'; +import type {PointerEvent, StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; type TransparentOverlayProps = { onPress: () => void; + + /** Additional styles for the overlay */ + style?: StyleProp; }; type OnPressHandler = PressableProps['onPress']; -function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { +function TransparentOverlay({onPress: onPressProp, style}: TransparentOverlayProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const dropZone = useRef(null); @@ -54,7 +57,7 @@ function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { return ( 7) { - // Calculate the rate of change between reference points - const paddingRate = (referencePaddingMax - referencePaddingMin) / (referenceHeightMax - referenceHeightMin); - - // Interpolate the padding value based on the current screen height - const padding = referencePaddingMin + (height - referenceHeightMin) * paddingRate; - - // Clamp the padding value between -60 and -5 to prevent extreme values - return Math.round(Math.max(-60, Math.min(-5, padding))); - } - - return basePadding; -} - -export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts deleted file mode 100644 index b82e2bc0a66b..000000000000 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts +++ /dev/null @@ -1,6 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { - return 30; -} - -export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts new file mode 100644 index 000000000000..d7c7b47a0021 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts @@ -0,0 +1,5 @@ +function getBottomSuggestionPadding(isMenuAbove: boolean): number { + return isMenuAbove ? 30 : 0; +} + +export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts index a159938366a1..0a543ccbd775 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { +function getBottomSuggestionPadding(isMenuAbove: boolean): number { return 6; } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index 44fd2c4d2d62..946d4168bbe6 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -1,27 +1,42 @@ import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions'; +import useKeyboardState from '@hooks/useKeyboardState'; import useStyleUtils from '@hooks/useStyleUtils'; +import useWindowDimensionsForAutoCompleteSuggestions from '@hooks/useWindowDimensionsForAutoCompleteSuggestions'; + +import variables from '@styles/variables'; import {Portal} from '@gorhom/portal'; -import React, {useMemo} from 'react'; -import {View} from 'react-native'; +import React, {useEffect, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; import type {AutoCompleteSuggestionsPortalProps} from './types'; import getBottomSuggestionPadding from './getBottomSuggestionPadding'; import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; +const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; + function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, resetSuggestions = () => {}, - isInLandscapeMode = false, + isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps) { const StyleUtils = useStyleUtils(); - const bottomPadding = getBottomSuggestionPadding(bottom, isInLandscapeMode); - const styles = useMemo(() => StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: bottom + bottomPadding}), [StyleUtils, left, width, bottom, bottomPadding]); + const {height: windowHeight} = useWindowDimensionsForAutoCompleteSuggestions(); + const {keyboardHeight} = useKeyboardState(); + const hostFrameRef = useRef(null); + + const [hostBottomInset, setHostBottomInset] = useState(null); + + const measureHostFrame = () => { + hostFrameRef.current?.measureInWindow((x, y, frameWidth, height) => setHostBottomInset(windowHeight - y - height)); + }; + + useEffect(measureHostFrame, [windowHeight, keyboardHeight]); if (!width) { return null; @@ -29,13 +44,32 @@ function AutoCompleteSuggestionsPortal({ return ( - - - - width={width} - {...props} - /> - + {/* Zero-cost probe filling the portal host, so the host's position in the window is known before positioning the suggestions. */} + + {hostBottomInset !== null && ( + <> + + + + width={width} + {...props} + /> + + + )} ); } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index 53f45af0b59b..84fa67621141 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -2,6 +2,8 @@ import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/Bas import useStyleUtils from '@hooks/useStyleUtils'; +import variables from '@styles/variables'; + import type {ReactElement} from 'react'; import React from 'react'; @@ -13,6 +15,8 @@ import type {AutoCompleteSuggestionsPortalProps} from './types'; import getBottomSuggestionPadding from './getBottomSuggestionPadding'; import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; +const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; + /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, * we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback). @@ -25,9 +29,7 @@ function AutoCompleteSuggestionsPortal({ width = 0, bottom = 0, resetSuggestions = () => {}, - // isInLandscapeMode is only used on native platforms to adjust the bottom padding - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isInLandscapeMode = false, + isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps): ReactElement | null | false { const StyleUtils = useStyleUtils(); @@ -46,8 +48,13 @@ function AutoCompleteSuggestionsPortal({ bodyElement && ReactDOM.createPortal( <> - - {componentToRender} + + + {componentToRender} + , bodyElement, ) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts index 62fcc5afd77a..0fc47283933e 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -7,7 +7,9 @@ type AutoCompleteSuggestionsPortalProps = ExternalProps { if (isInLandscapeMode) { @@ -42,17 +44,20 @@ function isSuggestionMenuRenderedAbove(isEnoughSpaceAboveForBigMenu: boolean, is } type IsEnoughSpaceToRenderMenuAboveCursor = Pick & { - contentHeight: number; + menuHeight: number; topInset: number; }; -function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean { - return y + (cursorCoordinates.y - scrollValue) > contentHeight + topInset + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; +function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, menuHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean { + const gapAboveCursor = Math.max(CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE, getBottomSuggestionPadding(true)); + + return y + (cursorCoordinates.y - scrollValue) > menuHeight + gapAboveCursor + topInset; } const initialContainerState = { width: 0, left: 0, bottom: 0, + isMenuAbove: false, cursorCoordinates: {x: 0, y: 0}, }; @@ -112,14 +117,16 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu : xCoordinatesOfCursor; const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false, isInLandscapeMode); - let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; + // Read in the same tick as the composer measurement above, so both describe the same layout state. + const viewportBottom = getSuggestionsViewportBottom(windowHeight, keyboardHeight); + let bottomValue = viewportBottom - (cursorCoordinates.y - scrollValue + y); const widthValue = shouldUseNarrowLayout ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({ y, cursorCoordinates, scrollValue, - contentHeight: contentMaxHeight, + menuHeight: StyleUtils.getAutoCompleteSuggestionContainerHeight(contentMaxHeight), topInset, }); @@ -130,15 +137,17 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu y, cursorCoordinates, scrollValue, - contentHeight: contentMinHeight, + menuHeight: StyleUtils.getAutoCompleteSuggestionContainerHeight(contentMinHeight), topInset, }); const newLeftOffset = getLeftOffset(x, insets, bigScreenLeftOffset, shouldUseNarrowLayout, width, windowWidth, isInLandscapeMode); // If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150; - if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode) { - isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + const shouldRenderMenuAbove = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + const hasRunOutOfSpaceAbove = isSuggestionMenuAboveRef.current && !shouldRenderMenuAbove; + if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode || hasRunOutOfSpaceAbove) { + isSuggestionMenuAboveRef.current = shouldRenderMenuAbove; leftValue.current = newLeftOffset; isInitialRender.current = false; prevLeftValue.current = newLeftOffset; @@ -146,6 +155,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu } let measuredHeight = 0; + const isMenuAbove = isSuggestionMenuAboveRef.current && (isEnoughSpaceToRenderMenuAboveForBig || isEnoughSpaceToRenderMenuAboveForSmall); if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) { // calculation for big suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); @@ -155,7 +165,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu } else { // calculation for big suggestion box below the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); - bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; + bottomValue = viewportBottom - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } setSuggestionHeight(measuredHeight); @@ -163,6 +173,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu left: leftValue.current, bottom: bottomValue, width: widthValue, + isMenuAbove, cursorCoordinates, }); }); @@ -178,6 +189,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu isKeyboardAnimatingRef, isInLandscapeMode, insets, + StyleUtils, ]); // Prevent rendering if container dimensions are not set or if we have no suggestions @@ -192,7 +204,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu width={containerState.width} bottom={containerState.bottom} measuredHeightOfSuggestionRows={suggestionHeight} - isInLandscapeMode={isInLandscapeMode} + isMenuAbove={containerState.isMenuAbove} /> ); } diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index eca3e5db4876..c573a2138130 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -15,16 +15,12 @@ type RenderSuggestionMenuItemProps = { }; type AutoCompleteSuggestionsProps = { - /** Array of suggestions */ suggestions: TSuggestion[]; /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */ renderSuggestionMenuItem: (item: TSuggestion, index: number) => ReactElement; - /** Create unique keys for each suggestion item */ keyExtractor: (item: TSuggestion, index: number) => string; - - /** The index of the highlighted suggestion */ highlightedSuggestionIndex: number; /** Fired when the user selects a suggestion */ @@ -35,7 +31,6 @@ type AutoCompleteSuggestionsProps = { * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ isSuggestionPickerLarge: boolean; - /** create accessibility label for each item */ accessibilityLabelExtractor: (item: TSuggestion, index: number) => string; /** Measures the parent container's position and dimensions. Also add a cursor coordinates */ diff --git a/src/components/Avatar/WorkspaceAvatar.tsx b/src/components/Avatar/WorkspaceAvatar.tsx index 3f546dda90d6..5301696f36cc 100644 --- a/src/components/Avatar/WorkspaceAvatar.tsx +++ b/src/components/Avatar/WorkspaceAvatar.tsx @@ -21,11 +21,20 @@ type WorkspaceAvatarProps = AvatarCommonProps & { name: string; /** Workspace/policy ID. Picks the background color of the default workspace avatar. */ - avatarID: number | string; + avatarID?: number | string; }; /** Renders a workspace avatar, falling back to a default icon derived from the workspace name. */ -function WorkspaceAvatar({source, imageStyles, iconAdditionalStyles, containerStyles, size = CONST.AVATAR_SIZE.DEFAULT, name, avatarID, testID}: WorkspaceAvatarProps) { +function WorkspaceAvatar({ + source, + imageStyles, + iconAdditionalStyles, + containerStyles, + size = CONST.AVATAR_SIZE.DEFAULT, + name, + avatarID = CONST.DEFAULT_NUMBER_ID, + testID, +}: WorkspaceAvatarProps) { const theme = useTheme(); const StyleUtils = useStyleUtils(); const defaultAvatars = useDefaultAvatars(); diff --git a/src/components/Avatar/connected/AccountAvatar.tsx b/src/components/Avatar/connected/AccountAvatar.tsx index 64810b864d71..c53369d96795 100644 --- a/src/components/Avatar/connected/AccountAvatar.tsx +++ b/src/components/Avatar/connected/AccountAvatar.tsx @@ -15,7 +15,6 @@ type AccountAvatarProps = { /** Account ID of the user to display the avatar for */ accountID: number; - /** Size of the avatar */ size?: ValueOf; /** Display name used as a fallback for the avatar tooltip */ diff --git a/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx b/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx index 637f91618eb8..576f9c2f7391 100644 --- a/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx +++ b/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx @@ -23,7 +23,6 @@ type AccountAvatarWithCardFeedProps = { /** Border color for the subscript card feed icon container */ borderColor?: ColorValue; - /** Size of the avatar */ size?: ValueOf; /** Display name used as a fallback for the avatar tooltip */ diff --git a/src/components/Avatar/connected/GroupChatAvatar.tsx b/src/components/Avatar/connected/GroupChatAvatar.tsx index 684c80284fd9..72be1e0d3f0a 100644 --- a/src/components/Avatar/connected/GroupChatAvatar.tsx +++ b/src/components/Avatar/connected/GroupChatAvatar.tsx @@ -23,7 +23,6 @@ type GroupChatAvatarProps = { /** Group chat whose avatar to render */ reportID: string; - /** Size of the avatar */ size: ValueOf; /** Container styles for the avatar. Replaces the size-derived default container styles when provided */ diff --git a/src/components/Avatar/connected/MultiAccountAvatar.tsx b/src/components/Avatar/connected/MultiAccountAvatar.tsx index 1bd925192546..83ddd9b60c3d 100644 --- a/src/components/Avatar/connected/MultiAccountAvatar.tsx +++ b/src/components/Avatar/connected/MultiAccountAvatar.tsx @@ -30,7 +30,6 @@ type MultiAccountAvatarProps = { /** Account IDs to display avatars for. Entries equal to `CONST.DEFAULT_NUMBER_ID` are dropped; when none remain, a single placeholder avatar renders so the slot keeps its size. */ accountIDs: number[]; - /** Options for the horizontal stack */ horizontalOptions?: HorizontalStackingOptions; /** How to order the avatars before rendering them. Every avatar sits in an equivalent slot, so any order is renderable. Omit to leave them in the order `accountIDs` was passed */ @@ -39,7 +38,6 @@ type MultiAccountAvatarProps = { /** Emails of invited, not-yet-registered accounts. Also seeds a deterministic fallback avatar for each invited account */ invitedEmailsToAccountIDs?: InvitedEmailsToAccountIDs; - /** Set the size of avatars */ size?: ValueOf; /** Display name used as a fallback for the avatar tooltip */ diff --git a/src/components/Avatar/connected/PolicyAvatar.tsx b/src/components/Avatar/connected/PolicyAvatar.tsx index b609eb6d4f36..42c9c117052e 100644 --- a/src/components/Avatar/connected/PolicyAvatar.tsx +++ b/src/components/Avatar/connected/PolicyAvatar.tsx @@ -20,7 +20,6 @@ type PolicyAvatarProps = { /** Account to render as the subscript on the workspace avatar. Omit to render the workspace avatar on its own. */ accountID?: number; - /** Size of the avatar */ size?: ValueOf; /** Workspace name used to seed the default avatar, and as a fallback for the avatar tooltip */ @@ -29,7 +28,6 @@ type PolicyAvatarProps = { /** Container styles for the avatar. Replaces the size-derived default container styles when provided. Only applies to the single-avatar layout. */ containerStyle?: StyleProp; - /** Border color for the subscript avatar */ subscriptAvatarBorderColor?: ColorValue; }; diff --git a/src/components/Avatar/connected/ReportAvatar.tsx b/src/components/Avatar/connected/ReportAvatar.tsx index 49e9338c1aeb..586cd847e502 100644 --- a/src/components/Avatar/connected/ReportAvatar.tsx +++ b/src/components/Avatar/connected/ReportAvatar.tsx @@ -23,16 +23,9 @@ type ReportAvatarProps = { /** Report whose avatars to render */ reportID?: string; - /** Set the size of avatars */ size?: ValueOf; - - /** Single avatar container styles */ singleAvatarContainerStyle?: StyleProp; - - /** Style for the second avatar */ secondaryAvatarContainerStyle?: StyleProp; - - /** Border color for the subscript avatar */ subscriptAvatarBorderColor?: ColorValue; /** Whether to show the subscript avatar without margin */ diff --git a/src/components/Avatar/layouts/DiagonalAvatars.tsx b/src/components/Avatar/layouts/DiagonalAvatars.tsx index 9610315946c5..f98c8abcab72 100644 --- a/src/components/Avatar/layouts/DiagonalAvatars.tsx +++ b/src/components/Avatar/layouts/DiagonalAvatars.tsx @@ -24,10 +24,7 @@ import getDiagonalAvatarSizing from './getDiagonalAvatarSizing'; const EMPTY_USER_ICON: Icon = {source: '', type: CONST.ICON_TYPE_AVATAR}; type DiagonalAvatarsProps = MultipleAvatarsProps & { - /** Style for the secondary avatar container */ secondaryAvatarContainerStyle?: StyleProp; - - /** Whether the avatars are hovered */ isHovered?: boolean; }; diff --git a/src/components/Avatar/layouts/PressableDiagonalAvatars.tsx b/src/components/Avatar/layouts/PressableDiagonalAvatars.tsx index d0f6c82df13d..cd68b6fea570 100644 --- a/src/components/Avatar/layouts/PressableDiagonalAvatars.tsx +++ b/src/components/Avatar/layouts/PressableDiagonalAvatars.tsx @@ -30,7 +30,6 @@ type PressableDiagonalAvatarsProps = { /** Total number of icons the stack represents (affects the xxxx-large right margin, matching `DiagonalAvatars`) */ iconCount: number; - /** Called with the pressed avatar */ onAvatarPress: (avatar: IconType) => void; /** Sentry label applied to both pressables */ diff --git a/src/components/Avatar/layouts/PressableSubscriptAvatar.tsx b/src/components/Avatar/layouts/PressableSubscriptAvatar.tsx index 3620aaa7dcbc..a2acf1d6b680 100644 --- a/src/components/Avatar/layouts/PressableSubscriptAvatar.tsx +++ b/src/components/Avatar/layouts/PressableSubscriptAvatar.tsx @@ -22,19 +22,13 @@ type PressableSubscriptAvatarProps = { /** Size of the subscript stack */ size: ValueOf; - /** The primary (main) avatar */ primaryAvatar: IconType; - - /** The secondary (subscript) avatar */ secondaryAvatar: IconType; - - /** Called with the pressed avatar */ onAvatarPress: (avatar: IconType) => void; /** Sentry label applied to both pressables */ sentryLabel: string; - /** Style for the avatar container */ containerStyle?: StyleProp; }; diff --git a/src/components/Avatar/layouts/SingleAvatar.tsx b/src/components/Avatar/layouts/SingleAvatar.tsx index 31ba2bc101f5..7b63c3755ba1 100644 --- a/src/components/Avatar/layouts/SingleAvatar.tsx +++ b/src/components/Avatar/layouts/SingleAvatar.tsx @@ -12,7 +12,6 @@ type SingleAvatarProps = BaseAvatarProps & { /** The resolved avatar icon to render */ avatar: AvatarIcon; - /** Container styles for the avatar */ containerStyles: StyleProp; }; diff --git a/src/components/Avatar/layouts/SubscriptAvatar.tsx b/src/components/Avatar/layouts/SubscriptAvatar.tsx index 301f56627bd9..bfeaf0ec85ae 100644 --- a/src/components/Avatar/layouts/SubscriptAvatar.tsx +++ b/src/components/Avatar/layouts/SubscriptAvatar.tsx @@ -17,16 +17,12 @@ import getSubscriptAvatarSizing from './getSubscriptAvatarSizing'; import SubscriptAvatarFrame from './SubscriptAvatarFrame'; type SubscriptAvatarProps = BaseAvatarProps & { - /** The primary (main) avatar icon */ primaryAvatar: IconType; /** The secondary (subscript) avatar icon */ secondaryAvatar?: IconType; - /** Border color for the subscript avatar */ subscriptAvatarBorderColor?: ColorValue; - - /** Style for avatar container */ containerStyle?: StyleProp; }; diff --git a/src/components/Avatar/layouts/SubscriptAvatarFrame.tsx b/src/components/Avatar/layouts/SubscriptAvatarFrame.tsx index b414e10358b9..2efbbfb395e1 100644 --- a/src/components/Avatar/layouts/SubscriptAvatarFrame.tsx +++ b/src/components/Avatar/layouts/SubscriptAvatarFrame.tsx @@ -13,7 +13,6 @@ type SubscriptAvatarFrameProps = { /** Size of the subscript stack */ size: ValueOf; - /** Style for the avatar container */ containerStyle?: StyleProp; /** Content of the primary slot */ diff --git a/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx b/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx index 06e1afa18949..2ffee1dbc568 100644 --- a/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx +++ b/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx @@ -25,19 +25,16 @@ import type {BaseAvatarProps} from './types'; import SubscriptAvatarFrame from './SubscriptAvatarFrame'; type SubscriptCardFeedAvatarProps = BaseAvatarProps & { - /** The primary (main) avatar icon */ primaryAvatar: IconType; /** Card feed to display as the subscript */ cardFeed: CardFeed; - /** Size of the subscript card feed icon */ cardFeedIconSize?: {width: number; height: number}; /** Border color for the subscript card feed icon container */ subscriptAvatarBorderColor?: ColorValue; - /** Style for the avatar container */ containerStyle?: StyleProp; }; diff --git a/src/components/Avatar/layouts/getAvatarLayout.ts b/src/components/Avatar/layouts/getAvatarLayout.ts index 28f644f2a13a..9919b4fe493d 100644 --- a/src/components/Avatar/layouts/getAvatarLayout.ts +++ b/src/components/Avatar/layouts/getAvatarLayout.ts @@ -30,7 +30,6 @@ type AvatarLayoutResult = { /** Resolved layout primitive to render */ layout: AvatarLayout; - /** The primary avatar icon */ primaryIcon: Icon | undefined; /** The secondary avatar icon, when present */ diff --git a/src/components/Avatar/layouts/types.ts b/src/components/Avatar/layouts/types.ts index 3e283155375c..31f211ea00b6 100644 --- a/src/components/Avatar/layouts/types.ts +++ b/src/components/Avatar/layouts/types.ts @@ -5,7 +5,6 @@ import type {ValueOf} from 'type-fest'; /** Props shared by every avatar layout primitive */ type BaseAvatarProps = { - /** Size of the avatar(s) to render */ size: ValueOf; /** Display name used as a fallback for the avatar tooltip */ diff --git a/src/components/Avatar/primitives/AvatarIcon.tsx b/src/components/Avatar/primitives/AvatarIcon.tsx index 2c27a4903edf..73d3ccefb507 100644 --- a/src/components/Avatar/primitives/AvatarIcon.tsx +++ b/src/components/Avatar/primitives/AvatarIcon.tsx @@ -14,13 +14,8 @@ import React from 'react'; import {View} from 'react-native'; type AvatarIconProps = AvatarPrimitivesCommonProps & { - /** Icon asset to render as the avatar. */ avatarSource: IconAsset; - - /** Test ID used for the fallback avatar. */ fallbackAvatarTestID: string; - - /** Styles for View wrapping Icon / Image. */ iconContainerStyles?: StyleProp; /** Fill and background colors for the icon, or null to use the defaults. */ @@ -29,7 +24,6 @@ type AvatarIconProps = AvatarPrimitivesCommonProps & { /** The fill color for the icon */ fill?: string; - /** Additional styles for Icon */ iconAdditionalStyles?: StyleProp; }; diff --git a/src/components/Avatar/primitives/AvatarLetter.tsx b/src/components/Avatar/primitives/AvatarLetter.tsx index 156ccfec2d24..d2c61e879e0a 100644 --- a/src/components/Avatar/primitives/AvatarLetter.tsx +++ b/src/components/Avatar/primitives/AvatarLetter.tsx @@ -29,7 +29,6 @@ type AvatarLetterProps = AvatarPrimitivesCommonProps & { /** Styles for View wrapping the initials. */ containerStyles?: StyleProp; - /** Additional styles for the wrapping View. */ containerAdditionalStyles?: StyleProp; }; diff --git a/src/components/Avatar/tooltips/AvatarTooltip.tsx b/src/components/Avatar/tooltips/AvatarTooltip.tsx index 306f53ebf4ca..c22547919243 100644 --- a/src/components/Avatar/tooltips/AvatarTooltip.tsx +++ b/src/components/Avatar/tooltips/AvatarTooltip.tsx @@ -18,7 +18,6 @@ type AvatarTooltipProps = PropsWithChildren<{ /** Display name shown when the account has no personal details yet. Falls back to `avatar.name` */ fallbackDisplayName?: string; - /** Style for the wrapper View */ style?: StyleProp; }>; diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts index a6c45a4ac1e1..3203e5316d72 100644 --- a/src/components/Avatar/types.ts +++ b/src/components/Avatar/types.ts @@ -19,7 +19,6 @@ type AvatarCommonProps = { /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; - /** Set the size of Avatar */ size?: AvatarSizeName; /** Test ID for the Avatar component */ @@ -27,7 +26,6 @@ type AvatarCommonProps = { }; type AvatarPrimitivesCommonProps = { - /** Size of Avatar */ size: AvatarSizeName; /** Shape of the avatar: round for users, rounded square for workspaces */ diff --git a/src/components/AvatarButtonWithIcon.tsx b/src/components/AvatarButtonWithIcon.tsx index 7b86ef5fe8d7..bf809725eaf1 100644 --- a/src/components/AvatarButtonWithIcon.tsx +++ b/src/components/AvatarButtonWithIcon.tsx @@ -29,13 +29,8 @@ type AvatarButtonWithIconProps = WithSentryLabel & { /** Style applied to the avatar. Defaults to centering it within the parent. */ avatarStyle?: StyleProp; - /** Executed on click */ onPress: () => void; - - /** Ref of the anchor */ anchorRef?: RefObject; - - /** The avatar to display. */ avatar: React.ReactNode; /** Size of the displayed avatar. Keeps the button footprint even when `avatar` is empty. */ @@ -44,7 +39,6 @@ type AvatarButtonWithIconProps = WithSentryLabel & { /** Additional style props for disabled picker */ disabledStyle?: StyleProp; - /** Additional style props for the edit icon */ editIconStyle?: StyleProp; /** The type of action that's pending */ diff --git a/src/components/AvatarCropModal/AvatarCropView.tsx b/src/components/AvatarCropModal/AvatarCropView.tsx index 94c1c33b49b7..2066cd569d00 100644 --- a/src/components/AvatarCropModal/AvatarCropView.tsx +++ b/src/components/AvatarCropModal/AvatarCropView.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -37,7 +37,6 @@ type AvatarCropViewProps = { /** Link to image for cropping */ imageUri?: string; - /** Name of the image */ imageName?: string; /** Type of the image file */ diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx index 314a64226bd7..9fbc8270cb45 100644 --- a/src/components/AvatarCropModal/ImageCropView.tsx +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -23,10 +23,7 @@ type ImageCropViewProps = { /** Size of the image container that will be rendered */ containerSize?: number; - /** The height of the selected image */ originalImageHeight: SharedValue; - - /** The width of the selected image */ originalImageWidth: SharedValue; /** The rotation value of the selected image */ diff --git a/src/components/AvatarPageFooter.tsx b/src/components/AvatarPageFooter.tsx index 1fad12b392b3..bf2a162757e9 100644 --- a/src/components/AvatarPageFooter.tsx +++ b/src/components/AvatarPageFooter.tsx @@ -6,7 +6,7 @@ import type {TranslationPaths} from '@src/languages/types'; import React from 'react'; -import Button from './ButtonComposed'; +import Button from './Button'; import DotIndicatorMessage from './DotIndicatorMessage'; import FixedFooter from './FixedFooter'; diff --git a/src/components/AvatarSelector.tsx b/src/components/AvatarSelector.tsx index 449a0bb88221..ab0309d3e6f4 100644 --- a/src/components/AvatarSelector.tsx +++ b/src/components/AvatarSelector.tsx @@ -18,7 +18,6 @@ import Text from './Text'; import UserInitialsAvatar from './UserInitialsAvatar'; type AvatarSelectorProps = { - /** Currently selected avatar ID */ selectedID?: string; /** Called when an avatar is selected */ diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index fd0c363bcacd..f7b7a8bf6c93 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -54,7 +54,6 @@ import type DisplayNamesProps from './DisplayNames/types'; import type {TransactionListItemType} from './Search/SearchList/ListItem/types'; import ReportAvatar from './Avatar/connected/ReportAvatar'; -import {getButtonRole} from './Button/utils'; import DisplayNames from './DisplayNames'; import Icon from './Icon'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; @@ -62,16 +61,12 @@ import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; type AvatarWithDisplayNameProps = { - /** The report currently being looked at */ report: OnyxEntry; - - /** The size of the avatar */ size?: ValueOf; /** Whether if it's an unauthenticated user */ isAnonymous?: boolean; - /** Whether we should enable detail page navigation */ shouldEnableDetailPageNavigation?: boolean; /** Whether the avatar is pressable to open the actor details */ @@ -92,13 +87,8 @@ type AvatarWithDisplayNameProps = { /** Color of the secondary avatar border, usually should match the container background */ avatarBorderColor?: ColorValue; - /** The style of the custom display name text */ customDisplayNameStyle?: TextStyle; - - /** The style of the parent navigation subtitle text */ parentNavigationSubtitleTextStyles?: StyleProp; - - /** The style of the parent navigation status container */ parentNavigationStatusContainerStyles?: StyleProp; }; @@ -199,6 +189,7 @@ function AvatarWithDisplayName({ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST) ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const policy = usePolicy(report?.policyID); const theme = useTheme(); const styles = useThemeStyles(); @@ -212,7 +203,7 @@ function AvatarWithDisplayName({ const isReportArchived = useReportIsArchived(report?.reportID); const title = getReportName(report, getReportNameFromNames(derivedReportNames, report?.reportID)); const isParentReportArchived = useReportIsArchived(report?.parentReportID); - const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, true, isReportArchived); + const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, true, isReportArchived); const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived); const isMoneyRequestOrReport = isMoneyRequestReport(report) || isMoneyRequest(report) || isTrackExpenseReport(report) || isInvoiceReport(report); const ownerPersonalDetails = getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); @@ -222,7 +213,7 @@ function AvatarWithDisplayName({ 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); + const shouldShowReportTitleEditButton = shouldEnableDetailPageNavigation && !isSmallScreenWidth && canEditReportTitle(report, policy, currentUserAccountID, rules); const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const humanAgentAccountID = getHumanAgentAccountIDFromReportAction(parentReportAction); @@ -315,7 +306,7 @@ function AvatarWithDisplayName({ sentryLabel={CONST.SENTRY_LABEL.AVATAR_WITH_DISPLAY_NAME.SHOW_ACTOR_DETAILS} onPress={showActorDetails} accessibilityLabel={title} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} > {multipleAvatars} diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 9b1f6bf4961e..9e24710bb78d 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -47,31 +47,23 @@ type MenuItem = { }; type AvatarWithImagePickerProps = Omit & { - /** Additional style props */ style?: StyleProp; /** Current avatar source. Used to clear the validation error whenever the avatar changes. */ source?: AvatarSource; - /** Executed once an image has been selected */ onImageSelected?: (file: File | CustomRNImageManipulatorResult) => void; - - /** Execute when the user taps "remove" */ onImageRemoved?: () => void; - - /** Whether we are using the default avatar */ isUsingDefaultAvatar?: boolean; /** Image crop vector mask */ editorMaskImage?: IconAsset; - /** Additional style object for the error row */ errorRowStyles?: StyleProp; /** A function to run when the X button next to the error is clicked */ onErrorClose?: () => void; - /** The errors to display */ errors?: OnyxCommon.Errors | null; /** If set, the AvatarWithImagePicker will show a "View Photo" option and use this callback on press */ diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 0b31201ce8bd..458bd2e4ba13 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -16,10 +16,7 @@ import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; type BadgeProps = { - /** Is Success type */ success?: boolean; - - /** Is Error type */ error?: boolean; /** Whether badge uses strong (filled) style instead of outlined */ @@ -37,10 +34,7 @@ type BadgeProps = { /** Text to display in the Badge */ environment?: string; - /** Styles for Badge */ badgeStyles?: StyleProp; - - /** Styles for Badge Text */ textStyles?: StyleProp; /** Callback to be called on onPress */ diff --git a/src/components/BankAccountVerificationView.tsx b/src/components/BankAccountVerificationView.tsx index f1f9c1532701..44ab3ecaaa3b 100644 --- a/src/components/BankAccountVerificationView.tsx +++ b/src/components/BankAccountVerificationView.tsx @@ -17,7 +17,7 @@ import type {ValueOf} from 'type-fest'; import React from 'react'; import BlockingView from './BlockingViews/BlockingView'; -import Button from './ButtonComposed'; +import Button from './Button'; import LottieAnimations from './LottieAnimations'; type BankAccountVerificationViewProps = { diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index cda5898f475c..2a79ec92e538 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -14,7 +14,7 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import React, {memo} from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import Hoverable from './Hoverable'; import Icon from './Icon'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; @@ -35,7 +35,6 @@ type BannerProps = { /** Should this component render the left-aligned exclamation icon? */ shouldShowIcon?: boolean; - /** Should this component render a close button? */ shouldShowCloseButton?: boolean; /** Should this component render the text as HTML? */ @@ -56,7 +55,6 @@ type BannerProps = { /** Whether to display button in the banner */ shouldShowButton?: boolean; - /** Callback called when pressing the button */ onButtonPress?: () => void; /** Custom action content rendered in the right side of the banner. Overrides the configured `shouldShowButton` when provided. */ diff --git a/src/components/BaseMiniContextMenuItem.tsx b/src/components/BaseMiniContextMenuItem.tsx index 6cfc21bd5b88..63b5b802c157 100644 --- a/src/components/BaseMiniContextMenuItem.tsx +++ b/src/components/BaseMiniContextMenuItem.tsx @@ -26,9 +26,6 @@ type BaseMiniContextMenuItemProps = WithSentryLabel & { */ tooltipText: string; - /** - * Callback to fire on press - */ onPress: () => void; /** @@ -45,9 +42,6 @@ type BaseMiniContextMenuItemProps = WithSentryLabel & { */ shouldPreventDefaultFocusOnPress?: boolean; - /** - * Reference to the outer element - */ ref?: PressableRef; }; diff --git a/src/components/BaseVacationDelegateSelectionComponent.tsx b/src/components/BaseVacationDelegateSelectionComponent.tsx index 9bc2723f7901..a383b33d00df 100644 --- a/src/components/BaseVacationDelegateSelectionComponent.tsx +++ b/src/components/BaseVacationDelegateSelectionComponent.tsx @@ -26,25 +26,15 @@ import UserListItem from './SelectionList/ListItem/UserListItem'; import SelectionList from './SelectionList/SelectionListWithSections'; type BaseVacationDelegateSelectionComponentProps = { - /** Current vacation delegate */ vacationDelegate?: BaseVacationDelegate; - - /** Callback when a row is selected */ onSelectRow: (option: Participant) => void; - - /** Title for the header */ headerTitle: string; - - /** Function to call when the back button is pressed */ onBackButtonPress?: () => void; /** Message to display when the user can't set a vacation delegate */ cannotSetDelegateMessage: string; - /** Additional logins to exclude from the list */ additionalExcludeLogins?: Record; - - /** Whether to include the current user in the list */ includeCurrentUser?: boolean; }; @@ -61,7 +51,7 @@ function BaseVacationDelegateSelectionComponent({ const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const currentVacationDelegate = vacationDelegate?.delegate ?? ''; const initialVacationDelegate = useInitialSelection(currentVacationDelegate || undefined, {resetOnFocus: true}); @@ -230,7 +220,7 @@ function BaseVacationDelegateSelectionComponent({ }} textInputOptions={textInputOptions} shouldShowLoadingPlaceholder={!areOptionsInitialized} - isLoadingNewOptions={!!isSearchingForReports} + isLoadingNewOptions={!!isSearchingForUsers} searchValueForFocusSync={debouncedSearchTerm} initiallyFocusedItemKey={initialVacationDelegate ? `vacationDelegate-${pinnedDelegateLogin}` : undefined} initialScrollIndex={0} diff --git a/src/components/BaseWidgetItem.tsx b/src/components/BaseWidgetItem.tsx index c6157034ec43..eda476825450 100644 --- a/src/components/BaseWidgetItem.tsx +++ b/src/components/BaseWidgetItem.tsx @@ -11,7 +11,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import Icon from './Icon'; import {PressableWithoutFeedback} from './Pressable'; import Text from './Text'; @@ -19,12 +19,8 @@ import Text from './Text'; const ICON_SIZE = variables.iconSizeNormal; type BaseWidgetItemProps = { - /** Icon to display */ icon: IconAsset; - /** Background color for the icon container */ - iconBackgroundColor: string; - /** Primary title text */ title: string; @@ -34,17 +30,13 @@ type BaseWidgetItemProps = { /** Text for the CTA button */ ctaText: string; - /** Callback when CTA is pressed */ onCtaPress: () => void; - /** Optional: fill color for the icon (defaults to white) */ - iconFill?: string; - /** The visual variant of the CTA button */ buttonVariant?: ButtonVariant; }; -function BaseWidgetItem({icon, iconBackgroundColor, title, subtitle, ctaText, onCtaPress, iconFill, buttonVariant}: BaseWidgetItemProps) { +function BaseWidgetItem({icon, title, subtitle, ctaText, onCtaPress, buttonVariant}: BaseWidgetItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -58,12 +50,12 @@ function BaseWidgetItem({icon, iconBackgroundColor, title, subtitle, ctaText, on > {({hovered}) => ( - + diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index b3090bdba096..1d8895df9991 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -12,7 +12,7 @@ import type {ValueOf} from 'type-fest'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; type BigNumberPadProps = { /** Callback to inform parent modal with key pressed */ @@ -24,7 +24,6 @@ type BigNumberPadProps = { /** Used to locate this view from native classes. */ id?: string; - /** Whether long press is disabled */ isLongPressDisabled?: boolean; }; diff --git a/src/components/BiometricsTestToolRow.tsx b/src/components/BiometricsTestToolRow.tsx index dfe43b214994..2424c7546d7e 100644 --- a/src/components/BiometricsTestToolRow.tsx +++ b/src/components/BiometricsTestToolRow.tsx @@ -1,6 +1,5 @@ import useBiometricRegistrationStatus, {REGISTRATION_STATUS} from '@hooks/useBiometricRegistrationStatus'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication'; @@ -12,7 +11,8 @@ import ROUTES from '@src/ROUTES'; import React, {useState} from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; +import ButtonDisabledWhenOffline from './Button/composed/ButtonDisabledWhenOffline'; import {useMultifactorAuthentication} from './MultifactorAuthentication/Context'; import TestToolRow from './TestToolRow'; @@ -21,7 +21,6 @@ function BiometricsTestToolRow() { const {translate} = useLocalize(); const {executeScenario} = useMultifactorAuthentication(); const {localCredentialID, isCurrentDeviceRegistered, otherDeviceCount, registrationStatus} = useBiometricRegistrationStatus(); - const {isOffline} = useNetwork(); const [isMFARevokeLoading, setIsMFARevokeLoading] = useState(false); const statusTextMap = { @@ -35,9 +34,8 @@ function BiometricsTestToolRow() { return ( - + {isCurrentDeviceRegistered && !!localCredentialID && ( - + )} diff --git a/src/components/BlockingViews/BlockingView.tsx b/src/components/BlockingViews/BlockingView.tsx index 0e2cfacf6193..c7e4d921c95d 100644 --- a/src/components/BlockingViews/BlockingView.tsx +++ b/src/components/BlockingViews/BlockingView.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import Lottie from '@components/Lottie'; import type DotLottieAnimation from '@components/LottieAnimations/types'; @@ -57,25 +57,15 @@ type BaseBlockingViewProps = { /** Function to call when pressing the CTA button. The button only renders when this and `buttonTranslationKey` are both provided */ onButtonPress?: () => void; - /** Whether we should embed the link with subtitle */ shouldEmbedLinkWithSubtitle?: boolean; - - /** Render custom subtitle */ CustomSubtitle?: React.ReactElement; /** Determines how the image should be resized to fit its container */ contentFitImage?: ImageContentFit; - /** Additional styles to apply to the container */ containerStyle?: StyleProp; - - /** Whether to add bottom safe area padding to the view. */ addBottomSafeAreaPadding?: boolean; - - /** Accessibility label for the view */ accessibilityLabel?: string; - - /** Whether to add bottom safe area padding to the content. */ addOfflineIndicatorBottomSafeAreaPadding?: boolean; /** A testing ID that can be applied to the element on the page */ @@ -86,10 +76,7 @@ type BlockingViewIconProps = { /** Expensicon for the page */ icon: React.FC | ImageSourcePropType; - /** The custom icon width */ iconWidth?: number; - - /** The custom icon height */ iconHeight?: number; /** Color for the icon (should be from theme) */ @@ -100,10 +87,7 @@ type BlockingViewAnimationProps = { /** Animation for the page */ animation: DotLottieAnimation; - /** Style for the animation */ animationStyles?: StyleProp; - - /** Style for the animation on web */ animationWebStyle?: WebStyle; }; diff --git a/src/components/BlockingViews/FullPageErrorView.tsx b/src/components/BlockingViews/FullPageErrorView.tsx index 649a9e9444b7..6ffb8defccf0 100644 --- a/src/components/BlockingViews/FullPageErrorView.tsx +++ b/src/components/BlockingViews/FullPageErrorView.tsx @@ -14,10 +14,7 @@ import BlockingView from './BlockingView'; import ForceFullScreenView from './ForceFullScreenView'; type FullPageErrorViewProps = { - /** TestID for test */ testID?: string; - - /** Child elements */ children?: React.ReactNode; /** If true, child components are replaced with a blocking "error page" view */ @@ -29,7 +26,6 @@ type FullPageErrorViewProps = { /** The subtitle text to be displayed */ subtitle?: string; - /** Whether we should force the full page view */ shouldForceFullScreen?: boolean; /** The style of the subtitle message */ diff --git a/src/components/BlockingViews/FullPageNotFoundView.tsx b/src/components/BlockingViews/FullPageNotFoundView.tsx index 89fa92a3923f..c9c93a18c637 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.tsx +++ b/src/components/BlockingViews/FullPageNotFoundView.tsx @@ -21,10 +21,7 @@ import BlockingView from './BlockingView'; import ForceFullScreenView from './ForceFullScreenView'; type FullPageNotFoundViewProps = { - /** TestID for test */ testID?: string; - - /** Child elements */ children?: React.ReactNode; /** If true, child components are replaced with a blocking "not found" view */ @@ -54,7 +51,6 @@ type FullPageNotFoundViewProps = { /** Function to call when pressing the navigation link */ onLinkPress?: () => void; - /** Whether we should force the full page view */ shouldForceFullScreen?: boolean; /** The style of the subtitle message */ @@ -63,10 +59,7 @@ type FullPageNotFoundViewProps = { /** Whether we should display the button that opens new SearchRouter */ shouldDisplaySearchRouter?: boolean; - /** Whether to add bottom safe area padding to the view. */ addBottomSafeAreaPadding?: boolean; - - /** Whether to add bottom safe area padding to the content. */ addOfflineIndicatorBottomSafeAreaPadding?: boolean; }; diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx index cd1e7080a6f6..9f5fb003b683 100644 --- a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx +++ b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx @@ -10,10 +10,7 @@ import React from 'react'; import BlockingView from './BlockingView'; type FullPageOfflineBlockingViewProps = ChildrenProps & { - /** Whether to add bottom safe area padding to the view. */ addBottomSafeAreaPadding?: boolean; - - /** Whether to add bottom safe area padding to the content. */ addOfflineIndicatorBottomSafeAreaPadding?: boolean; }; diff --git a/src/components/BookCallButton.tsx b/src/components/BookCallButton.tsx index ba248006273d..8543f0e41016 100644 --- a/src/components/BookCallButton.tsx +++ b/src/components/BookCallButton.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import UserAvatar from './Avatar/UserAvatar'; -import Button from './ButtonComposed'; +import Button from './Button'; import {usePersonalDetails} from './OnyxListItemProvider'; type BookCallButtonProps = { diff --git a/src/components/BookTravelButton.tsx b/src/components/BookTravelButton.tsx index b4c218d125a9..600815723502 100644 --- a/src/components/BookTravelButton.tsx +++ b/src/components/BookTravelButton.tsx @@ -7,12 +7,14 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; +import usePrimaryContactMethod from '@hooks/usePrimaryContactMethod'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useVerifyAccountAndResume from '@hooks/useVerifyAccountAndResume'; -import {cleanupTravelProvisioningSession, requestTravelAccess, setTravelProvisioningNextStep} from '@libs/actions/Travel'; +import {cleanupTravelProvisioningSession, requestTravelAccess} from '@libs/actions/Travel'; import {isEmailPublicDomain} from '@libs/LoginUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; import {getActivePolicies, getAdminsPrivateEmailDomains, hasAcceptedTravelTerms, isPaidGroupPolicy, isWorkspaceProvisionedForTravel} from '@libs/PolicyUtils'; @@ -27,24 +29,18 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ReactElement} from 'react'; -import {emailSelector} from '@selectors/Session'; import {Str} from 'expensify-common'; import React, {useEffect, useState} from 'react'; -import Button from './ButtonComposed'; +import Button from './Button'; import DotIndicatorMessage from './DotIndicatorMessage'; import RenderHTML from './RenderHTML'; type BookTravelButtonProps = WithSentryLabel & { text: string; activePolicyID?: string; - - /** Whether to render the error message below the button */ shouldRenderErrorMessageBelowButton?: boolean; - - /** Function to set the shouldScrollToBottom state */ setShouldScrollToBottom?: (shouldScrollToBottom: boolean) => void; - shouldShowVerifyAccountModal?: boolean; /** Whether to render a large button */ @@ -71,26 +67,43 @@ function BookTravelButton({ const illustrations = useMemoizedLazyIllustrations(['RocketDude']); const {translate} = useLocalize(); const {environmentURL} = useEnvironment(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const isUserValidated = account?.validated ?? false; - const primaryLogin = account?.primaryLogin ?? ''; - const policy = usePolicy(activePolicyID); const blockIfDefaultWorkspaceLacksTravel = useDefaultWorkspaceTravelGuard(); const [errorMessage, setErrorMessage] = useState(''); const [travelSettings] = useOnyx(ONYXKEYS.NVP_TRAVEL_SETTINGS); - const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - const primaryContactMethod = primaryLogin ?? sessionEmail ?? ''; + const primaryContactMethod = usePrimaryContactMethod(); const {isBetaEnabled} = usePermissions(); const {showConfirmModal} = useConfirmModal(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const activePolicies = getActivePolicies(policies, currentUserLogin); const groupPaidPolicies = activePolicies.filter((activePolicy) => activePolicy.type !== CONST.POLICY.TYPE.PERSONAL && isPaidGroupPolicy(activePolicy)); + const {isUserValidated, verifyAccountAndResume} = useVerifyAccountAndResume((resumeBooking?: () => void) => resumeBooking?.()); + + const completeTravelAccessRequest = () => { + if (shouldShowVerifyAccountModal) { + showConfirmModal({ + title: translate('travel.verifyCompany.title'), + titleStyles: styles.textHeadlineH1, + titleContainerStyles: styles.mb2, + prompt: translate('travel.verifyCompany.message'), + promptStyles: styles.mb2, + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + image: illustrations.RocketDude, + imageStyles: StyleUtils.getBackgroundColorStyle(colors.ice600), + }); + } + if (!travelSettings?.lastTravelSignupRequestTime) { + requestTravelAccess(); + } + }; + const navigateToPublicDomainError = () => { const dynamicSuffix = getPolicyDynamicSuffix(DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR, activePolicyID); - Navigation.navigate(createDynamicRoute(dynamicSuffix)); + Navigation.navigate(buildDynamicRoute(dynamicSuffix)); }; useEffect(() => { @@ -120,7 +133,7 @@ function BookTravelButton({ // The primary login of the user is where Spotnana sends the emails with booking confirmations, itinerary etc. It can't be a phone number. if (!primaryContactMethod || Str.isSMSLogin(primaryContactMethod)) { - const phoneErrorMethodsRoute = `${environmentURL}/${createDynamicRoute(DYNAMIC_ROUTES.CONTACT_METHODS.path)}`; + const phoneErrorMethodsRoute = `${environmentURL}/${buildDynamicRoute(DYNAMIC_ROUTES.CONTACT_METHODS.path)}`; setErrorMessage(); return; } @@ -141,7 +154,7 @@ function BookTravelButton({ } if (groupPaidPolicies.length < 1) { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_UPGRADE.path)); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.TRAVEL_UPGRADE.path)); return; } @@ -162,38 +175,18 @@ function BookTravelButton({ // Legacy request-access path for not-yet-provisioned workspaces when the self-serve provisioning beta is off. if (!isPolicyProvisioned && !isBetaEnabled(CONST.BETAS.IS_TRAVEL_VERIFIED)) { if (!isUserValidated) { - Navigation.navigate(createDynamicRoute(getPolicyDynamicSuffix(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT, activePolicyID))); + verifyAccountAndResume(completeTravelAccessRequest); return; } - if (shouldShowVerifyAccountModal) { - showConfirmModal({ - title: translate('travel.verifyCompany.title'), - titleStyles: styles.textHeadlineH1, - titleContainerStyles: styles.mb2, - prompt: translate('travel.verifyCompany.message'), - promptStyles: styles.mb2, - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - image: illustrations.RocketDude, - imageStyles: StyleUtils.getBackgroundColorStyle(colors.ice600), - }); - } - if (!travelSettings?.lastTravelSignupRequestTime) { - requestTravelAccess(); - } + completeTravelAccessRequest(); return; } // Hand off to the enablement stepper, which computes and collects only the steps this workspace still needs. cleanupTravelProvisioningSession(); const enableTravelRoute = ROUTES.TRAVEL_ENABLE.getRoute(activePolicyID ?? String(CONST.DEFAULT_NUMBER_ID)); - // EnableTravel's own entry-mount effect would catch this and redirect regardless (it also has to, to - // protect a direct/deep link straight into the stepper), but checking here too avoids a visible URL - // blink: without this, the button would navigate to the stepper's URL first, then immediately get - // replaced with the verify URL a render later. if (!isUserValidated) { - setTravelProvisioningNextStep(enableTravelRoute); - Navigation.navigate(createDynamicRoute(getPolicyDynamicSuffix(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT, activePolicyID))); + verifyAccountAndResume(() => Navigation.navigate(enableTravelRoute)); return; } Navigation.navigate(enableTravelRoute); diff --git a/src/components/BrokenConnectionDescription.tsx b/src/components/BrokenConnectionDescription.tsx index 56a665ae900d..8312d6e94281 100644 --- a/src/components/BrokenConnectionDescription.tsx +++ b/src/components/BrokenConnectionDescription.tsx @@ -19,7 +19,6 @@ type BrokenConnectionDescriptionProps = { /** Transaction id of the corresponding report */ transactionID: string | undefined; - /** Current report */ report: OnyxEntry; /** Policy which the report is tied to */ diff --git a/src/components/ButtonComposed/Button.tsx b/src/components/Button/Button.tsx similarity index 96% rename from src/components/ButtonComposed/Button.tsx rename to src/components/Button/Button.tsx index 5365f18c45f0..29e62ca71729 100644 --- a/src/components/ButtonComposed/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,5 +1,4 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import {getButtonRole} from '@components/Button/utils'; import type {PressableRef} from '@components/Pressable/GenericPressable/types'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -135,13 +134,14 @@ function Button({ return ( setIsHovered(true) : undefined} onHoverOut={!isDisabled || !stayNormalOnDisable ? () => setIsHovered(false) : undefined} onPress={(event) => { - if (event?.type === 'click') { - const currentTarget = event?.currentTarget as HTMLElement; - currentTarget?.blur(); + if (event?.type === 'click' && event.currentTarget instanceof HTMLElement) { + event.currentTarget.blur(); } if (enableHapticFeedback) { diff --git a/src/components/Button/ButtonDisabledWhenOffline.tsx b/src/components/Button/ButtonDisabledWhenOffline.tsx deleted file mode 100644 index 5ec1cc636bb7..000000000000 --- a/src/components/Button/ButtonDisabledWhenOffline.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type {ButtonProps} from '@components/ButtonComposed'; -import Button from '@components/ButtonComposed'; - -import useNetwork from '@hooks/useNetwork'; - -import React from 'react'; - -type ButtonDisabledWhenOfflineProps = ButtonProps & { - disabledWhenOffline?: boolean; -}; - -function ButtonDisabledWhenOffline({disabledWhenOffline = true, ...props}: ButtonDisabledWhenOfflineProps) { - const {isOffline} = useNetwork(); - return ( - + ); +} +export default ButtonDisabledWhenOffline; diff --git a/src/components/ButtonComposed/composed/LinkButton.tsx b/src/components/Button/composed/LinkButton.tsx similarity index 81% rename from src/components/ButtonComposed/composed/LinkButton.tsx rename to src/components/Button/composed/LinkButton.tsx index f72d00b0f234..e432eb0c591f 100644 --- a/src/components/ButtonComposed/composed/LinkButton.tsx +++ b/src/components/Button/composed/LinkButton.tsx @@ -1,8 +1,8 @@ -import Button from '@components/ButtonComposed/Button'; -import ButtonIcon from '@components/ButtonComposed/primitives/ButtonIcon'; -import ButtonKeyboardShortcut from '@components/ButtonComposed/primitives/ButtonKeyboardShortcut'; -import type {ButtonTextProps} from '@components/ButtonComposed/primitives/ButtonText'; -import ButtonText from '@components/ButtonComposed/primitives/ButtonText'; +import Button from '@components/Button/Button'; +import ButtonIcon from '@components/Button/primitives/ButtonIcon'; +import ButtonKeyboardShortcut from '@components/Button/primitives/ButtonKeyboardShortcut'; +import type {ButtonTextProps} from '@components/Button/primitives/ButtonText'; +import ButtonText from '@components/Button/primitives/ButtonText'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -38,9 +38,9 @@ function LinkButtonText({children, numberOfLines, style, hoverStyle}: ButtonText /** * LinkButton – composable link-style button. * - * Drop-in replacement for ` + * ``` + */ +import React from 'react'; + +import ButtonComponent from './Button'; +import ButtonIcon from './primitives/ButtonIcon'; +import ButtonKeyboardShortcut from './primitives/ButtonKeyboardShortcut'; +import ButtonText from './primitives/ButtonText'; + +function ButtonBase(props: React.ComponentProps) { + return ; } -export default withNavigationFallback(Button); +const Button = Object.assign(ButtonBase, { + Icon: ButtonIcon, + Text: ButtonText, + KeyboardShortcut: ButtonKeyboardShortcut, +}); -export type {ButtonProps}; +export default Button; +export type {ButtonProps} from './types'; diff --git a/src/components/ButtonComposed/primitives/ButtonIcon.tsx b/src/components/Button/primitives/ButtonIcon.tsx similarity index 91% rename from src/components/ButtonComposed/primitives/ButtonIcon.tsx rename to src/components/Button/primitives/ButtonIcon.tsx index eb99bf9cfded..8f319afc9f46 100644 --- a/src/components/ButtonComposed/primitives/ButtonIcon.tsx +++ b/src/components/Button/primitives/ButtonIcon.tsx @@ -1,4 +1,4 @@ -import {useButtonContext} from '@components/ButtonComposed/context'; +import {useButtonContext} from '@components/Button/context'; import Icon from '@components/Icon'; import useTheme from '@hooks/useTheme'; @@ -12,7 +12,6 @@ import React from 'react'; import {View} from 'react-native'; type ButtonIconProps = { - /** The icon asset to display */ src: IconAsset; /** Icon color used on hover. */ @@ -53,4 +52,3 @@ function ButtonIcon({src, style, hoverFill, fill, accessibilityLabel}: ButtonIco } export default ButtonIcon; -export type {ButtonIconProps}; diff --git a/src/components/ButtonComposed/primitives/ButtonKeyboardShortcut.tsx b/src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx similarity index 93% rename from src/components/ButtonComposed/primitives/ButtonKeyboardShortcut.tsx rename to src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx index 29ebdbe2b059..2597e0f2669e 100644 --- a/src/components/ButtonComposed/primitives/ButtonKeyboardShortcut.tsx +++ b/src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx @@ -1,6 +1,5 @@ -import validateSubmitShortcut from '@components/Button/validateSubmitShortcut'; -import {useButtonContext} from '@components/ButtonComposed/context'; -import type {ButtonKeyboardShortcutProps} from '@components/ButtonComposed/types'; +import {useButtonContext} from '@components/Button/context'; +import type {ButtonKeyboardShortcutProps} from '@components/Button/types'; import withNavigationFallback from '@components/withNavigationFallback'; import useActiveElementRole from '@hooks/useActiveElementRole'; @@ -13,6 +12,8 @@ import type {GestureResponderEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; import {useCallback, useMemo} from 'react'; +import validateSubmitShortcut from './validateSubmitShortcut'; + const accessibilityRoles: string[] = Object.values(CONST.ROLE); /** diff --git a/src/components/Button/validateSubmitShortcut/types.ts b/src/components/Button/primitives/ButtonKeyboardShortcut/types.ts similarity index 100% rename from src/components/Button/validateSubmitShortcut/types.ts rename to src/components/Button/primitives/ButtonKeyboardShortcut/types.ts diff --git a/src/components/Button/validateSubmitShortcut/index.native.ts b/src/components/Button/primitives/ButtonKeyboardShortcut/validateSubmitShortcut.native.ts similarity index 100% rename from src/components/Button/validateSubmitShortcut/index.native.ts rename to src/components/Button/primitives/ButtonKeyboardShortcut/validateSubmitShortcut.native.ts diff --git a/src/components/Button/validateSubmitShortcut/index.ts b/src/components/Button/primitives/ButtonKeyboardShortcut/validateSubmitShortcut.ts similarity index 67% rename from src/components/Button/validateSubmitShortcut/index.ts rename to src/components/Button/primitives/ButtonKeyboardShortcut/validateSubmitShortcut.ts index d79eff81430a..e7e06d54b6b3 100644 --- a/src/components/Button/validateSubmitShortcut/index.ts +++ b/src/components/Button/primitives/ButtonKeyboardShortcut/validateSubmitShortcut.ts @@ -12,13 +12,13 @@ import type ValidateSubmitShortcut from './types'; */ const validateSubmitShortcut: ValidateSubmitShortcut = (isDisabled, isLoading, event) => { - const eventTarget = event?.target as HTMLInputElement; + const eventTarget = event?.target; if ( isDisabled || isLoading || - eventTarget.nodeName === CONST.ELEMENT_NAME.TEXTAREA || - (eventTarget.nodeName === CONST.ELEMENT_NAME.INPUT && eventTarget.autocomplete === CONST.AUTO_COMPLETE_VARIANTS.ONE_TIME_CODE) || - (eventTarget?.contentEditable === 'true' && eventTarget.ariaMultiLine) + eventTarget instanceof HTMLTextAreaElement || + (eventTarget instanceof HTMLInputElement && eventTarget.autocomplete === CONST.AUTO_COMPLETE_VARIANTS.ONE_TIME_CODE) || + (eventTarget instanceof HTMLElement && eventTarget.contentEditable === 'true' && eventTarget.ariaMultiLine) ) { return false; } diff --git a/src/components/ButtonComposed/primitives/ButtonText.tsx b/src/components/Button/primitives/ButtonText.tsx similarity index 91% rename from src/components/ButtonComposed/primitives/ButtonText.tsx rename to src/components/Button/primitives/ButtonText.tsx index b99dc3b38389..83e5634765fe 100644 --- a/src/components/ButtonComposed/primitives/ButtonText.tsx +++ b/src/components/Button/primitives/ButtonText.tsx @@ -1,4 +1,4 @@ -import {useButtonContext} from '@components/ButtonComposed/context'; +import {useButtonContext} from '@components/Button/context'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,10 +13,7 @@ type ButtonTextProps = { /** The text to display */ children: string; - /** The number of lines to display */ numberOfLines?: number; - - /** Additional text styles */ style?: StyleProp; /** Additional text styles to apply when the button is hovered. */ diff --git a/src/components/ButtonComposed/types.ts b/src/components/Button/types.ts similarity index 87% rename from src/components/ButtonComposed/types.ts rename to src/components/Button/types.ts index 71a089bdee06..29f022ae5812 100644 --- a/src/components/ButtonComposed/types.ts +++ b/src/components/Button/types.ts @@ -21,7 +21,6 @@ type ButtonEventsProps = { /** A function that is called when the button is released */ onPressOut?: (event: GestureResponderEvent) => void; - /** Callback that is called when mousedown is triggered. */ onMouseDown?: (e: React.MouseEvent) => void; /** Invoked on mount and layout changes */ @@ -41,10 +40,7 @@ type ButtonBehaviorProps = { /** Indicates whether the button should be disabled */ isDisabled?: boolean; - /** Should enable the haptic feedback? */ enableHapticFeedback?: boolean; - - /** Should disable the long press? */ isLongPressDisabled?: boolean; /** @@ -84,16 +80,12 @@ type ButtonStyleProps = { /** Any additional styles to pass to the content container wrapping all children (icons + text). */ contentContainerStyle?: StyleProp; - /** Additional styles to add to the component when it's disabled */ disabledStyle?: StyleProp; /** Additional hover styles applied on top of theme-derived hovers (default/success/danger). */ hoverStyles?: StyleProp; - /** Should we remove the border radius on a specific side? */ removeBorderRadius?: ValueOf; - - /** The size of the button */ size?: ValueOf; /** The visual variant of the button, which controls its color scheme */ @@ -110,15 +102,11 @@ type BaseButtonProps = WithSentryLabel & /** The testID of the button. Used to locate this view in end-to-end tests. */ testID?: string; - /** Accessibility label for the component */ accessibilityLabel?: string; /** Accessibility state to pass to the pressable */ accessibilityState?: AccessibilityState; - /** - * Reference to the outer element. - */ ref?: ForwardedRef; }; @@ -127,4 +115,4 @@ type ButtonProps = BaseButtonProps & { children: React.ReactNode; }; -export type {ButtonEventsProps, ButtonBehaviorProps, ButtonStyleProps, BaseButtonProps, ButtonProps, ButtonKeyboardShortcutProps}; +export type {ButtonProps, ButtonKeyboardShortcutProps}; diff --git a/src/components/Button/utils/index.ts b/src/components/Button/utils/index.ts deleted file mode 100644 index fd7407b6136a..000000000000 --- a/src/components/Button/utils/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import CONST from '@src/CONST'; - -import type {GetButtonRole} from './types'; - -const getButtonRole: GetButtonRole = () => CONST.ROLE.BUTTON; - -// eslint-disable-next-line import/prefer-default-export -export {getButtonRole}; diff --git a/src/components/Button/utils/index.web.ts b/src/components/Button/utils/index.web.ts deleted file mode 100644 index fd7407b6136a..000000000000 --- a/src/components/Button/utils/index.web.ts +++ /dev/null @@ -1,8 +0,0 @@ -import CONST from '@src/CONST'; - -import type {GetButtonRole} from './types'; - -const getButtonRole: GetButtonRole = () => CONST.ROLE.BUTTON; - -// eslint-disable-next-line import/prefer-default-export -export {getButtonRole}; diff --git a/src/components/Button/utils/types.ts b/src/components/Button/utils/types.ts deleted file mode 100644 index 4ebf9f93fb38..000000000000 --- a/src/components/Button/utils/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {Role} from 'react-native'; - -type GetButtonRole = (isNested: boolean) => Role | undefined; - -// eslint-disable-next-line import/prefer-default-export -export type {GetButtonRole}; diff --git a/src/components/ButtonComposed/index.tsx b/src/components/ButtonComposed/index.tsx deleted file mode 100644 index 9de24c4ef475..000000000000 --- a/src/components/ButtonComposed/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Button – a new Button built with composition API. - * - * Instead of a large flat props list (icon, text, shouldShowRightIcon, …), - * sub-components are composed as children: - * - * @example - * ```tsx - * import Button from '@components/Button'; - * import * as icons from '@expensify/react-native-expensify-icons'; - * - * - * ``` - * - * The old `Button` component is not affected – migration can be gradual. - */ -import React from 'react'; - -import ButtonComponent from './Button'; -import ButtonIcon from './primitives/ButtonIcon'; -import ButtonKeyboardShortcut from './primitives/ButtonKeyboardShortcut'; -import ButtonText from './primitives/ButtonText'; - -function ButtonBase(props: React.ComponentProps) { - return ; -} - -const Button = Object.assign(ButtonBase, { - Icon: ButtonIcon, - Text: ButtonText, - KeyboardShortcut: ButtonKeyboardShortcut, -}); - -export default Button; -export type {ButtonProps} from './types'; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 54bba0bf798a..dac2f080d1aa 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import InlineIcon from '@components/Icon/InlineIcon'; import PopoverMenu from '@components/PopoverMenu'; @@ -69,6 +69,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM disabledStyle, size = CONST.BUTTON_SIZE.MEDIUM, anchorAlignment = defaultAnchorAlignment, + shouldSwitchPositionIfOverflow = false, buttonRef, onPress, options, @@ -89,9 +90,11 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM shouldUseModalPaddingStyle = true, shouldUseShortForm = false, shouldUseOptionIcon = false, + headerTextStyles, stayNormalOnDisable = false, brickRoadIndicator, sentryLabel, + shouldPutHeaderTextAfterBackButton = false, } = props; const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'DotIndicator']); @@ -336,11 +339,13 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM setIsMenuVisible(false); } }} + headerStyles={headerTextStyles} anchorPosition={popoverAnchorPosition} shouldShowRadioButton={shouldShowRadioButton} anchorRef={dropdownAnchor} scrollContainerStyle={!shouldUseModalPaddingStyle && isSmallScreenWidth && styles.pt4} anchorAlignment={anchorAlignment} + shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} enableEdgeToEdgeBottomSafeAreaPadding headerText={menuHeaderText} @@ -369,6 +374,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM shouldCallAfterModalHide: true, })), }))} + shouldPutHeaderTextAfterBackButton={shouldPutHeaderTextAfterBackButton} /> )} diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 58197b6fe639..17f3cd1af072 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -52,11 +52,9 @@ type DropdownOption = WithSentryLabel & { backButtonText?: string; avatarSize?: ValueOf; shouldShow?: boolean; - /** Whether to show a loading spinner for this option */ shouldShowLoadingSpinnerIcon?: boolean; /** Whether to render a divider before this option */ addSeparatorBefore?: boolean; - /** The type of brick road indicator to show */ brickRoadIndicator?: ValueOf; /** Whether selecting this option should bypass the delete confirmation modal */ shouldSkipDeleteModal?: boolean; @@ -72,13 +70,11 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** The custom text to display on the main button instead of selected option */ customText?: string; - /** Text to display for the menu header */ menuHeaderText?: string; /** Callback to execute when the main button is pressed */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: TValueType) => void; - /** Callback to execute when a dropdown option is selected */ onOptionSelected?: (option: DropdownOption) => void; /** Callback when the options popover is shown */ @@ -93,7 +89,6 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Whether we should show a loading state for the main button */ isLoading?: boolean; - /** The size of the button */ size?: ValueOf; /** Should the confirmation button be disabled? */ @@ -102,13 +97,11 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Whether the button should stay visually normal even when disabled. */ stayNormalOnDisable?: boolean; - /** Additional styles to add to the component */ style?: StyleProp; /** Additional styles for the inner button when isSplitButton is false (wrapper uses style). */ buttonStyle?: StyleProp; - /** Additional styles to add to the component when it's disabled */ disabledStyle?: StyleProp; /** Menu options to display */ @@ -118,7 +111,9 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** The anchor alignment of the popover menu */ anchorAlignment?: AnchorAlignment; - /* ref for the button */ + /** Whether the popover menu should flip to the opposite side of the button when it doesn't fit, instead of being clamped to the window edge */ + shouldSwitchPositionIfOverflow?: boolean; + buttonRef?: RefObject; /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ @@ -130,10 +125,7 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Whether the dropdown menu should be shown even if it has only one option */ shouldAlwaysShowDropdownMenu?: boolean; - /** Additional style to add to the wrapper */ wrapperStyle?: StyleProp; - - /** Whether the button should use split style or not */ isSplitButton?: boolean; /** Whether to use keyboard shortcuts for confirmation or not */ @@ -142,7 +134,6 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Decides which index in menuItems should be selected */ defaultSelectedIndex?: number; - /** Whether to show a radio button on each item to indicate which one is currently selected */ shouldShowRadioButton?: boolean; /** Used to locate the component in the tests */ @@ -157,7 +148,6 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Icon for main button */ icon?: IconAsset; - /** Whether the popover content should be scrollable */ shouldPopoverUseScrollView?: boolean; /** Container style to be applied to the popover of the dropdown menu */ @@ -166,17 +156,19 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Whether to use modal padding style for the popover menu */ shouldUseModalPaddingStyle?: boolean; - /** Whether to use short form for the button */ shouldUseShortForm?: boolean; /** Whether to display the option icon when only one option is available */ shouldUseOptionIcon?: boolean; - /** The type of brick road indicator to show */ - brickRoadIndicator?: ValueOf; + /** Used to apply styles specifically to the header text */ + headerTextStyles?: StyleProp; - /** Reference to the outer element */ + brickRoadIndicator?: ValueOf; ref?: React.Ref; + + /** Whether to put the header text after the back button */ + shouldPutHeaderTextAfterBackButton?: boolean; }; type ButtonWithDropdownMenuRef = { diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index 4eba6ce597a0..0afa23a72485 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -25,13 +25,11 @@ const DEFAULT_ANCHOR_ALIGNMENT = { }; type CategoryPickerModalProps = { - /** Callback to close the modal */ onClose: () => void; /** The policy whose categories should be shown */ policyID: string | undefined; - /** Currently selected category */ selectedCategory?: string; /** Called when the user confirms a category selection */ diff --git a/src/components/CenteredModalLayout/index.tsx b/src/components/CenteredModalLayout/index.tsx index 895a2958958f..89183ed87bea 100644 --- a/src/components/CenteredModalLayout/index.tsx +++ b/src/components/CenteredModalLayout/index.tsx @@ -5,9 +5,6 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddi import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; import CONST from '@src/CONST'; @@ -44,12 +41,10 @@ type CenteredModalLayoutProps = { function CenteredModalLayout({children, width, height, onBackdropPress, contentStyle, addBottomSafeAreaPadding = true}: CenteredModalLayoutProps) { const styles = useThemeStyles(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowWidth, windowHeight} = useWindowDimensions(); + const {onboardingIsMediumOrLargerScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const shouldDockToBottom = !onboardingIsMediumOrLargerScreenWidth; - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); const safeAreaHorizontalPadding = useSafeAreaHorizontalPadding(); const safeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding: addBottomSafeAreaPadding && !isInLandscapeMode && shouldDockToBottom, diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx index 17acf6dd05c3..9f7a084447f2 100644 --- a/src/components/ChangeWorkspaceMenuSectionList.tsx +++ b/src/components/ChangeWorkspaceMenuSectionList.tsx @@ -19,7 +19,6 @@ type ChangeWorkspaceMenuSection = { /** The icon supplied with the section */ icon: IconAsset; - /** Translation key for the title */ titleTranslationKey: TranslationPaths; }; diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index f89b8e00a2c2..9dec8d814710 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -39,7 +39,6 @@ import type {CartesianChartProps, ChartDataPoint} from '..'; const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; type BarChartProps = CartesianChartProps & { - /** Callback when a bar is pressed */ onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 614aa459136b..b7a89cd62069 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -45,7 +45,6 @@ const DOT_HOVER_EXTRA_RADIUS = 2; const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0}; type LineChartProps = CartesianChartProps & { - /** Callback when a data point is pressed */ onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; }; diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 33ea00f34383..ac7fbb91ecac 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -21,7 +21,6 @@ import {Pie, PolarChart} from 'victory-native'; import PaddedPieSlice from './PaddedPieSlice'; type PieChartProps = ChartProps & { - /** Callback when a slice is pressed */ onSlicePress?: (dataPoint: ChartDataPoint, index: number) => void; /** Symbol/unit for value labels in tooltip (e.g., '$', '€'). */ diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index bc1782b02f0e..0c497dc1eecb 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -23,7 +23,6 @@ type ChartTooltipProps = { /** The width of the chart container */ chartWidth: number; - /** The initial tooltip position */ initialTooltipPosition: SharedValue<{x: number; y: number}>; }; diff --git a/src/components/Charts/components/ChartTooltipLayer.tsx b/src/components/Charts/components/ChartTooltipLayer.tsx index 51d69addcd22..35938865812f 100644 --- a/src/components/Charts/components/ChartTooltipLayer.tsx +++ b/src/components/Charts/components/ChartTooltipLayer.tsx @@ -10,7 +10,6 @@ import {scheduleOnRN} from 'react-native-worklets'; import ChartTooltip from './ChartTooltip'; type ChartTooltipLayerProps = { - /** SharedValue for the currently matched data index */ matchedIndex: SharedValue; /** DerivedValue that is true when the tooltip should be visible */ diff --git a/src/components/Charts/types/index.ts b/src/components/Charts/types/index.ts index e220ec1162fa..f70b26d8c50c 100644 --- a/src/components/Charts/types/index.ts +++ b/src/components/Charts/types/index.ts @@ -27,10 +27,7 @@ type UnitWithFallback = {value: string; fallback: string}; type UnitPosition = 'left' | 'right'; type ChartProps = { - /** Data points to display */ data: ChartDataPoint[]; - - /** Whether data is loading */ isLoading?: boolean; }; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index d814511d6bea..6001b4d719d2 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -24,7 +24,6 @@ type RequiredLabelProps = LabelComponent?: ComponentType; } | { - /** Component to display for label */ LabelComponent: ComponentType; /** Text that appears next to check box @@ -34,7 +33,6 @@ type RequiredLabelProps = }; type CheckboxWithLabelProps = RequiredLabelProps & { - /** Whether the checkbox is checked */ isChecked?: boolean; /** Called when the checkbox or label is pressed */ @@ -43,13 +41,11 @@ type CheckboxWithLabelProps = RequiredLabelProps & { /** Container styles */ style?: StyleProp; - /** Error text to display */ errorText?: string; /** Value for checkbox. This prop is intended to be set by FormProvider only */ value?: boolean; - /** The default value for the checkbox */ defaultValue?: boolean; /** The ID used to uniquely identify the input in a Form */ @@ -60,10 +56,7 @@ type CheckboxWithLabelProps = RequiredLabelProps & { // eslint-disable-next-line react/no-unused-prop-types shouldSaveDraft?: boolean; - /** An accessibility label for the checkbox */ accessibilityLabel?: string; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx b/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx index 7c90228a642d..b51a2c6fff63 100644 --- a/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx +++ b/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx @@ -1,8 +1,11 @@ import usePrevious from '@hooks/usePrevious'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useWindowDimensions from '@hooks/useWindowDimensions'; import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import variables from '@styles/variables'; + import type {LayoutChangeEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; @@ -15,8 +18,9 @@ import type {CollapsibleHeaderOnKeyboardProps} from './types'; const COLLAPSE_DURATION = 100; const RESTORE_DURATION = 300; // Assumed vertical space for the focused input field — used to reserve space above the keyboard. -const VERTICAL_SPACE_FOR_FOCUSED_INPUT = 120; +const VERTICAL_SPACE_FOR_FOCUSED_INPUT = variables.inputHeight + variables.inputPaddingBottom + variables.inputPaddingTop; const KEYBOARD_OPENING_PROGRESS_THRESHOLDS = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99]; +const MIN_HEADER_HEIGHT_ON_COLLAPSE = 8; function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboardProgress: number, requiredProgress: number[]): boolean { 'worklet'; @@ -32,7 +36,7 @@ function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboard * Intended for landscape mode on phones where the keyboard + header can leave no room for inputs. * Uses height animation (not translateY) so the freed space is reclaimed by the layout below. */ -function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: CollapsibleHeaderOnKeyboardProps) { +function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0, alwaysCollapseHeaderOnKeyboard = false}: CollapsibleHeaderOnKeyboardProps) { const isFocused = useIsFocused(); const prevIsFocused = usePrevious(isFocused); // JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0 @@ -45,15 +49,17 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co const {height: keyboardHeightSV, progress: keyboardProgressSV} = useReanimatedKeyboardAnimation(); const {windowWidth, windowHeight} = useWindowDimensions(); + const {top: topSafeAreaInset} = useSafeAreaInsets(); + const availableWindowHeight = windowHeight - topSafeAreaInset; const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); // Keep window dimensions and offset accessible on the UI thread. Stable refs, excluded from deps. - const windowHeightSV = useSharedValue(windowHeight); + const availableWindowHeightSV = useSharedValue(availableWindowHeight); const collapsibleHeaderOffsetSV = useSharedValue(collapsibleHeaderOffset); const isFocusedSV = useSharedValue(isFocused); const isInLandscapeModeSV = useSharedValue(isInLandscapeMode); useEffect(() => { - windowHeightSV.set(windowHeight); - }, [windowHeight, windowHeightSV]); + availableWindowHeightSV.set(availableWindowHeight); + }, [availableWindowHeight, availableWindowHeightSV]); useEffect(() => { collapsibleHeaderOffsetSV.set(collapsibleHeaderOffset); }, [collapsibleHeaderOffset, collapsibleHeaderOffsetSV]); @@ -80,9 +86,9 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co return; } - // First measurement, or content changed while header is fully open + // First measurement, or content changed while keyboard is fully open // (to skip onLayout calls triggered by our own height animation collapsing the view to 0) - if (naturalHeightRef.current === -1 || animatedHeight.get() >= naturalHeightRef.current) { + if (naturalHeightRef.current === -1 || (animatedHeight.get() >= naturalHeightRef.current && height !== naturalHeightRef.current)) { naturalHeightRef.current = height; naturalHeight.set(height); animatedHeight.set(height); @@ -124,9 +130,9 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co () => ({ keyboardHeight: keyboardHeightSV.get(), keyboardProgress: keyboardProgressSV.get(), - windowHeightValue: windowHeightSV.get(), + availableWindowHeightValue: availableWindowHeightSV.get(), }), - ({keyboardHeight, keyboardProgress, windowHeightValue}, previous) => { + ({keyboardHeight, keyboardProgress, availableWindowHeightValue}, previous) => { // If the screen is not focused, bail out if (!isFocusedSV.get() || !isInLandscapeModeSV.get()) { return; @@ -141,23 +147,26 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co // If the keyboard is closing, bail out const prevKeyboardProgress = previous?.keyboardProgress ?? 0; - if (prevKeyboardProgress >= keyboardProgress) { + if (prevKeyboardProgress > keyboardProgress) { return; } - // Only act when the keyboard is starting to open or reaching a threshold, not on every intermediate frame. + // Only act when the keyboard is starting to open, reaching a threshold or fully open, not on every intermediate frame. const isKeyboardStartingOpening = prevKeyboardProgress === 0 && keyboardProgress > 0; const isKeyboardOpeningAndReachingThreshold = isKeyboardOpeningAtGivenProgress(keyboardProgress, prevKeyboardProgress, KEYBOARD_OPENING_PROGRESS_THRESHOLDS); + const isKeyboardFullyOpen = keyboardProgress === 1; - if (!isKeyboardStartingOpening && !isKeyboardOpeningAndReachingThreshold) { + if (!isKeyboardStartingOpening && !isKeyboardOpeningAndReachingThreshold && !isKeyboardFullyOpen) { return; } - // keyboardHeight is negative when open (e.g. -291), so keyboardTop = windowHeightValue + keyboardHeight. + // keyboardHeight is negative when open (e.g. -291), so keyboardTop = availableWindowHeightValue + keyboardHeight. // Target header height: give the input exactly the space it needs above the keyboard, - // the header gets what remains. Clamped to [0, naturalHeight]. - const keyboardTop = windowHeightValue + keyboardHeight; - const targetHeight = Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get()); + // the header gets what remains. Clamped to [MIN_HEADER_HEIGHT_ON_COLLAPSE, naturalHeight]. + const keyboardTop = availableWindowHeightValue + keyboardHeight; + const targetHeight = alwaysCollapseHeaderOnKeyboard + ? MIN_HEADER_HEIGHT_ON_COLLAPSE + : Math.max(MIN_HEADER_HEIGHT_ON_COLLAPSE, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get()); const naturalHeightValue = naturalHeight.get(); if (targetHeight >= naturalHeightValue) { @@ -182,11 +191,11 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co // Inner wrapper slides the content upward during landscape keyboard collapse only. const innerStyle = useAnimatedStyle(() => { if (animatedHeight.get() >= naturalHeight.get()) { - return {}; + return {transform: [{translateY: 0}]}; } if (!isInLandscapeModeSV.get()) { - return {}; + return {transform: [{translateY: 0}]}; } return {transform: [{translateY: animatedHeight.get() - naturalHeight.get()}]}; diff --git a/src/components/CollapsibleHeaderOnKeyboard/types.ts b/src/components/CollapsibleHeaderOnKeyboard/types.ts index d5281b0d2d6e..7734ac5eb062 100644 --- a/src/components/CollapsibleHeaderOnKeyboard/types.ts +++ b/src/components/CollapsibleHeaderOnKeyboard/types.ts @@ -4,6 +4,12 @@ type CollapsibleHeaderOnKeyboardProps = { * component, keyboard, and focused input — e.g. a tab bar below the list. * The collapse target is reduced by this amount so those elements are not counted twice. */ collapsibleHeaderOffset?: number; + + /** + * If true, the header will always collapse on keyboard open, + * regardless if there is enough space for the input above the keyboard. + */ + alwaysCollapseHeaderOnKeyboard?: boolean; }; // eslint-disable-next-line import/prefer-default-export diff --git a/src/components/CollapsibleSection/index.tsx b/src/components/CollapsibleSection/index.tsx index 4d867f2b88e8..b4d7db361688 100644 --- a/src/components/CollapsibleSection/index.tsx +++ b/src/components/CollapsibleSection/index.tsx @@ -17,16 +17,9 @@ import {View} from 'react-native'; import Collapsible from './Collapsible'; type CollapsibleSectionProps = ChildrenProps & { - /** Title of the Collapsible section */ title: string; - - /** Style of title of the collapsible section */ titleStyle?: StyleProp; - - /** Style for the text */ textStyle?: StyleProp; - - /** Style for the wrapper view */ wrapperStyle?: StyleProp; /** Whether or not to show border between section title and expandable items */ diff --git a/src/components/ColumnsSettingsList.tsx b/src/components/ColumnsSettingsList.tsx index a34c474663bc..304de4ec2acb 100644 --- a/src/components/ColumnsSettingsList.tsx +++ b/src/components/ColumnsSettingsList.tsx @@ -14,7 +14,7 @@ import {View} from 'react-native'; import type {SearchCustomColumnIds} from './Search/types'; import type {ListItem} from './SelectionList/types'; -import Button from './ButtonComposed'; +import Button from './Button'; import DraggableList from './DraggableList'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; @@ -28,10 +28,7 @@ type ColumnItem = { /** Display label for the column */ text: string; - /** Column identifier value */ value: SearchCustomColumnIds; - - /** Unique key used for list rendering */ keyForList: SearchCustomColumnIds; /** Whether the column is currently enabled/visible */ @@ -48,7 +45,6 @@ type ColumnItem = { }; type ColumnsSettingsListProps = { - /** All available column IDs that can be displayed */ allColumns: SearchCustomColumnIds[]; /** The default set of selected columns when no customization has been applied */ diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index c1384ef0b693..7962200faf42 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -114,7 +114,7 @@ function Composer({ const fileURI = clipboardContent?.data; const baseFileName = fileURI?.split('/').pop() ?? 'file'; const {fileName: stem, fileExtension: originalFileExtension} = splitExtensionFromFileName(baseFileName); - const fileExtension = originalFileExtension || (mimeDb[mimeType].extensions?.[0] ?? 'bin'); + const fileExtension = originalFileExtension || (mimeDb[mimeType]?.extensions?.[0] ?? 'bin'); const fileName = `${stem}.${fileExtension}`; let file: FileObject = {uri: fileURI, name: fileName, type: mimeType, size: 0}; getFileSize(file.uri ?? '') diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 0b9429c7657f..8e5d2d2cd711 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -29,7 +29,7 @@ import {DeviceEventEmitter, StyleSheet} from 'react-native'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; -const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL]; +const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL, CONST.QA_EXPENSIFY_URL]; // Enable Markdown parsing. // On web we like to have the Text Input field always focused so the user can easily type a new chat diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 8eb2036fccac..e00420b8025d 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -59,16 +59,12 @@ type ComposerProps = Omit & /** Update selection position on change */ onSelectionChange?: (event: CustomSelectionChangeEvent) => void; - /** Selection Object */ selection?: TextSelection; - - /** Should we calculate the caret position */ shouldCalculateCaretPosition?: boolean; /** Function to check whether composer is covered up or not */ checkComposerVisibility?: () => boolean; - /** Whether the full composer is open */ isComposerFullSize?: boolean; /** Should make the input only scroll inside the element avoid scroll out to parent */ diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 913de5fbfabe..80b1e07c368c 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -18,8 +18,8 @@ import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from './ActivityIndicator'; -import Button from './ButtonComposed'; -import Header from './Header'; +import Button from './Button'; +import HeaderTitle from './HeaderTitle'; import Icon from './Icon'; import ImageSVG from './ImageSVG'; import {PressableWithoutFeedback} from './Pressable'; @@ -49,13 +49,8 @@ type ConfirmContentProps = { /** Subtitle shown between the title and the prompt. Stays fixed above the prompt when the prompt is scrollable. */ subtitle?: string | ReactNode; - /** Button variant */ buttonVariant?: ButtonVariant; - - /** Whether we should disable the confirm button when offline */ shouldDisableConfirmButtonWhenOffline?: boolean; - - /** Whether we should show the cancel button */ shouldShowCancelButton?: boolean; /** Icon to display above the title */ @@ -64,58 +59,26 @@ type ConfirmContentProps = { /** Fill color for the Icon */ iconFill?: string | false; - /** Icon width */ iconWidth?: number; - - /** Icon height */ iconHeight?: number; - - /** Should the icon be centered? */ shouldCenterIcon?: boolean; /** Whether to center the icon / text content */ shouldCenterContent?: boolean; - /** Whether to show the dismiss icon */ shouldShowDismissIcon?: boolean; - - /** Whether to stack the buttons */ shouldStackButtons?: boolean; - - /** Whether to reverse the order of the stacked buttons */ shouldReverseStackedButtons?: boolean; - - /** Styles for title */ titleStyles?: StyleProp; - - /** Styles for title container */ titleContainerStyles?: StyleProp; - - /** Styles for prompt */ promptStyles?: StyleProp; - - /** Styles for subtitle */ subtitleStyles?: StyleProp; - - /** Styles for view */ contentStyles?: StyleProp; - - /** Styles for icon */ iconAdditionalStyles?: StyleProp; - - /** Image to display with content */ image?: IconAsset; - - /** Styles for the image */ imageStyles?: StyleProp; - - /** Image width */ imageWidth?: number; - - /** Image height */ imageHeight?: number; - - /** Whether to fit the image to the container */ shouldFitImageToContainer?: boolean; /** Whether the modal is visible */ @@ -240,10 +203,9 @@ function ConfirmContent({ )} -
+ + {title} + {isTitleLoading && } {subtitleContent} diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index 1dbf454f2dc2..57b705f154bc 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -27,7 +27,6 @@ type ConfirmModalProps = { /** A callback to call when the form has been closed */ onCancel?: () => void; - /** A callback to call when backdrop is pressed */ onBackdropPress?: () => void; /** Modal visibility */ @@ -45,13 +44,8 @@ type ConfirmModalProps = { /** Subtitle shown between the title and the prompt. Stays fixed above the prompt when the prompt is scrollable. */ subtitle?: string | ReactNode; - /** Button variant */ buttonVariant?: ButtonVariant; - - /** Whether we should disable the confirm button when offline */ shouldDisableConfirmButtonWhenOffline?: boolean; - - /** Whether we should show the cancel button */ shouldShowCancelButton?: boolean; /** Callback method fired when the modal is hidden */ @@ -66,55 +60,25 @@ type ConfirmModalProps = { /** Fill color for the Icon */ iconFill?: string | false; - /** Icon width */ iconWidth?: number; - - /** Icon height */ iconHeight?: number; - - /** Should the icon be centered */ shouldCenterIcon?: boolean; - - /** Whether to show the dismiss icon */ shouldShowDismissIcon?: boolean; - - /** Styles for title container */ titleContainerStyles?: StyleProp; - - /** Styles for title */ titleStyles?: StyleProp; - - /** Styles for prompt */ promptStyles?: StyleProp; - - /** Styles for subtitle */ subtitleStyles?: StyleProp; - - /** Styles for icon */ iconAdditionalStyles?: StyleProp; /** Whether to center the icon / text content */ shouldCenterContent?: boolean; - /** Whether to stack the buttons */ shouldStackButtons?: boolean; - - /** Whether to reverse the order of the stacked buttons */ shouldReverseStackedButtons?: boolean; - - /** Image to display with content */ image?: IconAsset; - - /** Styles for the image */ imageStyles?: StyleProp; - - /** Image width */ imageWidth?: number; - - /** Image height */ imageHeight?: number; - - /** Whether to fit the image to the container */ shouldFitImageToContainer?: boolean; /** @@ -135,7 +99,6 @@ type ConfirmModalProps = { /** Whether to handle navigation back when modal show. */ shouldHandleNavigationBack?: boolean; - /** Whether to ignore the back handler during transition */ shouldIgnoreBackHandlerDuringTransition?: boolean; /** Merged into the modal container after default confirm styles (e.g. `width` overrides `variables.sideBarWidth` on wide screens). */ diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index 799b53c02586..2bec67d4e35e 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -14,7 +14,7 @@ import {StyleSheet, View} from 'react-native'; import type DotLottieAnimation from './LottieAnimations/types'; -import Button from './ButtonComposed'; +import Button from './Button'; import FixedFooter from './FixedFooter'; import ImageSVG from './ImageSVG'; import Lottie from './Lottie'; @@ -23,16 +23,9 @@ import ScrollView from './ScrollView'; import Text from './Text'; type ConfirmationPageProps = { - /** The asset to render */ illustration?: DotLottieAnimation | IconAsset; - - /** Heading of the confirmation page */ heading: string; - - /** Description of the confirmation page */ description?: React.ReactNode; - - /** Description component of the confirmation page */ descriptionComponent?: React.ReactNode; /** The text for the call to action */ @@ -59,34 +52,27 @@ type ConfirmationPageProps = { /** The text for the secondary button label */ secondaryButtonText?: string; - /** A function that is called when the secondary button is clicked on */ onSecondaryButtonPress?: () => void; - - /** Whether we should show a secondary confirmation button */ shouldShowSecondaryButton?: boolean; - /** Additional style for the heading */ + /** 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; - /** Additional style for the description */ descriptionStyle?: StyleProp; - - /** Additional style for the cta */ ctaStyle?: TextStyle; - - /** Additional style for the footer */ footerStyle?: ViewStyle; /** Component rendered inside the footer, above the buttons (e.g. an inline error message) */ footerComponent?: React.ReactNode; - /** Additional style for the container */ containerStyle?: ViewStyle; - - /** Additional style for the inner container */ innerContainerStyle?: ViewStyle; }; @@ -105,6 +91,8 @@ function ConfirmationPage({ secondaryButtonText = '', onSecondaryButtonPress = () => {}, shouldShowSecondaryButton = false, + isSecondaryButtonDisabled = false, + isSecondaryButtonLoading = false, headingStyle, illustrationStyle, descriptionStyle, @@ -175,6 +163,8 @@ function ConfirmationPage({ size={CONST.BUTTON_SIZE.LARGE} testID="confirmation-secondary-button" style={styles.mt3} + isDisabled={isSecondaryButtonDisabled} + isLoading={isSecondaryButtonLoading} onPress={onSecondaryButtonPress} > {secondaryButtonText} diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index adf2f78e2c47..847b4c6ead3c 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -29,17 +29,13 @@ type ConfirmedRouteProps = { /** Whether the size of the route pending icon is smaller. */ isSmallerIcon?: boolean; - /** Whether it should have border radius */ shouldHaveBorderRadius?: boolean; /** Whether it should display the Mapbox map only when the route/coordinates exist otherwise * it will display pending map icon */ requireRouteToDisplayMap?: boolean; - /** Whether the map is interactive or not */ interactive?: boolean; - - /** Whether it should display the compass on the map */ shouldDisplayCompass?: boolean; }; diff --git a/src/components/ConnectToCampfireFlow/index.tsx b/src/components/ConnectToCampfireFlow/index.tsx new file mode 100644 index 000000000000..76aa6aeb9422 --- /dev/null +++ b/src/components/ConnectToCampfireFlow/index.tsx @@ -0,0 +1,30 @@ +import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import {useEffect} from 'react'; + +type ConnectToCampfireFlowProps = { + policyID: string; +}; + +function ConnectToCampfireFlow({policyID}: ConnectToCampfireFlowProps) { + const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID); + + useEffect(() => { + if (hasReusablePoliciesConnectedToCampfire) { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS.getRoute(policyID)); + return; + } + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SETUP.getRoute(policyID)); + // This needs to run once as we will navigate away + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + +export default ConnectToCampfireFlow; diff --git a/src/components/ConnectToCertiniaFlow/index.tsx b/src/components/ConnectToCertiniaFlow/index.tsx index f6b91f3fc2f8..69ed4c995a7e 100644 --- a/src/components/ConnectToCertiniaFlow/index.tsx +++ b/src/components/ConnectToCertiniaFlow/index.tsx @@ -9,7 +9,7 @@ import usePopoverPosition from '@hooks/usePopoverPosition'; import {isAuthenticationError} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; -import {useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import {useAccountingState} from '@pages/workspace/accounting/AccountingContext/contexts'; import type {AnchorPosition} from '@styles/index'; diff --git a/src/components/ConnectToNetSuiteFlow/index.tsx b/src/components/ConnectToNetSuiteFlow/index.tsx index a41807d14cc4..dd5e8c77ec98 100644 --- a/src/components/ConnectToNetSuiteFlow/index.tsx +++ b/src/components/ConnectToNetSuiteFlow/index.tsx @@ -9,7 +9,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {isAuthenticationError} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; -import {useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import {useAccountingState} from '@pages/workspace/accounting/AccountingContext/contexts'; import {getInitialSubPageForNetsuiteTokenInput} from '@pages/workspace/accounting/netsuite/utils'; import type {AnchorPosition} from '@styles/index'; diff --git a/src/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.tsx b/src/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.tsx index 780e104f79c0..2262b71fcf71 100644 --- a/src/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.tsx +++ b/src/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.tsx @@ -12,7 +12,7 @@ import usePopoverPosition from '@hooks/usePopoverPosition'; import {isAuthenticationError} from '@libs/actions/connections'; -import {useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import {useAccountingState} from '@pages/workspace/accounting/AccountingContext/contexts'; import type {AnchorPosition} from '@styles/index'; diff --git a/src/components/ConnectionLayout.tsx b/src/components/ConnectionLayout.tsx index f3519f6272d9..fae05249527b 100644 --- a/src/components/ConnectionLayout.tsx +++ b/src/components/ConnectionLayout.tsx @@ -29,7 +29,6 @@ type ConnectionLayoutProps = { /** Header title to be translated for the connection component */ headerTitle?: TranslationPaths; - /** The subtitle to show in the header */ headerSubtitle?: string; /** React nodes that will be shown */ @@ -38,7 +37,6 @@ type ConnectionLayoutProps = { /** Title to be translated for the connection component */ title?: TranslationPaths; - /** The current policyID */ policyID?: string; /** Defines which types of access should be verified */ @@ -50,13 +48,8 @@ type ConnectionLayoutProps = { /** The content container style of ScrollView */ contentContainerStyle?: StyleProp | undefined; - /** Style of the title text */ titleStyle?: StyleProp | undefined; - - /** Whether to include safe area padding bottom or not */ shouldIncludeSafeAreaPaddingBottom?: boolean; - - /** Whether to use ScrollView or not */ shouldUseScrollView?: boolean; /** Used for dynamic header title translation with parameters */ @@ -65,13 +58,8 @@ type ConnectionLayoutProps = { /** Used for dynamic title translation with parameters */ titleAlreadyTranslated?: string; - /** Name of the current connection */ connectionName: ConnectionName; - - /** Whether the screen should load for an empty connection */ shouldLoadForEmptyConnection?: boolean; - - /** Handler for back button press */ onBackButtonPress?: () => void; /** Whether or not to block user from accessing the page */ diff --git a/src/components/ConnectionStatusMessage.tsx b/src/components/ConnectionStatusMessage.tsx index b01f696282f1..1bf76c492cbd 100644 --- a/src/components/ConnectionStatusMessage.tsx +++ b/src/components/ConnectionStatusMessage.tsx @@ -10,7 +10,7 @@ import type {ComponentProps} from 'react'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import Icon from './Icon'; import RenderHTML from './RenderHTML'; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index a48e329c0829..abcaa16ae6e9 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -18,10 +18,7 @@ import FocusableMenuItem from './FocusableMenuItem'; import Icon from './Icon'; type ContextMenuItemProps = WithSentryLabel & { - /** Icon Component */ icon: IconAsset; - - /** Text to display */ text: string; /** Icon to show when interaction was successful */ @@ -33,7 +30,6 @@ type ContextMenuItemProps = WithSentryLabel & { /** Whether to show the mini menu */ isMini?: boolean; - /** Callback to fire when the item is pressed */ onPress: (event?: GestureResponderEvent | MouseEvent | KeyboardEvent) => void; /** A description text to show under the title */ @@ -42,10 +38,7 @@ type ContextMenuItemProps = WithSentryLabel & { /** The action accept for anonymous user or not */ isAnonymousAction?: boolean; - /** Whether the menu item is focused or not */ isFocused?: boolean; - - /** Whether the width should be limited */ shouldLimitWidth?: boolean; /** Styles to apply to MenuItem wrapper */ @@ -56,16 +49,12 @@ type ContextMenuItemProps = WithSentryLabel & { /** The ref of mini context menu item */ buttonRef?: React.RefObject; - /** Handles what to do when the item is focused */ onFocus?: () => void; /** Handles what to do when the item loose focus */ onBlur?: () => void; - /** Whether the menu item is disabled or not */ disabled?: boolean; - - /** Whether the menu item should show loading icon */ shouldShowLoadingSpinnerIcon?: boolean; }; diff --git a/src/components/CopyTextToClipboard.tsx b/src/components/CopyTextToClipboard.tsx index 5ccc6411f51f..1517d62093d2 100644 --- a/src/components/CopyTextToClipboard.tsx +++ b/src/components/CopyTextToClipboard.tsx @@ -17,9 +17,7 @@ type CopyTextToClipboardProps = { /** The text to display and copy to the clipboard */ text?: string; - /** Styles to apply to the text */ textStyles?: StyleProp; - urlToCopy?: string; accessibilityRole?: AccessibilityRole; diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index e9b236d5465b..9fcea03a7114 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -20,19 +20,13 @@ import type {TranslationPaths} from '@src/languages/types'; import React, {useMemo} from 'react'; type CountrySelectorModalProps = { - /** Whether the modal is visible */ isVisible: boolean; /** Function to call when the user closes the business type selector modal */ onClose: () => void; - /** Label to display on field */ label: string; - - /** Country selected */ currentCountry: string; - - /** Function to call when the user selects a country */ onCountrySelected: (value: Option) => void; /** Function to call when the user presses on the modal backdrop */ diff --git a/src/components/CountryPicker/index.tsx b/src/components/CountryPicker/index.tsx index 4b8ebe767688..a5cfa603a428 100644 --- a/src/components/CountryPicker/index.tsx +++ b/src/components/CountryPicker/index.tsx @@ -12,7 +12,6 @@ import React, {useState} from 'react'; import CountrySelectorModal from './CountrySelectorModal'; type CountryPickerProps = { - /** Current value of the selected item */ value?: string; /** Callback when the list item is selected */ diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index a9d7aaf83196..9d177c5e3916 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -24,7 +24,6 @@ type CountrySelectorProps = { /** Callback called when the country changes. */ onInputChange?: (value?: string) => void; - /** Current selected country */ value?: Country | ''; /** inputID used by the Form component */ @@ -34,7 +33,6 @@ type CountrySelectorProps = { /** Callback to call when the picker modal is dismissed */ onBlur?: () => void; - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/CurrencyListContextProvider/index.tsx b/src/components/CurrencyListContextProvider/index.tsx index c6b8c8fb3a23..bb24ca3eaace 100644 --- a/src/components/CurrencyListContextProvider/index.tsx +++ b/src/components/CurrencyListContextProvider/index.tsx @@ -1,8 +1,8 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; -import {format, formatToParts} from '@libs/NumberFormatUtils'; +import {convertToDisplayStringWithoutCurrencyForLocale, convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; +import {format} from '@libs/NumberFormatUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -57,25 +57,8 @@ function CurrencyListContextProvider({children}: React.PropsWithChildren) { ); const convertToDisplayStringWithoutCurrency = useCallback( - (amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string => { - const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); - const decimals = getCurrencyDecimals(sanitizedCurrency); - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); - return formatToParts(preferredLocale, convertedAmount, { - style: 'currency', - currency: sanitizedCurrency, - - // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies - // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: decimals, - // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. - maximumFractionDigits: 2, - }) - .filter((x) => x.type !== 'currency') - .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) - .map((x) => x.value) - .join(''); - }, + (amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string => + convertToDisplayStringWithoutCurrencyForLocale(preferredLocale, amountInCents, currencyCode, getCurrencyDecimals), [getCurrencyDecimals, preferredLocale], ); diff --git a/src/components/CurrencyPicker.tsx b/src/components/CurrencyPicker.tsx index bd77ac432deb..d6923ca28521 100644 --- a/src/components/CurrencyPicker.tsx +++ b/src/components/CurrencyPicker.tsx @@ -23,10 +23,7 @@ type CurrencyPickerProps = { /** Label for the input */ label: string; - /** Current value of the selected item */ value?: string; - - /** Custom content to display in the header */ headerContent?: ReactNode; /** Callback when the list item is selected */ @@ -35,7 +32,6 @@ type CurrencyPickerProps = { /** Form Error description */ errorText?: string; - /** List of currencies to exclude from the list */ excludeCurrencies?: string[]; /** Is the MenuItem disabled */ diff --git a/src/components/CurrencySelectionList/types.ts b/src/components/CurrencySelectionList/types.ts index d714f2d1645a..78dd52ce8868 100644 --- a/src/components/CurrencySelectionList/types.ts +++ b/src/components/CurrencySelectionList/types.ts @@ -9,25 +9,18 @@ type CurrencySelectionListProps = Partial void; /** The array of selected currencies. This prop should be used when multiple currencies can be selected */ selectedCurrencies?: string[]; - /** Whether this is a multi-select list */ canSelectMultiple?: boolean; /** List of excluded currency codes */ excludedCurrencies?: string[]; - /** Whether the screen transition has ended */ didScreenTransitionEnd?: boolean; }; diff --git a/src/components/CurrencySelector.tsx b/src/components/CurrencySelector.tsx index ab2e4d04fdc7..a6aa6f4ae69a 100644 --- a/src/components/CurrencySelector.tsx +++ b/src/components/CurrencySelector.tsx @@ -42,7 +42,6 @@ type CurrencySelectorProps = { /** Whether to show currency symbol in the title */ shouldShowCurrencySymbol?: boolean; - /** Reference to the outer element */ ref: ForwardedRef; }; diff --git a/src/components/CustomUnitDefaultCategorySelector/index.tsx b/src/components/CustomUnitDefaultCategorySelector/index.tsx index 99b9f47c83df..b39619a65941 100644 --- a/src/components/CustomUnitDefaultCategorySelector/index.tsx +++ b/src/components/CustomUnitDefaultCategorySelector/index.tsx @@ -17,10 +17,7 @@ type CustomUnitDefaultCategorySelectorProps = { /** Currently selected category */ defaultValue?: string; - /** Label to display on field */ label: string; - - /** Any additional styles to apply */ wrapperStyle: StyleProp; /** Whether item is focused or active */ diff --git a/src/components/DatePicker/CalendarPicker/Day.tsx b/src/components/DatePicker/CalendarPicker/Day.tsx index ee0b8b7f0ab8..b9b5ce5926ff 100644 --- a/src/components/DatePicker/CalendarPicker/Day.tsx +++ b/src/components/DatePicker/CalendarPicker/Day.tsx @@ -10,16 +10,9 @@ import React from 'react'; import {View} from 'react-native'; type DayProps = { - /** Whether day is disabled */ disabled?: boolean; - - /** Whether day is selected */ selected?: boolean; - - /** Whether day is pressed */ pressed?: boolean; - - /** Whether day is hovered */ hovered?: boolean; /** date to show */ diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index e13858c1b309..bdb1545a68f7 100644 --- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -15,13 +15,11 @@ import React, {useEffect, useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; type MonthPickerModalProps = { - /** Whether the modal is visible */ isVisible: boolean; /** Currently selected month (0-indexed) */ currentMonth?: number; - /** Function to call when the user selects a month */ onMonthChange?: (month: number) => void; /** Function to call when the user closes the month picker */ diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index fdd23e69f946..91aa8285df39 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -15,16 +15,9 @@ import {Keyboard} from 'react-native'; import type CalendarPickerListItem from './types'; type YearPickerModalProps = { - /** Whether the modal is visible */ isVisible: boolean; - - /** The list of years to render */ years: CalendarPickerListItem[]; - - /** Currently selected year */ currentYear?: number; - - /** Function to call when the user selects a year */ onYearChange?: (year: number) => void; /** Function to call when the user closes the year picker */ diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 5650c24e0e15..bd70d3ca7094 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -42,7 +42,6 @@ type CalendarPickerProps = { /** Day component to render for dates */ DayComponent?: typeof Day; - /** A function called when the date is selected */ onSelected?: (selectedDate: string) => void; /** Optional style override for the header container */ diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 0bc160e36d5b..362255f2dda6 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -35,7 +35,6 @@ type DatePickerBaseProps = ForwardedFSClassProps & { /** Saves a draft of the input value when used in a form */ shouldSaveDraft?: boolean; - /** ID of the wrapping form */ formID?: keyof OnyxFormValuesMapping; /** @@ -102,7 +101,6 @@ type DatePickerProps = { /** Saves a draft of the input value when used in a form */ shouldSaveDraft?: boolean; - /** ID of the wrapping form */ formID?: keyof OnyxFormValuesMapping; /** Whether the modal is visible */ @@ -111,7 +109,6 @@ type DatePickerProps = { /** Callback to close the modal */ onClose: () => void; - /** Callback when date is selected */ onSelected?: (value: string) => void; /** Whether to close the modal when browser navigation changes */ diff --git a/src/components/DecisionModal.tsx b/src/components/DecisionModal.tsx index f6959fac1b4f..1e65563cc346 100644 --- a/src/components/DecisionModal.tsx +++ b/src/components/DecisionModal.tsx @@ -7,8 +7,8 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; -import Header from './Header'; +import Button from './Button'; +import HeaderTitle from './HeaderTitle'; import Modal from './Modal'; import RenderHTML from './RenderHTML'; import ScrollView from './ScrollView'; @@ -53,7 +53,6 @@ type DecisionModalProps = { /** Callback when modal has fully disappeared */ onModalHide?: () => void; - /** Whether modal is visible */ isVisible: boolean; /** Whether to handle browser navigation back to close the modal */ @@ -111,10 +110,9 @@ function DecisionModal({ > -
+ + {title} + diff --git a/src/components/DelegateNoAccessModalProvider/types.ts b/src/components/DelegateNoAccessModalProvider/types.ts index 3df0af1d3ab3..17b5be1596c1 100644 --- a/src/components/DelegateNoAccessModalProvider/types.ts +++ b/src/components/DelegateNoAccessModalProvider/types.ts @@ -7,7 +7,6 @@ type DelegateNoAccessStateContextType = { }; type DelegateNoAccessActionsContextType = { - /** Function to show the delegate no access modal */ showDelegateNoAccessModal: () => void; }; diff --git a/src/components/DelegatorList.tsx b/src/components/DelegatorList.tsx index 06fce77dc3cb..8e4c9ccfc606 100644 --- a/src/components/DelegatorList.tsx +++ b/src/components/DelegatorList.tsx @@ -13,9 +13,7 @@ import MenuItem from './MenuItem'; import Text from './Text'; type DelegatorListProps = { - /** List of delegators */ delegators?: string[]; - message: string; }; diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 1f67fdd575ef..fefdcb66891c 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -18,7 +18,6 @@ type DisplayNamesTooltipItemProps = ForwardedFSClassProps & { /** The function to get a distance to shift the tooltip horizontally */ getTooltipShiftX?: (index: number) => number; - /** The Account ID for the tooltip */ accountID?: number; /** The name to display in bold */ diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx index 57e2315fda22..9d609fc6eece 100644 --- a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx @@ -15,7 +15,6 @@ type DisplayNamesWithoutTooltipProps = ForwardedFSClassProps & { /** Arbitrary styles of the displayName text */ textStyles?: StyleProp; - /** Number of lines before wrapping */ numberOfLines?: number; /** Additional Text component to render after the displayNames */ diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts index da9eef83598e..55d4345f5340 100644 --- a/src/components/DisplayNames/types.ts +++ b/src/components/DisplayNames/types.ts @@ -29,10 +29,7 @@ type DisplayNamesProps = ForwardedFSClassProps & { */ shouldParseFullTitle?: boolean; - /** Array of objects that map display names to their corresponding tooltip */ displayNamesWithTooltips?: DisplayNameWithTooltip[]; - - /** Number of lines before wrapping */ numberOfLines: number; /** Is tooltip needed? When true, triggers complex title rendering */ @@ -47,7 +44,6 @@ type DisplayNamesProps = ForwardedFSClassProps & { */ accessibilityLabel?: string; - /** If the full title needs to be displayed */ shouldUseFullTitle?: boolean; /** If we should add an ellipsis after the participants list */ @@ -59,7 +55,6 @@ type DisplayNamesProps = ForwardedFSClassProps & { /** Additional Text component to render after the displayNames */ renderAdditionalText?: () => React.ReactNode; - /** TestID indicating order */ testID?: number; }; diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 229564f8a4a2..ab36349ac24e 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DistanceMapView from '@components/DistanceMapView'; import type {WayPoint} from '@components/MapView/MapViewTypes'; @@ -38,10 +38,7 @@ type DistanceRequestFooterProps = { /** The transaction being interacted with */ transaction: OnyxEntry; - /** The policy */ policy: OnyxEntry; - - /** Optional style for the map container */ mapContainerStyle?: StyleProp; /** The state of the transaction (draft, current, etc.) used to persist route selection to the correct Onyx key */ diff --git a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx index 7802edfc0678..215768b1fb1d 100644 --- a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx +++ b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx @@ -20,19 +20,10 @@ type DistanceRequestProps = WithSentryLabel & { /** The index of the item */ item?: string; - /** Function to call when the secondary interaction is triggered */ onSecondaryInteraction?: () => void; - - /** Function to get the index of the item */ getIndex?: () => number | undefined; - - /** Whether the item is active */ isActive?: boolean; - - /** Function to call when the user clicks the item */ onPress?: (index: number) => void; - - /** Whether the item is disabled */ disabled?: boolean; }; diff --git a/src/components/Domain/CopyableTextField.tsx b/src/components/Domain/CopyableTextField.tsx index 234373264b4f..21aab4318eed 100644 --- a/src/components/Domain/CopyableTextField.tsx +++ b/src/components/Domain/CopyableTextField.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import Text from '@components/Text'; @@ -25,7 +25,6 @@ type CopyableTextFieldProps = { /** Custom styles for the outer most View */ style?: StyleProp; - /** Custom styles for the displayed text */ textStyle?: StyleProp; /** Whether the text field should be expandable */ diff --git a/src/components/Domain/FormHelpMessageRowWithRetryButton.tsx b/src/components/Domain/FormHelpMessageRowWithRetryButton.tsx index 2591c217f21b..aa823d995c5e 100644 --- a/src/components/Domain/FormHelpMessageRowWithRetryButton.tsx +++ b/src/components/Domain/FormHelpMessageRowWithRetryButton.tsx @@ -1,8 +1,8 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FormHelpMessage from '@components/FormHelpMessage'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -20,7 +20,6 @@ type FormHelpMessageRowWithRetryButtonProps = { /** Callback function invoked when the retry button is clicked. */ onRetry: () => void | Promise; - /** The retry button's size. */ size?: FormHelpMessageRowButtonSize; /** Visual variant of the retry button. */ @@ -33,7 +32,6 @@ type FormHelpMessageRowWithRetryButtonProps = { function FormHelpMessageRowWithRetryButton({message, size = CONST.BUTTON_SIZE.MEDIUM, onRetry, variant, shouldAlignButtonToMessage = false}: FormHelpMessageRowWithRetryButtonProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); return ( @@ -41,14 +39,13 @@ function FormHelpMessageRowWithRetryButton({message, size = CONST.BUTTON_SIZE.ME message={message} style={[styles.mt0, styles.mb0, shouldAlignButtonToMessage ? styles.flexShrink1 : styles.flex1]} /> - + ); } diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 8d0e8174c74e..5475d768f715 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -20,7 +20,7 @@ import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import Icon from './Icon'; import RenderHTML from './RenderHTML'; import Text from './Text'; @@ -41,10 +41,7 @@ type DotIndicatorMessageProps = { /** Additional styles to apply to the container */ style?: StyleProp; - /** Additional styles to apply to the text */ textStyles?: StyleProp; - - /** A function to dismiss error */ dismissError?: () => void; }; diff --git a/src/components/DragAndDrop/Consumer/types.ts b/src/components/DragAndDrop/Consumer/types.ts index 57d134849d74..0bfa98a839f9 100644 --- a/src/components/DragAndDrop/Consumer/types.ts +++ b/src/components/DragAndDrop/Consumer/types.ts @@ -1,7 +1,6 @@ import type {ReactNode} from 'react'; type DragAndDropConsumerProps = { - /** Children to render inside this component. */ children: ReactNode; /** Function to execute when an item is dropped in the drop zone. */ diff --git a/src/components/DragAndDrop/NoDropZone/types.ts b/src/components/DragAndDrop/NoDropZone/types.ts index 0735f4936ada..495e45b2d9aa 100644 --- a/src/components/DragAndDrop/NoDropZone/types.ts +++ b/src/components/DragAndDrop/NoDropZone/types.ts @@ -1,7 +1,6 @@ import type {ReactNode} from 'react'; type NoDropZoneProps = { - /** Content */ children: ReactNode; }; diff --git a/src/components/DragAndDrop/Provider/index.tsx b/src/components/DragAndDrop/Provider/index.tsx index 71c710555d36..21d86b6fc702 100644 --- a/src/components/DragAndDrop/Provider/index.tsx +++ b/src/components/DragAndDrop/Provider/index.tsx @@ -1,6 +1,8 @@ import useDragAndDrop from '@hooks/useDragAndDrop'; import useThemeStyles from '@hooks/useThemeStyles'; +import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; + import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; @@ -13,10 +15,6 @@ import type {DragAndDropActionsContextType, DragAndDropProviderProps, DragAndDro import {DragAndDropActionsContext, DragAndDropStateContext} from './DragAndDropContext'; -function shouldAcceptDrop(event: DragEvent): boolean { - return !!event.dataTransfer?.types.some((type) => type === 'Files'); -} - function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver = () => {}}: DragAndDropProviderProps) { const styles = useThemeStyles(); const dropZone = useRef(null); diff --git a/src/components/DragAndDrop/Provider/types.ts b/src/components/DragAndDrop/Provider/types.ts index e4df3709cbd5..4c5197bae589 100644 --- a/src/components/DragAndDrop/Provider/types.ts +++ b/src/components/DragAndDrop/Provider/types.ts @@ -1,7 +1,6 @@ import type {ReactNode} from 'react'; type DragAndDropProviderProps = { - /** Children to render inside this component. */ children: ReactNode; /** Should this dropZone be disabled? */ diff --git a/src/components/DraggableList/types.ts b/src/components/DraggableList/types.ts index 6f8f56aca391..55fcfd9dee8c 100644 --- a/src/components/DraggableList/types.ts +++ b/src/components/DraggableList/types.ts @@ -26,7 +26,6 @@ type DraggableListProps = { /** Called when a row is selected via Enter/Space key */ onSelectRow?: (item: T) => void; - /** Whether dragging is disabled for the given item */ isItemDragDisabled?: (item: T) => boolean; /** Whether the given item should be skipped during keyboard navigation */ @@ -35,7 +34,6 @@ type DraggableListProps = { /** Rendered at the bottom of all the items. Just like in the FlatList. */ ListFooterComponent?: React.ReactElement; - /** Disables scrolling of the list */ disableScroll?: boolean; /** Externally controlled focused index. When provided, DraggableList skips its internal keyboard navigation. */ diff --git a/src/components/DropZone/DropZoneUI.tsx b/src/components/DropZone/DropZoneUI.tsx index 3b59541496f4..4c94a4af1020 100644 --- a/src/components/DropZone/DropZoneUI.tsx +++ b/src/components/DropZone/DropZoneUI.tsx @@ -32,7 +32,6 @@ type DropZoneUIProps = { /** Custom styles for the inner wrapper of the drop zone */ dashedBorderStyles?: StyleProp; - /** Custom styles for the drop wrapper */ dropWrapperStyles?: StyleProp; }; diff --git a/src/components/DropZone/DropZoneWrapper.tsx b/src/components/DropZone/DropZoneWrapper.tsx index d44b7e986a90..8ca25175118b 100644 --- a/src/components/DropZone/DropZoneWrapper.tsx +++ b/src/components/DropZone/DropZoneWrapper.tsx @@ -1,6 +1,8 @@ import useDragAndDrop from '@hooks/useDragAndDrop'; import useThemeStyles from '@hooks/useThemeStyles'; +import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; + import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; @@ -13,7 +15,6 @@ type DropZoneWrapperProps = { /** Callback to execute when a file is dropped */ onDrop: (event: DragEvent) => void; - /** Function to render the children */ children: (props: {isDraggingOver: boolean}) => ReactNode; }; @@ -22,7 +23,7 @@ function DropZoneWrapper({onDrop, children}: DropZoneWrapperProps) { const dropZone = useRef(null); const {isDraggingOver} = useDragAndDrop({ - shouldAcceptDrop: (event) => !!event.dataTransfer?.types.some((type) => type === 'Files'), + shouldAcceptDrop, onDrop, shouldStopPropagation: false, shouldHandleDragEvent: false, diff --git a/src/components/DropZone/DualDropZone.tsx b/src/components/DropZone/DualDropZone.tsx index e9d4ac730853..1ea91f0279e5 100644 --- a/src/components/DropZone/DualDropZone.tsx +++ b/src/components/DropZone/DualDropZone.tsx @@ -19,7 +19,6 @@ import DropZoneUI from './DropZoneUI'; import DropZoneWrapper from './DropZoneWrapper'; type DropZoneProps = { - /** Whether the user is editing */ isEditing: boolean; /** Callback to execute when a file is dropped */ @@ -28,7 +27,6 @@ type DropZoneProps = { /** Callback to execute when a file is dropped */ onReceiptDrop: (event: DragEvent) => void; - /** Whether the drop zone should accept a single receipt */ shouldAcceptSingleReceipt?: boolean; }; diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index 2960d2c3fd00..a4a9aab404cf 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -55,7 +55,6 @@ type OverrideThemeProps = { }; type EReceiptProps = { - /* TransactionID of the transaction this EReceipt corresponds to */ transactionID: string | undefined; /** The transaction data in search */ diff --git a/src/components/EReceiptThumbnail.tsx b/src/components/EReceiptThumbnail.tsx index 7e744f09fff5..455bcb959dfc 100644 --- a/src/components/EReceiptThumbnail.tsx +++ b/src/components/EReceiptThumbnail.tsx @@ -31,7 +31,6 @@ type EReceiptThumbnailProps = { /** The file extension of the receipt that the preview thumbnail is being displayed for. */ fileExtension?: string; - /** Whether it is a receipt thumbnail we are displaying. */ isReceiptThumbnail?: boolean; /** Center the eReceipt Icon vertically */ diff --git a/src/components/EReceiptWithSizeCalculation.tsx b/src/components/EReceiptWithSizeCalculation.tsx index 351529986439..0f4c1a4882a4 100644 --- a/src/components/EReceiptWithSizeCalculation.tsx +++ b/src/components/EReceiptWithSizeCalculation.tsx @@ -16,7 +16,6 @@ import EReceipt from './EReceipt'; import PerDiemEReceipt from './PerDiemEReceipt'; type EReceiptWithSizeCalculationProps = { - /* TransactionID of the transaction this EReceipt corresponds to */ transactionID: string | undefined; /** The transaction data in search */ @@ -28,7 +27,6 @@ type EReceiptWithSizeCalculationProps = { /** Callback to be called when the image loads */ onLoad?: () => void; - /** Determines which receipt component to render */ receiptType?: 'default' | 'perDiem'; /** Overrides theme props set by default by useReceipt */ diff --git a/src/components/EmbeddedDemo/types.ts b/src/components/EmbeddedDemo/types.ts index a93d3afcf2f7..28cce5302822 100644 --- a/src/components/EmbeddedDemo/types.ts +++ b/src/components/EmbeddedDemo/types.ts @@ -2,7 +2,6 @@ import type {DetailedHTMLProps, IframeHTMLAttributes} from 'react'; import type {WebViewProps} from 'react-native-webview'; type EmbeddedDemoProps = { - /** Embedded demo URL */ url: string; /** **(web)** Description for screen readers */ diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index ed315c2d4242..eade7858788d 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -24,7 +24,6 @@ type EmojiPickerButtonProps = { /** Flag to disable the emoji picker button */ isDisabled?: boolean; - /** Unique id for emoji picker */ emojiPickerID?: string; /** A callback function when the button is pressed */ diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 0e2e62347f5a..59f8dab0210e 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -23,7 +23,6 @@ type BaseEmojiPickerMenuProps = { /** Indicates if the emoji list is filtered or not */ isFiltered: boolean; - /** Array of header emojis */ headerEmojis: HeaderIndices[]; /** Function to scroll to a specific header in the emoji list */ @@ -32,31 +31,17 @@ type BaseEmojiPickerMenuProps = { /** The index of the currently selected category header */ selectedHeaderIndex?: number | null; - /** Style to be applied to the list wrapper */ listWrapperStyle?: StyleProp; - - /** The data for the emoji list */ data: EmojiPickerList; - - /** Function to render each item in the list */ renderItem: ListRenderItem; - - /** Extra data to be passed to the list for re-rendering */ extraData?: Array | ((skinTone: number) => void)>; - - /** Array of indices for the sticky headers */ stickyHeaderIndices?: number[]; - - /** Whether the list should always bounce vertically */ alwaysBounceVertical?: boolean; - - /** Callback fired when scroll momentum ends */ onMomentumScrollEnd?: () => void; /** The current search input value, used for accessibility re-announcements */ searchValue?: string; - /** Reference to the outer element */ ref?: ForwardedRef>; }; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/types.ts b/src/components/EmojiPicker/EmojiPickerMenu/types.ts index edc05ea14797..632b9a1e2aac 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/types.ts +++ b/src/components/EmojiPicker/EmojiPickerMenu/types.ts @@ -9,8 +9,6 @@ type EmojiPickerMenuProps = { onEmojiSelected: (emoji: string, emojiObject: Emoji, preferredSkinTone: number) => void; activeEmoji?: string; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/types.ts b/src/components/EmojiPicker/EmojiPickerMenuItem/types.ts index ca5a4d12a988..9112f8dbbfc3 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/types.ts +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/types.ts @@ -2,13 +2,11 @@ type EmojiPickerMenuItemProps = { /** The unicode that is used to display the emoji */ emoji: string; - /** The function to call when an emoji is selected */ onPress: (emoji: string) => void; /** Handles what to do when we hover over this item with our cursor */ onHoverIn?: () => void; - /** Handles what to do when the hover is out */ onHoverOut?: () => void; /** Handles what to do when the pressable is focused */ @@ -20,7 +18,6 @@ type EmojiPickerMenuItemProps = { /** Whether this menu item is currently focused or not */ isFocused?: boolean; - /** Whether the menu item should be highlighted or not */ isHighlighted?: boolean; /** Whether the emoji is highlighted by the keyboard/mouse */ diff --git a/src/components/EmojiSuggestions.tsx b/src/components/EmojiSuggestions.tsx deleted file mode 100644 index 6b6b616128d1..000000000000 --- a/src/components/EmojiSuggestions.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import type {Emoji} from '@assets/emojis/types'; - -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import * as EmojiUtils from '@libs/EmojiUtils'; -import getStyledTextArray from '@libs/GetStyledTextArray'; - -import type {ReactElement} from 'react'; - -import React, {useCallback} from 'react'; -import {View} from 'react-native'; - -import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; - -import AutoCompleteSuggestions from './AutoCompleteSuggestions'; -import Text from './Text'; - -type EmojiSuggestionsProps = { - /** The index of the highlighted emoji */ - highlightedEmojiIndex?: number; - - /** Array of suggested emoji */ - emojis: Emoji[]; - - /** Fired when the user selects an emoji */ - onSelect: (index: number) => void; - - /** Emoji prefix that follows the colon */ - prefix: string; - - /** Show that we can use large emoji picker. Depending on available space - * and whether the input is expanded, we can have a small or large emoji - * suggester. When this value is false, the suggester will have a height of - * 2.5 items. When this value is true, the height can be up to 5 items. */ - isEmojiPickerLarge: boolean; - - /** Stores user's preferred skin tone */ - preferredSkinToneIndex: number; - - /** Measures the parent container's position and dimensions. Also add cursor coordinates */ - measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; - - /** Reset the emoji suggestions */ - resetSuggestions: () => void; -}; - -/** - * Create unique keys for each emoji item - */ -const keyExtractor = (item: Emoji, index: number): string => `${item.name}+${index}}`; - -function EmojiSuggestions({ - emojis, - onSelect, - prefix, - isEmojiPickerLarge, - preferredSkinToneIndex, - highlightedEmojiIndex = 0, - measureParentContainerAndReportCursor = () => {}, - resetSuggestions, -}: EmojiSuggestionsProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - /** - * Render an emoji suggestion menu item component. - */ - const renderSuggestionMenuItem = useCallback( - (item: Emoji): ReactElement => { - const styledTextArray = getStyledTextArray(item.name, prefix); - - return ( - - {EmojiUtils.getEmojiCodeWithSkinColor(item, preferredSkinToneIndex)} - - : - {styledTextArray.map(({text, isColored}) => ( - - {text} - - ))} - : - - - ); - }, - [prefix, styles.autoCompleteSuggestionContainer, styles.emojiSuggestionsEmoji, styles.emojiSuggestionsText, preferredSkinToneIndex, StyleUtils], - ); - - return ( - - ); -} - -export default EmojiSuggestions; diff --git a/src/components/EmojiSuggestions/EmojiSuggestionItem.tsx b/src/components/EmojiSuggestions/EmojiSuggestionItem.tsx new file mode 100644 index 000000000000..f6c812152123 --- /dev/null +++ b/src/components/EmojiSuggestions/EmojiSuggestionItem.tsx @@ -0,0 +1,52 @@ +import type {Emoji} from '@assets/emojis/types'; + +import Text from '@components/Text'; + +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getEmojiCodeWithSkinColor} from '@libs/EmojiUtils'; +import getStyledTextArray from '@libs/GetStyledTextArray'; + +import React from 'react'; +import {View} from 'react-native'; + +type EmojiSuggestionItemProps = { + item: Emoji; + + /** Emoji prefix that follows the colon, highlighted within the rendered name */ + prefix: string; + + preferredSkinToneIndex: number; +}; + +/** A single row of the emoji suggester: the emoji glyph followed by its `:name:`. */ +function EmojiSuggestionItem({item, prefix, preferredSkinToneIndex}: EmojiSuggestionItemProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const styledTextArray = getStyledTextArray(item.name, prefix); + + return ( + + {getEmojiCodeWithSkinColor(item, preferredSkinToneIndex)} + + : + {styledTextArray.map(({text, isColored}) => ( + + {text} + + ))} + : + + + ); +} + +export default EmojiSuggestionItem; diff --git a/src/components/EmojiSuggestions/index.tsx b/src/components/EmojiSuggestions/index.tsx new file mode 100644 index 000000000000..c59245c691d4 --- /dev/null +++ b/src/components/EmojiSuggestions/index.tsx @@ -0,0 +1,72 @@ +import type {Emoji} from '@assets/emojis/types'; + +import AutoCompleteSuggestions from '@components/AutoCompleteSuggestions'; +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; + +import React from 'react'; + +import EmojiSuggestionItem from './EmojiSuggestionItem'; + +type EmojiSuggestionsProps = { + highlightedEmojiIndex?: number; + + /** Array of suggested emoji */ + emojis: Emoji[]; + + /** Fired when the user selects an emoji */ + onSelect: (index: number) => void; + + /** Emoji prefix that follows the colon */ + prefix: string; + + /** Show that we can use large emoji picker. Depending on available space + * and whether the input is expanded, we can have a small or large emoji + * suggester. When this value is false, the suggester will have a height of + * 2.5 items. When this value is true, the height can be up to 5 items. */ + isEmojiPickerLarge: boolean; + + preferredSkinToneIndex: number; + + /** Measures the parent container's position and dimensions. Also add cursor coordinates */ + measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; + + resetSuggestions: () => void; +}; + +/** + * Create unique keys for each emoji item + */ +const keyExtractor = (item: Emoji, index: number): string => `${item.name}+${index}}`; + +function EmojiSuggestions({ + emojis, + onSelect, + prefix, + isEmojiPickerLarge, + preferredSkinToneIndex, + highlightedEmojiIndex = 0, + measureParentContainerAndReportCursor = () => {}, + resetSuggestions, +}: EmojiSuggestionsProps) { + return ( + ( + + )} + keyExtractor={keyExtractor} + highlightedSuggestionIndex={highlightedEmojiIndex} + onSelect={onSelect} + isSuggestionPickerLarge={isEmojiPickerLarge} + accessibilityLabelExtractor={keyExtractor} + measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} + resetSuggestions={resetSuggestions} + /> + ); +} + +export default EmojiSuggestions; diff --git a/src/components/EmptySelectionListContent.tsx b/src/components/EmptySelectionListContent.tsx index f76868197a41..8ae86f85233e 100644 --- a/src/components/EmptySelectionListContent.tsx +++ b/src/components/EmptySelectionListContent.tsx @@ -16,7 +16,6 @@ import ScrollView from './ScrollView'; import Text from './Text'; type EmptySelectionListContentProps = { - /** Type of selection list */ contentType: string; }; diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx index 86cd3f194fc7..8f5d59db0e8d 100644 --- a/src/components/EmptyStateComponent/index.tsx +++ b/src/components/EmptyStateComponent/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import ImageSVG from '@components/ImageSVG'; import Text from '@components/Text'; diff --git a/src/components/EnableGlobalReimbursementsPayModal.tsx b/src/components/EnableGlobalReimbursementsPayModal.tsx new file mode 100644 index 000000000000..ddb2fea19793 --- /dev/null +++ b/src/components/EnableGlobalReimbursementsPayModal.tsx @@ -0,0 +1,74 @@ +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import {getEnableGlobalReimbursementsBusinessNavigationRoute} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import {clearCorpayPayModal} from '@userActions/App'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type CorpayPayModal from '@src/types/onyx/CorpayPayModal'; + +import {useEffect, useEffectEvent, useRef} from 'react'; + +import {useLockedAccountActions, useLockedAccountState} from './LockedAccountModalProvider'; +import {ModalActions} from './Modal/Global/ModalContext'; + +function EnableGlobalReimbursementsPayModal() { + const {translate} = useLocalize(); + const [corpayPayModal] = useOnyx(ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL); + const {showConfirmModal} = useConfirmModal(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + const isModalOpenRef = useRef(false); + + const showCorpayPayModal = useEffectEvent(async (modalData: CorpayPayModal) => { + if (isModalOpenRef.current) { + return; + } + isModalOpenRef.current = true; + const navigationPathAtSignal = Navigation.getActiveRoute(); + const result = await showConfirmModal({ + id: 'corpayPayModal', + title: translate('common.corpayPayModalTitle'), + prompt: translate('common.corpayPayModalPrompt'), + confirmText: translate('common.enableGlobalReimbursements'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + }); + isModalOpenRef.current = false; + if (result.action === ModalActions.CONFIRM) { + if (isAccountLocked) { + showLockedAccountModal(); + } else { + const {bankAccountID, bankCountry, bankCurrency} = modalData; + Navigation.navigate( + getEnableGlobalReimbursementsBusinessNavigationRoute( + bankAccountID, + CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER, + { + bankCountry, + bankCurrency, + }, + navigationPathAtSignal, + ), + {skipMatchingFullScreenRoute: true}, + ); + } + } + clearCorpayPayModal(); + }); + + useEffect(() => { + if (!corpayPayModal) { + return; + } + showCorpayPayModal(corpayPayModal); + }, [corpayPayModal]); + + return null; +} + +export default EnableGlobalReimbursementsPayModal; diff --git a/src/components/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx deleted file mode 100644 index b3bb5180cb75..000000000000 --- a/src/components/EnvironmentBadge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import useEnvironment from '@hooks/useEnvironment'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import * as Environment from '@libs/Environment/Environment'; - -import CONST from '@src/CONST'; - -import React from 'react'; - -import pkg from '../../package.json'; -import Badge from './Badge'; - -const ENVIRONMENT_SHORT_FORM = { - [CONST.ENVIRONMENT.DEV]: 'DEV', - [CONST.ENVIRONMENT.STAGING]: 'STG', - [CONST.ENVIRONMENT.PRODUCTION]: 'PROD', - [CONST.ENVIRONMENT.ADHOC]: 'ADHOC', -}; - -function EnvironmentBadge() { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {environment, isProduction} = useEnvironment(); - - const adhoc = environment === CONST.ENVIRONMENT.ADHOC; - const success = environment === CONST.ENVIRONMENT.STAGING; - const error = environment !== CONST.ENVIRONMENT.STAGING && environment !== CONST.ENVIRONMENT.ADHOC; - - const badgeEnvironmentStyle = StyleUtils.getEnvironmentBadgeStyle(success, error, adhoc); - - // If we are on production, don't show any badge - if (isProduction) { - return null; - } - - const text = Environment.isInternalTestBuild() ? `v${pkg.version} PR:${CONST.PULL_REQUEST_NUMBER}` : ENVIRONMENT_SHORT_FORM[environment]; - - return ( - - ); -} - -export default EnvironmentBadge; diff --git a/src/components/EnvironmentContextProvider/default.ts b/src/components/EnvironmentContextProvider/default.ts index 258270600a6b..012478f36da4 100644 --- a/src/components/EnvironmentContextProvider/default.ts +++ b/src/components/EnvironmentContextProvider/default.ts @@ -8,7 +8,9 @@ const defaultEnvironmentStateContextValue: EnvironmentStateContextType = { }; const defaultEnvironmentActionsContextValue: EnvironmentActionsContextType = { - adjustExpensifyLinksForEnv: () => '', + // Outside of EnvironmentProvider we cannot know the current environment, so return the HTML unchanged rather + // than an empty string, which would blank out the message this is rendering. + adjustExpensifyLinksForEnv: (html: string) => html, }; export {defaultEnvironmentStateContextValue, defaultEnvironmentActionsContextValue}; diff --git a/src/components/EnvironmentContextProvider/index.tsx b/src/components/EnvironmentContextProvider/index.tsx index f16b61080326..60e968b3f845 100644 --- a/src/components/EnvironmentContextProvider/index.tsx +++ b/src/components/EnvironmentContextProvider/index.tsx @@ -3,7 +3,6 @@ import getEnvironment from '@libs/Environment/getEnvironment'; import type {ReactElement, ReactNode} from 'react'; -import {DomUtils, parseDocument} from 'htmlparser2'; import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; import type {EnvironmentActionsContextType, EnvironmentStateContextType, EnvironmentValue} from './types'; @@ -11,10 +10,66 @@ import type {EnvironmentActionsContextType, EnvironmentStateContextType, Environ import {defaultEnvironmentActionsContextValue, defaultEnvironmentStateContextValue} from './default'; type EnvironmentProviderProps = { - /** Actual content wrapped by this component */ children: ReactNode; }; +/** The production NewDot origin that the backend bakes into the HTML of the report actions it generates. */ +const PRODUCTION_NEW_DOT_ORIGIN = 'https://new.expensify.com'; + +/** Matches an opening anchor tag. Closing tags and every other element are left untouched. */ +const ANCHOR_START_TAG_REGEX = /]*>/gi; + +/** + * Matches an href attribute inside an opening anchor tag, in all three HTML quoting styles. + * The leading \s is required so that ExpensiMark's `data-raw-href="…"` is not matched. + */ +const HREF_ATTRIBUTE_REGEX = /(\shref\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/i; + +/** Characters that may follow an origin, so that a lookalike host such as new.expensify.com.example.com is not rewritten. */ +const ORIGIN_BOUNDARY_CHARACTERS = new Set(['/', '?', '#']); + +/** + * Rewrites production NewDot hrefs in anchor tags so they resolve against the current environment. + * + * Report action HTML is generated by the backend and stored once in a database that staging and production share, + * so any absolute origin baked into it is wrong for at least one of those environments. We therefore rewrite the + * origin at render time instead. + * + * This deliberately edits the raw HTML string rather than parsing and re-serializing it: a round trip through an + * HTML serializer rewrites the App's custom tags (it turns `` into a wrapper that + * swallows the rest of the message, lowercases the `accountID` attribute that MentionUserRenderer reads, and + * converts emoji to numeric character references). + */ +function rewriteProductionNewDotLinks(html: string, environmentURLWithoutTrailingSlash: string): string { + // Nothing to do when we are already on production, and the substring check skips the messages that hold no NewDot link. + if (!html || !environmentURLWithoutTrailingSlash || environmentURLWithoutTrailingSlash === PRODUCTION_NEW_DOT_ORIGIN || !html.includes(PRODUCTION_NEW_DOT_ORIGIN)) { + return html; + } + + return html.replaceAll(ANCHOR_START_TAG_REGEX, (anchorStartTag) => + anchorStartTag.replace(HREF_ATTRIBUTE_REGEX, (hrefAttribute, attributeNameAndEquals: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => { + const href = doubleQuoted ?? singleQuoted ?? unquoted ?? ''; + if (!href.startsWith(PRODUCTION_NEW_DOT_ORIGIN)) { + return hrefAttribute; + } + + const pathAndBeyond = href.slice(PRODUCTION_NEW_DOT_ORIGIN.length); + if (pathAndBeyond && !ORIGIN_BOUNDARY_CHARACTERS.has(pathAndBeyond.charAt(0))) { + return hrefAttribute; + } + + const newHref = `${environmentURLWithoutTrailingSlash}${pathAndBeyond}`; + if (doubleQuoted !== undefined) { + return `${attributeNameAndEquals}"${newHref}"`; + } + if (singleQuoted !== undefined) { + return `${attributeNameAndEquals}'${newHref}'`; + } + return `${attributeNameAndEquals}${newHref}`; + }), + ); +} + const EnvironmentStateContext = createContext(defaultEnvironmentStateContextValue); const EnvironmentActionsContext = createContext(defaultEnvironmentActionsContextValue); @@ -32,37 +87,7 @@ function EnvironmentProvider({children}: EnvironmentProviderProps): ReactElement * Adjusts Expensify links in HTML content to use the current environment URL * instead of the production URL (new.expensify.com). */ - const adjustExpensifyLinksForEnv = useCallback( - (html: string): string => { - if (!environmentURLWithoutTrailingSlash || !html) { - return html; - } - - try { - const dom = parseDocument(html); - const anchorTags = DomUtils.findAll((el) => el.name?.toLowerCase() === 'a', dom); - - let adjustedHtml = html; - - for (const anchorTag of anchorTags) { - const href = anchorTag.attribs?.href; - if (href?.startsWith('https://new.expensify.com')) { - const newHref = href.replace('https://new.expensify.com', environmentURLWithoutTrailingSlash); - - const oldSnippet = `href="${href}"`; - const newSnippet = `href="${newHref}"`; - - adjustedHtml = adjustedHtml.replace(oldSnippet, newSnippet); - } - } - - return adjustedHtml; - } catch { - return html; - } - }, - [environmentURLWithoutTrailingSlash], - ); + const adjustExpensifyLinksForEnv = useCallback((html: string): string => rewriteProductionNewDotLinks(html, environmentURLWithoutTrailingSlash), [environmentURLWithoutTrailingSlash]); const stateValue = useMemo( () => ({ diff --git a/src/components/ErrorBoundary/types.ts b/src/components/ErrorBoundary/types.ts index 61127f3ce4e9..51fccac30840 100644 --- a/src/components/ErrorBoundary/types.ts +++ b/src/components/ErrorBoundary/types.ts @@ -1,13 +1,12 @@ type LogError = (message: string, error: Error, errorInfo: string) => void; type BaseErrorBoundaryProps = { - /* A message posted to `logError` (along with error data) when this component intercepts an error */ + /** A message posted to `logError` (along with error data) when this component intercepts an error */ errorMessage: string; - /* A function to perform the actual logging since different platforms support different tools */ + /** A function to perform the actual logging since different platforms support different tools */ logError?: LogError; - /* Actual content wrapped by this error boundary */ children: React.ReactNode; }; diff --git a/src/components/ErrorMessageRow.tsx b/src/components/ErrorMessageRow.tsx index 004236e2c941..b76db0a1eb38 100644 --- a/src/components/ErrorMessageRow.tsx +++ b/src/components/ErrorMessageRow.tsx @@ -10,19 +10,13 @@ import React from 'react'; import MessagesRow from './MessagesRow'; type ErrorMessageRowProps = { - /** The errors to display */ errors?: OnyxCommon.Errors | ReceiptErrors | OnyxCommon.TranslationKeyErrors | null; - - /** Additional style object for the error row */ errorRowStyles?: StyleProp; - - /** Additional style object for the error row text */ errorRowTextStyles?: StyleProp; /** If passed, an X button next to the error will be shown and which triggers this callback */ onDismiss?: () => void; - /** A function to dismiss error */ dismissError?: () => void; }; diff --git a/src/components/ExpenseHeaderApprovalButton.tsx b/src/components/ExpenseHeaderApprovalButton.tsx new file mode 100644 index 000000000000..c359271cbd15 --- /dev/null +++ b/src/components/ExpenseHeaderApprovalButton.tsx @@ -0,0 +1,174 @@ +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import type {Report, Transaction} from '@src/types/onyx'; +import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +import type {LocaleContextProps} from './LocaleContextProvider'; + +import Button from './Button'; +import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; + +type ExpenseHeaderApprovalButtonProps = { + /** Whether any transaction is on hold */ + isAnyTransactionOnHold: boolean; + + /** Whether delegate access is restricted */ + isDelegateAccessRestricted: boolean; + + /** Callback when approval is confirmed */ + onApprove: (isFullApproval: boolean) => void; + + /** The anchor alignment of the popover menu */ + anchorAlignment?: AnchorAlignment; + + /** The money request report */ + moneyRequestReport?: OnyxEntry; + + /** The report's transactions, used to derive the held/unheld approval amounts */ + transactions: Transaction[]; + + /** Whether to show the pay button */ + shouldShowPayButton: boolean; + + /** Whether to disable the approve button */ + isDisabled?: boolean; +}; + +type ApprovalOption = { + value: string; + text: string; + icon: IconAsset; + onSelected: () => void; + keyForList: string; +}; + +type ApprovalDropdownOptionProps = { + moneyRequestReport: OnyxEntry; + onPartialApprove: () => void; + onFullApprove: () => void; + translate: LocaleContextProps['translate']; + illustrations: Record<'ThumbsUp' | 'DocumentCheck', IconAsset>; + shouldShowPayButton: boolean; + hasOnlyHeldExpenses: boolean; + transactions: Transaction[]; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; +}; + +/** + * Generates dropdown options for approve button when there are held expenses + */ +function getApprovalDropdownOptions({ + onPartialApprove, + onFullApprove, + translate, + illustrations, + moneyRequestReport, + shouldShowPayButton, + hasOnlyHeldExpenses, + transactions, + convertToDisplayString, +}: ApprovalDropdownOptionProps): ApprovalOption[] { + const APPROVE_PARTIAL = 'approve_partial'; + const APPROVE_FULL = 'approve_full'; + const options: ApprovalOption[] = []; + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton, transactions, convertToDisplayString); + + if (hasValidNonHeldAmount && !hasOnlyHeldExpenses) { + options.push({ + value: APPROVE_PARTIAL, + text: `${translate('iou.approveOnly')} ${nonHeldAmount}`, + icon: illustrations.ThumbsUp, + onSelected: onPartialApprove, + keyForList: APPROVE_PARTIAL, + }); + } + + options.push({ + value: APPROVE_FULL, + text: `${translate('iou.approve')} ${fullAmount}`, + icon: illustrations.DocumentCheck, + onSelected: onFullApprove, + keyForList: APPROVE_FULL, + }); + + return options; +} + +function ExpenseHeaderApprovalButton({ + isAnyTransactionOnHold, + isDelegateAccessRestricted, + onApprove, + anchorAlignment, + moneyRequestReport, + transactions, + shouldShowPayButton, + isDisabled, +}: ExpenseHeaderApprovalButtonProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyExpensifyIcons(['ThumbsUp', 'DocumentCheck']); + const {convertToDisplayString} = useCurrencyListActions(); + + const shouldShowDropdown = isAnyTransactionOnHold && !isDelegateAccessRestricted; + + if (shouldShowDropdown) { + const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(transactions); + const approvalOptions = getApprovalDropdownOptions({ + onPartialApprove: () => onApprove(false), + onFullApprove: () => onApprove(true), + translate, + illustrations, + moneyRequestReport, + shouldShowPayButton, + hasOnlyHeldExpenses, + transactions, + convertToDisplayString, + }); + + return ( + {}} + variant={CONST.BUTTON_VARIANT.SUCCESS} + customText={translate('iou.approve')} + headerTextStyles={styles.lineHeightNormal} + shouldAlwaysShowDropdownMenu + isSplitButton={false} + anchorAlignment={anchorAlignment} + // `anchorAlignment` only expresses the preferred side. Callers anchor away from whatever they sit next to + // (the preview opens upward to clear the composer), which overflows when the button is near the opposite + // edge — the menu would otherwise be clamped to the window edge and cover the header. Flip instead. + shouldSwitchPositionIfOverflow + isDisabled={isDisabled} + sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.APPROVE_BUTTON} + /> + ); + } + + return ( + + ); +} + +export default ExpenseHeaderApprovalButton; +export {getApprovalDropdownOptions}; diff --git a/src/components/ExpensifyWordmark.tsx b/src/components/ExpensifyWordmark.tsx index eaa4de146a24..707f70484a2a 100644 --- a/src/components/ExpensifyWordmark.tsx +++ b/src/components/ExpensifyWordmark.tsx @@ -20,13 +20,13 @@ import {View} from 'react-native'; import ImageSVG from './ImageSVG'; type ExpensifyWordmarkProps = { - /** Additional styles to add to the component */ style?: StyleProp; }; const logoComponents = { [CONST.ENVIRONMENT.DEV]: DevLogo, [CONST.ENVIRONMENT.STAGING]: StagingLogo, + [CONST.ENVIRONMENT.QA]: StagingLogo, [CONST.ENVIRONMENT.PRODUCTION]: ProductionLogo, [CONST.ENVIRONMENT.ADHOC]: AdHocLogo, }; diff --git a/src/components/ExportDownloadStatusModal.tsx b/src/components/ExportDownloadStatusModal.tsx index 7eba9c536a89..0997245827bd 100644 --- a/src/components/ExportDownloadStatusModal.tsx +++ b/src/components/ExportDownloadStatusModal.tsx @@ -28,7 +28,7 @@ import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import ActivityIndicator from './ActivityIndicator'; -import Button from './ButtonComposed'; +import Button from './Button'; import Modal from './Modal'; import RenderHTML from './RenderHTML'; import Text from './Text'; @@ -39,7 +39,6 @@ type ExportDownloadStatusModalProps = { /** The export ID to subscribe to */ exportID: string; - /** Whether the modal is visible */ isVisible: boolean; /** Callback when the modal is closed */ diff --git a/src/components/FeatureList.tsx b/src/components/FeatureList.tsx index 81f6276a9ffe..f923cb1f1359 100644 --- a/src/components/FeatureList.tsx +++ b/src/components/FeatureList.tsx @@ -15,7 +15,7 @@ import {View} from 'react-native'; import type DotLottieAnimation from './LottieAnimations/types'; -import Button from './ButtonComposed'; +import Button from './Button'; import MenuItem from './MenuItem'; import Section from './Section'; @@ -31,7 +31,6 @@ type FeatureListProps = { /** The text to display in the subtitle of the section */ subtitle?: string; - /** The component to display custom subtitle */ renderSubtitle?: () => ReactNode; /** Text of the call to action button */ @@ -43,31 +42,20 @@ type FeatureListProps = { /** Action to call on cta button press */ onCtaPress?: () => void; - /** A list of menuItems representing the feature list. */ menuItems: FeatureListItem[]; /** The illustration to display in the header. Can be an image or a JSON object representing a Lottie animation. */ illustration: DotLottieAnimation | IconAsset | undefined; - /** The style passed to the illustration */ illustrationStyle?: StyleProp; /** The background color to apply in the upper half of the screen. */ illustrationBackgroundColor?: string; - /** Customize the Illustration container */ illustrationContainerStyle?: StyleProp; - - /** The style used for the title */ titleStyles?: StyleProp; - - /** Padding for content on large screens */ contentPaddingOnLargeScreens?: {padding: number}; - - /** Custom content to display in the footer */ footer?: ReactNode; - - /** Whether the button should be disabled */ isButtonDisabled?: boolean; /** Additional styles to apply to the CTA button inner container */ diff --git a/src/components/FeatureTraining/hooks/useScrollableWrapper.ts b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts index bfff2ca6ac05..0db8e9515389 100644 --- a/src/components/FeatureTraining/hooks/useScrollableWrapper.ts +++ b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts @@ -7,8 +7,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; - import CONST from '@src/CONST'; // eslint-disable-next-line no-restricted-imports -- type-only import from react-native @@ -25,12 +23,11 @@ type UseScrollableWrapperOptions = { function useScrollableWrapper({shouldUseScrollView: shouldUseScrollViewProp = false, width}: UseScrollableWrapperOptions) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowHeight, windowWidth} = useWindowDimensions(); + const {onboardingIsMediumOrLargerScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const {windowHeight} = useWindowDimensions(); const insets = useSafeAreaInsets(); const {isKeyboardActive} = useKeyboardState(); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; const scrollViewRef = useRef(null); diff --git a/src/components/FeatureTraining/primitives/BackButton.tsx b/src/components/FeatureTraining/primitives/BackButton.tsx index 83e694074e8f..c7b0d5f963b0 100644 --- a/src/components/FeatureTraining/primitives/BackButton.tsx +++ b/src/components/FeatureTraining/primitives/BackButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/FeatureTraining/primitives/HelpButton.tsx b/src/components/FeatureTraining/primitives/HelpButton.tsx index f0fe989cf0bb..48efefe30024 100644 --- a/src/components/FeatureTraining/primitives/HelpButton.tsx +++ b/src/components/FeatureTraining/primitives/HelpButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/FeatureTraining/primitives/Illustration.tsx b/src/components/FeatureTraining/primitives/Illustration.tsx index a8b69f655fc9..2e27b0e236cc 100644 --- a/src/components/FeatureTraining/primitives/Illustration.tsx +++ b/src/components/FeatureTraining/primitives/Illustration.tsx @@ -13,7 +13,6 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import Accessibility from '@libs/Accessibility'; import {isMobile} from '@libs/Browser'; -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; import {getIsOffline} from '@libs/NetworkState'; import variables from '@styles/variables'; @@ -88,11 +87,10 @@ function Illustration({ const styles = useThemeStyles(); const isReduceMotionEnabled = Accessibility.useReducedMotion(); const illustrations = useMemoizedLazyIllustrations(['Hands']); - const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const videoStatus = useVideoStatus(); - const {windowHeight, windowWidth} = useWindowDimensions(); + const {windowHeight} = useWindowDimensions(); const [measuredAspectRatio, setMeasuredAspectRatio] = useState(aspectRatioProp ?? VIDEO_ASPECT_RATIO); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); const animationRef = useRef(null); useEffect(() => { diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 792aff26098b..abf6024e355b 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -1,8 +1,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; import Log from '@libs/Log'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; @@ -36,7 +34,6 @@ type FeatureTrainingModalProps = { /** Modal content width */ width?: number; - /** Style for the modal inner container */ modalInnerContainerStyle?: ViewStyle; /** Composed feature training content */ @@ -53,9 +50,8 @@ function FeatureTrainingModal({ children, }: FeatureTrainingModalProps) { const styles = useThemeStyles(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowWidth, windowHeight} = useWindowDimensions(); - const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeModeUtil(windowWidth, windowHeight); + const {onboardingIsMediumOrLargerScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; const [isModalVisible, setIsModalVisible] = useState(false); const pendingCloseRef = useRef(false); diff --git a/src/components/FeedSelector.tsx b/src/components/FeedSelector.tsx index 8c19932207f6..6691f5916dcb 100644 --- a/src/components/FeedSelector.tsx +++ b/src/components/FeedSelector.tsx @@ -14,16 +14,9 @@ import SearchInputSelectionSkeleton from './Skeletons/SearchInputSelectionSkelet import Text from './Text'; type Props = { - /** Function to call when the feed is selected */ onFeedSelect: () => void; - - /** Icon for the card */ CardFeedIcon: React.ReactNode; - - /** Feed name */ feedName?: string; - - /** Supporting text */ supportingText?: string; /** Whether the RBR indicator should be shown */ @@ -32,7 +25,6 @@ type Props = { /** Whether the feed selector should render a loading skeleton */ isLoading?: boolean; - /** Style for the wrapper */ wrapperStyle?: StyleProp; }; diff --git a/src/components/FeedbackSurvey.tsx b/src/components/FeedbackSurvey.tsx index 896c7122ed38..f50a175456ed 100644 --- a/src/components/FeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey.tsx @@ -28,19 +28,13 @@ type FeedbackSurveyProps = { /** A unique Onyx key identifying the form */ formID: typeof ONYXKEYS.FORMS.DISABLE_AUTO_RENEW_SURVEY_FORM | typeof ONYXKEYS.FORMS.CANCEL_SUBSCRIPTION_FORM; - /** Title of the survey */ title: string; - - /** Description of the survey */ description: string; - - /** Callback to be called when the survey is submitted */ onSubmit: (reason: FeedbackSurveyOptionID, note?: string) => void; /** Optional text to render over the submit button */ footerText?: React.ReactNode; - /** Indicates whether note field is required */ isNoteRequired?: boolean; /** Indicates whether a loading indicator should be shown */ diff --git a/src/components/FilePicker/types.ts b/src/components/FilePicker/types.ts index 08f2084a0503..3efd9edb56c1 100644 --- a/src/components/FilePicker/types.ts +++ b/src/components/FilePicker/types.ts @@ -3,7 +3,6 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {ReactNode} from 'react'; type PickerOptions = { - /** A callback that will be called with the selected file. */ onPicked: (file: FileObject) => void; /** A callback that will be called without a selected file. */ @@ -38,7 +37,6 @@ type FilePickerProps = { * */ children: (props: {openPicker: OpenPickerFunction}) => ReactNode; - /** A string of acceptable file types. */ acceptableFileTypes?: string; }; diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx index cecb24e8a3c7..ed7bb596088f 100644 --- a/src/components/FixedFooter.tsx +++ b/src/components/FixedFooter.tsx @@ -14,10 +14,7 @@ type FixedFooterProps = { /** Styles to be assigned to Container */ style?: StyleProp; - /** Whether to add bottom safe area padding to the content. */ addBottomSafeAreaPadding?: boolean; - - /** Whether to add bottom safe area padding to the content. */ addOfflineIndicatorBottomSafeAreaPadding?: boolean; /** Whether to stick the footer to the bottom of the screen. */ diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index a1ef9a4898b7..b343f95b8be1 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -8,10 +8,7 @@ import FlashList from '..'; import CellRendererComponent from './CellRendererComponent'; type InvertedFlashListProps = FlashListProps & { - /** The array of items to render in the list. */ data: T[]; - - /** Function that extracts a unique key for each item in the list. */ keyExtractor: (item: T, index: number) => string; /** Ref to the underlying list instance. */ diff --git a/src/components/FlatList/FlatList/types.ts b/src/components/FlatList/FlatList/types.ts index b6ddf4096302..f82f932fe9c7 100644 --- a/src/components/FlatList/FlatList/types.ts +++ b/src/components/FlatList/FlatList/types.ts @@ -2,14 +2,7 @@ import type {ForwardedRef} from 'react'; import type {CellRendererProps, FlatList, FlatListProps} from 'react-native'; type CustomFlatListProps = Omit, 'CellRendererComponent'> & { - /** - * Ref to the FlatList component - */ ref?: ForwardedRef; - - /** - * Whether to disable the visible content position - */ shouldDisableVisibleContentPosition?: boolean; /** @@ -18,9 +11,6 @@ type CustomFlatListProps = Omit, 'CellRendererComponent'> & */ enableAnimatedKeyboardDismissal?: boolean; - /** - * Custom cell renderer component - */ CellRendererComponent?: React.ComponentType> | null; /** diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index f7df8c1f54c2..9cf7b4840079 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -27,22 +27,13 @@ const SMALL_FAB_PATH = const AnimatedPath = Animated.createAnimatedComponent(Path); type FloatingActionButtonProps = WithSentryLabel & { - /* Callback to fire on request to toggle the FloatingActionButton */ + /** Callback to fire on request to toggle the FloatingActionButton */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined) => void; - /* Callback to fire on long press of the FloatingActionButton */ onLongPress?: (event: GestureResponderEvent | KeyboardEvent | undefined) => void; - - /* Current state (active or not active) of the component */ isActive: boolean; - - /* An accessibility label for the button */ accessibilityLabel: string; - - /* An accessibility role for the button */ role: Role; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/FloatingGPSButton/index.native.tsx b/src/components/FloatingGPSButton/index.native.tsx index 2873bdfd3d36..2b99b0475835 100644 --- a/src/components/FloatingGPSButton/index.native.tsx +++ b/src/components/FloatingGPSButton/index.native.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/FloatingReceiptButton.tsx b/src/components/FloatingReceiptButton.tsx index 7b9de4f8ce3b..e7c2bb4f79e8 100644 --- a/src/components/FloatingReceiptButton.tsx +++ b/src/components/FloatingReceiptButton.tsx @@ -20,13 +20,10 @@ import {PressableWithoutFeedback} from './Pressable'; import Tooltip from './Tooltip'; type FloatingReceiptButtonProps = WithSentryLabel & { - /* Callback to fire on request to toggle the FloatingReceiptButton */ + /** Callback to fire on request to toggle the FloatingReceiptButton */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined) => void; - /* An accessibility label for the button */ accessibilityLabel: string; - - /* An accessibility role for the button */ role: Role; }; diff --git a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts index 718ee986416b..14e18b93ab79 100644 --- a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts +++ b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts @@ -5,7 +5,6 @@ type FocusTrapContainerElementProps = ViewProps & { /** Callback to register focus trap container element */ onContainerElementChanged?: (element: HTMLElement | null) => void; - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 5f012df34c23..9d62d4bc70d3 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.INVOICES, SCREENS.WORKSPACE.MEMBERS, SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CATEGORIES, SCREENS.WORKSPACE.VENDORS, SCREENS.WORKSPACE.MORE_FEATURES, @@ -40,6 +41,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, SCREENS.WORKSPACE.PER_DIEM, + SCREENS.WORKSPACE.MCP, SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.TIME_TRACKING, ]; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 5972fdf2e685..66e3b7f78e9a 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -62,10 +62,7 @@ function getInitialValueByType(valueType?: ValueTypeKey): InitialDefaultValue { } type FormProviderProps = FormProps & { - /** Children to render. */ children: ((props: {inputValues: FormOnyxValues}) => ReactNode) | ReactNode; - - /** Callback to validate the form */ validate?: (values: FormOnyxValues, translate: LocalizedTranslate) => FormInputErrors; /** Should validate function be called when input loose focus */ @@ -77,7 +74,6 @@ type FormProviderProps = FormProps; /** Whether to apply flex to the submit button */ @@ -99,16 +95,9 @@ type FormProviderProps = FormProps = FormProps; + submitButtonAndFooterContainerStyles?: StyleProp; + + /** Styles for the submit button itself (`submitButtonStyles` targets the wrapping container) */ + submitButtonInnerStyles?: StyleProp; }; function FormProvider({ @@ -415,12 +407,17 @@ function FormProvider({ formWrapperRef.current?.scrollToEnd(); }, []); + const scrollTo = useCallback((y: number) => { + formWrapperRef.current?.scrollTo(y); + }, []); + useImperativeHandle(ref, () => ({ resetForm, resetErrors, resetFormFieldError, submit, scrollToEnd, + scrollTo, })); const registerInput = useCallback( diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 577c12b531eb..2702daf706fd 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -29,7 +29,6 @@ import FormContext from './FormContext'; type FormWrapperProps = ChildrenProps & FormProps & { - /** Submit button styles */ submitButtonStyles?: StyleProp; /** Whether to apply flex to the submit button */ @@ -44,13 +43,11 @@ type FormWrapperProps = ChildrenProps & /** Whether the submit button is disabled */ isSubmitDisabled?: boolean; - /** Callback to submit the form */ onSubmit: () => void; /** should render the extra button above submit button */ shouldRenderFooterAboveSubmit?: boolean; - /** Whether the form is loading */ isLoading?: boolean; /** Whether the fix errors alert should be visible */ @@ -59,16 +56,12 @@ type FormWrapperProps = ChildrenProps & /** Server side field errors keyed by field name */ serverErrorFields?: ErrorFields | null; - /** Server side error message */ serverErrorMessage?: string; /** If enabled, the content will have a bottom padding equal to account for the safe bottom area inset. */ addBottomSafeAreaPadding?: boolean; - /** Whether to add bottom safe area padding to the content. */ addOfflineIndicatorBottomSafeAreaPadding?: boolean; - - /** Whether the submit button should stick to the bottom of the screen. */ shouldSubmitButtonStickToBottom?: boolean; /** Fires at most once per frame during scrolling. */ @@ -78,6 +71,10 @@ type FormWrapperProps = ChildrenProps & shouldPreventDefaultFocusOnPressSubmit?: boolean; ref?: ForwardedRef; + submitButtonAndFooterContainerStyles?: StyleProp; + + /** Styles for the submit button itself (`submitButtonStyles` targets the wrapping container) */ + submitButtonInnerStyles?: StyleProp; }; function FormWrapper({ @@ -113,6 +110,8 @@ function FormWrapper({ forwardedFSClass, sentryLabel = CONST.SENTRY_LABEL.FORM.SUBMIT_BUTTON, ref, + submitButtonAndFooterContainerStyles, + submitButtonInnerStyles, }: FormWrapperProps) { const styles = useThemeStyles(); const formRef = useRef(null); @@ -162,6 +161,13 @@ function FormWrapper({ }, CONST.ANIMATED_TRANSITION); }; + const scrollTo = (y: number) => { + // Wait for the keyboard animation to complete + setTimeout(() => { + formRef.current?.scrollTo({y: Math.max(y, 0), animated: true}); + }, CONST.ANIMATED_TRANSITION); + }; + // If either of `addBottomSafeAreaPadding` or `shouldSubmitButtonStickToBottom` is explicitly set, // we expect that the user wants to use the new edge-to-edge mode. // In this case, we want to get and apply the padding unconditionally. @@ -187,6 +193,7 @@ function FormWrapper({ useImperativeHandle(ref, () => ({ scrollToEnd, + scrollTo, })); const SubmitButton = isSubmitButtonVisible && ( @@ -215,6 +222,8 @@ function FormWrapper({ shouldRenderFooterAboveSubmit={shouldRenderFooterAboveSubmit} shouldPreventDefaultFocusOnPress={shouldPreventDefaultFocusOnPressSubmit} sentryLabel={sentryLabel} + buttonAndFooterContainerStyles={submitButtonAndFooterContainerStyles} + buttonStyles={submitButtonInnerStyles} /> ); diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx index a2c2925035d7..0b42b4f50068 100644 --- a/src/components/Form/InputWrapper.tsx +++ b/src/components/Form/InputWrapper.tsx @@ -81,7 +81,6 @@ type InputWrapperProps; }; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index bb6873ccfe30..2a5df318e2f6 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -31,6 +31,8 @@ import type DimensionTypeSelector from '@pages/workspace/accounting/intacct/impo import type NetSuiteCustomFieldMappingPicker from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomFieldMappingPicker'; import type NetSuiteCustomListPicker from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker'; import type NetSuiteMenuWithTopDescriptionForm from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteMenuWithTopDescriptionForm'; +import type InitialListValueSelector from '@pages/workspace/reports/InitialListValueSelector'; +import type TypeSelector from '@pages/workspace/reports/TypeSelector'; import type TaxValuePicker from '@pages/workspace/taxes/TaxValuePicker'; import type {ButtonVariant} from '@styles/utils/types'; @@ -76,6 +78,8 @@ type ValidInputs = | typeof NetSuiteCustomListPicker | typeof NetSuiteCustomFieldMappingPicker | typeof NetSuiteMenuWithTopDescriptionForm + | typeof InitialListValueSelector + | typeof TypeSelector | typeof CountryPicker | typeof StatePicker | typeof ConstantSelector @@ -143,16 +147,12 @@ type FormProps = ForwardedFSClassProp /** A unique Onyx key identifying the form */ formID: TFormID; - /** Text to be displayed in the submit button */ submitButtonText: string; - - /** Submit button styles */ submitButtonStyles?: StyleProp; /** Controls the submit button's visibility */ isSubmitButtonVisible?: boolean; - /** Callback to submit the form */ onSubmit: (values: FormOnyxValues) => void; /** Should the button be enabled when offline */ @@ -170,7 +170,6 @@ type FormProps = ForwardedFSClassProp /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */ scrollContextEnabled?: boolean; - /** Whether to use ScrollView */ shouldUseScrollView?: boolean; /** Container styles */ @@ -201,10 +200,12 @@ type FormRef = { resetFormFieldError: (fieldID: keyof Form) => void; submit: () => void; scrollToEnd: () => void; + scrollTo: (y: number) => void; }; type FormWrapperRef = { scrollToEnd: () => void; + scrollTo: (y: number) => void; }; type InputRefs = Record>; diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index 4250e8623679..0e542d50d0c4 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -14,7 +14,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import FormAlertWrapper from './FormAlertWrapper'; type FormAlertWithSubmitButtonProps = WithSentryLabel & { @@ -27,7 +27,6 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & { /** Whether message is in html format */ isMessageHtml?: boolean; - /** Styles for container element */ containerStyles?: StyleProp; /** Is the button in a loading state */ @@ -41,10 +40,7 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & { */ shouldShowLoadingImmediatelyOnPress?: boolean; - /** Callback fired when the "fix the errors" link is pressed */ onFixTheErrorsLinkPressed?: () => void; - - /** Submit function */ onSubmit: () => void; /** Should the button be enabled when offline */ @@ -59,7 +55,6 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & { /** Custom content to display in the footer after submit button */ footerContent?: React.ReactNode; - /** Styles for the button */ buttonStyles?: StyleProp; /** Whether to show the alert text */ @@ -68,7 +63,6 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & { /** React ref being forwarded to the submit button */ buttonRef?: Ref; - /** Text for the button */ buttonText: string; /** Style for the error message for submit button */ @@ -86,11 +80,12 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & { */ blendButtonOpacity?: boolean; - /** Whether to add a bottom padding to the button */ addButtonBottomPadding?: boolean; /** Prevents the button from triggering blur on mouse down. */ shouldPreventDefaultFocusOnPress?: boolean; + + buttonAndFooterContainerStyles?: StyleProp; }; function FormAlertWithSubmitButton({ @@ -115,11 +110,12 @@ function FormAlertWithSubmitButton({ blendButtonOpacity = false, addButtonBottomPadding = true, shouldPreventDefaultFocusOnPress = false, + buttonAndFooterContainerStyles, shouldShowLoadingImmediatelyOnPress = true, sentryLabel, }: FormAlertWithSubmitButtonProps) { const styles = useThemeStyles(); - const style = [!shouldRenderFooterAboveSubmit && footerContent && addButtonBottomPadding ? styles.mb3 : {}, buttonStyles]; + const style = [!shouldRenderFooterAboveSubmit && footerContent && addButtonBottomPadding ? styles.mb3 : undefined, buttonStyles]; const {isLoading, startWithLoading} = usePressLoading({isLoading: isOnyxLoading}); @@ -147,7 +143,7 @@ function FormAlertWithSubmitButton({ errorMessageStyle={errorMessageStyle} > {(isOffline: boolean | undefined) => ( - + {shouldRenderFooterAboveSubmit && footerContent} {isOffline && !enabledWhenOffline ? ( diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index 0c3d79223f25..89951c9af8bb 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import * as Pressables from '@components/Pressable'; import Text from '@components/Text'; diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 8181b2ae809e..aea3afdf456a 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -18,7 +18,6 @@ type GrowlContent = { }; type GrowlNotificationProps = { - /** Reference to outer element */ ref?: ForwardedRef; }; diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 452a8a61cbd9..cf86bad4c113 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -17,7 +17,6 @@ import htmlRenderers from './HTMLRenderers'; import VICTORY_HTML_ELEMENT_MODELS from './HTMLRenderers/VictoryChartRenderer/victoryHtmlElementModels'; type BaseHTMLEngineProviderProps = ChildrenProps & { - /** Whether text elements should be selectable */ textSelectable?: boolean; /** Handle line breaks according to the HTML standard (default on web) */ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index ced9cae5648f..df3252f11630 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -26,7 +26,6 @@ import TableLinkColumnContext from './TableLinkColumnContext'; import {getTextContent, isLinkColumnAnchor} from './TableRowLink'; type AnchorRendererProps = CustomRendererProps & { - /** Key of the element */ key?: string; }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx index 86e05342d5cb..8873706b9b6e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx @@ -3,7 +3,7 @@ import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import {fontScale, lineHeightScale} from '@styles/typography'; import CONST from '@src/CONST'; @@ -19,7 +19,7 @@ function BulletItemRenderer({tnode}: {tnode: TNode}) { return ( - {CONST.DOT_SEPARATOR} + {CONST.DOT_SEPARATOR} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index f89382fdb93e..2a43c0ea4393 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -11,7 +11,6 @@ import React from 'react'; import {splitBoxModelStyle} from 'react-native-render-html'; type CodeRendererProps = CustomRendererProps & { - /** Key of the element */ key?: string; }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx index eb11bbdb0a6e..e6510df7028a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx @@ -4,7 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import {fontScale} from '@styles/typography'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; @@ -16,11 +16,11 @@ function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps const {translate} = useLocalize(); const isPendingDelete = !!(tnode.attributes.deleted !== undefined); return ( - - + + diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 5420c0b44356..b0eab6765a9b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,5 +1,4 @@ import {AttachmentContext} from '@components/AttachmentContext'; -import {getButtonRole} from '@components/Button/utils'; import {isDeletedNode} from '@components/HTMLEngineProvider/htmlEngineUtils'; import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuState} from '@components/ShowContextMenuContext'; @@ -29,8 +28,8 @@ function ImageRenderer({tnode}: CustomRendererProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - // Re-render this component when account.shouldUseStagingServer changes - useOnyx(ONYXKEYS.SHOULD_USE_STAGING_SERVER); + // Re-render this component when the active server changes + useOnyx(ONYXKEYS.ACTIVE_SERVER); const htmlAttribs = tnode.attributes; const isDeleted = isDeletedNode(tnode); @@ -133,7 +132,7 @@ function ImageRenderer({tnode}: CustomRendererProps) { }} isNested shouldUseHapticsOnLongPress - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} sentryLabel={CONST.SENTRY_LABEL.HTML_RENDERER.IMAGE} > diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 336760a5f44c..b2e16df4d591 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -28,7 +28,6 @@ type VictoryChartCartesianProps = { /** When true, renders without visible chrome (used for snapshots/tests) */ headless?: boolean; - /** Callback invoked with render args on each chart render pass */ onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx index 9da4ee5de273..b7e6c6df20c2 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Tooltip from '@components/Tooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 66ac91776a76..b76164c3d096 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -20,7 +20,6 @@ import {View} from 'react-native'; import VictoryChartContent from './VictoryChartContent'; type VictoryChartExpandModalProps = { - /** Whether the modal is visible */ isVisible: boolean; /** Called when the modal should close */ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 1094847ac2f0..c7addc6a3472 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts @@ -123,7 +123,6 @@ type LabelItem = { /** Text horizontal anchor */ textAnchor?: TextAnchor; - /** Text vertical anchor */ verticalAnchor?: TextAnchor; }; @@ -134,22 +133,11 @@ type LegendItemEntry = { /** The color of the text */ color?: Color; - /** Font size */ fontSize?: number; - - /** Font weight */ fontWeight?: 'normal' | 'bold'; - - /** Font family */ fontFamily?: string; - - /** Font style */ fontStyle?: string; - - /** The color of the symbol */ symbolColor?: Color; - - /** Symbol size */ symbolSize?: number; }; @@ -160,7 +148,6 @@ type LegendItem = { /** Position on the Y-axis */ y: number; - /** Legend entries */ entries: LegendItemEntry[]; /** Space between entries */ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index b19bffcc903f..a1386db509ca 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -16,7 +16,6 @@ import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import React from 'react'; type VideoRendererProps = CustomRendererProps & { - /** Key of the element */ key?: string; }; diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index c0dfe836ba6d..000000000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; -import useDialogContainerFocus from '@hooks/useDialogContainerFocus'; -import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import CONST from '@src/CONST'; - -import type {ReactNode} from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; - -import React, {useMemo} from 'react'; -import {Linking, View} from 'react-native'; - -import EnvironmentBadge from './EnvironmentBadge'; -import Text from './Text'; -import TextLink from './TextLink'; - -type HeaderProps = { - /** Title of the Header */ - title?: ReactNode; - - /** Subtitle of the header */ - subtitle?: ReactNode; - - /** Should we show the environment badge (dev/stg)? */ - shouldShowEnvironmentBadge?: boolean; - - /** Additional text styles */ - textStyles?: StyleProp; - - /** Additional header styles */ - style?: StyleProp; - - /** Additional header container styles */ - containerStyles?: StyleProp; - - /** The URL link associated with the attachment's subtitle, if available */ - subTitleLink?: string; - - /** Line number for the title */ - numberOfTitleLines?: number; - - /** Whether this is the screen-level header (registers dialog label and focus). Only HeaderWithBackButton should set this. */ - isScreenHeader?: boolean; - - /** Whether to skip focus of the first interactive element inside the header after the RHP transition for screen reader announcement. */ - shouldSkipFocusAfterTransition?: boolean; -}; - -function Header({ - title = '', - subtitle = '', - textStyles = [], - style, - containerStyles = [], - shouldShowEnvironmentBadge = false, - subTitleLink = '', - numberOfTitleLines = 2, - isScreenHeader = false, - shouldSkipFocusAfterTransition = false, -}: HeaderProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(isScreenHeader ? title : ''); - - useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, shouldSkipFocusAfterTransition); - - const dialogTitle = isScreenHeader && typeof title === 'string' ? title : ''; - const dialogAnnouncement = dialogTitle ? `${dialogTitle}, ${translate('common.dialogOpened')}` : ''; - // Polite so JAWS can finish the tab-title "(1) …" (left paren…) before "{title}, dialog" — assertive was cutting it off at "lef". - // Keep announcing even when shouldSkipFocusAfterTransition is set — that flag only skips focus moves (e.g. New Task / IOU confirmation). - // Web-only: iOS VoiceOver must not speak this (index.ios honors shouldAnnounceOnNative; default would announce). - useAccessibilityAnnouncement(dialogAnnouncement, isTransitionReady && !!dialogTitle, { - shouldAnnounceOnWeb: true, - shouldAnnounceOnNative: false, - politeness: 'polite', - }); - - const renderedSubtitle = useMemo( - () => ( - <> - {/* If there's no subtitle then display a fragment to avoid an empty space which moves the main title */} - {typeof subtitle === 'string' - ? !!subtitle && ( - - {subtitle} - - ) - : subtitle} - - ), - [subtitle, styles], - ); - - const renderedSubTitleLink = useMemo( - () => ( - { - Linking.openURL(subTitleLink); - }} - numberOfLines={1} - style={styles.label} - > - {subTitleLink} - - ), - [styles.label, subTitleLink], - ); - - return ( - - - {typeof title === 'string' - ? !!title && ( - - {title} - - ) - : title} - {renderedSubtitle} - {!!subTitleLink && renderedSubTitleLink} - - {shouldShowEnvironmentBadge && } - - ); -} - -export default Header; - -export type {HeaderProps}; diff --git a/src/components/HeaderPageLayout.tsx b/src/components/HeaderPageLayout.tsx index 232698519ed4..118a14724a50 100644 --- a/src/components/HeaderPageLayout.tsx +++ b/src/components/HeaderPageLayout.tsx @@ -39,18 +39,13 @@ type HeaderPageLayoutProps = ChildrenProps & /** Style to apply to the header image container */ headerContainerStyles?: StyleProp; - /** Style to apply to the ScrollView container */ scrollViewContainerStyles?: StyleProp; - - /** Style to apply to the children container */ childrenContainerStyles?: StyleProp; /** Style to apply to the whole section container */ style?: StyleProp; - /** Whether or not to show the offline indicator */ shouldShowOfflineIndicatorInWideScreen?: boolean; - keyboardShouldPersistTaps?: ScrollViewProps['keyboardShouldPersistTaps']; }; function HeaderPageLayout({ diff --git a/src/components/HeaderTitle/HeaderTitle.tsx b/src/components/HeaderTitle/HeaderTitle.tsx new file mode 100644 index 000000000000..06ce2b7358cf --- /dev/null +++ b/src/components/HeaderTitle/HeaderTitle.tsx @@ -0,0 +1,37 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import {View} from 'react-native'; + +import useHeaderDialogAnnouncement from './useHeaderDialogAnnouncement'; + +type HeaderTitleProps = { + /** Additional header container styles */ + style?: StyleProp; + + /** The title to be used for the dialog (used for screen reader announcements). */ + dialogLabel?: string; + + /** Whether to skip focus of the first interactive element inside the header after the RHP transition for screen reader announcement. */ + shouldSkipFocusAfterTransition?: boolean; + + /** Children to render below the title */ + children?: React.ReactNode; +}; + +function HeaderTitle({children, style, dialogLabel = '', shouldSkipFocusAfterTransition = false}: HeaderTitleProps) { + const styles = useThemeStyles(); + + useHeaderDialogAnnouncement(dialogLabel, shouldSkipFocusAfterTransition); + + return ( + + {children} + + ); +} + +export default HeaderTitle; + +export type {HeaderTitleProps}; diff --git a/src/components/HeaderTitle/HeaderTitleSubtitle.tsx b/src/components/HeaderTitle/HeaderTitleSubtitle.tsx new file mode 100644 index 000000000000..68095dd2ee45 --- /dev/null +++ b/src/components/HeaderTitle/HeaderTitleSubtitle.tsx @@ -0,0 +1,23 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +type HeaderTitleSubtitleProps = { + /** The subtitle text to display */ + children: string; +}; + +function HeaderTitleSubtitle({children}: HeaderTitleSubtitleProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +export default HeaderTitleSubtitle; diff --git a/src/components/HeaderTitle/HeaderTitleSubtitleLink.tsx b/src/components/HeaderTitle/HeaderTitleSubtitleLink.tsx new file mode 100644 index 000000000000..b9ebed70b3f4 --- /dev/null +++ b/src/components/HeaderTitle/HeaderTitleSubtitleLink.tsx @@ -0,0 +1,28 @@ +import TextLink from '@components/TextLink'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import {Linking} from 'react-native'; + +type HeaderTitleSubtitleLinkProps = { + /** The URL to open on press, also used as the displayed link text */ + children: string; +}; + +function HeaderTitleSubtitleLink({children}: HeaderTitleSubtitleLinkProps) { + const styles = useThemeStyles(); + + return ( + { + Linking.openURL(children); + }} + numberOfLines={1} + style={styles.label} + > + {children} + + ); +} + +export default HeaderTitleSubtitleLink; diff --git a/src/components/HeaderTitle/HeaderTitleText.tsx b/src/components/HeaderTitle/HeaderTitleText.tsx new file mode 100644 index 000000000000..c567be4685d8 --- /dev/null +++ b/src/components/HeaderTitle/HeaderTitleText.tsx @@ -0,0 +1,35 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {StyleProp, TextStyle} from 'react-native'; + +type HeaderTitleTextProps = { + /** The title text to display */ + children: string; + + /** Additional text styles */ + style?: StyleProp; + + /** Maximum number of lines before the title truncates */ + numberOfLines?: number; +}; + +function HeaderTitleText({children, style, numberOfLines = 2}: HeaderTitleTextProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +export default HeaderTitleText; diff --git a/src/components/HeaderTitle/index.tsx b/src/components/HeaderTitle/index.tsx new file mode 100644 index 000000000000..51a40154da51 --- /dev/null +++ b/src/components/HeaderTitle/index.tsx @@ -0,0 +1,18 @@ +import type {HeaderTitleProps} from './HeaderTitle'; + +import HeaderTitleComponent from './HeaderTitle'; +import HeaderTitleSubtitle from './HeaderTitleSubtitle'; +import HeaderTitleSubtitleLink from './HeaderTitleSubtitleLink'; +import HeaderTitleText from './HeaderTitleText'; + +function HeaderTitleBase(props: HeaderTitleProps) { + return ; +} + +const HeaderTitle = Object.assign(HeaderTitleBase, { + Text: HeaderTitleText, + Subtitle: HeaderTitleSubtitle, + SubtitleLink: HeaderTitleSubtitleLink, +}); + +export default HeaderTitle; diff --git a/src/components/HeaderTitle/useHeaderDialogAnnouncement.ts b/src/components/HeaderTitle/useHeaderDialogAnnouncement.ts new file mode 100644 index 000000000000..3b2db4d7ad99 --- /dev/null +++ b/src/components/HeaderTitle/useHeaderDialogAnnouncement.ts @@ -0,0 +1,32 @@ +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDialogContainerFocus from '@hooks/useDialogContainerFocus'; +import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; +import useLocalize from '@hooks/useLocalize'; + +/** + * Announces `dialogTitle` to screen readers as the RHP's dialog name, and moves focus into the dialog + * after its transition finishes (`shouldSkipFocusAfterTransition`) — the focus-steal applies to every + * dialog-shaped `HeaderTitle` usage, regardless of title. + * + * The title registration/announcement half is opt-in: only the ONE `HeaderTitle` that represents the + * actual RHP screen title should pass a real `dialogTitle`. Any other `HeaderTitle` inside the same + * dialog must pass '' (default) — otherwise a second dialog name/announcement fires for the same screen. + */ +function useHeaderDialogAnnouncement(dialogTitle: string, shouldSkipFocusAfterTransition: boolean) { + const {translate} = useLocalize(); + + const dialogAnnouncement = dialogTitle ? `${dialogTitle}, ${translate('common.dialogOpened')}` : ''; + const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(dialogTitle); + useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, shouldSkipFocusAfterTransition); + + // Polite so JAWS can finish the tab-title "(1) …" (left paren…) before "{title}, dialog" — assertive was cutting it off at "lef". + // Keep announcing even when shouldSkipFocusAfterTransition is set — that flag only skips focus moves (e.g. New Task / IOU confirmation). + // Web-only: iOS VoiceOver must not speak this (index.ios honors shouldAnnounceOnNative; default would announce). + useAccessibilityAnnouncement(dialogAnnouncement, isTransitionReady && !!dialogTitle, { + shouldAnnounceOnWeb: true, + shouldAnnounceOnNative: false, + politeness: 'polite', + }); +} + +export default useHeaderDialogAnnouncement; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 25741b294fe2..ea0576d2f18b 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -2,7 +2,7 @@ import ActivityIndicator from '@components/ActivityIndicator'; import UserAvatar from '@components/Avatar/UserAvatar'; import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; -import Header from '@components/Header'; +import HeaderTitle from '@components/HeaderTitle'; import Icon from '@components/Icon'; import PinButton from '@components/PinButton'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -116,16 +116,22 @@ function HeaderWithBackButton({ ); } + const resolvedSubtitle = stepCounterTranslation ?? subtitle; + return ( -
+ > + + {title} + + {!!resolvedSubtitle && {resolvedSubtitle}} + {!!subTitleLink && {subTitleLink}} + ); }, [ StyleUtils, diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 21c12bdf25d8..b0b73f87dfc2 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -12,7 +12,6 @@ import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ReactNode} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -24,18 +23,12 @@ type ThreeDotsMenuItem = { /** Translation key for the label */ translationKey: TranslationPaths; - /** A callback triggered when the item is selected */ onSelected: () => void; }; type HeaderWithBackButtonProps = Partial & { - /** Title of the Header */ title?: string; - - /** Subtitle of the header */ - subtitle?: ReactNode; - - /** Title color */ + subtitle?: string; titleColor?: string; /** @@ -45,10 +38,7 @@ type HeaderWithBackButtonProps = Partial & { * */ icon?: IconAsset; - /** Icon Width */ iconWidth?: number; - - /** Icon Height */ iconHeight?: number; /** Any additional styles to pass to the icon container. */ @@ -69,37 +59,28 @@ type HeaderWithBackButtonProps = Partial & { /** Method to trigger when pressing more options button of the header */ onThreeDotsButtonPress?: () => void; - /** Whether we should show a border on the bottom of the Header */ shouldShowBorderBottom?: boolean; /** Whether we should display the status of the report */ shouldDisplayStatus?: boolean; - /** Whether we should show a download button */ shouldShowDownloadButton?: boolean; /** Whether we should show a loading indicator replacing the download button */ isDownloading?: boolean; - /** Whether we should show a rotate button */ shouldShowRotateButton?: boolean; /** Whether we should show a loading indicator replacing the rotate button */ isRotating?: boolean; - /** Whether we should show a pin button */ shouldShowPinButton?: boolean; - - /** Whether we should show a more options (three dots) button */ shouldShowThreeDotsButton?: boolean; - - /** Whether we should disable three dots button */ shouldDisableThreeDotsButton?: boolean; /** Whether we should set modal visibility when three dot menu opens */ shouldSetModalVisibility?: boolean; - /** List of menu items for more(three dots) menu */ threeDotsMenuItems?: PopoverMenuItem[]; /** The anchor alignment of the menu */ @@ -111,16 +92,12 @@ type HeaderWithBackButtonProps = Partial & { /** The fill color to pass into the icon. */ threeDotsMenuIconFill?: string; - /** Whether we should show a close button */ shouldShowCloseButton?: boolean; - - /** Whether we should show a back button */ shouldShowBackButton?: boolean; /** Data to display a step counter in the header */ stepCounter?: StepCounterParams; - /** Whether we should show a report avatar */ shouldShowReportAvatarWithDisplay?: boolean; /** Parent report, if provided it will override props.report for AvatarWithDisplay */ @@ -135,7 +112,6 @@ type HeaderWithBackButtonProps = Partial & { /** Whether we should navigate to report page when the route have a topMostReport */ shouldNavigateToTopMostReport?: boolean; - /** Whether the header should use the headline header style */ shouldUseHeadlineHeader?: boolean; /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ @@ -144,10 +120,7 @@ type HeaderWithBackButtonProps = Partial & { /** Whether the popover menu should overlay the current view */ shouldOverlay?: boolean; - /** Whether we should enable detail page navigation */ shouldEnableDetailPageNavigation?: boolean; - - /** Number of lines to display for the title */ numberOfTitleLines?: number; /** Whether we should overlay the 3 dots menu */ @@ -165,10 +138,7 @@ type HeaderWithBackButtonProps = Partial & { /** Size of the policy avatar. Defaults to CONST.AVATAR_SIZE.DEFAULT */ policyAvatarSize?: ValueOf; - /** Additional styles to apply to the title text */ titleStyles?: StyleProp; - - /** Additional styles to add to the component */ style?: StyleProp; /** The URL link associated with the attachment's subtitle, if available */ diff --git a/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx b/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx index 6f78f78d3caa..e420c8ee3726 100644 --- a/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx +++ b/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx @@ -171,6 +171,7 @@ function HeaderWithBackButton({ /> ) : ( - {title} - + /> )} diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx b/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx index 51682ff8fb94..d7021ad6b17e 100644 --- a/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx +++ b/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx @@ -1,4 +1,4 @@ -import Header from '@components/Header'; +import HeaderTitleComponent from '@components/HeaderTitle'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -6,20 +6,16 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {StepCounterParams} from '@src/languages/params'; -import type {ReactNode} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; type HeaderTitleProps = { /** Title of the header. */ - children: string; + title: string; /** Subtitle of the header. */ - subtitle?: ReactNode; + subtitle?: string; - /** Title color. */ titleColor?: string; - - /** Additional styles to apply to the title text. */ titleStyles?: StyleProp; /** Data to display a step counter in the header. When set, it replaces the subtitle. */ @@ -35,21 +31,27 @@ type HeaderTitleProps = { shouldUseHeadlineHeader: boolean; }; -function HeaderTitle({children, subtitle = '', titleColor, titleStyles, stepCounter, subTitleLink = '', shouldSkipFocusAfterTransition = false, shouldUseHeadlineHeader}: HeaderTitleProps) { +function HeaderTitle({title, subtitle = '', titleColor, titleStyles, stepCounter, subTitleLink = '', shouldSkipFocusAfterTransition = false, shouldUseHeadlineHeader}: HeaderTitleProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const resolvedSubtitle = stepCounter ? translate('stepCounter', stepCounter.step, stepCounter.total, stepCounter.text) : subtitle; + return ( -
+ > + + {title} + + {!!resolvedSubtitle && {resolvedSubtitle}} + {!!subTitleLink && {subTitleLink}} + ); } diff --git a/src/components/HighlightableMenuItem.tsx b/src/components/HighlightableMenuItem.tsx index e29151afd23d..38d166193bd0 100644 --- a/src/components/HighlightableMenuItem.tsx +++ b/src/components/HighlightableMenuItem.tsx @@ -12,7 +12,6 @@ import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; type Props = MenuItemProps & { - /** Should the menu item be highlighted? */ highlighted?: boolean; }; diff --git a/src/components/HighlightableMenuItemWithTopDescription.tsx b/src/components/HighlightableMenuItemWithTopDescription.tsx index 6f2360aaf73d..4d52224f01f2 100644 --- a/src/components/HighlightableMenuItemWithTopDescription.tsx +++ b/src/components/HighlightableMenuItemWithTopDescription.tsx @@ -8,7 +8,6 @@ import type {MenuItemProps} from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; type HighlightableMenuItemWithTopDescriptionProps = MenuItemProps & { - /** Should the menu item be highlighted? */ highlighted?: boolean; }; diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx index 30af84f19df0..f8515a6d6957 100644 --- a/src/components/HoldMenuSectionList.tsx +++ b/src/components/HoldMenuSectionList.tsx @@ -17,7 +17,6 @@ type HoldMenuSection = { /** The icon supplied with the section */ icon: IconAsset; - /** Translation key for the title */ titleTranslationKey: TranslationPaths; }; diff --git a/src/components/HoldOrRejectEducationalModal.tsx b/src/components/HoldOrRejectEducationalModal.tsx index c37657c4dd95..8d30b7aee616 100644 --- a/src/components/HoldOrRejectEducationalModal.tsx +++ b/src/components/HoldOrRejectEducationalModal.tsx @@ -21,7 +21,6 @@ type SectionMenuItem = { /** The icon supplied with the section */ icon: IconAsset; - /** Translation key for the title */ titleTranslationKey: TranslationPaths; }; diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts index 8ac4e94d0d6a..aba5584eed82 100644 --- a/src/components/Hoverable/types.ts +++ b/src/components/Hoverable/types.ts @@ -8,7 +8,6 @@ type HoverableChild = ReactElement; type HoverableChildren = ((isHovered: boolean) => HoverableChild) | HoverableChild; type HoverableProps = { - /** Children to wrap with Hoverable. */ children: HoverableChildren; /** Whether to disable the hover action */ @@ -38,7 +37,6 @@ type HoverableProps = { */ shouldUseNativeHoverEvents?: boolean; - /** Reference to the outer element */ ref?: Ref; }; diff --git a/src/components/Icon/InlineIcon/index.tsx b/src/components/Icon/InlineIcon/index.tsx index ffaf878a817a..a34791cea1f7 100644 --- a/src/components/Icon/InlineIcon/index.tsx +++ b/src/components/Icon/InlineIcon/index.tsx @@ -16,7 +16,6 @@ import {View} from 'react-native'; import IconWrapperStyles from './IconWrapperStyles'; type InlineIconProps = { - /** Icon asset to render. */ src: IconAsset; /** Preset size that resolves to square pixel dimensions, matching `Icon`. */ diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 4d388c980589..daafb8764fc9 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -79,6 +79,7 @@ import CreditCard from '@assets/images/creditcard.svg'; import Crop from '@assets/images/crop.svg'; import Crosshair from '@assets/images/crosshair.svg'; import DocumentBookmark from '@assets/images/document-bookmark.svg'; +import DocumentCheck from '@assets/images/document-check.svg'; import DocumentMagicWand from '@assets/images/document-magic-wand.svg'; import DocumentMerge from '@assets/images/document-merge.svg'; import DocumentPlus from '@assets/images/document-plus.svg'; @@ -134,8 +135,12 @@ import ImageCropSquareMask from '@assets/images/image-crop-square-mask.svg'; import Inbox from '@assets/images/inbox.svg'; import Info from '@assets/images/info.svg'; import BillComSquare from '@assets/images/integrationicons/bill-com-icon-square.svg'; +import CampfireSquare from '@assets/images/integrationicons/campfire-icon-square.svg'; import CertiniaSquare from '@assets/images/integrationicons/certinia-icon-square.svg'; +import ChatGPTSquare from '@assets/images/integrationicons/chatgpt-icon-square.svg'; import CircleSlash from '@assets/images/integrationicons/circle-slash.svg'; +import ClaudeSquare from '@assets/images/integrationicons/claude-icon-square.svg'; +import CursorSquare from '@assets/images/integrationicons/cursor-icon-square.svg'; import DualEntrySquare from '@assets/images/integrationicons/dualentry-icon-square.svg'; import NetSuiteExport from '@assets/images/integrationicons/export/netsuite-icon.svg'; import QBOExport from '@assets/images/integrationicons/export/qbo-icon.svg'; @@ -202,6 +207,7 @@ import Paycheck from '@assets/images/paycheck.svg'; import Pencil from '@assets/images/pencil.svg'; import Percent from '@assets/images/percent.svg'; import Phone from '@assets/images/phone.svg'; +import PieChart from '@assets/images/pie-chart.svg'; import Pin from '@assets/images/pin.svg'; import PlaneCircleSlash from '@assets/images/plane-circle-slash.svg'; import Plane from '@assets/images/plane.svg'; @@ -466,6 +472,7 @@ const Expensicons = { Pencil, Percent, Phone, + PieChart, Pin, Play, Plus, @@ -526,6 +533,9 @@ const Expensicons = { Wrench, XeroSquare, GustoSquare, + ChatGPTSquare, + ClaudeSquare, + CursorSquare, IntacctSquare: SageIntacctSquare, AdvancedApprovalsSquare, Zoom, @@ -585,10 +595,12 @@ const Expensicons = { XeroExport, ArrowCircleClockwise, LuggageWithLines, + DocumentCheck, LuggageWithLinesPlus, TreasureChestGreenWithSparkle, UserShield, Compass, + CampfireSquare, }; // Create the ExpensifyIcons object from the imported Expensicons diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index 8736dcd1ca59..bdf3daa7f748 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -107,6 +107,7 @@ import Puzzle from '@assets/images/simple-illustrations/emptystate__puzzlepieces import Abacus from '@assets/images/simple-illustrations/simple-illustration__abacus.svg'; // Simple Illustrations - Original core ones import Accounting from '@assets/images/simple-illustrations/simple-illustration__accounting.svg'; +import AiAutomation from '@assets/images/simple-illustrations/simple-illustration__ai-automation.svg'; import AiBot from '@assets/images/simple-illustrations/simple-illustration__ai-bot.svg'; import Alert from '@assets/images/simple-illustrations/simple-illustration__alert.svg'; import Members from '@assets/images/simple-illustrations/simple-illustration__approval-members.svg'; @@ -124,6 +125,8 @@ import CalculatorMoney from '@assets/images/simple-illustrations/simple-illustra import CalendarMonthly from '@assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg'; import CarIce from '@assets/images/simple-illustrations/simple-illustration__car-ice.svg'; import Car from '@assets/images/simple-illustrations/simple-illustration__car.svg'; +import CardDenied from '@assets/images/simple-illustrations/simple-illustration__carddenied.svg'; +import CardIntoWallet from '@assets/images/simple-illustrations/simple-illustration__cardintowallet.svg'; import CardReaderAlt from '@assets/images/simple-illustrations/simple-illustration__cardreader-alt.svg'; import Chair from '@assets/images/simple-illustrations/simple-illustration__chair.svg'; import Chalkboard from '@assets/images/simple-illustrations/simple-illustration__chalkboard.svg'; @@ -338,11 +341,14 @@ const Illustrations = { // Simple Illustrations Accounting, + AiAutomation, AiBot, Building, BuildingCross, BankCoin, Coins, + CardDenied, + CardIntoWallet, CreditCardsNew, FolderOpen, HandCard, diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index a47596fa579d..6fa50f8568fd 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -15,7 +15,6 @@ import {View} from 'react-native'; import type IconSize from './types'; type IconProps = { - /** The asset to render. */ src: IconAsset | undefined; /** Custom width when no preset size is selected. */ @@ -30,10 +29,7 @@ type IconProps = { /** Preset icon size. */ size?: IconSize; - /** Whether the icon is hovered. */ hovered?: boolean; - - /** Whether the icon is pressed. */ pressed?: boolean; /** Additional styles for the icon wrapper. */ diff --git a/src/components/Image/getImageSource.ts b/src/components/Image/getImageSource.ts index bde0387e05a1..c00cb85c245f 100644 --- a/src/components/Image/getImageSource.ts +++ b/src/components/Image/getImageSource.ts @@ -1,4 +1,4 @@ -import {isExpiredSession} from '@libs/actions/Session'; +import {isDelegateSession, isExpiredSession} from '@libs/actions/Session'; import CONST from '@src/CONST'; import type Session from '@src/types/onyx/Session'; @@ -25,7 +25,7 @@ export default function getImageSource({propsSource, session, isAuthTokenRequire const authToken = session?.encryptedAuthToken ?? null; if (isAuthTokenRequired && authToken) { - if (isOffline || (!!session?.creationDate && !isExpiredSession(session.creationDate))) { + if (isOffline || isDelegateSession(session) || (!!session?.creationDate && !isExpiredSession(session.creationDate))) { return { source: { ...propsSource, diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index 49d4d0a1bd54..fe4c3cf22901 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -27,10 +27,7 @@ type BaseImageProps = { /** Called when the image starts to load. */ onLoadStart?: () => void; - /** Styles for the Image */ style?: StyleProp; - - /** The image cache policy */ cachePolicy?: ImagePrefetchOptions['cachePolicy']; /** Priorities for completing loads. If more than one load is queued at a time, @@ -49,10 +46,7 @@ type ImageOwnProps = BaseImageProps & { /** How should the image fit within its container */ resizeMode?: ImageResizeMode; - /** The size of the loading indicator */ loadingIconSize?: FullScreenLoadingIndicatorIconSize; - - /** The style of the loading indicator */ loadingIndicatorStyles?: StyleProp; /** Event for when the image begins loading */ @@ -61,13 +55,11 @@ type ImageOwnProps = BaseImageProps & { /** Event for when the image finishes loading */ onLoadEnd?: () => void; - /** Error handler */ onError?: () => void; /** Progress events while the image is downloading */ onProgress?: () => void; - /** The object position of image */ objectPosition?: ImageObjectPosition; /** diff --git a/src/components/ImageSVG/types.ts b/src/components/ImageSVG/types.ts index 919a0d2bb14f..424bd6898b3e 100644 --- a/src/components/ImageSVG/types.ts +++ b/src/components/ImageSVG/types.ts @@ -4,25 +4,15 @@ import type {ImageContentFit, ImageStyle} from 'expo-image'; import type {StyleProp, ViewStyle} from 'react-native'; type ImageSVGProps = { - /** The asset to render. */ src: IconAsset | undefined; - - /** The width of the image. */ width?: number | `${number}%` | 'auto'; - - /** The height of the image. */ height?: number | `${number}%` | 'auto'; /** The fill color for the image. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ fill?: string; - /** Is image hovered */ hovered?: boolean; - - /** Is image pressed */ pressed?: boolean; - - /** Additional styles to add to the component */ style?: StyleProp; /** Determines how the image should be resized to fit its container */ @@ -38,10 +28,9 @@ type ImageSVGProps = { // eslint-disable-next-line @typescript-eslint/naming-convention 'aria-hidden'?: boolean; - /** TestID for test */ testID?: string; - //* Called when the image load either succeeds or fails. */ + /** Called when the image load either succeeds or fails */ onLoadEnd?: () => void; }; diff --git a/src/components/ImageView/types.ts b/src/components/ImageView/types.ts index 76664c9f8711..3380d278877d 100644 --- a/src/components/ImageView/types.ts +++ b/src/components/ImageView/types.ts @@ -10,13 +10,11 @@ type ImageViewProps = Pick & { /** URL to full-sized image */ url: string; - /** image file name */ fileName: string; /** Handles errors while displaying the image */ onError?: () => void; - /** Additional styles to add to the component */ style?: StyleProp; /** Range of zoom that can be applied to the content by pinching or double tapping. */ diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 6a236ab06970..a7f362208c32 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -14,16 +14,12 @@ import Image from './Image'; import LoadingIndicator from './LoadingIndicator'; type ImageWithSizeLoadingProps = { - /** Any additional styles to apply */ containerStyles?: StyleProp; /** Whether the image requires an authToken */ isAuthTokenRequired: boolean; - /** The object position of image */ objectPosition?: ImageObjectPosition; - - /** Whether to show offline indicator */ shouldShowOfflineIndicator?: boolean; /** Invoked on mount and layout changes */ @@ -51,10 +47,6 @@ function ImageWithLoading({ const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); - // The full-resolution image is not guaranteed to ever emit `onLoad`/`onError` (e.g. a receipt derivative that is - // still being generated server-side), so `isLoading` can stay `true` indefinitely. Once the low-resolution preview - // is on screen we have something readable to show, so the loading state must stop being visible at that point. - const [isThumbnailLoading, setIsThumbnailLoading] = useState(!!previewUri); const {isOffline} = useNetwork(); const handleError = () => { @@ -101,12 +93,9 @@ function ImageWithLoading({ { - setIsThumbnailLoading(false); - onLoad?.(e); - }} + onLoad={onLoad} loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} /> @@ -132,13 +121,12 @@ function ImageWithLoading({ isLoadedRef.current = false; setIsImageCached(false); setIsLoading(true); - setIsThumbnailLoading(!!previewUri); waitForSession?.(); }} loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} /> - {isLoading && (!previewUri || isThumbnailLoading) && !isImageCached && !isOffline && ( + {(previewUri ? isLoading : isLoading && !isImageCached) && !isOffline && ( ; /** Callback fired when the image has been measured. */ @@ -41,19 +36,13 @@ type ImageWithSizeCalculationProps = { /** Whether the image requires an authToken */ isAuthTokenRequired: boolean; - /** The object position of image */ objectPosition?: ImageObjectPosition; - - /** The size of the loading indicator */ loadingIconSize?: FullScreenLoadingIndicatorIconSize; - - /** The style of the loading indicator */ loadingIndicatorStyles?: StyleProp; /** Callback to be called when the image loads */ onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; - /** The resize mode of the image */ resizeMode?: ImageResizeMode; /** Low-resolution URI shown as a placeholder while the full image loads */ diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index f85edfe5685a..5a9f1d1e9533 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -237,18 +237,15 @@ type ColumnRole = { /** Translated text to be displayed */ text: string; - /** Unique value of the option */ value: string; /** Used for any additional text - e.g. if the field is required */ description?: string; - /** Whether the column is required for import */ isRequired?: boolean; }; type ImportColumnProps = { - /** It is an array of all values in specific column */ column: string[]; /** It is column[0] when containsHeader = true or it is Column A, B, C,... otherwise */ @@ -260,7 +257,6 @@ type ImportColumnProps = { /** Index of the column in the spreadsheet */ columnIndex: number; - /** Whether to show the dropdown menu */ shouldShowDropdownMenu?: boolean; }; diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index 6ebf85634d41..bb091462897e 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -23,7 +23,7 @@ import React, {useRef, useState} from 'react'; import {PanResponder, PixelRatio, Platform, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; -import Button from './ButtonComposed'; +import Button from './Button'; import DragAndDropConsumer from './DragAndDrop/Consumer'; import DragAndDropProvider from './DragAndDrop/Provider'; import FilePicker from './FilePicker'; @@ -34,16 +34,15 @@ import ScreenWrapper from './ScreenWrapper'; import Text from './Text'; type ImportSpreadsheetProps = { - // The route to navigate to when the back button is pressed. + /** The route to navigate to when the back button is pressed */ backTo?: Routes; - // The route to navigate to after the file import is completed. + /** The route to navigate to after the file import is completed */ goTo: Routes; - // If true, replace the current route after import instead of pushing on top. + /** If true, replace the current route after import instead of pushing on top */ shouldForceReplaceNavigation?: boolean; - /** Whether the spreadsheet is importing multi-level tags */ isImportingMultiLevelTags?: boolean; }; diff --git a/src/components/ImportSpreadsheetColumns.tsx b/src/components/ImportSpreadsheetColumns.tsx index 527609ce11a7..917f5c3115fc 100644 --- a/src/components/ImportSpreadsheetColumns.tsx +++ b/src/components/ImportSpreadsheetColumns.tsx @@ -14,7 +14,7 @@ import {View} from 'react-native'; import type {ColumnRole} from './ImportColumn'; -import Button from './ButtonComposed'; +import Button from './Button'; import FixedFooter from './FixedFooter'; import ImportColumn from './ImportColumn'; import OfflineWithFeedback from './OfflineWithFeedback'; @@ -24,33 +24,18 @@ import Text from './Text'; import TextLink from './TextLink'; type ImportSpreadsheetColumnsProps = { - // An array of arrays containing strings, representing the spreadsheet data. spreadsheetColumns: string[][]; - - // An array of strings representing the names of the columns. columnNames: string[]; - - // An array of column roles to define the role of each column. columnRoles?: ColumnRole[]; - - // A function to perform the import operation. importFunction: () => void | Promise; - - // An optional Errors object containing any errors that may have occurred. errors?: Errors | null; - - // An optional boolean indicating whether the import button is in a loading state. isButtonLoading?: boolean; - // Link to learn more about the file preparation for import. + /** Link to learn more about the file preparation for import */ learnMoreLink?: string; - // An optional boolean indicating whether to show the column header. shouldShowColumnHeader?: boolean; - - // An optional boolean indicating whether to show the dropdown menu. shouldShowDropdownMenu?: boolean; - customHeaderText?: string; /** An optional boolean indicating whether the import button should be disabled while offline. Defaults to true. */ diff --git a/src/components/ImportedFromAccountingSoftware.tsx b/src/components/ImportedFromAccountingSoftware.tsx index bf6edd87a179..4cfd8d237ba7 100644 --- a/src/components/ImportedFromAccountingSoftware.tsx +++ b/src/components/ImportedFromAccountingSoftware.tsx @@ -27,10 +27,7 @@ type ImportedFromAccountingSoftwareProps = { /** The policy ID to link to */ policyID: string; - /** The name of the current connection */ currentConnectionName: string; - - /** The connected integration */ connectedIntegration: ConnectionName | undefined; /** The translated text for the "imported from" message */ @@ -59,6 +56,7 @@ function ImportedFromAccountingSoftware({policyID, currentConnectionName, transl 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', ]); const icon = getIntegrationIcon(connectedIntegration, expensifyIcons, policy); diff --git a/src/components/ImportedStateIndicator.tsx b/src/components/ImportedStateIndicator.tsx index c760cf328734..9417f06aa540 100644 --- a/src/components/ImportedStateIndicator.tsx +++ b/src/components/ImportedStateIndicator.tsx @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; function ImportedStateIndicator() { const styles = useThemeStyles(); diff --git a/src/components/InteractiveStepButton.tsx b/src/components/InteractiveStepButton.tsx index 19933cacf220..3140223915ac 100644 --- a/src/components/InteractiveStepButton.tsx +++ b/src/components/InteractiveStepButton.tsx @@ -33,7 +33,6 @@ type InteractiveStepButtonProps = { /** Whether this step is locked (future step) */ isLockedStep: boolean; - /** Whether this step is completed */ isCompletedStep: boolean; /** diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx index ed3ea2a7a72e..804ea9d544f8 100644 --- a/src/components/InteractiveStepSubHeader.tsx +++ b/src/components/InteractiveStepSubHeader.tsx @@ -13,27 +13,18 @@ type InteractiveStepSubHeaderProps = { /** List of the Route Name to navigate when the step is selected */ stepNames: readonly string[]; - /** Function to call when a step is selected */ onStepSelected?: (stepName: string) => void; - - /** The index of the step to start with */ startStepIndex?: number; /** Description of the current step, appended to its accessibility label */ currentStepAccessibilityDescription: string; - /** Reference to the outer element */ ref?: ForwardedRef; }; type InteractiveStepSubHeaderHandle = { - /** Move to the next step */ moveNext: () => void; - - /** Move to the previous step */ movePrevious: () => void; - - /** Move to a specific step */ moveTo: (step: number) => void; }; diff --git a/src/components/InteractiveStepSubPageHeader.tsx b/src/components/InteractiveStepSubPageHeader.tsx index f0cb024f9a52..413de30dc529 100644 --- a/src/components/InteractiveStepSubPageHeader.tsx +++ b/src/components/InteractiveStepSubPageHeader.tsx @@ -9,7 +9,6 @@ import {View} from 'react-native'; import InteractiveStepButton from './InteractiveStepButton'; type InteractiveStepSubPageHeaderProps = { - /** List of step names to display */ stepNames: readonly string[]; /** Current step index (0-based) */ @@ -18,7 +17,6 @@ type InteractiveStepSubPageHeaderProps = { /** Description of the current step, appended to its accessibility label */ currentStepAccessibilityDescription: string; - /** Function to call when a step is selected */ onStepSelected?: (stepIndex: number) => void; }; diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index 9f400a17d0f8..9c0e797e4319 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -16,40 +16,20 @@ import InteractiveStepSubHeader from './InteractiveStepSubHeader'; import ScreenWrapper from './ScreenWrapper'; type InteractiveStepWrapperProps = { - // Step content children: React.ReactNode; - - // ID of the wrapper wrapperID: string; - - // Function to handle back button press handleBackButtonPress: () => void; - - // Title of the back button header headerTitle: string; - - // Subtitle of the back button header headerSubtitle?: string; - // Index of the highlighted step + /** Index of the highlighted step */ startStepIndex?: number; - // Array of step names stepNames?: readonly string[]; - - // Should enable max height shouldEnableMaxHeight?: boolean; - - // Should show offline indicator shouldShowOfflineIndicator?: boolean; - - // Should show offline indicator in wide screen shouldShowOfflineIndicatorInWideScreen?: boolean; - - // Should enable picker avoiding shouldEnablePickerAvoiding?: boolean; - - // Offline indicator style offlineIndicatorStyle?: StyleProp; /** @@ -65,12 +45,7 @@ type InteractiveStepWrapperProps = { */ enableEdgeToEdgeBottomSafeAreaPadding?: boolean; - /** - * Callback to be called when the screen entry transition ends. - */ onEntryTransitionEnd?: () => void; - - // Reference to the outer element ref?: ForwardedRef; }; diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 40f9804a91d6..18a6297c112c 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -28,7 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import {doesPersonalDetailExistSelector, personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; -import {lastWorkspaceNumberSelector} from '@src/selectors/Policy'; +import {lastWorkspaceNumberSelector, ownerPoliciesSelector} from '@src/selectors/Policy'; import type {BankAccountList, PersonalDetailsList, Policy} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import viewRef from '@src/types/utils/viewRef'; @@ -78,6 +78,7 @@ function KYCWall({ const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const ownerAccountID = iouReport?.ownerAccountID; const employeeLoginSelector = useCallback((personalDetailsList: OnyxEntry) => personalDetailsLoginSelector(ownerAccountID)(personalDetailsList), [ownerAccountID]); @@ -181,6 +182,7 @@ function KYCWall({ employeeLogin, doesSubmitterPersonalDetailExist ?? false, getCurrencyDecimals, + rules, reportTransactions, ); if (inviteResult?.policyExpenseChatReportID) { @@ -192,7 +194,7 @@ function KYCWall({ Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID: adminPolicy.id})); }); } else { - const moveResult = moveIOUReportToPolicy(iouReport, adminPolicy, reportPreviewAction, getCurrencyDecimals, true, reportTransactions); + const moveResult = moveIOUReportToPolicy(iouReport, adminPolicy, reportPreviewAction, getCurrencyDecimals, rules, true, reportTransactions); savePreferredPaymentMethod(iouReport.policyID, adminPolicy.id, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[adminPolicy.id]); if (moveResult?.policyExpenseChatReportID && !moveResult.useTemporaryOptimisticExpenseChatReportID) { @@ -208,13 +210,14 @@ function KYCWall({ return; } - const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, currentUserEmail); + const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, currentUserEmail, currentUserDetails.displayName); const {policyID, workspaceChatReportID, adminsChatReportID} = createWorkspaceFromIOUPayment({ iouReport, reportPreviewAction, currentUserAccountID, currentUserEmail, + currentUserDisplayName: currentUserDetails.displayName, iouReportOwnerEmail: employeeLogin ?? '', currentUserLocalCurrency: localCurrency, lastWorkspaceNumber, @@ -222,6 +225,7 @@ function KYCWall({ reportActionsList: filteredReportActions, doesEmployeePersonalDetailExist: doesSubmitterPersonalDetailExist ?? false, getCurrencyDecimals, + hasOwnedPaidPolicy: ownerPoliciesSelector(policies, currentUserAccountID).length > 0, }) ?? {}; if (policyID && iouReport?.policyID) { savePreferredPaymentMethod(iouReport.policyID, policyID, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[iouReport?.policyID]); @@ -277,6 +281,7 @@ function KYCWall({ reportPreviewAction, currentUserAccountID, currentUserEmail, + currentUserDetails.displayName, employeeLogin, doesSubmitterPersonalDetailExist, introSelected, @@ -290,6 +295,7 @@ function KYCWall({ conciergeChat, localCurrency, getCurrencyDecimals, + rules, ], ); diff --git a/src/components/KYCWall/types.ts b/src/components/KYCWall/types.ts index dc1b0f0c0f32..4f94b202b356 100644 --- a/src/components/KYCWall/types.ts +++ b/src/components/KYCWall/types.ts @@ -59,16 +59,12 @@ type KYCWallProps = { /** Where the popover should be positioned relative to the anchor points. */ anchorAlignment?: AnchorAlignment; - /** Whether the option to add a debit card should be included */ shouldIncludeDebitCard?: boolean; - - /** Callback for when a payment method has been selected */ onSelectPaymentMethod?: (paymentMethod: PaymentMethod) => void; /** Returns the route to continue into after adding a personal bank account */ getPersonalBankAccountOnSuccessFallbackRoute?: (paymentMethod: PaymentMethod) => Route | undefined; - /** Whether the personal bank account option should be shown */ shouldShowPersonalBankAccountOption?: boolean; /** Callback for the end of the onContinue trigger on option selection */ diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx index d8800b89f579..9eefb2f89cf6 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx @@ -35,7 +35,6 @@ type PressableProps = { /** Whether the row is the currently focused/active option. Drives the focused background and accessibility metadata. */ isOptionFocused: boolean; - /** Press handler invoked with the option data and the popover anchor ref. */ onSelectRow: (optionItem: OptionData, popoverAnchor: React.RefObject) => void; /** Layout handler forwarded to the underlying pressable. */ diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index c8db8bcea5b4..54113abbebe4 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -53,7 +53,7 @@ function OptionRowLHNData({ const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const isReportFocused = isOptionFocused && currentReportIDValue === reportID; const {translate, localeCompare, dateFnsLocale, formatPhoneNumber} = useLocalize(); - const {convertToDisplayString} = useCurrencyListActions(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const oneTransactionThreadReportID = oneTransactionThreadReport?.reportID; @@ -131,6 +131,7 @@ function OptionRowLHNData({ 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 card = useGetExpensifyCardFromReportAction({reportAction: lastAction, policyID: fullReport?.policyID}); @@ -162,6 +163,7 @@ function OptionRowLHNData({ lastAction, translate, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, isReportArchived, lastActionReport, @@ -173,6 +175,7 @@ function OptionRowLHNData({ currentUserLogin: login ?? '', isTrackIntentUser, formatPhoneNumber, + rules, }); // For single-sender IOUs, trim to the sender's avatar to match the header. @@ -209,6 +212,7 @@ function OptionRowLHNData({ report: fullReport, isTrackIntentUser, policy, + rules, }) && finalOptionItem?.actionBadge === CONST.REPORT.ACTION_BADGE.SUBMIT; return ( diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx index b19252661378..9c9fba6d5f61 100644 --- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx +++ b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx @@ -6,7 +6,6 @@ type OptionRowRendererComponentProps = { /** The index position of this option row in the list */ index: number; - /** Callback function called when the component layout changes */ onLayout?: () => void; /** Style prop for customizing the option row */ diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index a5d859730efb..bff7983637e8 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -17,16 +17,12 @@ type CustomLHNOptionsListProps = { /** Extra styles for the section list container */ contentContainerStyles?: StyleProp; - /** List of reports */ data: Report[]; - - /** Callback to fire when a row is selected */ onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; /** Toggle between compact and default view of the option */ optionMode: OptionMode; - /** Whether to allow option focus or not */ shouldDisableFocusOptions?: boolean; /** Callback to fire when the list is laid out */ @@ -39,22 +35,14 @@ type OptionRowLHNDataProps = { /** Whether row should be focused */ isOptionFocused?: boolean; - /** List of users' personal details */ personalDetails?: PersonalDetailsList; - - /** The full data of the report */ fullReport: OnyxEntry; - - /** The transaction thread report associated with the current report, if any */ oneTransactionThreadReport: OnyxEntry; /** The policy which the user has access to and which the report could be tied to */ policy?: OnyxEntry; - /** Invoice receiver policy */ invoiceReceiverPolicy?: OnyxEntry; - - /** The reportID of the report */ reportID: string; /** Toggle between compact and default view */ @@ -66,10 +54,7 @@ type OptionRowLHNDataProps = { /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; - /** The report attributes for the report */ reportAttributes: OnyxEntry; - - /** The derived report attributes for all reports */ reportAttributesDerived?: ReportAttributesDerivedValue['reports']; /** TestID of the row, indicating order */ @@ -77,7 +62,6 @@ type OptionRowLHNDataProps = { }; type OptionRowLHNProps = { - /** Whether this option is currently in focus so we can modify its style */ isOptionFocused?: boolean; /** A function that is called when an option is selected. Selected option is passed as a param */ @@ -86,7 +70,6 @@ type OptionRowLHNProps = { /** Toggle between compact and default view */ viewMode?: OptionMode; - /** The item that should be rendered */ optionItem: OptionData; /** Whether a report contains a draft */ diff --git a/src/components/LegendList/setLegendListItemZIndex.ts b/src/components/LegendList/setLegendListItemZIndex.ts new file mode 100644 index 000000000000..9570fc3d1e16 --- /dev/null +++ b/src/components/LegendList/setLegendListItemZIndex.ts @@ -0,0 +1,22 @@ +import type {LegendListRef} from '@legendapp/list/react-native'; + +type NativeListItem = { + setNativeProps?: (props: {style: {zIndex: number}}) => void; +}; + +/** Applies z-index to LegendList's native item container rather than to the rendered item inside it. */ +function setLegendListItemZIndex(list: LegendListRef | null, index: number, zIndex: number): boolean { + const itemContainer: unknown = list?.getState().elementAtIndex(index); + if (!isNativeListItem(itemContainer)) { + return false; + } + + itemContainer.setNativeProps({style: {zIndex}}); + return true; +} + +function isNativeListItem(value: unknown): value is NativeListItem & Required> { + return typeof value === 'object' && value !== null && 'setNativeProps' in value && typeof value.setNativeProps === 'function'; +} + +export default setLegendListItemZIndex; diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index e464ac97653b..a4df05548a56 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -53,7 +53,6 @@ type LightboxProps = Pick & { /** Handles errors while displaying the image */ onError?: () => void; - /** Additional styles to add to the component */ style?: StyleProp; /** Range of zoom that can be applied to the content by pinching or double tapping. */ diff --git a/src/components/LoadingBar.tsx b/src/components/LoadingBar.tsx index ba61a9b2e754..40befab35f40 100644 --- a/src/components/LoadingBar.tsx +++ b/src/components/LoadingBar.tsx @@ -6,7 +6,6 @@ import React, {useEffect} from 'react'; import Animated, {cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming} from 'react-native-reanimated'; type LoadingBarProps = { - // Whether or not to show the loading bar shouldShow: boolean; }; diff --git a/src/components/LoadingIndicator.tsx b/src/components/LoadingIndicator.tsx index 393a8f5750ea..0acc191135a3 100644 --- a/src/components/LoadingIndicator.tsx +++ b/src/components/LoadingIndicator.tsx @@ -12,10 +12,7 @@ import ActivityIndicator from './ActivityIndicator'; type LoadingIndicatorIconSize = RNActivityIndicatorProps['size']; type LoadingIndicatorProps = { - /** Styles of the outer view */ style?: StyleProp; - - /** Size of the icon */ iconSize?: LoadingIndicatorIconSize; }; diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index a5b363a755ca..35d4578b9382 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -1,6 +1,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import getCollator from '@libs/CollatorUtils'; import DateUtils from '@libs/DateUtils'; import {fromLocaleDigit as fromLocaleDigitLocaleDigitUtils, toLocaleDigit as toLocaleDigitLocaleDigitUtils, toLocaleOrdinal as toLocaleOrdinalLocaleDigitUtils} from '@libs/LocaleDigitUtils'; import {formatPhoneNumberWithCountryCode} from '@libs/LocalePhoneNumber'; @@ -24,7 +25,6 @@ import {format as formatDate} from 'date-fns'; import React, {createContext, useEffect, useState} from 'react'; type LocaleContextProviderProps = { - /** Actual content wrapped by this component */ children: React.ReactNode; }; @@ -95,8 +95,6 @@ const LocaleContext = createContext({ dateFnsLocale: undefined, }); -const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'}; - function LocaleContextProvider({children}: LocaleContextProviderProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [areTranslationsLoading = true] = useOnyx(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING); @@ -141,7 +139,7 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) { const selectedTimezone = currentUserPersonalDetails?.timezone?.selected; const effectiveTimezone = selectedTimezone ?? CONST.DEFAULT_TIME_ZONE.selected; - const collator = new Intl.Collator(currentLocale, COLLATOR_OPTIONS); + const collator = getCollator(currentLocale); const translate: LocaleContextProps['translate'] = (path, ...parameters) => translateLocalize(currentLocale, path, ...parameters); diff --git a/src/components/LocationPermissionModal/types.ts b/src/components/LocationPermissionModal/types.ts index 51bed075e50b..fcf0cf095219 100644 --- a/src/components/LocationPermissionModal/types.ts +++ b/src/components/LocationPermissionModal/types.ts @@ -5,13 +5,8 @@ type LocationPermissionModalProps = { /** A callback to call when the permission has been denied */ onDeny: (wasUserInitiated: boolean) => void; - /** Should start the permission flow? */ startPermissionFlow: boolean; - - /** Reset the permission flow */ resetPermissionFlow: () => void; - - /** A callback to call when the initial get location is completed */ onInitialGetLocationCompleted?: () => void; }; diff --git a/src/components/MapView/GPSMapView.tsx b/src/components/MapView/GPSMapView.tsx index bbe9b3b6bb1e..719027073e5b 100644 --- a/src/components/MapView/GPSMapView.tsx +++ b/src/components/MapView/GPSMapView.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ImageSVG from '@components/ImageSVG'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; diff --git a/src/components/MapView/GPSWaypointLayer.tsx b/src/components/MapView/GPSWaypointLayer.tsx index 3abb1d1cdcb8..331cc870b751 100644 --- a/src/components/MapView/GPSWaypointLayer.tsx +++ b/src/components/MapView/GPSWaypointLayer.tsx @@ -17,9 +17,8 @@ const WAYPOINT_ICON_NAMES: Record = { }; type GPSWaypointLayerProps = { - // List of waypoints to render waypoints?: WayPoint[]; - // ID of the layer to render the waypoints below + /** ID of the layer to render the waypoints below */ belowLayerID?: string; }; diff --git a/src/components/MapView/MapMarkerIcon.tsx b/src/components/MapView/MapMarkerIcon.tsx index bc803849be63..7e64b3c198e6 100644 --- a/src/components/MapView/MapMarkerIcon.tsx +++ b/src/components/MapView/MapMarkerIcon.tsx @@ -2,7 +2,6 @@ import useMapMarkers from '@hooks/useMapMarkers'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; type MapMarkerIconProps = { - /** The type of the map marker to display */ markerType: MapMarkerType; }; diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 3e2eb57f87ca..79b60982987d 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ImageSVG from '@components/ImageSVG'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; diff --git a/src/components/MapView/MapViewImpl.web.tsx b/src/components/MapView/MapViewImpl.web.tsx index 1ac3081219bf..1b23c6b959f8 100644 --- a/src/components/MapView/MapViewImpl.web.tsx +++ b/src/components/MapView/MapViewImpl.web.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import 'mapbox-gl/dist/mapbox-gl.css'; import ImageSVG from '@components/ImageSVG'; diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts index 533032da1963..30aaf33db68f 100644 --- a/src/components/MapView/MapViewTypes.ts +++ b/src/components/MapView/MapViewTypes.ts @@ -42,20 +42,15 @@ type MapViewProps = { /** Whether the alternate direction is selected */ isSelected: boolean; - /** Distance displayed on the map in meters. */ distanceInMeters: number; }; - /** Callback to call when the alternate direction is selected */ setIsAlternateDirectionSelected?: (isAlternateDirectionSelected: boolean) => void; /** Callback to call when the map is idle / ready. */ onMapReady?: () => void; - /** Whether the map is interactive or not */ interactive?: boolean; - - /** Distance displayed on the map in meters. */ distanceInMeters?: number; /** Unit of measurement for distance */ @@ -64,7 +59,6 @@ type MapViewProps = { /** Reference to the outerElement */ ref?: React.ForwardedRef; - /** Whether it should display the current user's location on the map */ shouldDisplayCurrentLocation?: boolean; /** Whether it should display the compass overlay on the map */ @@ -72,16 +66,9 @@ type MapViewProps = { }; type CompassProps = { - /** Whether the map is interactive or not */ interactive: boolean; - - /** Whether the compass should be displayed or not */ shouldDisplayCompass: boolean; - - /** Shared value for the map heading */ mapHeading: SharedValue; - - /** Reference to the camera */ cameraRef: RefObject; }; @@ -97,7 +84,6 @@ type GPSDirectionProps = { /** Whether the GPS trip is active */ isTrackingGPS: boolean; - /** Last location of the user */ lastLocation: {longitude: number; latitude: number} | undefined; /** List of coordinates which together forms a direction. */ @@ -108,7 +94,6 @@ type GPSDirectionProps = { }; type DistanceSymbolProps = { - /** Distance displayed on the map in meters. */ distanceInMeters?: number; /** Unit of measurement for distance. */ @@ -145,7 +130,6 @@ type AlternateDirection = { /** Whether the alternate direction is selected */ isSelected: boolean; - /** Distance displayed on the map in meters. */ distanceInMeters: number; }; @@ -156,16 +140,12 @@ type DirectionsProps = { /** List of coordinates which together forms an alternate direction. */ alternateDirection?: AlternateDirection; - /** Callback to call when the alternate direction is selected */ setIsAlternateDirectionSelected?: (isAlternateDirectionSelected: boolean) => void; - - /** Distance displayed on the map in meters. */ distanceInMeters?: number; /** Unit of measurement for distance */ unit?: Unit; - /** List of waypoints on the map */ waypoints?: WayPoint[]; }; @@ -206,7 +186,6 @@ type WayPoint = { /** Represents a handle to interact with a map view. */ type MapViewHandle = { - /** Fly to a location on the map */ flyTo: (location: Coordinate, zoomLevel: number, animationDuration?: number) => void; /** Fit the map view to a bounding box */ diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx deleted file mode 100644 index 4d880aa84fd4..000000000000 --- a/src/components/MentionSuggestions.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import getStyledTextArray from '@libs/GetStyledTextArray'; -import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; - -import CONST from '@src/CONST'; -import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; - -import React, {useCallback} from 'react'; -import {View} from 'react-native'; - -import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; - -import AutoCompleteSuggestions from './AutoCompleteSuggestions'; -import UserAvatar from './Avatar/UserAvatar'; -import Icon from './Icon'; -import Text from './Text'; - -type Mention = { - /** - * Main display text of the mention - * always visible right after icon (if present) - */ - text: string; - - /** - * Additional text for the mention - * visible if it's value is different than Mention.text value - * rendered after Mention.text - */ - alternateText: string; - - /** - * Handle of the mention - * used as a value for the mention (e.g. in for the filtering or putting the mention in the message) - */ - handle?: string; - - /** Array of icons of the mention. If present, we use the first element of this array. For room suggestions, the icons are not used */ - icons?: IconType[]; -}; - -type MentionSuggestionsProps = { - /** The index of the highlighted mention */ - highlightedMentionIndex?: number; - - /** Array of suggested mentions */ - mentions: Mention[]; - - /** Fired when the user selects a mention */ - onSelect: (highlightedMentionIndex: number) => void; - - /** Mention prefix that follows the @ sign */ - prefix: string; - - /** Show that we can use large mention picker. - * Depending on available space and whether the input is expanded, we can have a small or large mention suggester. - * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ - isMentionPickerLarge: boolean; - - /** Measures the parent container's position and dimensions. Also add cursor coordinates */ - measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; - - /** Reset the emoji suggestions */ - resetSuggestions: () => void; -}; - -/** - * Create unique keys for each mention item - */ -const keyExtractor = (item: Mention) => item.alternateText; - -function MentionSuggestions({ - prefix, - mentions, - highlightedMentionIndex = 0, - onSelect, - isMentionPickerLarge, - measureParentContainerAndReportCursor = () => {}, - resetSuggestions, -}: MentionSuggestionsProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - /** - * Render a suggestion menu item component. - */ - const renderSuggestionMenuItem = useCallback( - (item: Mention) => { - // The @here suggestion carries a static SVG (Megaphone), not a user's avatar. - const isIcon = item.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT; - const firstIcon = item.icons?.at(0); - const styledDisplayName = getStyledTextArray(item.text, prefix); - const styledHandle = item.text === item.alternateText ? undefined : getStyledTextArray(item.alternateText, prefix); - - return ( - - {!!firstIcon && ( - - {isIcon ? ( - - ) : ( - - )} - - )} - - {styledDisplayName?.map(({text, isColored}, i) => ( - - {text} - - ))} - - - {styledHandle?.map( - ({text, isColored}, i) => - !!text && ( - - {text} - - ), - )} - - - ); - }, - [ - prefix, - styles.autoCompleteSuggestionContainer, - styles.ph2, - styles.mentionSuggestionsAvatarContainer, - styles.mentionSuggestionsText, - styles.flexShrink1, - styles.flex1, - styles.mentionSuggestionsDisplayName, - styles.textSupporting, - theme.success, - StyleUtils, - ], - ); - - return ( - - ); -} - -export default MentionSuggestions; - -export type {Mention}; diff --git a/src/components/MentionSuggestions/MentionSuggestionItem.tsx b/src/components/MentionSuggestions/MentionSuggestionItem.tsx new file mode 100644 index 000000000000..8ce3a9d5af61 --- /dev/null +++ b/src/components/MentionSuggestions/MentionSuggestionItem.tsx @@ -0,0 +1,89 @@ +import Icon from '@components/Icon'; +import ListItemCompactAvatar from '@components/SelectionList/ListItemComposed/primitives/ListItemCompactAvatar'; +import Text from '@components/Text'; + +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import getStyledTextArray from '@libs/GetStyledTextArray'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +import type Mention from './types'; + +type MentionSuggestionItemProps = { + item: Mention; + + /** Mention prefix that follows the @ sign, highlighted within the rendered text */ + prefix: string; +}; + +/** A single row of the mention suggester: optional avatar, display name, then the handle. */ +function MentionSuggestionItem({item, prefix}: MentionSuggestionItemProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const styledDisplayName = getStyledTextArray(item.text, prefix); + const styledHandle = item.text === item.alternateText ? undefined : getStyledTextArray(item.alternateText, prefix); + const icon = item.icons?.at(0); + // The "@here" row renders a plain icon rather than an avatar, so it is smaller and takes the success fill. + const isIcon = item.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT; + + return ( + + {!!icon && + (isIcon ? ( + + + + ) : ( + + ))} + + {styledDisplayName?.map(({text, isColored}, i) => ( + + {text} + + ))} + + + {styledHandle?.map( + ({text, isColored}, i) => + !!text && ( + + {text} + + ), + )} + + + ); +} + +export default MentionSuggestionItem; diff --git a/src/components/MentionSuggestions/index.tsx b/src/components/MentionSuggestions/index.tsx new file mode 100644 index 000000000000..7d1cfd92ac29 --- /dev/null +++ b/src/components/MentionSuggestions/index.tsx @@ -0,0 +1,67 @@ +import AutoCompleteSuggestions from '@components/AutoCompleteSuggestions'; +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; + +import React from 'react'; + +import type Mention from './types'; + +import MentionSuggestionItem from './MentionSuggestionItem'; + +type MentionSuggestionsProps = { + highlightedMentionIndex?: number; + + /** Array of suggested mentions */ + mentions: Mention[]; + + onSelect: (highlightedMentionIndex: number) => void; + + /** Mention prefix that follows the @ sign */ + prefix: string; + + /** Show that we can use large mention picker. + * Depending on available space and whether the input is expanded, we can have a small or large mention suggester. + * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ + isMentionPickerLarge: boolean; + + /** Measures the parent container's position and dimensions. Also add cursor coordinates */ + measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; + + /** Reset the emoji suggestions */ + resetSuggestions: () => void; +}; + +/** + * Create unique keys for each mention item + */ +const keyExtractor = (item: Mention) => item.alternateText; + +function MentionSuggestions({ + prefix, + mentions, + highlightedMentionIndex = 0, + onSelect, + isMentionPickerLarge, + measureParentContainerAndReportCursor = () => {}, + resetSuggestions, +}: MentionSuggestionsProps) { + return ( + ( + + )} + keyExtractor={keyExtractor} + highlightedSuggestionIndex={highlightedMentionIndex} + onSelect={onSelect} + isSuggestionPickerLarge={isMentionPickerLarge} + accessibilityLabelExtractor={keyExtractor} + measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} + resetSuggestions={resetSuggestions} + /> + ); +} + +export default MentionSuggestions; diff --git a/src/components/MentionSuggestions/types.ts b/src/components/MentionSuggestions/types.ts new file mode 100644 index 000000000000..cd870d8d819b --- /dev/null +++ b/src/components/MentionSuggestions/types.ts @@ -0,0 +1,27 @@ +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +type Mention = { + /** + * Main display text of the mention + * always visible right after icon (if present) + */ + text: string; + + /** + * Additional text for the mention + * visible if it's value is different than Mention.text value + * rendered after Mention.text + */ + alternateText: string; + + /** + * Handle of the mention + * used as a value for the mention (e.g. in for the filtering or putting the mention in the message) + */ + handle?: string; + + /** Array of icons of the mention. If present, we use the first element of this array. For room suggestions, the icons are not used */ + icons?: Icon[]; +}; + +export default Mention; diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index ebf29091f41e..1504b9afba07 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -45,6 +45,7 @@ import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {fontScale, lineHeightScale} from '@styles/typography'; import variables from '@styles/variables'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -89,13 +90,8 @@ type NoIcon = { type MenuItemBaseProps = ForwardedFSClassProps & WithSentryLabel & Pick & { - /** Reference to the outer element */ ref?: PressableRef | Ref; - - /** Function to fire when component is pressed */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; - - /** Whether the menu item should be interactive at all */ interactive?: boolean; /** Text to be shown as badge near the right end. */ @@ -104,7 +100,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Icon to display on the left side of the badge */ badgeIcon?: IconAsset; - /** Whether the badge should be shown as success */ isBadgeSuccess?: boolean; /** Whether the badge should use strong (filled) variant */ @@ -113,37 +108,25 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether the badge should use condensed (smaller) sizing */ isBadgeCondensed?: boolean; - /** Whether the badge should be shown as error */ isBadgeError?: boolean; - - /** Callback to fire when the badge is pressed */ onBadgePress?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** Used to apply offline styles to child text components */ style?: StyleProp; - /** Outer wrapper styles */ outerWrapperStyle?: StyleProp>; - - /** Any additional styles to apply */ wrapperStyle?: StyleProp; - - /** Styles to apply on the title wrapper */ titleWrapperStyle?: StyleProp; /** Styles to apply on the inner row containing the icon and text content */ innerContainerStyle?: StyleProp; - /** Any additional styles to apply on the outer element */ containerStyle?: StyleProp; /** Used to apply styles specifically to the title */ titleStyle?: StyleProp; - /** Any additional styles to apply on the badge element */ badgeStyle?: StyleProp; - - /** Any additional styles to apply to the label */ labelStyle?: StyleProp; /** Additional styles to style the description text below the title */ @@ -158,13 +141,8 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** The fill color to pass into the secondary icon. */ secondaryIconFill?: string; - /** Whether the secondary icon should have hover style */ isSecondaryIconHoverable?: boolean; - - /** Icon Width */ iconWidth?: number; - - /** Icon Height */ iconHeight?: number; /** Any additional styles to pass to the icon container. */ @@ -185,19 +163,13 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Icon to display at right side of title */ titleIcon?: IconAsset; - /** Boolean whether to display the right icon */ shouldShowRightIcon?: boolean; /** Overrides the icon for shouldShowRightIcon */ iconRight?: IconAsset; - /** Should render component on the right */ shouldShowRightComponent?: boolean; - - /** Component to be displayed on the right */ rightComponent?: ReactNode; - - /** Component to be displayed on the left */ leftComponent?: ReactNode; /** A description text to show under the title */ @@ -209,7 +181,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Text to show below menu item. This text is not interactive */ helperText?: string; - /** Any additional styles to pass to helper text. */ helperTextStyle?: StyleProp; /** Should the description be shown above the title (instead of the other way around) */ @@ -218,7 +189,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Error to display at the bottom of the component */ errorText?: string | ReactNode; - /** Any additional styles to pass to error text. */ errorTextStyle?: StyleProp; /** Hint to display at the bottom of the component */ @@ -243,41 +213,29 @@ type MenuItemBaseProps = ForwardedFSClassProps & characterLimit?: number; isLabelHoverable?: boolean; - - /** Label to be displayed on the right */ rightLabel?: string; - - /** Icon to be displayed next to the right label */ rightLabelIcon?: IconAsset; /** Text to display for the item */ title?: string; - /** Accessibility label for the menu item */ accessibilityLabel?: string; /** Optional accessibility role for the title. Only set when the title is a section heading (e.g. CONST.ROLE.HEADER); omit for regular menu items. */ titleAccessibilityRole?: typeof CONST.ROLE.HEADER; - /** Component to display as the title */ titleComponent?: ReactElement; - - /** Any additional styles to apply to the container for title components */ titleContainerStyle?: StyleProp; /** A right-aligned subtitle for this menu option */ subtitle?: string | number; - /** Any additional styles to apply to the subtitle */ subtitleStyle?: StyleProp; /** Should the title show with normal font weight (not bold) */ shouldShowBasicTitle?: boolean; - /** Should we truncate the title */ shouldTruncateTitle?: boolean; - - /** Whether this item is selected */ isSelected?: boolean; /** Prop to represent the size of the avatar images to be shown */ @@ -295,16 +253,12 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether we should use small avatar subscript sizing the for menu item */ isSmallAvatarSubscriptMenu?: boolean; - /** The type of brick road indicator to show. */ brickRoadIndicator?: ValueOf; /** Should render the content in HTML format. A title without HTML content is rendered as plain text even when this is set. */ shouldRenderAsHTML?: boolean; - /** Whether or not the text should be escaped */ shouldEscapeText?: boolean; - - /** Should we grey out the menu item when it is disabled? */ shouldGreyOutWhenDisabled?: boolean; /** Should we remove the background color of the menu item */ @@ -316,8 +270,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & rightIconAccountID?: number | string; iconAccountID?: number; - - /** Should we use default cursor for disabled content */ shouldUseDefaultCursorWhenDisabled?: boolean; /** The action accept for anonymous user or not */ @@ -350,7 +302,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** The maximum number of lines for further details text */ furtherDetailsNumberOfLines?: number; - /** The further details additional style */ furtherDetailsStyle?: StyleProp; /** Render custom content under the main item */ @@ -371,7 +322,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Determines how the icon should be resized to fit its container */ contentFit?: ImageContentFit; - /** Is this in the Pane */ isPaneMenu?: boolean; /** Adds padding to the left of the text when there is no icon. */ @@ -380,7 +330,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether to apply icon left padding to HTML-rendered titles. */ shouldApplyIconPaddingToHTMLTitle?: boolean; - /** Handles what to do when the item is focused */ onFocus?: () => void; /** Handles what to do when the item loose focus */ @@ -389,16 +338,12 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Optional account id if it's user avatar or policy id if it's workspace avatar */ avatarID?: number | string; - /** Whether to show the tooltip */ shouldRenderTooltip?: boolean; /** Whether the tooltip content should be visible. When omitted, matches shouldRenderTooltip. */ shouldDisplayEducationalTooltip?: boolean; - /** Anchor alignment of the tooltip */ tooltipAnchorAlignment?: TooltipAnchorAlignment; - - /** Additional styles for tooltip wrapper */ tooltipWrapperStyle?: StyleProp; /** Any additional amount to manually adjust the horizontal position of the tooltip */ @@ -407,18 +352,13 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Any additional amount to manually adjust the vertical position of the tooltip */ tooltipShiftVertical?: number; - /** Render custom content inside the tooltip. */ renderTooltipContent?: () => ReactNode; - - /** Callback to fire when the education tooltip is pressed */ onEducationTooltipPress?: () => void; /** Whether the tooltip should hide on scroll */ shouldHideOnScroll?: boolean; shouldShowLoadingSpinnerIcon?: boolean; - - /** Whether to show a radio button on each item to indicate which one is currently selected */ shouldShowRadioButton?: boolean; /** Should use auto width for the icon container. */ @@ -430,7 +370,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Pressable component Test ID. Used to locate the component in tests. */ pressableTestID?: string; - /** Whether to teleport the portal to the modal layer */ shouldTeleportPortalToModalLayer?: boolean; /** The value to copy in copy to clipboard action. Must be used in conjunction with `copyable=true`. Default value is `title` prop. */ @@ -454,25 +393,21 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether the screen containing the item is focused */ isFocused?: boolean; - /** Additional styles for the root wrapper View */ rootWrapperStyle?: StyleProp; /** The accessibility role to use for this menu item */ role?: Role; - /** Whether to show the badge in a separate row */ shouldShowBadgeInSeparateRow?: boolean; /** Whether to show the badge below the title */ shouldShowBadgeBelow?: boolean; - /** Whether item should be accessible */ shouldBeAccessible?: boolean; /** Whether item should be focusable with keyboard */ tabIndex?: 0 | -1; - /** Additional styles for the right icon wrapper */ rightIconWrapperStyle?: StyleProp; /** Whether to ignore compact popover menu styling for this item */ @@ -691,8 +626,8 @@ function MenuItem({ const descriptionTextStyles = StyleUtils.combineStyles([ styles.textLabelSupporting, styles.flex1, - title ? {} : StyleUtils.getFontSizeStyle(variables.fontSizeNormal), - title ? styles.textLineHeightNormal : StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight), + title ? {} : StyleUtils.getFontSizeStyle(fontScale.text), + title ? styles.textLineHeightNormal : StyleUtils.getLineHeightStyle(lineHeightScale.text), !descriptionAddon && hasIcon ? styles.ml3 : {}, descriptionAddon ? styles.ml2 : {}, (descriptionTextStyle as TextStyle) || styles.breakWord, diff --git a/src/components/MenuItem/MenuItemAccessibilityContext.tsx b/src/components/MenuItem/MenuItemAccessibilityContext.tsx index de2e5510195e..a464e7ee001f 100644 --- a/src/components/MenuItem/MenuItemAccessibilityContext.tsx +++ b/src/components/MenuItem/MenuItemAccessibilityContext.tsx @@ -1,25 +1,44 @@ -import type {TupleToUnion} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import {createContext, useContext, useEffect, useState} from 'react'; -/** The label slots a `MenuItem` row can contribute, in the order they are announced */ -const MENU_ITEM_LABEL_SLOTS = ['title', 'description'] as const; +/** + * Label slots a `MenuItem` row can contribute, in the order they are announced. Keyed by line rather + * than role, so the announced order matches the visual one for both field and navigation rows. + */ +const MENU_ITEM_LABEL_SLOTS = ['top', 'bottom'] as const; type MenuItemLabelSlot = TupleToUnion; +/** Accessibility facts a sub-component can contribute about its row, announced after the label as their own sentences */ +const MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT = { + OPENS_IN_NEW_TAB: 'opensInNewTab', +} as const; + +type MenuItemAccessibilityAnnouncement = ValueOf; + +/** The announcement slots in the order they are announced */ +const MENU_ITEM_ANNOUNCEMENT_SLOTS = Object.values(MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT); + type MenuItemAccessibilityActions = { /** Registers a label (the title or description text) under a fixed slot key */ registerLabel: (slot: MenuItemLabelSlot, text: string) => void; /** Removes the label registered under the given slot */ unregisterLabel: (slot: MenuItemLabelSlot) => void; + + /** Announces a fact about the row under a fixed slot key. Announcing the same fact twice announces it once */ + registerAnnouncement: (announcement: MenuItemAccessibilityAnnouncement, text: string) => void; + + /** Stops announcing the given fact */ + unregisterAnnouncement: (announcement: MenuItemAccessibilityAnnouncement) => void; }; const MenuItemAccessibilityContext = createContext(undefined); /** * Contributes text to the label `MenuItem.Root` derives. Registered under a fixed slot key so the - * announced order is deterministic (`title`, then `description`) regardless of mount/render timing + * announced order is deterministic (`top`, then `bottom`) regardless of mount/render timing. * No-op when `text` is empty or when rendered outside a `MenuItem.Root`. */ function useMenuItemAccessibilityLabel(slot: MenuItemLabelSlot, text: string | undefined) { @@ -36,31 +55,45 @@ function useMenuItemAccessibilityLabel(slot: MenuItemLabelSlot, text: string | u }, [slot, text, registerLabel, unregisterLabel]); } -/** - * Small `slot -> text` registry backed by an immutable `Map`. - * Writes are no-ops when the value is unchanged, so unrelated re-renders don't churn the map identity. +/** Contributes an already translated announcement about the row */ +function useMenuItemAccessibilityAnnouncement(announcement: MenuItemAccessibilityAnnouncement | undefined, text: string | undefined) { + const actions = useContext(MenuItemAccessibilityContext); + const registerAnnouncement = actions?.registerAnnouncement; + const unregisterAnnouncement = actions?.unregisterAnnouncement; + + useEffect(() => { + if (!announcement || !text || !registerAnnouncement || !unregisterAnnouncement) { + return; + } + registerAnnouncement(announcement, text); + return () => unregisterAnnouncement(announcement); + }, [announcement, text, registerAnnouncement, unregisterAnnouncement]); +} + +/** Small `key -> value` registry backed by an immutable `Map`. + * Writes are no-ops when the value is unchanged, so unrelated re-renders don't churn the map identity */ -function useLabelSlotRegistry() { - const [entries, setEntries] = useState>(() => new Map()); +function useKeyedRegistry() { + const [entries, setEntries] = useState>(() => new Map()); - const register = (slot: MenuItemLabelSlot, value: string) => { + const register = (key: TKey, value: TValue) => { setEntries((prev) => { - if (prev.get(slot) === value) { + if (prev.get(key) === value) { return prev; } const next = new Map(prev); - next.set(slot, value); + next.set(key, value); return next; }); }; - const unregister = (slot: MenuItemLabelSlot) => { + const unregister = (key: TKey) => { setEntries((prev) => { - if (!prev.has(slot)) { + if (!prev.has(key)) { return prev; } const next = new Map(prev); - next.delete(slot); + next.delete(key); return next; }); }; @@ -68,22 +101,26 @@ function useLabelSlotRegistry() { return {entries, register, unregister}; } -/** - * Collects the text registered by `Title`/`Description` sub-components and derives the row's accessibility label. - * Returns the props to spread on the pressable plus the value for `MenuItemAccessibilityContext.Provider`. - */ +/** Assembles the row's accessibility label from what its sub-components registered, plus the value for `MenuItemAccessibilityContext.Provider` */ function useMenuItemAccessibility() { - // Text contributed by Title/Description children, keyed by fixed slot - const {entries: labels, register: registerLabel, unregister: unregisterLabel} = useLabelSlotRegistry(); + // Text contributed by the text leaves, keyed by the line each one occupies + const {entries: labels, register: registerLabel, unregister: unregisterLabel} = useKeyedRegistry(); - const accessibilityActions: MenuItemAccessibilityActions = {registerLabel, unregisterLabel}; + // Facts contributed by any child, keyed by the fact + const {entries: announcements, register: registerAnnouncement, unregister: unregisterAnnouncement} = useKeyedRegistry(); - const accessibilityLabel = MENU_ITEM_LABEL_SLOTS.map((slot) => labels.get(slot)) + const accessibilityActions: MenuItemAccessibilityActions = {registerLabel, unregisterLabel, registerAnnouncement, unregisterAnnouncement}; + + const derivedLabel = MENU_ITEM_LABEL_SLOTS.map((slot) => labels.get(slot)) .filter(Boolean) .join(', '); + const announcementTexts = MENU_ITEM_ANNOUNCEMENT_SLOTS.map((announcement) => announcements.get(announcement)).filter(Boolean); + const accessibilityLabel = [derivedLabel, ...announcementTexts].filter(Boolean).join('. '); + return {accessibilityLabel, accessibilityActions}; } export default MenuItemAccessibilityContext; -export {useMenuItemAccessibilityLabel, useMenuItemAccessibility}; +export type {MenuItemLabelSlot}; +export {MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT, useMenuItemAccessibilityLabel, useMenuItemAccessibilityAnnouncement, useMenuItemAccessibility}; diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index 68a2dd4adb4c..f615659f7408 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -5,7 +5,8 @@ * imports keep working), extended with the compound sub-components following the * composition-over-configuration pattern. * - * The row's accessibility label is derived from the `Title`/`Description` text. + * The row's accessibility label is derived from the text leaves, announced top line first and followed by the + * hints trailing leaves register. * * @example Simple navigation row * ```tsx @@ -31,11 +32,15 @@ import MenuItemLeading from './layout/MenuItemLeading'; import MenuItemRoot from './layout/MenuItemRoot'; import MenuItemRow from './layout/MenuItemRow'; import MenuItemTrailing from './layout/MenuItemTrailing'; +import MenuItemDescription from './leaves/content/MenuItemDescription'; +import MenuItemFieldName from './leaves/content/MenuItemFieldName'; +import MenuItemFieldNamePlaceholder from './leaves/content/MenuItemFieldNamePlaceholder'; +import MenuItemFieldValue from './leaves/content/MenuItemFieldValue'; +import MenuItemLabel from './leaves/content/MenuItemLabel'; +import MenuItemTitle from './leaves/content/MenuItemTitle'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; -import MenuItemDescription from './leaves/text/description/MenuItemDescription'; -import MenuItemDescriptionPlaceholder from './leaves/text/description/MenuItemDescriptionPlaceholder'; -import MenuItemTitle from './leaves/text/MenuItemTitle'; -import MenuItemChevron from './leaves/trailing/MenuItemChevron'; +import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron'; +import MenuItemNewWindowIcon from './leaves/trailing/icons/MenuItemNewWindowIcon'; import MenuItemRightLabel from './leaves/trailing/MenuItemRightLabel'; import LegacyMenuItem from './MenuItem'; @@ -46,10 +51,14 @@ const MenuItem = Object.assign(LegacyMenuItem, { Content: MenuItemContent, Trailing: MenuItemTrailing, Icon: MenuItemIcon, + Label: MenuItemLabel, Title: MenuItemTitle, Description: MenuItemDescription, - DescriptionPlaceholder: MenuItemDescriptionPlaceholder, + FieldName: MenuItemFieldName, + FieldNamePlaceholder: MenuItemFieldNamePlaceholder, + FieldValue: MenuItemFieldValue, Chevron: MenuItemChevron, + NewWindowIcon: MenuItemNewWindowIcon, RightLabel: MenuItemRightLabel, }); diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index 4e7cace2e138..bd87913cf08d 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -28,7 +28,6 @@ type MenuItemRootProps = PropsWithChildren & /** Function to fire when the row is pressed */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; - /** Whether the menu item is disabled */ isDisabled?: boolean; /** diff --git a/src/components/MenuItem/leaves/content/MenuItemDescription.tsx b/src/components/MenuItem/leaves/content/MenuItemDescription.tsx new file mode 100644 index 000000000000..d003c517de9b --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemDescription.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** Supporting text under a `MenuItem.Title`, the bottom line of a `MenuItem.Content` */ +function MenuItemDescription(props: MenuItemSupportingTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemDescription; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx new file mode 100644 index 000000000000..4880f9deac52 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** Name of a filled field */ +function MenuItemFieldName(props: MenuItemSupportingTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemFieldName; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx new file mode 100644 index 000000000000..9425f09e90a2 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx @@ -0,0 +1,24 @@ +import useStyleUtils from '@hooks/useStyleUtils'; + +import {fontScale, lineHeightScale} from '@styles/typography'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** Name of an empty field, standing in for the missing value */ +function MenuItemFieldNamePlaceholder(props: MenuItemSupportingTextProps) { + const StyleUtils = useStyleUtils(); + + return ( + + ); +} + +export default MenuItemFieldNamePlaceholder; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx new file mode 100644 index 000000000000..d6bc0a31bb84 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import type {MenuItemPrimaryTextProps} from './base/types'; + +import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText'; + +/** Value a field holds */ +function MenuItemFieldValue(props: MenuItemPrimaryTextProps) { + return ( + + ); +} + +export default MenuItemFieldValue; diff --git a/src/components/MenuItem/leaves/content/MenuItemLabel.tsx b/src/components/MenuItem/leaves/content/MenuItemLabel.tsx new file mode 100644 index 000000000000..c2cfda11ad2f --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemLabel.tsx @@ -0,0 +1,28 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** + * Text naming what the row holds, the top line of a `MenuItem.Content`. + * + * Use this when the label and the value should read as one lockup — it lives inside `Root`, so it + * shares the row's press target and hover background. Reach for the `MenuItemWithLabel` preset + * instead when the label should stay outside both. + */ +function MenuItemLabel(props: MenuItemSupportingTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemLabel; diff --git a/src/components/MenuItem/leaves/content/MenuItemTitle.tsx b/src/components/MenuItem/leaves/content/MenuItemTitle.tsx new file mode 100644 index 000000000000..764e7ae35c79 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemTitle.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemPrimaryTextProps} from './base/types'; + +import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText'; + +/** The title block of a `MenuItem.Content`. Bold, single line */ +function MenuItemTitle(props: MenuItemPrimaryTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemTitle; diff --git a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx similarity index 51% rename from src/components/MenuItem/leaves/text/MenuItemTitle.tsx rename to src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx index d9fbf40d7fc0..fd95c1b6a980 100644 --- a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx @@ -8,34 +8,20 @@ import convertToLTR from '@libs/convertToLTR'; import CONST from '@src/CONST'; -import type {ReactElement} from 'react'; - import React from 'react'; -type MenuItemTitleProps = - | { - /** Text to render as the title */ - children: string | number; - - accessibilityLabel?: never; - } - | { - /** Element to render in place of plain text, e.g. a `DisplayNames` with per-name tooltips */ - children: ReactElement; - - accessibilityLabel: string; - }; +import type {BaseMenuItemTextProps, MenuItemPrimaryTextProps} from './types'; -/** The title block of a `MenuItem.Content`. Bold, single line */ -function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) { +/** Base of the full-contrast leaves */ +function BaseMenuItemPrimaryText({children, accessibilityLabel, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) { const styles = useThemeStyles(); const {isDisabled, isInteractive} = useMenuItemConfig(); - useMenuItemAccessibilityLabel('title', accessibilityLabel ?? String(children)); + useMenuItemAccessibilityLabel(slot, accessibilityLabel ?? String(children)); return ( @@ -44,4 +30,4 @@ function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) { ); } -export default MenuItemTitle; +export default BaseMenuItemPrimaryText; diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx new file mode 100644 index 000000000000..7c7c5744a256 --- /dev/null +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx @@ -0,0 +1,26 @@ +import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types'; + +/** Base of the muted leaves */ +function BaseMenuItemSupportingText({children, numberOfLines = 2, slot, style}: MenuItemSupportingTextProps & BaseMenuItemTextProps) { + const styles = useThemeStyles(); + + useMenuItemAccessibilityLabel(slot, String(children)); + + return ( + + {children} + + ); +} + +export default BaseMenuItemSupportingText; diff --git a/src/components/MenuItem/leaves/content/base/types.ts b/src/components/MenuItem/leaves/content/base/types.ts new file mode 100644 index 000000000000..61ce842c11c2 --- /dev/null +++ b/src/components/MenuItem/leaves/content/base/types.ts @@ -0,0 +1,40 @@ +import type {MenuItemLabelSlot} from '@components/MenuItem/MenuItemAccessibilityContext'; + +import type {ReactElement} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; + +/** Props of the full-contrast leaves */ +type MenuItemPrimaryTextProps = + | { + /** Plain text to render as the primary content */ + children: string | number; + + accessibilityLabel?: never; + } + | { + /** For content plain text can't express, e.g. `DisplayNames` with per-name tooltips */ + children: ReactElement; + + /** Required here because the row builds its label from strings and can't read one out of an element */ + accessibilityLabel: string; + }; + +/** Props of the muted leaves */ +type MenuItemSupportingTextProps = { + /** Text to render */ + children: string | number; + + /** Defaults to 2. Supporting text wraps, unlike primary text, which is always one line */ + numberOfLines?: number; +}; + +/** Props shared by MenuItem's content text */ +type BaseMenuItemTextProps = { + /** Which line of the row this leaf occupies, so the row announces its text in visual order */ + slot: MenuItemLabelSlot; + + /** Typography layered on top of the shared base. Each leaf brings its own size and line height */ + style?: StyleProp; +}; + +export type {MenuItemSupportingTextProps, MenuItemPrimaryTextProps, BaseMenuItemTextProps}; diff --git a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx index ce01c3244591..698fe2a1a7d9 100644 --- a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx +++ b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx @@ -12,7 +12,6 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; type MenuItemIconProps = { - /** Icon to display */ src: IconAsset; }; diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx deleted file mode 100644 index adeca7966a67..000000000000 --- a/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; -import Text from '@components/Text'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; - -import type MenuItemDescriptionProps from './types'; - -/** The small supporting-label description of a `MenuItem.Content`, for a description that sits under a title */ -function MenuItemDescription({children, numberOfLines = 2}: MenuItemDescriptionProps) { - const styles = useThemeStyles(); - - useMenuItemAccessibilityLabel('description', String(children)); - - return ( - - {children} - - ); -} - -export default MenuItemDescription; diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx deleted file mode 100644 index 478338f6bc67..000000000000 --- a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; -import Text from '@components/Text'; - -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import variables from '@styles/variables'; - -import React from 'react'; - -import type MenuItemDescriptionProps from './types'; - -/** The normal-size description of a `MenuItem.Content`, for a description-only row */ -function MenuItemDescriptionPlaceholder({children, numberOfLines = 2}: MenuItemDescriptionProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - - useMenuItemAccessibilityLabel('description', String(children)); - - return ( - - {children} - - ); -} - -export default MenuItemDescriptionPlaceholder; diff --git a/src/components/MenuItem/leaves/text/description/types.ts b/src/components/MenuItem/leaves/text/description/types.ts deleted file mode 100644 index 8b2b782a0d2e..000000000000 --- a/src/components/MenuItem/leaves/text/description/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Props shared by every description leaf of a `MenuItem.Content` */ -type MenuItemDescriptionProps = { - /** Text to render as the description */ - children: string | number; - - /** Maximum number of lines to render before the text is truncated */ - numberOfLines?: number; -}; - -export default MenuItemDescriptionProps; diff --git a/src/components/MenuItem/leaves/trailing/MenuItemChevron.tsx b/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx similarity index 84% rename from src/components/MenuItem/leaves/trailing/MenuItemChevron.tsx rename to src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx index 34c407a7693e..88c3322639a9 100644 --- a/src/components/MenuItem/leaves/trailing/MenuItemChevron.tsx +++ b/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx @@ -22,7 +22,10 @@ function MenuItemChevron() { const {isHovered} = useMenuItemInteraction(); return ( - + + + + ); +} + +export default MenuItemNewWindowIcon; diff --git a/src/components/MenuItem/presets/MenuItemAction.tsx b/src/components/MenuItem/presets/MenuItemAction.tsx index 31007833738f..92a5e875d185 100644 --- a/src/components/MenuItem/presets/MenuItemAction.tsx +++ b/src/components/MenuItem/presets/MenuItemAction.tsx @@ -2,8 +2,8 @@ import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTitle from '@components/MenuItem/leaves/content/MenuItemTitle'; import MenuItemIcon from '@components/MenuItem/leaves/leading/MenuItemIcon'; -import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; diff --git a/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx b/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx index 74ba9b915b34..01b81ae3dce6 100644 --- a/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx +++ b/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx @@ -4,9 +4,9 @@ import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; -import MenuItemDescription from '@components/MenuItem/leaves/text/description/MenuItemDescription'; -import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle'; -import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; +import MenuItemDescription from '@components/MenuItem/leaves/content/MenuItemDescription'; +import MenuItemTitle from '@components/MenuItem/leaves/content/MenuItemTitle'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; import type {AvatarSource} from '@libs/UserAvatarUtils'; diff --git a/src/components/MenuItem/presets/MenuItemEmptyField.tsx b/src/components/MenuItem/presets/MenuItemEmptyField.tsx deleted file mode 100644 index 23e0c6b02edb..000000000000 --- a/src/components/MenuItem/presets/MenuItemEmptyField.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; -import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; -import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; -import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; -import MenuItemDescriptionPlaceholder from '@components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder'; -import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; - -import {callFunctionIfActionIsAllowed} from '@userActions/Session'; - -import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type WithTestID from '@src/types/utils/TestID'; - -import type {PropsWithChildren} from 'react'; -import type {GestureResponderEvent} from 'react-native'; - -import React from 'react'; - -type MenuItemEmptyFieldProps = PropsWithChildren & - WithSentryLabel & - WithTestID & { - /** Name of the field, standing in for the value the field does not have yet */ - description: string; - - /** Function to fire when the row is pressed */ - onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; - - /** Whether the menu item is disabled */ - isDisabled?: boolean; - }; - -/** The empty-field MenuItem preset — a form field the user has not filled in yet */ -function MenuItemEmptyField({description, onPress, children, isDisabled = false, sentryLabel, testID}: MenuItemEmptyFieldProps) { - return ( - - - - {description} - - - {children} - - - - - ); -} - -export default MenuItemEmptyField; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx new file mode 100644 index 000000000000..81308f96e27d --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -0,0 +1,54 @@ +import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; +import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; +import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot'; +import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemFieldName from '@components/MenuItem/leaves/content/MenuItemFieldName'; +import MenuItemFieldNamePlaceholder from '@components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder'; +import MenuItemFieldValue from '@components/MenuItem/leaves/content/MenuItemFieldValue'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import React from 'react'; + +type MenuItemFieldProps = Omit & { + /** Name of the field */ + name: string; + + /** Value the field holds. Omit it, or pass an empty string, for a field not filled in yet */ + value?: string; +}; + +/** Field preset: a field name plus its value. With no `value` the name takes over the row */ +function MenuItemField({name, value, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { + return ( + + + + {value ? ( + <> + {name} + {value} + + ) : ( + {name} + )} + + {(!!children || !!onPress) && ( + + {children} + {!!onPress && } + + )} + + + ); +} + +export default MenuItemField; diff --git a/src/components/MenuItem/presets/MenuItemNavigation.tsx b/src/components/MenuItem/presets/MenuItemNavigation.tsx index 7dfb2fd48110..349517e1cc3f 100644 --- a/src/components/MenuItem/presets/MenuItemNavigation.tsx +++ b/src/components/MenuItem/presets/MenuItemNavigation.tsx @@ -3,9 +3,9 @@ import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemTitle from '@components/MenuItem/leaves/content/MenuItemTitle'; import MenuItemIcon from '@components/MenuItem/leaves/leading/MenuItemIcon'; -import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle'; -import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; diff --git a/src/components/MenuItem/presets/MenuItemWithLabel.tsx b/src/components/MenuItem/presets/MenuItemWithLabel.tsx index 5c0a82a28838..b276bf3595d5 100644 --- a/src/components/MenuItem/presets/MenuItemWithLabel.tsx +++ b/src/components/MenuItem/presets/MenuItemWithLabel.tsx @@ -1,6 +1,6 @@ import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; -import Text from '@components/Text'; +import MenuItemLabel from '@components/MenuItem/leaves/content/MenuItemLabel'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,7 +24,7 @@ function MenuItemWithLabel({label, onPress, isDisabled = false, sentryLabel, tes return ( - {label} + {label} (null); type MenuItemGroupProps = { - /* Actual content wrapped by this component */ children: React.ReactNode; /** Whether or not to use the single execution hook */ diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index d79420ddc439..64d49996a33e 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -21,22 +21,14 @@ import OfflineWithFeedback from './OfflineWithFeedback'; type MenuItemLink = string | (() => Promise); type MenuItemWithLink = MenuItemProps & { - /** The link to open when the menu item is clicked */ link?: MenuItemLink; /** A unique key for the menu item */ key?: string; - /** The pending action for the menu item */ pendingAction?: OnyxCommon.PendingAction | null; - - /** A function to dismiss the pending action */ onPendingActionDismiss?: () => void; - - /** The error for the menu item */ error?: OnyxCommon.Errors | null; - - /** Whether we should force opacity */ shouldForceOpacity?: boolean; }; @@ -47,16 +39,12 @@ type MenuItemListProps = { /** Whether or not to use the single execution hook */ shouldUseSingleExecution?: boolean; - /** Any additional styles to apply for each item */ wrapperStyle?: StyleProp; /** Icon to display on the left side of each item */ icon?: IconAsset; - /** Icon Width */ iconWidth?: number; - - /** Icon Height */ iconHeight?: number; }; diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx new file mode 100644 index 000000000000..b39a3f930608 --- /dev/null +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -0,0 +1,212 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearMerchantRuleSuggestionFields, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {setDraftMerchantRule} from '@libs/actions/User'; +import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import {useIsFocused, useRoute} from '@react-navigation/native'; +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import Animated, {Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming} from 'react-native-reanimated'; + +import Banner from './Banner'; +import Icon from './Icon'; +import Text from './Text'; +import TextLink from './TextLink'; +import {useWideRHPState} from './WideRHPContextProvider'; + +/** How far the callout travels on its way in, far enough for the spring to read as movement rather than a nudge */ +const CALLOUT_SLIDE_DISTANCE = 40; + +// The spring carries the movement, so the fade only has to stop the callout appearing before it has travelled. Linear +// because that is what reanimated's own fade keyframes run at on web. +const CALLOUT_FADE_CONFIG = {duration: CONST.ANIMATED_TRANSITION, easing: Easing.linear}; + +type MerchantRuleSuggestionBannerProps = { + /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ + reportID: string | undefined; + + /** The workspace the expense belongs to */ + policyID: string | undefined; + + /** Styles for the banner container */ + containerStyles?: StyleProp; + + /** When set, floats the callout in a wrapper carrying these styles instead of laying it out inline */ + overlayStyles?: StyleProp; + + /** + * Whether this is the mount above the composer. Sets the edge the callout slides in from, and which layouts it + * serves: the composer takes the wide ones, the report list the narrow ones. + */ + isAnchoredToBottom?: boolean; +}; + +type MerchantRuleSuggestionBannerContentProps = MerchantRuleSuggestionBannerProps & { + /** Whether the composer is expanded, which leaves no room for the callout */ + isComposerFullSize: boolean; +}; + +function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom, isComposerFullSize}: MerchantRuleSuggestionBannerContentProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); + const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); + const isShowing = !!suggestion && !!policyID; + + // Sprung in from the edge it is pinned to, the way FloatingMessageCounter moves its pill. A reanimated entering + // animation looked right when the callout arrived with a fresh page, but a toggle edit leaves the user on the page + // it appears in, and there the animation fought the surrounding layout and flickered. + const slideOffset = isAnchoredToBottom ? CALLOUT_SLIDE_DISTANCE : -CALLOUT_SLIDE_DISTANCE; + const translateY = useSharedValue(slideOffset); + const opacity = useSharedValue(0); + + // Held until any navigation transition finishes. Coming back from a field's edit page mounts the callout while the + // page is still sliding, and the slide would be over before the page arrived. Nothing is transitioning after a + // toggle edit, where the callout appears on the page the user is already on, so there it starts at once. + useEffect(() => { + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + translateY.set(withSpring(0)); + opacity.set(withTiming(1, CALLOUT_FADE_CONFIG)); + }, + }); + return handle.cancel; + }, [translateY, opacity]); + + const slideStyle = useAnimatedStyle(() => ({ + opacity: opacity.get(), + transform: [{translateY: translateY.get()}], + })); + + // Recorded so leaving this report can retire the offer. The report cannot work this out for itself, because the + // one showing an expense is not always the one the edit was recorded against. + // + // A full-size composer hides the callout, and composer size is remembered per report. Marking it seen there would + // retire an offer the user never had a chance to read. + useEffect(() => { + if (!isShowing || !reportID || isComposerFullSize || suggestion?.seenInReportID === reportID) { + return; + } + markMerchantRuleSuggestionSeen(reportID); + }, [isShowing, reportID, isComposerFullSize, suggestion?.seenInReportID]); + + if (!suggestion || !policyID) { + return null; + } + + const dismiss = () => dismissMerchantRuleSuggestion(suggestion); + + const createRule = () => { + const draft = getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels); + if (!draft) { + return; + } + // Opened as a suffix on the expense's own path, so the expense stays under the modal and the flow returns + // here rather than to the workspace Rules page. + setDraftMerchantRule(draft); + // The offer was taken, so it must not still be asking on the way back, and the recording ends here. Editing + // the expense again starts fresh instead of repeating fields already in this rule. + clearMerchantRuleSuggestionFields(suggestion.transactionID); + retireMerchantRuleSuggestion(); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.getRoute(policyID))); + }; + + // The composer check sits inside rather than around this wrapper on purpose. Expanding the composer should take + // the callout away at once, and emptying the wrapper does that without disturbing the slide. + return ( + + {!isComposerFullSize && ( + + + + + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + + } + /> + )} + + ); +} + +/** + * Offers the chance to turn an expense edit into a merchant rule, right on the expense that was just edited. Renders + * nothing unless there is a qualifying edit to act on. + */ +function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { + const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + // A full-size composer leaves no room for the callout, and on narrow layouts it would sit over the button that + // collapses the composer again. Handled inside the content rather than here, so the callout goes at once instead + // of animating out over the composer as it grows. + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + // A wide RHP reports a narrow layout but looks wide, so it belongs to the composer mount. + const route = useRoute(); + const {wideRHPRouteKeys} = useWideRHPState(); + const isInWideRHP = !!route?.key && wideRHPRouteKeys.includes(route.key); + + // Both mounts always render; this picks the one for the layout. Deciding here keeps the two halves from drifting, + // and keeps the navigation-state subscription out of the report actions list, which re-renders far more often. + const isMountForThisLayout = isAnchoredToBottom ? !shouldUseNarrowLayout || isInWideRHP : shouldUseNarrowLayout && !isInWideRHP; + + // The same report can be mounted twice at once, in the central pane and in the RHP over it. Each tree measures its + // own layout, so the layout check alone elects a mount in both and the callout appears twice, each fading in on its + // own beat. Only the view the user is actually on should offer it. + const isFocused = useIsFocused(); + + // Nothing is stored for most of a session, so skip the inner component and its Onyx subscriptions until there is + // an edit to offer. + if (!isFocused || !isMountForThisLayout || !isMerchantRuleSuggestionLive(storedSuggestion)) { + return null; + } + + return ( + + ); +} + +export default MerchantRuleSuggestionBanner; diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index 704c83a512ca..ed5871f96aec 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -21,7 +21,6 @@ import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Tooltip from './Tooltip'; type MessagesRowProps = { - /** The messages to display */ messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ @@ -30,13 +29,8 @@ type MessagesRowProps = { /** A function to run when the X button next to the message is clicked */ onDismiss?: () => void; - /** Additional style object for the container */ containerStyles?: StyleProp; - - /** Additional style object for the error text */ errorTextStyles?: StyleProp; - - /** A function to dismiss error */ dismissError?: () => void; }; diff --git a/src/components/Modal/Global/HoldMenuModalWrapper.tsx b/src/components/Modal/Global/HoldMenuModalWrapper.tsx index fd6df1e86a87..1bea9e5d2b72 100644 --- a/src/components/Modal/Global/HoldMenuModalWrapper.tsx +++ b/src/components/Modal/Global/HoldMenuModalWrapper.tsx @@ -19,7 +19,10 @@ import type {ModalProps} from './ModalContext'; type HoldMenuModalWrapperProps = ModalProps & { reportID: string | undefined; chatReportID: string | undefined; - requestType: ActionHandledType; + /** Which action the modal confirms. Chat surfaces the approval choice up front via the approve + * dropdown and never open this modal to approve, so it defaults to the pay copy when omitted. The Search + * page still routes its approve action here until it gets the same treatment. */ + requestType?: ActionHandledType; paymentType?: PaymentMethodType; methodID?: number; nonHeldAmount?: string; @@ -27,8 +30,10 @@ type HoldMenuModalWrapperProps = ModalProps & { hasNonHeldExpenses?: boolean; transactionCount: number; onConfirm?: (full: boolean) => void; - // Optional overrides for callers that source reports from a place other - // than the main report collection (e.g. Search rows render from a snapshot). + /** + * Optional overrides for callers that source reports from a place other + * than the main report collection (e.g. Search rows render from a snapshot). + */ moneyRequestReport?: OnyxEntry; chatReport?: OnyxEntry; }; @@ -69,16 +74,20 @@ function HoldMenuModalWrapper({ onConfirm, }); + let approvalPrompt: string; + if (isApprove) { + // Reuse the copy the approve dropdown shows, so both surfaces phrase the same choice identically. + approvalPrompt = hasNonHeldExpenses ? translate('iou.confirmApprovalWithHeldAmount') : translate('iou.confirmApprovalAllHoldAmount'); + } else { + approvalPrompt = hasNonHeldExpenses ? translate('iou.confirmPayAmount') : translate('iou.confirmPayAllHoldAmount', {count: transactionCount}); + } + return ( setIsVisible(false)} isVisible={isVisible} - prompt={ - hasNonHeldExpenses - ? translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount') - : translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {count: transactionCount}) - } + prompt={approvalPrompt} firstOptionText={hasNonHeldExpenses ? `${translate(isApprove ? 'iou.approveOnly' : 'iou.payOnly')} ${nonHeldAmount}` : undefined} secondOptionText={`${translate(isApprove ? 'iou.approve' : 'iou.pay')} ${fullAmount}`} onFirstOptionSubmit={() => onSubmit(false)} @@ -95,4 +104,4 @@ function HoldMenuModalWrapper({ } export default HoldMenuModalWrapper; -export type {ActionHandledType, HoldMenuModalWrapperProps}; +export type {HoldMenuModalWrapperProps}; diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index 73e46d76f474..690154ad81eb 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -57,7 +57,6 @@ type ReanimatedModalProps = ViewProps & }>, ) => void; - /** The presentation style of the modal */ presentationStyle?: 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen'; /** Enum for animation type when modal appears */ @@ -84,10 +83,7 @@ type ReanimatedModalProps = ViewProps & /** Whether the modal should have a backdrop */ hasBackdrop?: boolean; - /** Color of the backdrop */ backdropColor?: string; - - /** Opacity of the backdrop */ backdropOpacity?: number; /** Duration of backdrop transition when modal appears */ @@ -96,13 +92,8 @@ type ReanimatedModalProps = ViewProps & /** Duration of backdrop transition when modal disappears */ backdropTransitionOutTiming?: number; - /** Custom component to use as the backdrop */ customBackdrop?: ReactNode; - - /** Whether to hide modal content during animations */ hideModalContentWhileAnimating?: boolean; - - /** Whether the modal is visible */ isVisible?: boolean; /** Callback when modal has fully appeared */ @@ -117,7 +108,6 @@ type ReanimatedModalProps = ViewProps & /** Callback when modal is about to disappear */ onModalWillHide?: () => void; - /** Callback when the backdrop is pressed */ onBackdropPress?: () => void; /** Callback when the back button is pressed (on Android) */ @@ -130,11 +120,7 @@ type ReanimatedModalProps = ViewProps & supportedOrientations?: Array<'portrait' | 'portrait-upside-down' | 'landscape' | 'landscape-left' | 'landscape-right'>; navigationBarTranslucent?: boolean; - - /** Modal type */ type?: ValueOf; - - /** Whether to prevent scroll on focus */ shouldPreventScrollOnFocus?: boolean; /** Whether to use a custom backdrop for the modal? (This prevents focus issues on desktop) */ @@ -157,7 +143,6 @@ type ReanimatedModalProps = ViewProps & */ launcherRef?: RefObject; - /** Whether to ignore the back handler during transition */ shouldIgnoreBackHandlerDuringTransition?: boolean; }; @@ -165,25 +150,15 @@ type BackdropProps = { /** Style applied to the modal backdrop */ style: StyleProp; - /** Custom backdrop component */ customBackdrop?: ReactNode; - - /** Callback fired when pressing the backdrop */ onBackdropPress?: () => void; - - /** Delay set to animation on enter */ animationInDelay?: number; - - /** Timing of animation on enter */ animationInTiming?: number; /** Timing of animation on exit */ animationOutTiming?: number; - /** Opacity of the backdrop */ backdropOpacity?: number; - - /** Shows backdrop content */ isBackdropVisible: boolean; }; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 21d50e6de86f..db13b5894951 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -48,7 +48,6 @@ type BaseModalProps = Partial & /** Callback method fired when the modal is shown */ onModalShow?: () => void; - /** Style of modal to display */ type?: ValueOf; /** The anchor position of a popover modal. Has no effect on other modal types. */ @@ -63,7 +62,6 @@ type BaseModalProps = Partial & /** Whether the modal should go under the system navigation bar */ navigationBarTranslucent?: boolean; - /** Whether the modal should avoid the keyboard */ avoidKeyboard?: boolean; /** Modal container styles */ @@ -75,7 +73,6 @@ type BaseModalProps = Partial & /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; - /** Unique id for the modal */ modalId?: number; /** @@ -93,10 +90,7 @@ type BaseModalProps = Partial & /** After swipe more than threshold modal will close */ swipeThreshold?: number; - /** In which direction modal will swipe */ swipeDirection?: SwipeDirection; - - /** Used to set the element that should receive the initial focus */ initialFocus?: FocusTrapOptions['initialFocus']; /** Whether to prevent the focus trap from scrolling the element into view. */ @@ -120,9 +114,6 @@ type BaseModalProps = Partial & */ shouldDisableBottomSafeAreaPadding?: boolean; - /** - * Reference to the outer element. - */ ref?: ForwardedRef; /** diff --git a/src/components/Modal/useSyncModalWithHistory/index.ts b/src/components/Modal/useSyncModalWithHistory/index.ts index d47bcfb35881..1ac07c4f4fef 100644 --- a/src/components/Modal/useSyncModalWithHistory/index.ts +++ b/src/components/Modal/useSyncModalWithHistory/index.ts @@ -12,7 +12,6 @@ import {EMPTY_MODAL_GUARD_SNAPSHOT_KEY, getModalGuardSnapshotKey, parseModalGuar import reduceModalGuardState, {getModalGuardEventFromSnapshotChange, MODAL_GUARD_EFFECT, MODAL_GUARD_STATE} from './modalGuardState'; type UseSyncModalWithHistoryParams = { - /** Whether the modal is currently visible */ isVisible: boolean; /** Whether this modal participates in browser-history back handling */ diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 1fba8db9c14a..14c912cf25ef 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -30,7 +30,6 @@ import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; import {useSearchSelectionActions} from './Search/SearchContext'; type MoneyReportHeaderProps = { - /** The reportID of the report currently being looked at */ reportID: string | undefined; /** Whether back button should be displayed in header */ diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 3fdf27788e11..15905e0a9e2c 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; @@ -129,6 +129,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [invoiceReceiverPolicy] = useOnyx( `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, {}, @@ -165,7 +166,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); @@ -219,6 +220,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo chatReportActions: getChatReportActions(payAsBusiness), delegateAccountID, isTrackIntentUser, + rules, }); } else { startAnimation(); @@ -246,6 +248,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo delegateAccountID, isTrackIntentUser, conciergeChat, + rules, }); if (currentSearchQueryJSON && !isOffline) { search({ @@ -389,6 +392,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo outstandingReportsByPolicyID, isChatReportArchived, isOffline, + rules, }) : []; @@ -441,6 +445,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo isASAPSubmitBetaEnabled, confirmApproval: () => confirmApproval(), iouReport: moneyRequestReport, + rules, betas, userBillingGracePeriodEnds, amountOwed, @@ -475,6 +480,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo dropdownMenuRef={dropdownMenuRef} onOptionsMenuHide={handleOptionsMenuHide} ref={kycWallRef} + shouldPutHeaderTextAfterBackButton /> ); } diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 07950e802611..dcb8f46293b1 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -76,6 +76,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const [session] = useOnyx(ONYXKEYS.SESSION); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); @@ -113,21 +114,22 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); // Submit/approve via shared lifecycle actions - const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval} = useLifecycleActions({ - reportID, - startApprovedAnimation, - startAnimation, - startSubmittingAnimation, - onHoldMenuOpen: (requestType, onConfirm, paymentType) => - openHoldMenu({ - requestType, - onConfirm: () => { - onConfirm?.(); - clearSelectedTransactions(true); - }, - paymentType, - }), - }); + const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval, approveSubMenuItems, approveSubMenuHeaderText, shouldShowApproveSubMenu} = + useLifecycleActions({ + reportID, + startApprovedAnimation, + startAnimation, + startSubmittingAnimation, + onHoldMenuOpen: (requestType, onConfirm, paymentType) => + openHoldMenu({ + requestType, + onConfirm: () => { + onConfirm?.(); + clearSelectedTransactions(true); + }, + paymentType, + }), + }); const { options: originalSelectedTransactionsOptions, @@ -168,6 +170,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn outstandingReportsByPolicyID, isChatReportArchived, isOffline, + rules, }) : []; @@ -241,11 +244,12 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn policy, report: moneyRequestReport, isTrackIntentUser, + rules, }); const submitButtonText = shouldShowMarkAsDoneCopy ? translate('common.markAsDone') : translate('common.submit'); const approveButtonText = shouldShowMarkAsDoneCopy ? translate('common.markAsDone') : translate('iou.approve'); - const selectionModeReportLevelActions: Array & Pick> = [ + const selectionModeReportLevelActions: Array & Pick> = [ ...(hasSubmitAction && !shouldBlockSubmit ? [ { @@ -262,6 +266,11 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn text: approveButtonText, icon: expensifyIcons.ThumbsUp, value: CONST.REPORT.PRIMARY_ACTIONS.APPROVE, + rightIcon: shouldShowApproveSubMenu ? expensifyIcons.ArrowRight : undefined, + backButtonText: shouldShowApproveSubMenu ? approveButtonText : undefined, + subMenuItems: shouldShowApproveSubMenu ? approveSubMenuItems : undefined, + subMenuHeaderText: shouldShowApproveSubMenu ? approveSubMenuHeaderText : undefined, + // Only reached when there is no submenu; otherwise PopoverMenu opens the submenu instead. onSelected: () => confirmApproval(true), }, ] @@ -340,6 +349,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} shouldShowSuccessStyle ref={kycWallRef} + shouldPutHeaderTextAfterBackButton /> ); @@ -355,6 +365,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} isSplitButton={false} shouldAlwaysShowDropdownMenu + shouldPutHeaderTextAfterBackButton shouldPopoverUseScrollView={popoverUseScrollView} wrapperStyle={wrapperStyle} /> diff --git a/src/components/MoneyReportHeaderActions/types.ts b/src/components/MoneyReportHeaderActions/types.ts index 6e4fa078cffe..57f4c3c02297 100644 --- a/src/components/MoneyReportHeaderActions/types.ts +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -6,7 +6,7 @@ import type {Route} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -type SecondaryActionEntry = DropdownOption> & Pick; +type SecondaryActionEntry = DropdownOption> & Pick; type MoneyReportHeaderActionsProps = { reportID: string | undefined; diff --git a/src/components/MoneyReportHeaderEducationalModals.tsx b/src/components/MoneyReportHeaderEducationalModals.tsx index fbc3a286f5a5..d5ae8ddbef44 100644 --- a/src/components/MoneyReportHeaderEducationalModals.tsx +++ b/src/components/MoneyReportHeaderEducationalModals.tsx @@ -58,6 +58,7 @@ function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEd const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); useImperativeHandle(ref, () => ({ openHoldEducational: () => setIsHoldEducationalModalVisible(true), @@ -77,6 +78,7 @@ function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEd transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); } }; @@ -94,6 +96,7 @@ function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEd transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); } } else if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK) { diff --git a/src/components/MoneyReportHeaderKYCDropdown.tsx b/src/components/MoneyReportHeaderKYCDropdown.tsx index eeab32d48136..42bf33cd382e 100644 --- a/src/components/MoneyReportHeaderKYCDropdown.tsx +++ b/src/components/MoneyReportHeaderKYCDropdown.tsx @@ -29,6 +29,10 @@ type MoneyReportHeaderKYCDropdownProps = Omit void; + headerText?: string; + + shouldPutHeaderTextAfterBackButton?: boolean; + /** * Called when a workspace-policy sub-item is picked. The parent owns the full flow (guards, telemetry, * then invoking `triggerKYCFlow({policy})` when ready). If omitted, defaults to `triggerKYCFlow({policy})`. @@ -38,8 +42,6 @@ type MoneyReportHeaderKYCDropdownProps = Omit; /** Callback fired when the dropdown menu hides */ @@ -59,6 +61,8 @@ function MoneyReportHeaderKYCDropdown({ dropdownMenuRef, onOptionsMenuHide, ref, + headerText = '', + shouldPutHeaderTextAfterBackButton = false, ...props }: MoneyReportHeaderKYCDropdownProps) { const styles = useThemeStyles(); @@ -113,6 +117,8 @@ function MoneyReportHeaderKYCDropdown({ shouldUseModalPaddingStyle onOptionsMenuHide={onOptionsMenuHide} sentryLabel={CONST.SENTRY_LABEL.MORE_MENU.MORE_BUTTON} + menuHeaderText={headerText} + shouldPutHeaderTextAfterBackButton={shouldPutHeaderTextAfterBackButton} /> )} diff --git a/src/components/MoneyReportHeaderModalsContext.tsx b/src/components/MoneyReportHeaderModalsContext.tsx index 08d0519c5966..1e1e6821d362 100644 --- a/src/components/MoneyReportHeaderModalsContext.tsx +++ b/src/components/MoneyReportHeaderModalsContext.tsx @@ -1,12 +1,13 @@ +import type {ActionHandledType} from '@hooks/useHoldMenuSubmit'; + import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {createContext, useContext} from 'react'; -import type {ActionHandledType} from './Modal/Global/HoldMenuModalWrapper'; import type {RejectModalAction} from './MoneyReportHeaderEducationalModals'; type HoldMenuParams = { - requestType: ActionHandledType; + requestType?: ActionHandledType; paymentType?: PaymentMethodType; methodID?: number; onConfirm?: (full: boolean) => void; diff --git a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx index 9ae09ece23ca..412934ef2286 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx @@ -1,53 +1,87 @@ -import Button from '@components/ButtonComposed'; +import {useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import ExpenseHeaderApprovalButton from '@components/ExpenseHeaderApprovalButton'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; -import useLocalize from '@hooks/useLocalize'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getNextApproverAccountID, isReportOwner, shouldShowMarkAsDone} from '@libs/ReportUtils'; +import {getNextApproverAccountID, hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, isReportOwner} from '@libs/ReportUtils'; + +import {canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React from 'react'; import useConfirmApproval from './useConfirmApproval'; type ApprovePrimaryActionProps = { reportID: string | undefined; + chatReportID: string | undefined; }; -function ApprovePrimaryAction({reportID}: ApprovePrimaryActionProps) { - const {startApprovedAnimation} = usePaymentAnimationsContext(); - const {translate} = useLocalize(); +function ApprovePrimaryAction({reportID, chatReportID}: ApprovePrimaryActionProps) { + const {isPaidAnimationRunning, startApprovedAnimation} = usePaymentAnimationsContext(); + const currentUserDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserDetails.accountID; + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const activePolicy = usePolicy(activePolicyID); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [expenseReportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(iouReport?.policyID)}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); + const [invoiceReceiverPolicy] = useOnyx( + `${ONYXKEYS.COLLECTION.POLICY}${iouReport?.invoiceReceiver && 'policyID' in iouReport.invoiceReceiver ? iouReport.invoiceReceiver.policyID : ''}`, + ); - const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ - isTrackIntentUser, - report: moneyRequestReport, - policy, - }); + const nextApproverAccountID = getNextApproverAccountID(iouReport, rules); + const isSubmitterSameAsNextApprover = isReportOwner(iouReport) && (nextApproverAccountID === iouReport?.ownerAccountID || iouReport?.managerID === iouReport?.ownerAccountID); + const isBlockSubmitDueToPreventSelfApproval = isSubmitterSameAsNextApprover && expenseReportPolicy?.preventSelfApproval; - const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); - const isSubmitterSameAsNextApprover = - isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID); - const isBlockSubmitDueToPreventSelfApproval = isSubmitterSameAsNextApprover && policy?.preventSelfApproval; + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(reportID); + const transactions = Object.values(reportTransactions); + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(transactions); + const canIOUBePaid = canIOUBePaidAction( + iouReport, + chatReport, + activePolicy, + bankAccountList, + currentUserDetails.login ?? '', + currentUserAccountID, + undefined, + false, + undefined, + invoiceReceiverPolicy, + ); + const onlyShowPayElsewhere = + !canIOUBePaid && + canIOUBePaidAction(iouReport, chatReport, activePolicy, bankAccountList, currentUserDetails.login ?? '', currentUserAccountID, undefined, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - const confirmApproval = useConfirmApproval(reportID, startApprovedAnimation); + const {onApprove} = useConfirmApproval(reportID, startApprovedAnimation); return ( - + /> ); } diff --git a/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx index e25132808f63..a4e126caed06 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; diff --git a/src/components/MoneyReportHeaderPrimaryAction/MarkAsCashPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/MarkAsCashPrimaryAction.tsx index 6789583f7cf2..41b7eb93b16e 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/MarkAsCashPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/MarkAsCashPrimaryAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useTransactionViolations from '@hooks/useTransactionViolations'; diff --git a/src/components/MoneyReportHeaderPrimaryAction/MarkAsResolvedPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/MarkAsResolvedPrimaryAction.tsx index fa253bdef833..19569f976fce 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/MarkAsResolvedPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/MarkAsResolvedPrimaryAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index be80c5e50e06..c837a072cdfb 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -67,6 +67,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const activePolicy = usePolicy(activePolicyID); const chatReportPolicy = usePolicy(chatReport?.policyID); @@ -116,7 +117,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const {openHoldMenu} = useMoneyReportHeaderModals(); @@ -156,6 +157,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { chatReportActions: getChatReportActions(payAsBusiness), delegateAccountID, isTrackIntentUser, + rules, }); } else { startAnimation(); @@ -181,6 +183,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { delegateAccountID, isTrackIntentUser, conciergeChat, + rules, }); if (currentSearchQueryJSON && !isOffline) { search({ diff --git a/src/components/MoneyReportHeaderPrimaryAction/RemoveHoldPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/RemoveHoldPrimaryAction.tsx index f94c91b1ee56..fa9e8a7517d4 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/RemoveHoldPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/RemoveHoldPrimaryAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -33,6 +33,7 @@ function RemoveHoldPrimaryAction({reportID, chatReportID}: SimpleActionProps) { const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {transactions: reportTransactionsMap} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const transactions = Object.values(reportTransactionsMap); @@ -47,7 +48,7 @@ function RemoveHoldPrimaryAction({reportID, chatReportID}: SimpleActionProps) { } const parentReportAction = getReportAction(moneyRequestReport?.parentReportID, moneyRequestReport?.parentReportActionID); - const IOUActions = getAllExpensesToHoldIfApplicable(moneyRequestReport, reportActions, transactions, policy, currentUserAccountID); + const IOUActions = getAllExpensesToHoldIfApplicable(moneyRequestReport, reportActions, transactions, policy, currentUserAccountID, rules); if (IOUActions.length) { for (const action of IOUActions) { @@ -62,6 +63,7 @@ function RemoveHoldPrimaryAction({reportID, chatReportID}: SimpleActionProps) { transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); } return; @@ -83,6 +85,7 @@ function RemoveHoldPrimaryAction({reportID, chatReportID}: SimpleActionProps) { transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); }} > diff --git a/src/components/MoneyReportHeaderPrimaryAction/ReviewDuplicatesPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ReviewDuplicatesPrimaryAction.tsx index 8c9f30a1682e..cf060d42b426 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ReviewDuplicatesPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ReviewDuplicatesPrimaryAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -18,6 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import type {SimpleActionProps} from './types'; @@ -35,6 +36,7 @@ function ReviewDuplicatesPrimaryAction({reportID, chatReportID}: SimpleActionPro const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); @@ -71,7 +73,16 @@ function ReviewDuplicatesPrimaryAction({reportID, chatReportID}: SimpleActionPro iouReportAction: duplicateIOUAction, transaction: duplicateTransaction, }, - {introSelected, betas, conciergeChat, currentUserEmail: email, currentUserAccountID: accountID, personalDetails}, + { + introSelected, + betas, + conciergeChat, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + currentUserEmail: email, + currentUserAccountID: accountID, + personalDetails, + }, ) : undefined); if (threadID) { diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 1499234134f7..1f5ec9758e9e 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -90,6 +90,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const delegateAccountID = useDelegateAccountID(); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [preferredSubmissionMethod] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_PREFERRED_REPORT_SUBMISSION_METHOD}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -108,7 +109,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {showConfirmModal} = useConfirmModal(); - const isBlockSubmitDueToPreventSelfApproval = shouldBlockSubmitDueToPreventSelfApproval(moneyRequestReport, policy); + const isBlockSubmitDueToPreventSelfApproval = shouldBlockSubmitDueToPreventSelfApproval(moneyRequestReport, policy, rules); const isBlockSubmitDueToStrictPolicyRules = shouldBlockSubmitDueToStrictPolicyRules( moneyRequestReport?.reportID, violations, @@ -122,6 +123,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { policy, report: moneyRequestReport, isTrackIntentUser, + rules, }); // Submit via PDF is offered for any draft report the current user submits on a Submit workspace. The PDF flow @@ -130,7 +132,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'Document']); @@ -163,6 +165,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { getCurrencyDecimals, expenseReport: moneyRequestReport, policy, + rules, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', hasViolations, @@ -182,7 +185,18 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { // Stop the "Submitted" animation in lockstep with the retract so the header goes straight // back to the Submit button instead of finishing the animation on a report that is open again. stopAnimation(); - retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, delegateEmail, isTrackIntentUser); + retractReport( + moneyRequestReport, + chatReport, + policy, + accountID, + email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + delegateEmail, + isTrackIntentUser, + rules, + ); }, }); } diff --git a/src/components/MoneyReportHeaderPrimaryAction/index.tsx b/src/components/MoneyReportHeaderPrimaryAction/index.tsx index 8476ab4ac57c..6ed447668d5f 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/index.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/index.tsx @@ -23,7 +23,12 @@ function MoneyReportHeaderPrimaryAction({reportID, chatReportID, primaryAction, } if (primaryAction === CONST.REPORT.PRIMARY_ACTIONS.APPROVE) { - return ; + return ( + + ); } if (primaryAction === CONST.REPORT.PRIMARY_ACTIONS.PAY) { diff --git a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts index 7efbb5184e10..9f56e1b69c94 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts +++ b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts @@ -1,5 +1,4 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; -import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -27,7 +26,6 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation const {isBetaEnabled} = usePermissions(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {openHoldMenu} = useMoneyReportHeaderModals(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); @@ -38,49 +36,52 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const delegateAccountID = useDelegateAccountID(); - const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); + const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID), + }); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(Object.values(reportTransactions)); - const confirmApproval = () => { + const onApprove = (full: boolean) => { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); - } else if (isAnyTransactionOnHold) { - openHoldMenu({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.APPROVE, - onConfirm: () => startApprovedAnimation(), - }); - } else { - if (!isSubmitPolicy(policy)) { - startApprovedAnimation(); - } - approveMoneyRequest({ - getCurrencyDecimals, - expenseReport: moneyRequestReport, - expenseReportPolicy: policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - ownerLogin, - full: true, - onApproved: startApprovedAnimation, - delegateEmail, - delegateAccountID, - isTrackIntentUser, - }); + return; } + if (!isSubmitPolicy(policy)) { + startApprovedAnimation(); + } + approveMoneyRequest({ + getCurrencyDecimals, + expenseReport: moneyRequestReport, + expenseReportPolicy: policy, + rules, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + ownerLogin, + full, + onApproved: startApprovedAnimation, + delegateEmail, + delegateAccountID, + isTrackIntentUser, + }); }; - return confirmApproval; + // When there are held expenses the partial/full choice is surfaced up front via the settlement button's approve + // submenu, so this always approves the full report (used for the non-held case and as a safe fallback). + const confirmApproval = () => onApprove(true); + + return {confirmApproval, onApprove, isAnyTransactionOnHold}; } export default useConfirmApproval; diff --git a/src/components/MoneyReportTransactionThreadContext.tsx b/src/components/MoneyReportTransactionThreadContext.tsx index 00f22cdc6036..8870ee5d6a79 100644 --- a/src/components/MoneyReportTransactionThreadContext.tsx +++ b/src/components/MoneyReportTransactionThreadContext.tsx @@ -17,9 +17,7 @@ type MoneyReportTransactionThreadContextValue = { iouTransactionID: string | undefined; /** The parent IOU report action for the transaction thread */ requestParentReportAction: OnyxTypes.ReportAction | null; - /** The transaction thread report ID */ transactionThreadReportID: string | undefined; - /** The transaction thread report */ transactionThreadReport: OnyxTypes.Report | undefined; /** Filtered report actions for the transaction thread */ reportActions: OnyxTypes.ReportAction[]; @@ -38,7 +36,6 @@ const MoneyReportTransactionThreadContext = createContext string; /** Currency chosen by user or saved in Onyx */ @@ -33,37 +32,22 @@ type MoneyRequestAmountInputProps = { /** Fired when back button pressed, navigates to currency selection page */ onCurrencyButtonPress?: () => void; - /** Function to call when the amount changes */ onAmountChange?: (amount: string) => void; - - /** Style for the input */ inputStyle?: StyleProp; - - /** Style for the container */ containerStyle?: StyleProp; /** Character to be shown before the amount */ prefixCharacter?: string; - /** Whether to hide the currency symbol */ hideCurrencySymbol?: boolean; /** Whether to disable native keyboard on mobile */ disableKeyboard?: boolean; - /** Style for the prefix */ prefixStyle?: StyleProp; - - /** Style for the prefix container */ prefixContainerStyle?: StyleProp; - - /** Style for the touchable input wrapper */ touchableInputWrapperStyle?: StyleProp; - - /** Whether we want to format the display amount on blur */ formatAmountOnBlur?: boolean; - - /** Max length for the amount input */ maxLength?: number; /** Hide the focus styles on TextInput */ @@ -77,19 +61,13 @@ type MoneyRequestAmountInputProps = { */ autoGrow?: boolean; - /** The width of inner content */ contentWidth?: number; /** Whether to apply padding to the input, some inputs doesn't require any padding, e.g. Amount input in money request flow */ shouldApplyPaddingToContainer?: boolean; - /** Whether the amount is negative */ isNegative?: boolean; - - /** Function to toggle the amount to negative */ toggleNegative?: () => void; - - /** Function to clear the negative amount */ clearNegative?: () => void; /** Whether to allow flipping amount (shows flip button and enables toggle mechanism) */ @@ -98,16 +76,12 @@ type MoneyRequestAmountInputProps = { /** Whether to allow direct negative input (for split amounts where value is already negative) */ allowNegativeInput?: boolean; - /** Style for the negative symbol */ negativeSymbolStyle?: StyleProp; /** The testID of the input. Used to locate this view in end-to-end tests. */ testID?: string; - /** Whether to show the big number pad */ shouldShowBigNumberPad?: boolean; - - /** Whether to use dynamic font size for the amount input */ shouldUseDynamicFontSize?: boolean; /** Error to display at the bottom of the form */ @@ -116,7 +90,6 @@ type MoneyRequestAmountInputProps = { /** Footer to display at the bottom of the form */ footer?: React.ReactNode; - /** Reference to the amount form */ moneyRequestAmountInputRef?: ForwardedRef; /** @@ -135,13 +108,8 @@ type MoneyRequestAmountInputProps = { */ shouldRefocusOnScrollViewClick?: boolean; - /** Whether the input is disabled or not */ disabled?: boolean; - - /** Reference to the outer element */ ref?: ForwardedRef; - - /** Determines which keyboard to open */ keyboardType?: KeyboardTypeOptions; } & Pick; diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index c0f273488179..03490ff090a1 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -37,12 +37,12 @@ type ProviderProps = { /** Whether we're editing an existing split expense */ isEditingSplitBill?: boolean; - /** Whether the new manual expense flow beta is enabled */ - isNewManualExpenseFlowEnabled?: boolean; - /** Whether the surface is in a policy-expense chat */ isPolicyExpenseChat?: boolean; + /** Whether the active transaction is a scan request */ + isScanRequest?: boolean; + /** Whether the active transaction is a distance request */ isDistanceRequest?: boolean; @@ -87,8 +87,8 @@ function Provider({ isReadOnly = false, didConfirm = false, isEditingSplitBill = false, - isNewManualExpenseFlowEnabled = false, isPolicyExpenseChat = false, + isScanRequest = false, isDistanceRequest = false, isPerDiemRequest = false, isTimeRequest = false, @@ -111,8 +111,8 @@ function Provider({ isReadOnly, didConfirm, isEditingSplitBill, - isNewManualExpenseFlowEnabled, isPolicyExpenseChat, + isScanRequest, isDistanceRequest, isPerDiemRequest, isTimeRequest, diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index adf31c4bece5..21fa3abfae1e 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -23,10 +23,10 @@ type ConfirmationFieldsContextValue = { isReadOnly: boolean; didConfirm: boolean; isEditingSplitBill: boolean; - isNewManualExpenseFlowEnabled: boolean; isPolicyExpenseChat: boolean; // Mode — *what kind* of expense is being confirmed + isScanRequest: boolean; isDistanceRequest: boolean; isPerDiemRequest: boolean; isTimeRequest: boolean; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 40ffec069526..32d0ac1832e2 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -4,7 +4,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import {MouseProvider} from '@hooks/useMouseContext'; -import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; @@ -15,8 +14,6 @@ import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {arePolicyRulesEnabled, isTaxTrackingEnabled} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -34,7 +31,6 @@ import { import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -76,8 +72,8 @@ type MoneyRequestConfirmationListProps = { /** Callback to inform parent modal of success */ onConfirm?: () => void; - /** When set, used in the new manual expense flow to open the parent-owned participant picker instead of navigating away */ - onOpenParticipantPicker?: () => void; + /** Opens the participant picker owned by the page hosting this list. Pages that cannot show an editable participant row pass a no-op. */ + onOpenParticipantPicker: () => void; /** Whether the parent-owned participant picker modal is currently open (new manual expense flow). Drives amount autofocus on picker close. */ isParticipantPickerVisible?: boolean; @@ -85,10 +81,7 @@ type MoneyRequestConfirmationListProps = { /** Callback to parent modal to pay someone */ onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; - /** IOU type */ iouType?: Exclude; - - /** Callback to toggle the billable state */ onToggleBillable?: (isOn: boolean) => void; /** Selected participants from MoneyRequestModal with login / accountID */ @@ -100,19 +93,13 @@ type MoneyRequestConfirmationListProps = { /** Should the list be read only, and not editable? */ isReadOnly?: boolean; - /** Number of expenses to be created */ expensesNumber?: number; - - /** The policyID of the request */ policyID?: string; - - /** The reportID of the request */ reportID?: string; /** File path of the receipt */ receiptPath?: string | number; - /** File name of the receipt */ receiptFilename?: string; /** Transaction that represents the expense */ @@ -145,13 +132,8 @@ type MoneyRequestConfirmationListProps = { /** A flag for verifying that the current report is a sub-report of a expense chat */ isPolicyExpenseChat?: boolean; - /** Whether smart scan failed */ hasSmartScanFailed?: boolean; - - /** The ID of the report action */ reportActionID?: string; - - /** The action to take */ action?: IOUAction; /** Whether the expense is confirmed or not */ @@ -163,16 +145,9 @@ type MoneyRequestConfirmationListProps = { /** Whether the receipt can be replaced */ isReceiptEditable?: boolean; - /** The PDF load error callback */ onPDFLoadError?: () => void; - - /** The PDF password callback */ onPDFPassword?: () => void; - - /** Function to toggle reimbursable */ onToggleReimbursable?: (isOn: boolean) => void; - - /** Show remove expense confirmation modal */ showRemoveExpenseConfirmModal?: () => void; /** When true, hide the "To:" section (e.g. when adding an expense directly to the current report) */ @@ -223,8 +198,6 @@ function MoneyRequestConfirmationList({ const transactionReport = useTransactionReportForConfirmation(transaction?.reportID); const {policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseUtil(action); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const isInLandscapeMode = useIsInLandscapeMode(); @@ -271,7 +244,6 @@ function MoneyRequestConfirmationList({ const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSend = iouType === CONST.IOU.TYPE.PAY; - const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE; const isFromGlobalCreateAndCanEditParticipant = !!transaction?.isFromGlobalCreate && !isPerDiemRequest && !isTimeRequest; @@ -334,7 +306,7 @@ function MoneyRequestConfirmationList({ }); const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; - const shouldForceTopEmptySections = isNewManualExpenseFlowEnabled && (iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest); + const shouldForceTopEmptySections = iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest; const isFocused = useIsFocused(); @@ -351,6 +323,9 @@ function MoneyRequestConfirmationList({ const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const shouldShowReadOnlySplits = isPolicyExpenseChat || isReadOnly || isScanRequest; + // Both the validation gate and the clear gate below key off this, so it is computed once here rather than + // being re-derived per hook, where the two could be updated independently. + const shouldShowDate = shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest); const {formError, setFormError, clearFormErrors, shouldDisplayFieldError, isMerchantEmpty, isMerchantFieldValid, isMerchantRequired, errorMessage} = useFormErrorManagement({ transaction, @@ -371,8 +346,9 @@ function MoneyRequestConfirmationList({ routeError, isTypeSplit, shouldShowReadOnlySplits, - isNewManualExpenseFlowEnabled, isDistanceRequest, + isReadOnly, + shouldShowDate, }); const isCategoryRequired = !!policy?.requiresCategory && !isTypeInvoice; @@ -391,7 +367,6 @@ function MoneyRequestConfirmationList({ const splitOrRequestOptions = useConfirmationCtaText({ expensesNumber, isTypeInvoice, - isTypeTrackExpense, isTypeSplit, isTypeRequest, iouAmount, @@ -401,7 +376,6 @@ function MoneyRequestConfirmationList({ receiptPath, isDistanceRequestWithPendingRoute, isPerDiemRequest, - isNewManualExpenseFlowEnabled, }); const selectedParticipants = selectedParticipantsProp.filter((participant) => participant.selected); @@ -459,13 +433,7 @@ function MoneyRequestConfirmationList({ return; } - if (isNewManualExpenseFlowEnabled) { - onOpenParticipantPicker?.(); - return; - } - - const newIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType; - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action, iouType: newIOUType, transactionID, reportID: transaction?.reportID}))); + onOpenParticipantPicker(); }; const {validate} = useConfirmationValidation({ @@ -496,9 +464,8 @@ function MoneyRequestConfirmationList({ isMovingTransactionFromTrackExpense, isTimeRequest, routeError, - isNewManualExpenseFlowEnabled, isReadOnly, - shouldShowDate: shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest), + shouldShowDate, isTaxAmountEmpty, }); @@ -561,8 +528,8 @@ function MoneyRequestConfirmationList({ isReadOnly={isReadOnly} didConfirm={!!didConfirm} isEditingSplitBill={isEditingSplitBill} - isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} isPolicyExpenseChat={isPolicyExpenseChat} + isScanRequest={isScanRequest} isDistanceRequest={isDistanceRequest} isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} diff --git a/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx b/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx index 43b06f14184c..f55e03ecb3ca 100644 --- a/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx +++ b/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -63,13 +63,11 @@ type ConfirmationFooterContentProps = { /** Number of expenses that will be created on confirm (drives bulk copy) */ expensesNumber: number; - /** Optional callback to show a confirm-modal before removing an expense */ showRemoveExpenseConfirmModal: (() => void) | undefined; /** Whether the product-training tooltip should anchor to the button */ shouldShowProductTrainingTooltip: boolean; - /** Renders the product-training tooltip content */ renderProductTrainingTooltip: () => React.ReactElement; }; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts index d599da7c823f..a109c1d747b1 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts @@ -16,9 +16,6 @@ type UseConfirmationCtaTextParams = { /** Whether the current IOU type is invoice */ isTypeInvoice: boolean; - /** Whether the current IOU type is track-expense */ - isTypeTrackExpense: boolean; - /** Whether the current IOU type is split */ isTypeSplit: boolean; @@ -45,22 +42,18 @@ type UseConfirmationCtaTextParams = { /** Whether the transaction is a per-diem request */ isPerDiemRequest: boolean; - - /** Whether the new manual expense flow beta is enabled */ - isNewManualExpenseFlowEnabled: boolean; }; /** * Computes the primary confirm button label for the Money Request confirmation flow. * - * Picks between create / create-with-amount / split / invoice / next variants based on - * the IOU type, manual-expense-flow beta, bulk-expense count, and amount, returning a - * single-entry DropdownOption array shaped for the ButtonWithDropdownMenu consumer. + * Picks between create / split / invoice / next variants based on the IOU type and + * bulk-expense count, returning a single-entry DropdownOption array shaped for the + * ButtonWithDropdownMenu consumer. */ function useConfirmationCtaText({ expensesNumber, isTypeInvoice, - isTypeTrackExpense, isTypeSplit, isTypeRequest, iouAmount, @@ -70,7 +63,6 @@ function useConfirmationCtaText({ receiptPath, isDistanceRequestWithPendingRoute, isPerDiemRequest, - isNewManualExpenseFlowEnabled, }: UseConfirmationCtaTextParams): Array> { const {translate} = useLocalize(); @@ -83,29 +75,16 @@ function useConfirmationCtaText({ } else { text = translate('common.next'); } - } else if (isTypeTrackExpense) { - text = translate('iou.createExpense'); - if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { - text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); - } } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) { + // Sits between the two split branches on purpose: a non-zero split with a pending distance route or per diem + // keeps the create copy rather than falling through to "Split expense". text = translate('iou.createExpense'); - if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { - text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); - } } else if (isTypeSplit) { - text = translate('iou.splitAmount', formattedAmount); - if (isNewManualExpenseFlowEnabled) { - text = translate('iou.splitExpense'); - } - } else if (iouAmount === 0) { - text = translate('iou.createExpense'); - } else if (isNewManualExpenseFlowEnabled) { - text = translate('iou.createExpense'); + text = translate('iou.splitExpense'); } else { - text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); + text = translate('iou.createExpense'); } return [ { diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 40510de6fa36..9ad71cb5aa46 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -4,7 +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 {isTaxAmountInvalid, isValidMoneyRequestAmount, validateAmount} from '@libs/MoneyRequestUtils'; +import {isConfirmationAmountMissing, isConfirmationDateMissing, isTaxAmountInvalid, isValidMoneyRequestAmount, validateAmount} from '@libs/MoneyRequestUtils'; import type {getTagLists as getTagListsFn} from '@libs/PolicyUtils'; import {canSubmitPerDiemExpenseFromWorkspace, isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {hasEnabledTags, hasMatchingTag} from '@libs/TagsOptionsListUtils'; @@ -15,7 +15,6 @@ import { getTag, getTaxAmount, hasTaxRateWithMatchingValue, - isCreatedMissing, isMerchantMissing, isScanRequest as isScanRequestUtil, } from '@libs/TransactionUtils'; @@ -76,7 +75,6 @@ type UseConfirmationValidationParams = { /** Participants selected for this IOU */ selectedParticipants: Participant[]; - /** Personal details of the current user */ currentUserPersonalDetails: CurrentUserPersonalDetails; /** Whether we are editing an existing split bill */ @@ -115,9 +113,6 @@ type UseConfirmationValidationParams = { /** Truthy when the route to the confirmation page has a known error */ routeError: string | null | undefined; - /** Whether the new manual expense flow is enabled */ - isNewManualExpenseFlowEnabled: boolean; - /** Whether the confirmation fields are read-only (date is not inline-editable) */ isReadOnly: boolean; @@ -170,7 +165,6 @@ function useConfirmationValidation({ isMovingTransactionFromTrackExpense, isTimeRequest, routeError, - isNewManualExpenseFlowEnabled, isReadOnly, shouldShowDate, isTaxAmountEmpty, @@ -193,12 +187,10 @@ function useConfirmationValidation({ if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { return {errorKey: 'common.error.invalidAmount'}; } - // isAmountSet only applies to manual expenses — scan, per diem, distance, and time set amount programmatically. - if (isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet) { + if (isConfirmationAmountMissing(transaction)) { return {errorKey: 'common.error.fieldRequired'}; } if ( - isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && transaction?.isAmountSet && !isScanRequestUtil(transaction) && @@ -209,11 +201,9 @@ function useConfirmationValidation({ ) { return {errorKey: 'common.error.invalidAmount'}; } - // The date is an inline, clearable required field in the new manual flow for every type that shows it - // (manual, distance, time, invoice, ...). Block confirmation when the user cleared it. Gating on the same - // `shouldShowDate && !isReadOnly` condition that renders the inline picker keeps validation and UI in sync, - // and skips read-only/scan flows where the date is populated server-side. - if (isNewManualExpenseFlowEnabled && shouldShowDate && !isReadOnly && isCreatedMissing(transaction)) { + // The date is an inline, clearable required field for every type that shows it (manual, distance, time, + // invoice, ...). Block confirmation when the user cleared it. + if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) { return {errorKey: 'common.error.fieldRequired'}; } const merchantValue = iouMerchant ?? ''; @@ -273,14 +263,14 @@ function useConfirmationValidation({ return {errorKey: 'violations.taxOutOfPolicy'}; } - if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest && isTaxAmountEmpty) { + if (shouldShowTax && !isDistanceRequest && isTaxAmountEmpty) { return {errorKey: 'iou.error.invalidAmount'}; } - // In the new manual expense flow the tax amount is edited inline, so the standalone tax amount step's + // In the manual expense flow the tax amount is edited inline, so the standalone tax amount step's // guard (tax amount can't exceed the tax computed from the rate and the expense amount) runs here. // This also blocks creation when an invalid tax amount was persisted to the draft and then reloaded. - if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest) { + if (shouldShowTax && !isDistanceRequest) { const decimals = getCurrencyDecimals(iouCurrencyCode); const maxTaxAmount = getCalculatedTaxAmount(policy, transaction, iouCurrencyCode, decimals); const currentTaxAmount = convertToFrontendAmountAsString(Math.abs(getTaxAmount(transaction, false)), decimals); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index 1c332e164b83..1ad875b0c2e7 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -87,7 +87,8 @@ function useDistanceRequestState({ const calculateFromTransactionData = isMovingTransactionFromTrackExpense && !distanceRate; const customUnit = transaction?.comment?.customUnit; const unit = calculateFromTransactionData ? customUnit?.distanceUnit : distanceUnit; - const rate = calculateFromTransactionData ? Math.abs(iouAmount) / (customUnit?.quantity ?? 1) : distanceRate; + const backCalculationQuantity = customUnit?.quantity; + const rate = calculateFromTransactionData && !!backCalculationQuantity ? Math.abs(iouAmount) / backCalculationQuantity : distanceRate; const currency = calculateFromTransactionData ? iouCurrencyCode : (mileageRate.currency ?? CONST.CURRENCY.USD); const prevRate = usePrevious(rate); const prevUnit = usePrevious(unit); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 5b8480912985..752f6eeb3c25 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -1,6 +1,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; +import {isConfirmationAmountMissing, isConfirmationDateMissing} from '@libs/MoneyRequestUtils'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; @@ -42,7 +43,6 @@ type UseFormErrorManagementParams = { /** Policy categories, used for category validation */ policyCategories: OnyxEntry; - /** Personal details of the current user */ currentUserPersonalDetails: CurrentUserPersonalDetails; /** Whether we are editing an existing split bill */ @@ -72,11 +72,14 @@ type UseFormErrorManagementParams = { /** Whether splits are rendered read-only (suppresses some field errors) */ shouldShowReadOnlySplits: boolean; - /** Whether the new manual expense flow is enabled (amount/date errors surface inline) */ - isNewManualExpenseFlowEnabled: boolean; - /** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */ isDistanceRequest: boolean; + + /** Whether the date field is rendered (a hidden date can't be missing, so it never keeps the required error alive) */ + shouldShowDate: boolean; + + /** Whether the confirmation is read-only (a read-only date is populated server-side, so it can't be missing) */ + isReadOnly: boolean; }; type UseFormErrorManagementResult = { @@ -141,8 +144,9 @@ function useFormErrorManagement({ routeError, isTypeSplit, shouldShowReadOnlySplits, - isNewManualExpenseFlowEnabled, isDistanceRequest, + shouldShowDate, + isReadOnly, }: UseFormErrorManagementParams): UseFormErrorManagementResult { const isFocused = useIsFocused(); const {translate} = useLocalize(); @@ -209,6 +213,17 @@ function useFormErrorManagement({ setFormError(''); }, [isMerchantFieldValid, setFormError]); + // These reuse the very predicates `useConfirmationValidation` raises `common.error.fieldRequired` from, so the + // clear side can never drift from the validation side and strand a required error that can no longer be cleared (#96568). + const isAmountRequiredMissing = isConfirmationAmountMissing(transaction); + const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly); + useEffect(() => { + if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) { + return; + } + setFormError(''); + }, [isAmountRequiredMissing, isDateRequiredMissing, setFormError]); + useEffect(() => { const currentFormError = formErrorRef.current; if (shouldDisplayFieldError && didConfirmSplit) { @@ -234,12 +249,27 @@ function useFormErrorManagement({ } }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed, setFormError]); + // The amount/date/merchant fields surface these required/invalid errors inline, so repeating them at the bottom of + // the form would show "This field is required" twice. + // `common.error.invalidAmount` is the one exception: it is only surfaced inline while the editable amount input is + // rendered. Distance requests disable that input, and the read-only menu row it falls back to doesn't show the error, + // so the distance-amount error stays in the footer. Otherwise an invalid distance expense would fail silently. + const isSuppressedInline = (error: TranslationPaths | ''): boolean => + error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount'); + const computeErrorMessage = (): string | undefined => { if (routeError) { return routeError; } + // This runs ahead of the split branch below because splits render those same inline fields, so they duplicate the same way. + if (isSuppressedInline(formError)) { + return undefined; + } if (isTypeSplit && !shouldShowReadOnlySplits) { - return debouncedFormError ? translate(debouncedFormError) : undefined; + // Splits render the debounced value, so the suppression has to be re-checked against it. `formError` clears + // the instant the user fixes the field while `debouncedFormError` lags by USE_DEBOUNCED_STATE_DELAY, and + // without this the suppressed error would pop into the footer for that window (#96565). + return debouncedFormError && !isSuppressedInline(debouncedFormError) ? translate(debouncedFormError) : undefined; } // Don't show error at the bottom of the form for missing attendees — the field surfaces it inline. if (formError === 'violations.missingAttendees') { @@ -249,17 +279,6 @@ function useFormErrorManagement({ if (formError === 'iou.error.invalidTaxAmount') { return undefined; } - // In the new manual expense flow the amount/date/merchant fields surface these required/invalid errors inline, so - // don't repeat them at the bottom of the form (which would show "This field is required" twice). - if (isNewManualExpenseFlowEnabled && (formError === 'common.error.fieldRequired' || formError === 'iou.error.invalidMerchant')) { - return undefined; - } - // `common.error.invalidAmount` is only surfaced inline when the editable amount input is rendered. Distance requests - // disable that input (the amount falls back to a read-only menu row that doesn't show this error), so keep the - // distance-amount validation error in the footer — otherwise an invalid distance expense would fail silently. - if (isNewManualExpenseFlowEnabled && !isDistanceRequest && formError === 'common.error.invalidAmount') { - return undefined; - } return formError ? translate(formError) : undefined; }; const errorMessage = computeErrorMessage(); diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 18610711edce..10819d0b7cf4 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -11,7 +11,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearMoneyRequestAmount, getMoneyRequestParticipantsFromReport, setMoneyRequestAmount, setMoneyRequestTaxAmount, setMoneyRequestTaxRate} 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 {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {shouldEnableNegative} from '@libs/ReportUtils'; import {calculateTaxAmount, getTaxCode, getTaxValue} from '@libs/TransactionUtils'; @@ -46,7 +48,6 @@ type AmountFieldProps = { policy: OnyxEntry; clearFormErrors: (errors: string[]) => void; setFormError: (error: TranslationPaths | '') => void; - autoFocus?: boolean; isParticipantPickerVisible?: boolean; }; @@ -62,10 +63,10 @@ function AmountField({ policy, clearFormErrors, setFormError, - autoFocus = false, isParticipantPickerVisible = false, }: AmountFieldProps) { - const {isEditingSplitBill, isNewManualExpenseFlowEnabled, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); + const shouldAutoFocusOnMount = !canUseTouchScreen(); + const {isEditingSplitBill, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -83,13 +84,12 @@ function AmountField({ const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; - const firstParticipant = transactionSlice?.participants?.at(0); - const isP2P = isNewManualExpenseFlowEnabled - ? isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)) - : !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); + const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)); // `common.error.fieldRequired` is shared with the date field, so only surface it on the amount input when the - // amount itself is the missing value. - const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && !transactionSlice?.isAmountSet; + // amount itself is the missing value. `isConfirmationAmountMissing` is the same predicate validation raises the + // error from, so a scan expense (where the amount is populated programmatically and `isAmountSet` is never set) + // can't show a phantom required error under a perfectly good amount. + const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice); const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount'; let amountFieldErrorText = ''; @@ -101,21 +101,20 @@ function AmountField({ const effectiveCurrency = isDistanceRequest ? distanceRateCurrency : (iouCurrencyCode ?? CONST.CURRENCY.USD); const decimals = getCurrencyDecimals(effectiveCurrency); - // In the new manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user + // In the manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user // touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the // real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate // amount programmatically and never set isAmountSet. - const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; + const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); - const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, isNewManualExpenseFlowEnabled); + const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); // `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 - // (visible → hidden) so the amount input gains focus once the user selects a participant in the new manual - // expense flow. The setTimeout defers focus past the RHP entry / picker close animation so the input reliably - // receives focus. + // (visible → hidden) so the amount input gains focus once the user selects a participant. The setTimeout defers + // focus past the RHP entry / picker close animation so the input reliably receives focus. useEffect(() => { - if (!autoFocus || isAmountFieldDisabled || !isNewManualExpenseFlowEnabled || isParticipantPickerVisible) { + if (!shouldAutoFocusOnMount || isAmountFieldDisabled || isParticipantPickerVisible) { return; } @@ -127,7 +126,7 @@ function AmountField({ } clearTimeout(focusTimeoutRef.current); }; - }, [autoFocus, isAmountFieldDisabled, isNewManualExpenseFlowEnabled, isParticipantPickerVisible]); + }, [shouldAutoFocusOnMount, isAmountFieldDisabled, isParticipantPickerVisible]); const showCurrencyPicker = () => { setIsCurrencyPickerVisible(true); @@ -268,7 +267,7 @@ function AmountField({ if (isInlineAmountInvalid && shouldDisplayFieldError) { setFormError('common.error.invalidAmount'); } else if (!isInlineAmountInvalid) { - clearFormErrors(['common.error.invalidAmount', 'common.error.fieldRequired']); + clearFormErrors(['common.error.invalidAmount']); } buildAndSaveSplitShares(parsedAmount, effectiveCurrency); @@ -296,7 +295,7 @@ function AmountField({ value={effectiveCurrency} onInputChange={updateCurrency} /> - {isNewManualExpenseFlowEnabled && !isAmountFieldDisabled ? ( + {!isAmountFieldDisabled ? ( void; transactionID: string | undefined; action: IOUAction; iouType: Exclude; @@ -45,19 +43,7 @@ type DateFieldProps = { reportActionID: string | undefined; }; -function DateField({ - shouldDisplayFieldError, - didConfirm, - isReadOnly, - isNewManualExpenseFlowEnabled, - formError, - clearFormErrors, - transactionID, - action, - iouType, - reportID, - reportActionID, -}: DateFieldProps) { +function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); const {isEditingSplitBill} = useConfirmationFields(); const styles = useThemeStyles(); @@ -92,10 +78,6 @@ function DateField({ return; } - if (newDate) { - clearFormErrors(['common.error.fieldRequired']); - } - if (isEditingSplitBill) { setDraftSplitTransaction(transactionID, splitDraftTransaction, {created: newDate}, getCurrencyDecimals, getCurrencySymbol); return; @@ -121,7 +103,7 @@ function DateField({ } }; - if (isNewManualExpenseFlowEnabled && !isReadOnly) { + if (!isReadOnly) { return ( - {isNewManualExpenseFlowEnabled && !isReadOnly ? ( + {!isReadOnly ? ( ; - reportID: string; - reportActionID: string | undefined; customUnit?: TransactionCustomUnit; }; -function DistanceField({ - hasRoute, - distance, - unit, - isManualDistanceRequest, - isOdometerDistanceRequest, - isGPSDistanceRequest, - isReadOnly, - didConfirm, - transactionID, - action, - iouType, - reportID, - reportActionID, - customUnit, -}: DistanceFieldProps) { +function DistanceField({hasRoute, distance, unit, customUnit}: DistanceFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isManualDistanceRequest, isOdometerDistanceRequest, isGPSDistanceRequest} = + useConfirmationFields(); const displayUnit = unit ?? customUnit?.distanceUnit; const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, displayUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); diff --git a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx index 429849945629..075cd445b2c8 100644 --- a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx @@ -1,6 +1,7 @@ import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import MenuItem from '@components/MenuItem'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; +import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -20,18 +21,12 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {emailSelector} from '@selectors/Session'; import React from 'react'; +import {invoiceSenderSliceSelector} from './selectors'; +import useTransactionSelector from './useTransactionSelector'; + type InvoiceSenderFieldProps = { /** The selected participants */ selectedParticipants: Participant[]; - - /** Flag indicating if it is read-only */ - isReadOnly: boolean; - - /** Flag indicating if the confirmation is done */ - didConfirm: boolean; - - /** The transaction (only the fields this field reads) */ - transaction: OnyxEntry>; }; const senderWorkspaceSelector = (policy: OnyxEntry) => (policy ? {id: policy.id, name: policy.name, avatarURL: policy.avatarURL} : undefined); @@ -41,8 +36,10 @@ const createCanUpdateSenderWorkspaceSelector = (policies: OnyxCollection): boolean => isFromGlobalCreate && !isInvoiceRoomParticipant && canSendInvoice(policies ?? null, currentUserLogin); -function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, transaction}: InvoiceSenderFieldProps) { +function InvoiceSenderField({selectedParticipants}: InvoiceSenderFieldProps) { const {translate} = useLocalize(); + const {transactionID, isReadOnly, didConfirm} = useConfirmationFields(); + const transaction = useTransactionSelector(transactionID, invoiceSenderSliceSelector); const senderPolicyID = selectedParticipants.find((participant) => participant.isSender)?.policyID; @@ -84,11 +81,13 @@ function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, trans /> - {!!senderWorkspace?.name && {senderWorkspace.name}} {senderWorkspace?.name ? ( - {translate('workspace.common.workspace')} + <> + {senderWorkspace.name} + {translate('workspace.common.workspace')} + ) : ( - {translate('workspace.common.workspace')} + {translate('workspace.common.workspace')} )} {isInteractive && ( diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index c72f3e7fd943..3467995a3e18 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -15,7 +15,6 @@ import {isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/Validation import {setDraftSplitTransaction} from '@userActions/IOU/Split'; import CONST from '@src/CONST'; -import type {IOUAction, IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -27,32 +26,12 @@ import useTransactionSelector from './useTransactionSelector'; type MerchantFieldProps = { isMerchantRequired: boolean | undefined; - isNewManualExpenseFlowEnabled: boolean; - isReadOnly: boolean; - didConfirm: boolean; shouldDisplayFieldError: boolean; formError: string; - transactionID: string | undefined; - action: IOUAction; - iouType: Exclude; - reportID: string; - reportActionID: string | undefined; }; -function MerchantField({ - isMerchantRequired, - isNewManualExpenseFlowEnabled, - isReadOnly, - didConfirm, - shouldDisplayFieldError, - formError, - transactionID, - action, - iouType, - reportID, - reportActionID, -}: MerchantFieldProps) { - const {isEditingSplitBill} = useConfirmationFields(); +function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) { + const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -132,7 +111,7 @@ function MerchantField({ setMoneyRequestMerchant(transactionID, newMerchant, true, transactionHasReceipt); }; - if (isNewManualExpenseFlowEnabled && !isReadOnly) { + if (!isReadOnly) { return ( ; - transactionID: string | undefined; - action: IOUAction; - iouType: Exclude; - reportID: string; - reportActionID: string | undefined; formError: string; shouldNavigateToUpgradePath: boolean; shouldSelectPolicy: boolean; @@ -54,20 +46,13 @@ function RateField({ mileageRate, expenseDate, customUnitRateID, - didConfirm, - isReadOnly, - isPolicyExpenseChat, policy, - transactionID, - action, - iouType, - reportID, - reportActionID, formError, shouldNavigateToUpgradePath, shouldSelectPolicy, shouldShowRateAutoUpdatedTooltip, }: RateFieldProps) { + const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isPolicyExpenseChat} = useConfirmationFields(); const styles = useThemeStyles(); const {translate, toLocaleDigit, dateFnsLocale} = useLocalize(); const {getCurrencySymbol, convertToDisplayString} = useCurrencyListActions(); diff --git a/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx b/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx index 3650649ae66c..25173e4abe19 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx @@ -26,28 +26,16 @@ import {createOutstandingReportsForPolicySelector, reportFieldTransactionStateSe import useTransactionSelector from './useTransactionSelector'; type ReportFieldProps = { - /** The selected participants */ selectedParticipants: Participant[]; - - /** The type of the IOU */ iouType: Exclude; - - /** The report ID */ reportID: string; - - /** The report action ID */ reportActionID: string | undefined; /** The action to perform */ action: IOUAction; - /** The transaction ID */ transactionID: string | undefined; - - /** Flag indicating if it is a per diem request */ isPerDiemRequest: boolean; - - /** Flag indicating if it is a policy expense chat */ isPolicyExpenseChat: boolean; }; @@ -58,6 +46,7 @@ function ReportField({selectedParticipants, iouType, reportID, reportActionID, a const policyID = selectedParticipants?.at(0)?.policyID; const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: createOutstandingReportsForPolicySelector(policyID)}); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Self-resolved narrow slice of the transaction; replaces the previously prop-drilled `transaction` object. const transactionState = useTransactionSelector(transactionID, reportFieldTransactionStateSelector); @@ -78,11 +67,11 @@ function ReportField({selectedParticipants, iouType, reportID, reportActionID, a * Also we need to check if transaction report exists in outstanding reports in order to show a correct report name. */ const transactionReportNameValuePair = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${transactionReportID}`]; - const shouldUseTransactionReport = (!!transactionReportEntry && isReportOutstanding(transactionReportEntry, policyID, transactionReportNameValuePair, false)) || isUnreported; + const shouldUseTransactionReport = (!!transactionReportEntry && isReportOutstanding(transactionReportEntry, policyID, rules, transactionReportNameValuePair, false)) || isUnreported; const ownerAccountID = selectedParticipants?.at(0)?.ownerAccountID; - const availableOutstandingReports = getOutstandingReportsForUser(policyID, ownerAccountID, reportNameValuePairs, outstandingReportsForPolicy ?? {}, false).sort((a, b) => + const availableOutstandingReports = getOutstandingReportsForUser(policyID, ownerAccountID, rules, reportNameValuePairs, outstandingReportsForPolicy ?? {}, false).sort((a, b) => localeCompare(a?.reportName?.toLowerCase() ?? '', b?.reportName?.toLowerCase() ?? ''), ); diff --git a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx index 800f23fb04ea..b09f95866b4c 100644 --- a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx @@ -50,7 +50,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isNewManualExpenseFlowEnabled, isEditingSplitBill, onTaxAmountEmptyChange} = useConfirmationFields(); + const {isEditingSplitBill, onTaxAmountEmptyChange} = useConfirmationFields(); const numberFormRef = useRef(null); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -103,9 +103,6 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT }; useEffect(() => { - if (!isNewManualExpenseFlowEnabled) { - return; - } // Compare the numeric value rather than the formatted string. An in-progress edit such as "5.0" (or an // empty field) represents the same stored amount as the re-padded "5.00", so it must not be overwritten // while the user is typing. Only refresh the field when the stored tax amount genuinely differs (e.g. the @@ -119,21 +116,21 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT } numberFormRef.current?.updateNumber(taxAmountInput); onTaxAmountEmptyChange?.(false); - }, [isNewManualExpenseFlowEnabled, taxAmount, taxAmountInput, onTaxAmountEmptyChange]); + }, [taxAmount, taxAmountInput, onTaxAmountEmptyChange]); useEffect(() => { - if (isNewManualExpenseFlowEnabled && canModifyTaxFields) { + if (canModifyTaxFields) { return () => onTaxAmountEmptyChange?.(false); } onTaxAmountEmptyChange?.(false); - }, [isNewManualExpenseFlowEnabled, canModifyTaxFields, onTaxAmountEmptyChange]); + }, [canModifyTaxFields, onTaxAmountEmptyChange]); useEffect(() => { - if (!isNewManualExpenseFlowEnabled || formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) { + if (formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) { return; } clearFormErrors(['iou.error.invalidTaxAmount']); - }, [isNewManualExpenseFlowEnabled, formError, taxAmount, maxTaxAmount, clearFormErrors]); + }, [formError, taxAmount, maxTaxAmount, clearFormErrors]); return ( <> @@ -158,7 +155,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT errorText={shouldDisplayTaxRateError ? translate(formError as TranslationPaths) : ''} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.TAX_RATE_FIELD} /> - {isNewManualExpenseFlowEnabled && canModifyTaxFields ? ( + {canModifyTaxFields ? ( ): OnyxEntry => { return slice; }; -// --- InvoiceSenderSection --- +// --- InvoiceSenderField --- type InvoiceSenderSlice = Pick; diff --git a/src/components/MoneyRequestConfirmationListFooter/ConfirmationFieldList.tsx b/src/components/MoneyRequestConfirmationListFooter/ConfirmationFieldList.tsx index c60b3ac91c3e..3ecb5fa8a23a 100644 --- a/src/components/MoneyRequestConfirmationListFooter/ConfirmationFieldList.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/ConfirmationFieldList.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; import Text from '@components/Text'; @@ -43,7 +43,6 @@ type ConfirmationFieldListProps = { /** Pre-formatted amount values */ amountDisplay: AmountDisplay; - /** Per-field "required" flags */ requiredFlags: RequiredFlags; /** The expense-type-driven fields, supplied by the footer variant. Rendered above the classification @@ -53,10 +52,7 @@ type ConfirmationFieldListProps = { /** Caller-supplied visibility decisions */ visibilityFlags: VisibilityFlags; - /** Error state */ errorState: ErrorState; - - /** Toggle handlers */ toggleHandlers: ToggleHandlers; /** Compact-mode bookkeeping */ @@ -179,7 +175,6 @@ function ConfirmationFieldList({ + ); } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx index c1bdb8625620..2710498e3cae 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -118,7 +117,7 @@ function MoneyRequestReportPreviewBody({ shouldShowBorder ? styles.borderedContentCardLarge : styles.reportContainerBorderRadius, isReportDeleted && styles.pointerEventsNone, ]} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} isNested accessibilityLabel={translate('iou.viewDetails')} sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.CARD} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index e8893460de17..0365bc237ab3 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -31,6 +31,8 @@ function MoneyRequestReportPreviewContent({ invoiceReceiverPersonalDetail, lastTransactionViolations, renderTransactionItem, + onOrderedTransactionsChange, + onCancelPendingPress, onCarouselLayout, onWrapperLayout, currentWidth, @@ -57,6 +59,8 @@ function MoneyRequestReportPreviewContent({ onPaymentOptionsShow={onPaymentOptionsShow} onPaymentOptionsHide={onPaymentOptionsHide} renderTransactionItem={renderTransactionItem} + onOrderedTransactionsChange={onOrderedTransactionsChange} + onCancelPendingPress={onCancelPendingPress} currentWidth={currentWidth} reportPreviewStyles={reportPreviewStyles} newTransactionIDs={newTransactionIDs} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts index 7fffa8c61475..6cf7e21fb6a6 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts @@ -1,5 +1,3 @@ -import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; - import type CONST from '@src/CONST'; import type {PersonalDetails, Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -73,7 +71,7 @@ type ReportPreviewActionState = { type ReportPreviewActions = { openReportFromPreview: () => void; - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => void; + onHoldMenuOpen: (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => void; onHoldMenuClose: () => void; onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; @@ -86,7 +84,6 @@ type ReportPreviewActions = { }; type ReportPreviewHoldMenuState = { - requestType: ActionHandledType; paymentType: PaymentMethodType | undefined; canPay: boolean; methodID: number | undefined; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx index 23b7a9e195e2..280a4d2433e1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx @@ -1,5 +1,3 @@ -import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; - import useOnyx from '@hooks/useOnyx'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; import useReportTransactionViolations from '@hooks/useReportTransactionViolations'; @@ -47,6 +45,7 @@ import { ReportPreviewTransactionViolationsContext, ReportPreviewUIStateContext, } from './MoneyRequestReportPreviewContext'; +import resolvePressOrigin from './resolvePressOrigin'; import usePreviewMessageAnimation from './usePreviewMessageAnimation'; import useReportPreviewActionDecision from './useReportPreviewActionDecision'; import useReportPreviewCarousel from './useReportPreviewCarousel'; @@ -68,6 +67,8 @@ type MoneyRequestReportPreviewProviderProps = ChildrenProps & { onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; renderTransactionItem: ListRenderItem; + onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; + onCancelPendingPress?: () => void; currentWidth: number; reportPreviewStyles: MoneyRequestReportPreviewStyleType; newTransactionIDs?: Set; @@ -96,6 +97,8 @@ function MoneyRequestReportPreviewProvider({ onPaymentOptionsShow, onPaymentOptionsHide, renderTransactionItem, + onOrderedTransactionsChange, + onCancelPendingPress, currentWidth, reportPreviewStyles, newTransactionIDs, @@ -110,7 +113,6 @@ function MoneyRequestReportPreviewProvider({ return pending?.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT && (pending?.reportID === chatReportID || pending?.reportID === iouReportID); }); const [holdMenu, setHoldMenu] = useState<{ - requestType: ActionHandledType; paymentType: PaymentMethodType | undefined; canPay: boolean; methodID: number | undefined; @@ -202,12 +204,23 @@ function MoneyRequestReportPreviewProvider({ currentWidth, newTransactionIDs, renderTransactionItem, + onOrderedTransactionsChange, }); const openReportFromPreview = useCallback(() => { if (!iouReportID) { return; } + const routeAtPress = Navigation.getActiveRoute(); + onCancelPendingPress?.(); + + // "View" pressed inside the cascade window lands on a report a card press already opened, so there is + // nothing left to push; pushing it again would nest the report under itself. + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, isSmallScreenWidth ? `r/${iouReportID}` : `e/${iouReportID}`); + if (wasPressedFromReport) { + return; + } + startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${iouReportID}`, { name: 'MoneyRequestReportPreviewContent', op: CONST.TELEMETRY.SPAN_OPEN_REPORT, @@ -215,22 +228,15 @@ function MoneyRequestReportPreviewProvider({ // Small screens navigate to full report view since super wide RHP // is not available on narrow layouts and would break the navigation logic. if (isSmallScreenWidth) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo)); } else { - Navigation.navigate( - ROUTES.EXPENSE_REPORT_RHP.getRoute({ - reportID: iouReportID, - backTo: Navigation.getActiveRoute(), - }), - ); + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); } - }, [iouReportID, isSmallScreenWidth]); + }, [iouReportID, isSmallScreenWidth, onCancelPendingPress]); - const onHoldMenuOpen = useCallback((requestType: string, paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { - if (requestType !== CONST.IOU.REPORT_ACTION_TYPE.PAY && requestType !== CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { - return; - } - setHoldMenu({requestType, paymentType, canPay: !!canPay, methodID}); + // Only the pay flow opens this menu; approve surfaces its partial/full choice in the approve dropdown instead. + const onHoldMenuOpen = useCallback((paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { + setHoldMenu({paymentType, canPay: !!canPay, methodID}); }, []); const onHoldMenuClose = useCallback(() => setHoldMenu(null), []); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 45a1a66fd2ab..c8ebb9a35bf8 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -59,6 +59,7 @@ function PayActionButton() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -78,7 +79,7 @@ function PayActionButton() { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (hasHeldExpensesReportUtils(transactions)) { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, shouldShowPayButton, methodID); + onHoldMenuOpen(type, shouldShowPayButton, methodID); } else if (chatReport && iouReport) { const currentChatReport = getReportOrDraftReport(chatReportID) ?? chatReport; if (isInvoiceReportUtils(iouReport)) { @@ -104,6 +105,7 @@ function PayActionButton() { chatReportActions: getChatReportActions(payAsBusiness), delegateAccountID, isTrackIntentUser, + rules, }); } else { payMoneyRequest({ @@ -128,6 +130,7 @@ function PayActionButton() { delegateAccountID, isTrackIntentUser, conciergeChat, + rules, }); } } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx index f2fc0616078e..8485c1d56799 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx @@ -1,10 +1,8 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import React from 'react'; @@ -24,7 +22,16 @@ function ReportPreviewActionButton() { const {buttonMaxWidth} = useReportPreviewUIState(); const {openReportFromPreview} = useReportPreviewActions(); - const renderButton = () => { + const viewButton = ( + + ); + + const renderPrimaryButton = () => { if (reportPreviewAction === CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT) { return ; } @@ -45,17 +52,21 @@ function ReportPreviewActionButton() { return ; } - return ( - - ); + return null; }; - return {renderButton()}; + const primaryButton = renderPrimaryButton(); + + if (!primaryButton) { + return {viewButton}; + } + + return ( + + {primaryButton} + {viewButton} + + ); } export default ReportPreviewActionButton; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx index e065af642c5d..cba808917a03 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx @@ -66,9 +66,6 @@ function ReportPreviewHeader() { [translate, numberOfRequests], ); - // A cancelled payment is a report level event and it isn't surfaced by the status badge, so we show it next to the expense count. - const supportingText = iouReport?.isCancelledIOU ? `${translate('iou.canceled')} ${CONST.DOT_SEPARATOR} ${expenseCount}` : expenseCount; - const reportStateNum = iouReport?.stateNum ?? action?.childStateNum; const reportStatusNum = iouReport?.statusNum ?? action?.childStatusNum; @@ -125,7 +122,7 @@ function ReportPreviewHeader() { tooltipText={reportStatusTooltip} /> )} - {!shouldShowAccessPlaceHolder && {supportingText}} + {!shouldShowAccessPlaceHolder && {expenseCount}} ) )} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx index f24c22ec36ff..b26b6c258ce5 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx @@ -4,8 +4,6 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; - import React from 'react'; import {useReportPreviewActions, useReportPreviewData, useReportPreviewHoldMenu} from './MoneyRequestReportPreviewContext'; @@ -17,7 +15,7 @@ import {useReportPreviewActions, useReportPreviewData, useReportPreviewHoldMenu} function ReportPreviewHoldMenu() { const holdMenu = useReportPreviewHoldMenu(); const {iouReport, chatReport, transactions} = useReportPreviewData(); - const {startAnimation, startApprovedAnimation, onHoldMenuClose} = useReportPreviewActions(); + const {startAnimation, onHoldMenuClose} = useReportPreviewActions(); const {convertToDisplayString} = useCurrencyListActions(); if (!holdMenu || !iouReport) { @@ -30,7 +28,6 @@ function ReportPreviewHoldMenu() { return ( { - if (holdMenu.requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { - startApprovedAnimation(); - } else { - startAnimation(); - } - }} + onConfirm={startAnimation} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx index a5a009c54b97..de91587016c5 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx @@ -8,10 +8,18 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; import {hasDynamicExternalWorkflow, isSubmitPolicy} from '@libs/PolicyUtils'; -import {hasOnlyHeldExpenses, hasViolations as hasViolationsReportUtils, shouldShowMarkAsDone} from '@libs/ReportUtils'; import { + hasOnlyHeldExpenses, + hasViolations as hasViolationsReportUtils, + shouldBlockSubmitDueToPreventSelfApproval, + shouldBlockSubmitDueToStrictPolicyRules, + shouldShowMarkAsDone, +} from '@libs/ReportUtils'; +import { + getTransactionViolations, hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, hasOnlyPendingCardTransactions, showHeldExpensesBlockModal, @@ -23,6 +31,7 @@ import {markPendingRTERTransactionsAsCash} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {TransactionViolations} from '@src/types/onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React from 'react'; @@ -57,6 +66,7 @@ function SubmitActionButtonContent() { const currentUserAccountID = currentUserDetails.accountID; const currentUserEmail = currentUserDetails.email ?? ''; const {isBetaEnabled} = usePermissions(); + const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {iouReportID, transactions} = useReportPreviewData(); @@ -76,6 +86,7 @@ function SubmitActionButtonContent() { const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {transactionViolations} = useReportPreviewTransactionViolations(); @@ -98,13 +109,46 @@ function SubmitActionButtonContent() { const confirmPendingRTERAndProceed = useConfirmPendingRTERAndProceed(hasAnyPendingRTERViolation, handleMarkPendingRTERTransactionsAsCash); + // The header's gate receives violations pre-filtered by useTransactionsAndViolationsForReport, which drops dismissals + // that are only detectable with report/owner/policy context (e.g. RTER violations dismissed under instant submit). The + // preview context exposes the raw slice, so apply the same filter here or the two Submit buttons disagree on dismissed + // violations. This stays a pure computation over data already in scope; calling the hook instead would duplicate its + // Onyx subscriptions for every preview on screen. + const filteredTransactionViolations: Record = {}; + for (const transactionViolationKey of Object.keys(transactionViolations ?? {})) { + const transactionID = transactionViolationKey.split('_').at(1) ?? ''; + const transaction = transactions.find((reportTransaction) => reportTransaction.transactionID === transactionID); + filteredTransactionViolations[transactionViolationKey] = + getTransactionViolations(transaction, transactionViolations, currentUserEmail, currentUserAccountID, iouReport, submitterLogin, policy) ?? []; + } + + const isBlockSubmitDueToPreventSelfApproval = shouldBlockSubmitDueToPreventSelfApproval(iouReport, policy, rules); + const isBlockSubmitDueToStrictPolicyRules = shouldBlockSubmitDueToStrictPolicyRules( + iouReport?.reportID, + filteredTransactionViolations, + areStrictPolicyRulesEnabled, + currentUserAccountID, + currentUserEmail, + transactions, + ); + const shouldBlockSubmit = isBlockSubmitDueToStrictPolicyRules || isBlockSubmitDueToPreventSelfApproval; + const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ isTrackIntentUser, report: iouReport, policy, + rules, }); const handleSubmit = () => { + // A domain that strictly enforces workspace rules, or a workspace that prevents self-approval, makes the backend + // reject this submission, which the user only ever sees as a generic "Unexpected error". Bail out before any API + // call. The button is disabled on the same condition, so this is the second layer, and it has to run before the + // submit-to popover branch below, which would otherwise run the submission itself and hit the same rejection. + if (shouldBlockSubmit) { + return; + } + if (hasOnlyPendingCardTransactions(transactions)) { showPendingCardTransactionsBlockModal(showConfirmModal, translate, shouldShowMarkAsDoneCopy); return; @@ -125,6 +169,7 @@ function SubmitActionButtonContent() { getCurrencyDecimals, expenseReport: iouReport, policy, + rules, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserEmail, hasViolations, @@ -150,6 +195,7 @@ function SubmitActionButtonContent() { onPress={handleSubmit} isSubmittingAnimationRunning={isSubmittingAnimationRunning} onAnimationFinish={stopAnimation} + isDisabled={shouldBlockSubmit} sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.SUBMIT_BUTTON} isDEWSubmission={isDEWSubmission} reportID={iouReportID} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index dc0979cf42d2..c0ef2f6d752c 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -1,6 +1,8 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import TransactionPreview from '@components/ReportActionItem/TransactionPreview'; +import {useWideRHPActions} from '@components/WideRHPContextProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useNewTransactions from '@hooks/useNewTransactions'; import useOnyx from '@hooks/useOnyx'; @@ -11,11 +13,22 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; +import {createTransactionThreadReport, openReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getIOUActionForReportID, isSplitBillAction as isSplitBillActionReportActionsUtils, isTrackExpenseAction as isTrackExpenseActionReportActionsUtils} from '@libs/ReportActionsUtils'; -import {areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, getTransactionsWithReceipts, isIOUReport} from '@libs/ReportUtils'; +import { + getAllReportActions, + getIOUActionForReportID, + getIOUActionForTransactionID, + getOriginalMessage, + isDeletedAction, + isMoneyRequestAction, + isSplitBillAction as isSplitBillActionReportActionsUtils, + isTrackExpenseAction as isTrackExpenseActionReportActionsUtils, +} from '@libs/ReportActionsUtils'; +import {areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, getReportOrDraftReport, getTransactionsWithReceipts, isIOUReport} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; -import {hasNonReimbursableTransactions as hasNonReimbursableTransactionsTransactionUtils} from '@libs/TransactionUtils'; +import {hasNonReimbursableTransactions as hasNonReimbursableTransactionsTransactionUtils, isTransactionPendingDelete} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; @@ -24,18 +37,29 @@ import {contextMenuRef} from '@pages/inbox/report/ContextMenu/ReportActionContex import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {hasOnceLoadedReportActionsSelector, pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; -import type {Transaction} from '@src/types/onyx'; +import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector, pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; +import type {ReportAction, ReportActions, Transaction} from '@src/types/onyx'; import type {ListRenderItem} from '@shopify/flash-list'; import type {LayoutChangeEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/core'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {MoneyRequestReportPreviewProps} from './types'; import MoneyRequestReportPreviewContent from './MoneyRequestReportPreviewContent'; +import resolvePressOrigin from './resolvePressOrigin'; + +const hasReportActionsSelector = (reportActions: OnyxEntry) => Object.keys(reportActions ?? {}).length > 0; + +// The stagger between the report and the expense that design asked for: https://github.com/Expensify/App/pull/92546#issuecomment-4687440972 +const PRESSED_EXPENSE_CASCADE_DELAY = 180; + +function isLiveIOUAction(reportAction: OnyxEntry): reportAction is ReportAction { + return !!reportAction && !isDeletedAction(reportAction) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} function MoneyRequestReportPreview({ iouReportID, @@ -54,7 +78,13 @@ function MoneyRequestReportPreview({ const StyleUtils = useStyleUtils(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const {markReportRHPWidth, unmarkReportRHPWidth} = useWideRHPActions(); const personalDetailsList = usePersonalDetails(); + const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined; const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(invoiceReceiverPolicyID)}`); const invoiceReceiverPersonalDetail = chatReport?.invoiceReceiver && 'accountID' in chatReport.invoiceReceiver ? personalDetailsList?.[chatReport.invoiceReceiver.accountID] : null; @@ -65,6 +95,29 @@ function MoneyRequestReportPreview({ // Kept local to this component rather than passed down, so children only receive the derived values they need. const allReportTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction); const transactions = allReportTransactions.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const orderedTransactionsRef = useRef([]); + const handleOrderedTransactionsChange = useCallback((orderedTransactions: Transaction[]) => { + orderedTransactionsRef.current = orderedTransactions; + }, []); + const [hasIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, { + selector: hasReportActionsSelector, + }); + const pendingExpenseTransactionRef = useRef<{transaction: Transaction; originRoute: string} | null>(null); + // Subscribing to the whole action list re-rendered every card on each write, and a split writes a burst of + // them. Narrow it to the one action a deferred press is waiting for, so nothing pending means nothing changes. + const pendingPressActionCountSelector = useCallback((reportActions: OnyxEntry) => { + if (!pendingExpenseTransactionRef.current) { + return undefined; + } + return Object.keys(reportActions ?? {}).length; + }, []); + const [pendingPressActionCount] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, { + selector: pendingPressActionCountSelector, + }); + const [isLoadingInitialIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(iouReportID)}`, { + selector: isLoadingInitialReportActionsSelector, + }); + const cascadeTimerRef = useRef<{timer: ReturnType; release: () => void} | null>(null); const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID, allReportTransactions); const hasNonReimbursableTransactions = hasNonReimbursableTransactionsTransactionUtils(allReportTransactions); const areAllRequestsBeingSmartScanned = areAllRequestsBeingSmartScannedReportUtils(iouReportID, action, allReportTransactions); @@ -118,11 +171,31 @@ function MoneyRequestReportPreview({ return transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0); }, [transactions, action.childType, iouReport]); + const cancelPendingPress = useCallback(() => { + pendingExpenseTransactionRef.current = null; + if (!cascadeTimerRef.current) { + return; + } + clearTimeout(cascadeTimerRef.current.timer); + cascadeTimerRef.current.release(); + cascadeTimerRef.current = null; + }, []); + const openReportFromPreview = useCallback(() => { if (!iouReportID || contextMenuRef.current?.isContextMenuOpening) { return; } + const routeAtPress = Navigation.getActiveRoute(); + cancelPendingPress(); + + // "View" pressed inside the cascade window lands on a report a card press already opened, so there is + // nothing left to push; pushing it again would nest the report under itself. + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, isSmallScreenWidth ? `r/${iouReportID}` : `e/${iouReportID}`); + if (wasPressedFromReport) { + return; + } + startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${iouReportID}`, { name: 'MoneyRequestReportPreview', op: CONST.TELEMETRY.SPAN_OPEN_REPORT, @@ -130,11 +203,11 @@ function MoneyRequestReportPreview({ // Small screens navigate to full report view since super wide RHP // is not available on narrow layouts and would break the navigation logic. if (isSmallScreenWidth) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo)); } else { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: Navigation.getActiveRoute()})); + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); } - }, [iouReportID, isSmallScreenWidth]); + }, [cancelPendingPress, iouReportID, isSmallScreenWidth]); const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${chatReportID}`, { selector: hasOnceLoadedReportActionsSelector, }); @@ -161,27 +234,232 @@ function MoneyRequestReportPreview({ const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle]; - const renderItem: ListRenderItem = ({item}) => ( - + const resolveChildReportID = useCallback( + (transaction: Transaction) => { + let transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID); + if (transactionIOUAction && !isLiveIOUAction(transactionIOUAction)) { + const liveIOUAction = getIOUActionForTransactionID(Object.values(getAllReportActions(transaction.reportID) ?? {}).filter(isLiveIOUAction), transaction.transactionID); + if (!liveIOUAction) { + return undefined; + } + transactionIOUAction = liveIOUAction; + } + let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID; + if (childReportID) { + const existingThread = getReportOrDraftReport(childReportID); + if (existingThread && !existingThread.reportID) { + return undefined; + } + setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID); + } else if (transactionIOUAction?.reportActionID) { + const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined; + if (transactionID) { + childReportID = createTransactionThreadReport({ + introSelected, + conciergeChat, + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + betas, + iouReport, + iouReportAction: transactionIOUAction, + personalDetails: personalDetailsList, + })?.reportID; + } + } + return childReportID; + }, + [betas, conciergeChat, currentUserAccountID, currentUserEmail, introSelected, iouReport, personalDetailsList, policyID], ); + // `routeAtPress` is captured when the user pressed, not read live: a second press inside the cascade window + // runs after the first press already navigated, so the live route is the report we opened and `backTo` would + // point at itself. + const navigateToExpense = useCallback( + (childReportID: string, routeAtPress: string) => { + startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, { + name: 'MoneyRequestReportPreview.Transaction', + op: CONST.TELEMETRY.SPAN_OPEN_REPORT, + }); + + const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions) + .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction)) + .map((pressedTransaction) => pressedTransaction.transactionID); + + if (isSmallScreenWidth && iouReportID) { + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`); + const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo); + if (!wasPressedFromReport) { + Navigation.navigate(reportRoute); + } + setActiveTransactionIDs(openableTransactionIDs); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); + return; + } + + if (isSmallScreenWidth) { + setActiveTransactionIDs(openableTransactionIDs); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); + return; + } + + if (iouReportID) { + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`); + const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}); + markReportRHPWidth(iouReportID, 'super-wide'); + if (!wasPressedFromReport) { + Navigation.navigate(reportRoute); + } + const seeded = setActiveTransactionIDs(openableTransactionIDs); + markReportRHPWidth(childReportID, 'wide'); + const release = () => { + unmarkReportRHPWidth(childReportID); + // Only clears if the report still carries our hint, so it can't undo one set by the report itself. + unmarkReportRHPWidth(iouReportID, 'super-wide'); + seeded.then(() => { + if (getActiveTransactionIDs().ids !== openableTransactionIDs) { + return; + } + clearActiveTransactionIDs(); + }); + }; + const timer = setTimeout(() => { + cascadeTimerRef.current = null; + if (!Navigation.isActiveRoute(reportRoute)) { + release(); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); + }, PRESSED_EXPENSE_CASCADE_DELAY); + cascadeTimerRef.current = {timer, release}; + return; + } + + setActiveTransactionIDs(openableTransactionIDs).then(() => { + markReportRHPWidth(childReportID, 'wide'); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); + }); + }, + [isSmallScreenWidth, iouReportID, markReportRHPWidth, unmarkReportRHPWidth, transactions], + ); + + const openTransactionFromPreview = useCallback( + (transaction: Transaction) => { + if (contextMenuRef.current?.isContextMenuOpening) { + return; + } + + const routeAtPress = Navigation.getActiveRoute(); + + pendingExpenseTransactionRef.current = null; + if (cascadeTimerRef.current) { + clearTimeout(cascadeTimerRef.current.timer); + cascadeTimerRef.current.release(); + cascadeTimerRef.current = null; + } + + if (transactions.length <= 1) { + openReportFromPreview(); + return; + } + + if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + openReportFromPreview(); + return; + } + + const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID); + const childReportID = resolveChildReportID(transaction); + if (childReportID) { + if (!isIOUActionLoaded && iouReportID) { + if (isOffline) { + openReportFromPreview(); + return; + } + openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions}); + } + navigateToExpense(childReportID, routeAtPress); + return; + } + + if (!isIOUActionLoaded && iouReportID && !isOffline) { + pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress}; + openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions}); + return; + } + + openReportFromPreview(); + }, + [ + betas, + conciergeChat, + currentUserAccountID, + hasIOUReportActions, + introSelected, + iouReportID, + isOffline, + navigateToExpense, + openReportFromPreview, + resolveChildReportID, + transactions.length, + ], + ); + + useEffect(() => { + const pendingPress = pendingExpenseTransactionRef.current; + if (!pendingPress || isLoadingInitialIOUReportActions) { + return; + } + if (!isFocused || Navigation.getActiveRoute() !== pendingPress.originRoute) { + pendingExpenseTransactionRef.current = null; + return; + } + const pendingTransaction = pendingPress.transaction; + const childReportID = resolveChildReportID(pendingTransaction); + if (childReportID) { + pendingExpenseTransactionRef.current = null; + navigateToExpense(childReportID, pendingPress.originRoute); + return; + } + pendingExpenseTransactionRef.current = null; + openReportFromPreview(); + }, [pendingPressActionCount, isFocused, isLoadingInitialIOUReportActions, navigateToExpense, openReportFromPreview, resolveChildReportID]); + + useEffect( + () => () => { + if (!cascadeTimerRef.current) { + return; + } + clearTimeout(cascadeTimerRef.current.timer); + cascadeTimerRef.current.release(); + cascadeTimerRef.current = null; + }, + [], + ); + + const renderItem: ListRenderItem = ({item}) => { + const transactionIOUAction = getIOUActionForReportID(item.reportID, item.transactionID); + return ( + openTransactionFromPreview(item)} + shouldShowPayerAndReceiver={shouldShowPayerAndReceiver} + shouldHighlight={!!newTransactionIDs?.has(item.transactionID)} + /> + ); + }; + return ( void; - /** Callback to render a transaction preview item */ renderTransactionItem: ListRenderItem; + /** Called with the transactions in the order the carousel renders them */ + onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; + + /** Cancels anything a carousel press staged, so opening the report cannot be overtaken by it */ + onCancelPendingPress?: () => void; + /** Callback called when the whole preview is pressed */ onPress: () => void; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts index 7ed2e561bbb1..44192eb87914 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts @@ -1,6 +1,7 @@ /** - * Returns the confirm-approval handler shared by the report-preview Approve and Pay buttons, - * handling delegate-access restrictions, held expenses, and the approveMoneyRequest call. + * Returns the confirm-approval handler for the report-preview Approve button, handling delegate-access + * restrictions and the approveMoneyRequest call. The partial/full choice for reports with held expenses is + * surfaced up front by `ExpenseHeaderApprovalButton`, so it arrives here as the `full` flag. */ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; @@ -9,43 +10,39 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import {hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils} from '@libs/ReportUtils'; - import {approveMoneyRequest} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction} from '@src/types/onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import type useReportPreviewActionButtonData from './useReportPreviewActionButtonData'; -import {useReportPreviewActions, useReportPreviewActionState} from './MoneyRequestReportPreviewContext'; +import {useReportPreviewActions} from './MoneyRequestReportPreviewContext'; -function useConfirmApproveReportAction(actionButtonData: ReturnType, transactions: Transaction[], hasViolations: boolean) { +function useConfirmApproveReportAction(actionButtonData: ReturnType, hasViolations: boolean) { const currentUserDetails = useCurrentUserPersonalDetails(); const {getCurrencyDecimals} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {shouldShowPayButton} = useReportPreviewActionState(); - const {startApprovedAnimation, onHoldMenuOpen} = useReportPreviewActions(); + const {startApprovedAnimation} = useReportPreviewActions(); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {iouReport, policy, ownerLogin, userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, delegateEmail, delegateAccountID} = actionButtonData; - return () => { + return (full = true) => { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); - } else if (hasHeldExpensesReportUtils(transactions)) { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.APPROVE, undefined, shouldShowPayButton); } else { approveMoneyRequest({ getCurrencyDecimals, expenseReport: iouReport, expenseReportPolicy: policy, + rules, currentUserAccountIDParam: currentUserDetails.accountID, currentUserEmailParam: currentUserDetails.email ?? '', hasViolations, @@ -55,7 +52,7 @@ function useConfirmApproveReportAction(actionButtonData: ReturnType; - /** Whether the report has non-reimbursable transactions */ hasNonReimbursableTransactions: boolean; /** The total amount spent on the report */ @@ -64,13 +59,8 @@ type UsePreviewMessageAnimationParams = { /** The manager account ID */ managerID: number; - /** Whether the paid animation is running */ isPaidAnimationRunning: boolean; - - /** Whether the approved animation is running */ isApprovedAnimationRunning: boolean; - - /** Whether the submitting animation is running */ isSubmittingAnimationRunning: boolean; }; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts index 757f55c6811c..a9d184657ab2 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts @@ -48,6 +48,7 @@ function useReportPreviewActionDecision({ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [iouReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(iouReport?.ownerAccountID)}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isDEWPolicy = hasDynamicExternalWorkflow(policy); const isDEWSubmitPending = hasPendingDEWSubmit(iouReportMetadata, isDEWPolicy); @@ -97,6 +98,7 @@ function useReportPreviewActionDecision({ violationsData: transactionViolations, reportMetadata: iouReportMetadata, ownerLogin, + rules, }); return {reportPreviewAction, canIOUBePaid, onlyShowPayElsewhere, shouldShowPayButton, connectedIntegration}; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx index de5ca7cf685d..ede408e2a1b1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx @@ -35,6 +35,9 @@ type UseReportPreviewCarouselParams = { /** Transactions that belong to the previewed report */ transactions: Transaction[]; + /** Called with the transactions in the order the carousel renders them, used to seed the expense view's arrows */ + onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; + /** Violations for the previewed transactions, used to sort RBR transactions first */ transactionViolations: Parameters[2]; @@ -66,6 +69,7 @@ type UseReportPreviewCarouselParams = { */ function useReportPreviewCarousel({ transactions, + onOrderedTransactionsChange, transactionViolations, iouReport, policy, @@ -81,7 +85,7 @@ function useReportPreviewCarousel({ const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(iouReport?.ownerAccountID)}); const isFocusedRef = useIsFocusedRef(); - const carouselTransactions = useMemo(() => { + const sortedTransactions = useMemo(() => { if (shouldShowAccessPlaceHolder) { return []; } @@ -102,8 +106,10 @@ function useReportPreviewCarousel({ // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes return localeCompare(getCreated(a), getCreated(b)); }); - return sorted.slice(0, MAX_PREVIEWS_NUMBER + 1); + return sorted; }, [shouldShowAccessPlaceHolder, transactions, transactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, iouReport, ownerLogin, policy, localeCompare]); + + const carouselTransactions = useMemo(() => sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1), [sortedTransactions]); const prevCarouselTransactionLength = useRef(0); useEffect(() => { @@ -156,7 +162,8 @@ function useReportPreviewCarousel({ useEffect(() => { carouselTransactionsRef.current = carouselTransactions; - }, [carouselTransactions]); + onOrderedTransactionsChange?.(sortedTransactions); + }, [carouselTransactions, onOrderedTransactionsChange, sortedTransactions]); useEffect(() => { const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.has(transaction.transactionID)); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 9bfd0e0ecfff..85b7686f119c 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -5,7 +5,7 @@ import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import {useSearchResultsContext} from '@components/Search/SearchContext'; @@ -16,6 +16,7 @@ import ViolationMessages from '@components/ViolationMessages'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useActiveRoute from '@hooks/useActiveRoute'; +import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; import useAttendees from '@hooks/useAttendees'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -86,7 +87,6 @@ import { canEditMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, getTransactionDetails, - getTripIDFromTransactionParentReportID, isExpenseReport, isInvoiceReport, isOpenReport, @@ -164,9 +164,7 @@ import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import MoneyRequestReceiptView from './MoneyRequestReceiptView'; type MoneyRequestViewProps = { - /** The report currently being looked at */ transactionThreadReport?: OnyxEntry; - parentReportID?: string; /** Policy that the report belongs to */ @@ -271,11 +269,9 @@ function MoneyRequestView({ // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); - const allPolicyCategories = usePolicyCategories(); - const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; - const allPolicyTags = usePolicyTags(); - const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`]; + const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(targetPolicyID)}`); const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); @@ -284,6 +280,7 @@ function MoneyRequestView({ const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); const transactionViolations = useTransactionViolations(transaction?.transactionID, true, distanceOriginalPolicy ?? policy); + const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -393,6 +390,7 @@ function MoneyRequestView({ const isCancelled = moneyRequestReport?.isCancelledIOU; const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const pendingAction = transaction?.pendingAction; const shouldShowPaid = isSettled && transactionReimbursable && !pendingAction; @@ -401,7 +399,9 @@ function MoneyRequestView({ const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID); const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly; const canEdit = - isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && isEditable; + isMoneyRequestAction(parentReportAction) && + canEditMoneyRequest(parentReportAction, transaction, rules, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && + isEditable; const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`; const {personalCardsWithBrokenConnection} = useCardFeedErrors(); const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL); @@ -414,7 +414,7 @@ function MoneyRequestView({ const isSplitAvailable = moneyRequestReport && transaction && - isSplitAction(moneyRequestReport, [transaction], originalTransaction, currentUserPersonalDetails.login ?? '', currentUserPersonalDetails.accountID, policy); + isSplitAction(moneyRequestReport, [transaction], originalTransaction, currentUserPersonalDetails.login ?? '', currentUserPersonalDetails.accountID, rules, policy); const canEditTaxFields = canEdit && !isDistanceRequest; const canEditAmount = @@ -426,6 +426,7 @@ function MoneyRequestView({ isChatReportArchived, reportNameValuePairs, transaction, + rules, }) || (shouldShowSplitIndicator && isSplitAvailable)); const canEditMerchant = @@ -438,6 +439,7 @@ function MoneyRequestView({ transaction, report: moneyRequestReport, policy, + rules, }); const canEditDate = @@ -450,6 +452,7 @@ function MoneyRequestView({ transaction, report: moneyRequestReport, policy, + rules, }); const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; @@ -465,6 +468,7 @@ function MoneyRequestView({ transaction, report: moneyRequestReport, policy, + rules, }) && canEditDistanceOrRate; @@ -478,6 +482,7 @@ function MoneyRequestView({ transaction, report: moneyRequestReport, policy, + rules, }) && canEditDistanceOrRate; @@ -492,6 +497,7 @@ function MoneyRequestView({ transaction, report: moneyRequestReport, policy, + rules, }) && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy) || (isExpenseUnreported && !!perDiemOriginalPolicy)); @@ -532,6 +538,7 @@ function MoneyRequestView({ transaction, report: moneyRequestReport, policy, + rules, }); const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); @@ -555,27 +562,21 @@ function MoneyRequestView({ const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); - const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); - const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; - const transactionTripID = transaction?.comment?.tripID; + const [tripRoomReportID] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { + selector: (allReportNameValuePairs: OnyxCollection): string | undefined => { + if (!transactionTripID || !allReportNameValuePairs) { + return undefined; + } - // Trip rooms are the grandparent report, so check that first before scanning the collection. - const grandparentReportID = parentReport?.parentReportID; - const tripRoomReportSelector = (reports: OnyxCollection): OnyxEntry => { - if (!transactionTripID || !reports) { - return undefined; - } - const grandparent = grandparentReportID ? reports[`${ONYXKEYS.COLLECTION.REPORT}${grandparentReportID}`] : undefined; - const match = - grandparent?.tripData?.tripID === transactionTripID ? grandparent : Object.values(reports).find((candidateReport) => candidateReport?.tripData?.tripID === transactionTripID); - if (!match?.reportID) { - return undefined; - } - return match; - }; - const [tripRoomReport] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.REPORT, {selector: tripRoomReportSelector}); - const tripRoomReportID = tripRoomReport?.reportID; + const tripRoomReportNameValuePairsKey = Object.keys(allReportNameValuePairs).find((key) => allReportNameValuePairs[key]?.tripData?.tripID === transactionTripID); + return tripRoomReportNameValuePairsKey?.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ''); + }, + }); + const [tripRoomReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${tripRoomReportID}`); + const [tripRoomReportNameValuePairs] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${tripRoomReportID}`); + const tripID = tripRoomReportNameValuePairs?.tripData?.tripID; + const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; const derivedReportNames = useDerivedReportNamesByReportIDs([tripRoomReportID, parentReport?.reportID]); @@ -614,7 +615,8 @@ function MoneyRequestView({ (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate)) && !isTrackExpense; const calculateFromTransactionData = isTrackExpense && !rate; const distanceUnit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : unit; - const distanceRate = calculateFromTransactionData ? (transactionAmount ?? 0) / (transaction?.comment?.customUnit?.quantity ?? 1) : rate; + const backCalculationQuantity = transaction?.comment?.customUnit?.quantity; + const distanceRate = calculateFromTransactionData && !!backCalculationQuantity ? (transactionAmount ?? 0) / backCalculationQuantity : rate; let rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay( rateName, isCustomUnitOutOfPolicy, @@ -696,6 +698,7 @@ function MoneyRequestView({ } updateMoneyRequestBillable({ transactionID: transaction.transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -712,6 +715,7 @@ function MoneyRequestView({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); }; @@ -722,6 +726,7 @@ function MoneyRequestView({ } updateMoneyRequestReimbursable({ transactionID: transaction.transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -736,8 +741,10 @@ function MoneyRequestView({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations: allTransactionViolations, getCurrencyDecimals, getCurrencySymbol, + rules, }); }; @@ -749,11 +756,6 @@ function MoneyRequestView({ if (formattedOriginalAmount) { amountHintText = `${translate('iou.purchase')} ${formattedOriginalAmount}`; } - if (isCancelled) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; - } - } else if (isCancelled) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; } else if (isApproved) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; } else if (shouldShowPaid) { @@ -866,6 +868,7 @@ function MoneyRequestView({ updateMoneyRequestTaxRate({ transactionID: transaction?.transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -881,8 +884,10 @@ function MoneyRequestView({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations: allTransactionViolations, getCurrencyDecimals, getCurrencySymbol, + rules, }); }); }; @@ -904,6 +909,7 @@ function MoneyRequestView({ updateMoneyRequestCategory({ transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -918,8 +924,10 @@ function MoneyRequestView({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations: allTransactionViolations, getCurrencyDecimals, getCurrencySymbol, + rules, }); }); }; @@ -943,10 +951,12 @@ function MoneyRequestView({ const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false); updateMoneyRequestTag({ transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, tag: updatedTag, + tagListIndex, policy, policyTagList, policyRecentlyUsedTags: undefined, @@ -958,8 +968,10 @@ function MoneyRequestView({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations: allTransactionViolations, getCurrencyDecimals, getCurrencySymbol, + rules, }); }); }; @@ -1167,8 +1179,7 @@ function MoneyRequestView({ const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport, parentReportDerivedName); const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; - const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); - + const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(transaction, transactionReport); // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); diff --git a/src/components/ReportActionItem/MovedTransactionAction.tsx b/src/components/ReportActionItem/MovedTransactionAction.tsx index 500724b1cc82..56094759d570 100644 --- a/src/components/ReportActionItem/MovedTransactionAction.tsx +++ b/src/components/ReportActionItem/MovedTransactionAction.tsx @@ -21,7 +21,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; type MovedTransactionActionProps = { - /** The moved transaction action data */ action: ReportAction; /** Original report from which the given reportAction is first created */ diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 68954067201f..8e73d9a89a98 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -57,10 +57,9 @@ type ReportActionItemImageProps = { /** URI for the image or local numeric reference for the image */ image?: string | number; - /** whether to enable the image preview modal */ enablePreviewModal?: boolean; - /* The transaction associated with this image, if any. Passed for handling eReceipts. */ + /** The transaction associated with this image, if any. Passed for handling eReceipts */ transaction?: OnyxEntry; /** whether thumbnail is refer the local file or not */ diff --git a/src/components/ReportActionItem/ReportActionItemImages.tsx b/src/components/ReportActionItem/ReportActionItemImages.tsx index 0cb754613649..9a5615d3efba 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.tsx +++ b/src/components/ReportActionItem/ReportActionItemImages.tsx @@ -18,7 +18,6 @@ import {Polygon, Svg} from 'react-native-svg'; import ReportActionItemImage from './ReportActionItemImage'; type ReportActionItemImagesProps = { - /** array of image and thumbnail URIs */ images: ThumbnailAndImageURI[]; // We're not providing default values for size and total and disabling the ESLint rule diff --git a/src/components/ReportActionItem/TaskAction.tsx b/src/components/ReportActionItem/TaskAction.tsx index f808a3fef119..a826f2ca42c3 100644 --- a/src/components/ReportActionItem/TaskAction.tsx +++ b/src/components/ReportActionItem/TaskAction.tsx @@ -16,7 +16,6 @@ import React from 'react'; import {View} from 'react-native'; type TaskActionProps = { - /** Name of the reportAction action */ action: OnyxEntry; }; diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index d9cece4993ef..14163368cb65 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -11,6 +11,7 @@ import PressableWithSecondaryInteraction from '@components/PressableWithSecondar import RenderHTML from '@components/RenderHTML'; import {ShowContextMenuActionsContext, ShowContextMenuStateContext} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; +import UserPill from '@components/UserPill'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; @@ -27,8 +28,7 @@ import getButtonState from '@libs/getButtonState'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; -import {getPersonalDetailsForAccountIDs} from '@libs/PersonalDetailsUtils'; -import {getDisplayNameForParticipant, getDisplayNamesWithTooltips, isCompletedTaskReport, isOpenTaskReport} from '@libs/ReportUtils'; +import {getDisplayNameForParticipant, isCompletedTaskReport, isOpenTaskReport} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import {isActiveTaskEditRoute} from '@libs/TaskUtils'; @@ -48,19 +48,14 @@ import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; type TaskViewProps = { - /** The report currently being looked at */ report: OnyxEntry; - - /** The parent report */ parentReport: OnyxEntry; - - /** The task report action */ action: OnyxEntry; }; function TaskView({report, parentReport, action}: TaskViewProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); - const {translate, localeCompare, formatPhoneNumber} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -90,13 +85,8 @@ function TaskView({report, parentReport, action}: TaskViewProps) { updateTaskCheckboxStateForAccessibility, } = useTaskCheckboxAccessibility(isCompletedFromOnyx, taskTitlePlainText); - const assigneeTooltipDetails = getDisplayNamesWithTooltips( - getPersonalDetailsForAccountIDs(report?.managerID ? [report?.managerID] : [], personalDetails), - false, - localeCompare, - formatPhoneNumber, - translate, - ); + const assigneePersonalDetails = report?.managerID ? personalDetails?.[report.managerID] : undefined; + const assigneeDisplayName = report?.managerID ? getDisplayNameForParticipant({accountID: report.managerID, formatPhoneNumber, translate}) : ''; const isOpen = isOpenTaskReport(report); @@ -278,19 +268,22 @@ function TaskView({report, parentReport, action}: TaskViewProps) { {report?.managerID ? ( + } onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TASK_ASSIGNEE.path))} shouldShowRightIcon={!isDisableInteractive} disabled={disableState} wrapperStyle={[styles.pv2]} - isSmallAvatarSubscriptMenu shouldGreyOutWhenDisabled={false} interactive={!isDisableInteractive} - titleWithTooltips={assigneeTooltipDetails} shouldUseDefaultCursorWhenDisabled sentryLabel={CONST.SENTRY_LABEL.TASK.VIEW_ASSIGNEE} /> diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 1dd8ee6fabbc..76c33eb07c81 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -1,8 +1,7 @@ import MultiAccountAvatar from '@components/Avatar/connected/MultiAccountAvatar'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {ReportPreviewDataContext} from '@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext'; import ReportActionItemImages from '@components/ReportActionItem/ReportActionItemImages'; import UserInfoCellsWithArrow from '@components/Search/SearchList/ListItem/UserInfoCellsWithArrow'; import Text from '@components/Text'; @@ -37,6 +36,7 @@ import {createTransactionPreviewConditionals, getIOUPayerAndReceiver, getTransac import {isManagedCardTransaction as isCardTransactionUtils, isGPSDistanceRequest, isMapDistanceRequest, isScanning} from '@libs/TransactionUtils'; import ViolationsUtils, {filterReceiptViolations} from '@libs/Violations/ViolationsUtils'; +import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -46,7 +46,7 @@ import {cardByIdSelector} from '@src/selectors/Card'; import {getStableReportSelector} from '@src/selectors/Report'; import truncate from 'lodash/truncate'; -import React, {useContext, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -135,13 +135,9 @@ function TransactionPreviewContent({ const {shouldShowRBR, shouldShowMerchant, shouldShowSplitShare, shouldShowCategory, shouldShowSkeleton, shouldShowDescription} = conditionals; - // Raw useContext (not the useReportPreviewData slice hook, which throws when absent): a missing provider means this is a - // standalone preview with no report header to carry the status, so the preview has to report a cancelled payment itself. - const isInsideReportPreview = !!useContext(ReportPreviewDataContext); - const shouldShowCanceledStatus = !isInsideReportPreview; - + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isIOUActionType = isMoneyRequestAction(action); - const canEdit = isIOUActionType && canEditMoneyRequest(action, transaction, isChatReportArchived, report, policy, reportActions); + const canEdit = isIOUActionType && canEditMoneyRequest(action, transaction, rules, isChatReportArchived, report, policy, reportActions); const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(report?.policyID)}`; const {personalCardsWithBrokenConnection} = useCardFeedErrors(); const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL); @@ -169,13 +165,12 @@ function TransactionPreviewContent({ dateFnsLocale, ...transactionPreviewCommonArguments, shouldShowRBR, - shouldShowCanceledStatus, violationMessage, reportActions, originalTransaction, convertToDisplayString, }), - [transactionPreviewCommonArguments, shouldShowRBR, shouldShowCanceledStatus, violationMessage, reportActions, originalTransaction, convertToDisplayString, dateFnsLocale], + [transactionPreviewCommonArguments, shouldShowRBR, violationMessage, reportActions, originalTransaction, convertToDisplayString, dateFnsLocale], ); const getTranslatedText = (item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')); @@ -329,7 +324,7 @@ function TransactionPreviewContent({ {shouldShowPrimaryText && ( @@ -339,7 +334,7 @@ function TransactionPreviewContent({ {!shouldUseScanningLayout && ( diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index 1a6032b019a7..9bec44e4b950 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -14,19 +14,13 @@ type TransactionPreviewProps = { /** The active reportID linked to the transaction */ iouReportID: string | undefined; - /** The associated chat report */ chatReport: OnyxEntry; - - /** The ID of the current report */ reportID: string | undefined; - - /** Callback called when the preview is pressed */ onPreviewPressed?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** All the data of the action, used for showing context menu */ action: OnyxEntry; - /** Optional custom styles to be applied to container component. */ containerStyles?: StyleProp; /** Width to use for skeleton loader of transaction preview */ @@ -47,7 +41,6 @@ type TransactionPreviewProps = { /** In the case where we have access to the transactionID in the parent */ transactionID?: string; - /** The action to be displayed in the preview */ reportPreviewAction?: ReportAction; /** Whether to show payer/receiver data in the preview */ @@ -56,7 +49,6 @@ type TransactionPreviewProps = { /** In case we want to override context menu action */ contextAction?: OnyxEntry; - /** Whether the item should be highlighted */ shouldHighlight?: boolean; }; @@ -67,19 +59,16 @@ type TransactionPreviewContentProps = { /** Navigates the user to a separate view or component for reviewing or editing transaction fields. */ navigateToReviewFields: () => void; - /** Whether the transaction is whisper. */ isWhisper?: boolean; /** Determines if the element is currently hovered over. */ isHovered?: boolean; - /** Optional custom styles to be applied to container component. */ containerStyles?: StyleProp; /** Width to use for skeleton loader of transaction preview */ transactionPreviewWidth: TransactionPreviewStyleType['width']; - /** Records any errors related to wallet terms. */ walletTermsErrors: Errors | undefined; /** Represents the report linked to the transaction */ @@ -91,20 +80,17 @@ type TransactionPreviewContentProps = { /** Flag to determine if a transaction involves a bill split among multiple parties. */ isBillSplit: boolean; - /** Holds the transaction data entry from Onyx */ transaction: OnyxEntry; /** The amount of the transaction saved in the database. This is used to deduce who is the sender and who is the receiver of the money request * In case of Splits the property `transaction` is actually an original transaction (for the whole split) and it does not have the data required to deduce who is the sender */ transactionRawAmount: number; - /** Represents the action entry from Onyx */ action: OnyxEntry; /** Contains data about potential transaction violations */ violations: TransactionViolations; - /** Holds the chat report entry from Onyx */ chatReport: OnyxEntry; /** Optional details about people involved in the transaction */ @@ -113,7 +99,6 @@ type TransactionPreviewContentProps = { /** Indicates whether the transaction consists of duplicates */ areThereDuplicates: boolean; - /** Session account ID */ sessionAccountID?: number; /** Name of the route where the transaction preview is being displayed */ @@ -122,7 +107,6 @@ type TransactionPreviewContentProps = { /** Determine whether to hide the component's children if deletion is pending */ shouldHideOnDelete?: boolean; - /** The action to be displayed in the preview */ reportPreviewAction?: ReportAction; /** Whether to show payer/receiver data in the preview */ @@ -131,7 +115,6 @@ type TransactionPreviewContentProps = { /** Is this component used during duplicate review flow */ isReviewDuplicateTransactionPage?: boolean; - /** Whether the item should be highlighted */ shouldHighlight?: boolean; }; diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 78a2fc6058f3..806e71864c37 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -8,6 +8,7 @@ import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -23,6 +24,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {ReservationData} from '@src/libs/TripReservationUtils'; import {formatCancelledDescription, formatTransitLocationLabel, getPNRReservationDataFromTripReport, getTripReservationCode, getTripReservationIcon} from '@src/libs/TripReservationUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; import type {Reservation} from '@src/types/onyx/Transaction'; @@ -181,7 +183,6 @@ type TripDetailsViewProps = { /** The active tripRoomReportID, used for Onyx subscription */ tripRoomReport: OnyxEntry; - /** Whether we should display the horizontal rule below the component */ shouldShowHorizontalRule: boolean; /** Trip transactions associated with the report */ @@ -248,11 +249,13 @@ function TripDetailsView({tripRoomReport, shouldShowHorizontalRule, tripTransact [translate], ); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${tripRoomReport?.reportID}`); + if (!tripRoomReport) { return null; } - const reservationsData = getPNRReservationDataFromTripReport(tripRoomReport, tripTransactions); + const reservationsData = getPNRReservationDataFromTripReport(tripRoomReport, reportNameValuePairs, tripTransactions); return ( diff --git a/src/components/ReportActionItem/TripRoomPreview.tsx b/src/components/ReportActionItem/TripRoomPreview.tsx index 31a2025c310f..3992df95b738 100644 --- a/src/components/ReportActionItem/TripRoomPreview.tsx +++ b/src/components/ReportActionItem/TripRoomPreview.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -38,7 +38,6 @@ import React, {useMemo} from 'react'; import {FlatList, View} from 'react-native'; type TripRoomPreviewProps = { - /** All the data of the action */ action: ReportAction; /** Extra styles to pass to View wrapper */ @@ -129,19 +128,20 @@ function TripRoomPreview({action, containerStyles, isHovered = false}: TripRoomP const originalMessage = getOriginalMessage(action); const linkedReportID = originalMessage && 'linkedReportID' in originalMessage ? originalMessage.linkedReportID : undefined; const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedReportID}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatReport?.reportID ?? linkedReportID}`); const [iouReportCurrency] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`, {selector: selectCurrency}); const chatReportID = chatReport?.reportID ?? linkedReportID; const tripTransactions = useTripTransactions(chatReportID); - const reservationsData: ReservationData[] = getReservationsFromTripReport(chatReport, tripTransactions); + const reservationsData: ReservationData[] = getReservationsFromTripReport(chatReport, reportNameValuePairs, tripTransactions); const dateInfo = - chatReport?.tripData?.startDate && chatReport?.tripData?.endDate - ? DateUtils.getFormattedDateRange(translate, dateFnsLocale, new Date(chatReport.tripData.startDate), new Date(chatReport.tripData.endDate)) + reportNameValuePairs?.tripData?.startDate && reportNameValuePairs?.tripData?.endDate + ? DateUtils.getFormattedDateRange(translate, dateFnsLocale, new Date(reportNameValuePairs.tripData.startDate), new Date(reportNameValuePairs.tripData.endDate)) : ''; const reportCurrency = iouReportCurrency ?? chatReport?.currency; - const {totalDisplaySpend = 0, currency = reportCurrency} = chatReport ? getTripTotal(chatReport) : {}; + const {totalDisplaySpend = 0, currency = reportCurrency} = chatReport ? getTripTotal(chatReport, reportNameValuePairs) : {}; const displayAmount = useMemo(() => { if (totalDisplaySpend) { diff --git a/src/components/ReportActionItem/UnreportedTransactionAction.tsx b/src/components/ReportActionItem/UnreportedTransactionAction.tsx index c2b3b4de2cfa..b115d66cc320 100644 --- a/src/components/ReportActionItem/UnreportedTransactionAction.tsx +++ b/src/components/ReportActionItem/UnreportedTransactionAction.tsx @@ -21,7 +21,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; type UnreportedTransactionActionProps = { - /** The action when a transaction is unreported */ action: ReportAction; /** Original report from which the given reportAction is first created */ diff --git a/src/components/ReportActionsSkeletonView/index.tsx b/src/components/ReportActionsSkeletonView/index.tsx index 62a06588d135..5e26dd684a10 100644 --- a/src/components/ReportActionsSkeletonView/index.tsx +++ b/src/components/ReportActionsSkeletonView/index.tsx @@ -8,13 +8,8 @@ import {Dimensions, View} from 'react-native'; import SkeletonViewLines from './SkeletonViewLines'; type ReportActionsSkeletonViewProps = { - /** Whether to animate the skeleton view */ shouldAnimate?: boolean; - - /** Number of possible visible content items */ possibleVisibleContentItems?: number; - - /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; }; diff --git a/src/components/ReportSearchHeader/types.ts b/src/components/ReportSearchHeader/types.ts index 4f13f56947b9..34ac35227e64 100644 --- a/src/components/ReportSearchHeader/types.ts +++ b/src/components/ReportSearchHeader/types.ts @@ -6,7 +6,6 @@ type ReportSearchHeaderProps = { /** Report, if we're showing the details for one and using AvatarWithDisplay */ report?: ExpenseReportListItemType; - /** Additional styles to add to the component */ style?: StyleProp; /** Transactions inside report */ diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 6a24717fcdb0..f7044e1874bd 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -5,10 +5,9 @@ import useOnyx from '@hooks/useOnyx'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailsForAccountIDs} from '@libs/PersonalDetailsUtils'; import {getReportName} from '@libs/ReportNameUtils'; import { @@ -41,7 +40,6 @@ import RenderHTML from './RenderHTML'; import Text from './Text'; type ReportWelcomeTextProps = { - /** The report currently being looked at */ report: OnyxEntry; /** The policy for the current route */ @@ -52,6 +50,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const derivedReportName = useDerivedReportNameByReportID(report?.reportID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); @@ -69,9 +68,10 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const isInvoiceRoom = isInvoiceRoomReportUtils(report); const isSystemChat = isSystemChatReportUtils(report); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isDefault = !(isChatRoom || isPolicyExpenseChat || isSelfDM || isSystemChat); const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, true, true, reportMetadata); - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, policy, participantAccountIDs, betas, isReportArchived, isRestrictedToPreferredPolicy); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, policy, participantAccountIDs, betas, rules, isReportArchived, isRestrictedToPreferredPolicy); const policyName = getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -98,8 +98,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK) || moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT); - const reportRHPActiveRoute = Navigation.getReportRHPActiveRoute(); - const reportDetailsPath = reportRHPActiveRoute ? createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS.path, reportRHPActiveRoute) : createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS.path); + const reportDetailsPath = buildDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS.path); const reportDetailsLink = report?.reportID ? `${environmentURL}/${reportDetailsPath}` : ''; let welcomeHeroText = translate('reportActionsView.sayHello'); diff --git a/src/components/RequireFieldsRules/FieldRequirementsDirectionToggle.tsx b/src/components/RequireFieldsRules/FieldRequirementsDirectionToggle.tsx index 1cf099c395c3..6ce56bc00894 100644 --- a/src/components/RequireFieldsRules/FieldRequirementsDirectionToggle.tsx +++ b/src/components/RequireFieldsRules/FieldRequirementsDirectionToggle.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/RequireTwoFactorAuthenticationModal.tsx b/src/components/RequireTwoFactorAuthenticationModal.tsx index bf62d5531b71..0489af957bc4 100644 --- a/src/components/RequireTwoFactorAuthenticationModal.tsx +++ b/src/components/RequireTwoFactorAuthenticationModal.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import ImageSVG from './ImageSVG'; import Lottie from './Lottie'; import LottieAnimations from './LottieAnimations'; @@ -29,7 +29,6 @@ type RequireTwoFactorAuthenticationModalProps = { /** Modal visibility */ isVisible: boolean; - /** Describe what is showing */ description: string; /** diff --git a/src/components/Rule/RuleCategoriesDisabledEmptyState.tsx b/src/components/Rule/RuleCategoriesDisabledEmptyState.tsx index 52f88456bcb0..04a0f626f64d 100644 --- a/src/components/Rule/RuleCategoriesDisabledEmptyState.tsx +++ b/src/components/Rule/RuleCategoriesDisabledEmptyState.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScrollView from '@components/ScrollView'; diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx index 4b1d046ca01a..692aa1f09dcf 100644 --- a/src/components/Rule/RuleSelectionBase.tsx +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -29,7 +29,6 @@ type RuleSelectionBaseProps = { /** The translated page title */ title?: string; - /** Test ID for the screen wrapper */ testID: string; /** Callback to go back */ @@ -43,10 +42,7 @@ type RuleSelectionBaseProps = { }; type RuleSelectionPickerProps = { - /** The currently selected item */ selectedItem?: SelectionItem; - - /** The list of items to display */ items: SelectionItem[]; /** Callback when a value is selected */ diff --git a/src/components/Rule/RuleTaxesDisabledEmptyState.tsx b/src/components/Rule/RuleTaxesDisabledEmptyState.tsx new file mode 100644 index 000000000000..5f97fb3d23d7 --- /dev/null +++ b/src/components/Rule/RuleTaxesDisabledEmptyState.tsx @@ -0,0 +1,82 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ScrollView from '@components/ScrollView'; +import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePolicyData from '@hooks/usePolicyData'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {enablePolicyTaxes} from '@libs/actions/Policy/Policy'; +import Navigation from '@libs/Navigation/Navigation'; +import {hasAccountingConnections} from '@libs/PolicyUtils'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +type RuleTaxesDisabledEmptyStateProps = { + /** ID of the policy the rule belongs to */ + policyID: string; +}; + +/** The taxes counterpart of {@link RuleCategoriesDisabledEmptyState}, so a rule picker explains a disabled feature + * rather than showing an empty list. */ +function RuleTaxesDisabledEmptyState({policyID}: RuleTaxesDisabledEmptyStateProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Coins']); + const policyData = usePolicyData(policyID); + const {showConfirmModal} = useConfirmModal(); + const isConnectedToAccounting = hasAccountingConnections(policyData.policy); + + const enableTaxes = async () => { + // Accounting owns Taxes while a connection is active, same as the Taxes toggle on More features. + if (isConnectedToAccounting) { + const {action} = await showConfirmModal({ + title: translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledText'), + confirmText: translate('workspace.moreFeatures.connectionsWarningModal.manageSettings'), + cancelText: translate('common.cancel'), + }); + if (action !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); + return; + } + + enablePolicyTaxes(policyID, true, policyData.policy?.taxRates, policyData); + }; + + return ( + + + + + + + + + ); +} + +export default RuleTaxesDisabledEmptyState; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index e370650405d7..d69d92dea856 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -23,13 +23,11 @@ type RuleTextBaseProps = { /** The translation key for the input label */ labelKey?: TranslationPaths; - /** Test ID for the screen wrapper */ testID: string; /** The translation key for the hint text to display below the TextInput */ hintKey?: TranslationPaths; - /** Whether this field is required */ isRequired?: boolean; /** The character limit for the input */ diff --git a/src/components/SAMLLoadingIndicator.tsx b/src/components/SAMLLoadingIndicator.tsx index 367137f1b210..edae5cb4c673 100644 --- a/src/components/SAMLLoadingIndicator.tsx +++ b/src/components/SAMLLoadingIndicator.tsx @@ -3,7 +3,7 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import {fontScale} from '@styles/typography'; import React from 'react'; import {StyleSheet, View} from 'react-native'; @@ -31,7 +31,7 @@ function SAMLLoadingIndicator() { {translate('samlSignIn.oneMoment')} diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx index 2550face0fbb..989fb1bff0c8 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -27,16 +27,12 @@ import {PickerAvoidingView} from 'react-native-picker-select'; type ScreenWrapperContainerProps = ForwardedFSClassProps & React.PropsWithChildren<{ - /** A unique ID to find the screen wrapper in tests */ testID: string; - - /** Additional styles to add */ style?: StyleProp; /** Content to display under the offline indicator */ bottomContent?: ReactNode; - /** Additional styles for bottom content */ bottomContentStyle?: StyleProp; /** Whether the screen wrapper has finished the transition */ @@ -46,7 +42,6 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps & * Search 'switch(behavior)' in ./node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js for more context */ keyboardAvoidingViewBehavior?: 'padding' | 'height' | 'position'; - /** The vertical offset to pass to the KeyboardAvoidingView */ keyboardVerticalOffset?: number; /** Whether KeyboardAvoidingView should be enabled. Use false for screens where this functionality is not necessary */ @@ -71,19 +66,13 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps & /** Whether to use the minHeight. Use true for screens where the window height are changing because of Virtual Keyboard */ shouldEnableMinHeight?: boolean; - /** Whether to avoid scroll on virtual viewport */ shouldAvoidScrollOnVirtualViewport?: boolean; /** Whether to use cached virtual viewport height */ shouldUseCachedViewportHeight?: boolean; - /** Whether to include padding bottom */ includeSafeAreaPaddingBottom?: boolean; - - /** Whether to include padding top */ includePaddingTop?: boolean; - - /** Whether to enable edge to edge bottom safe area padding */ enableEdgeToEdgeBottomSafeAreaPadding?: boolean; /** @@ -94,7 +83,6 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps & /** Whether this screen should be hidden from accessibility tree */ shouldHideFromAccessibility?: boolean; - /** Reference to the outer element */ ref?: ForwardedRef; }>; diff --git a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx index af81407e5a1a..2b9945d9dae7 100644 --- a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx @@ -16,22 +16,17 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; type ScreenWrapperOfflineIndicatorsProps = { - /** Styles for the offline indicator */ offlineIndicatorStyle?: StyleProp; /** Whether to show offline indicator on small screens */ shouldShowOfflineIndicator?: boolean; - /** Whether to show offline indicator on wide screens */ shouldShowOfflineIndicatorInWideScreen?: boolean; /** Whether to use a sticky small screen offline indicator. */ shouldMobileOfflineIndicatorStickToBottom?: boolean; - /** Whether the offline indicator should be translucent. */ isOfflineIndicatorTranslucent?: boolean; - - /** The extra content to display. */ extraContent?: ReactNode; /** Whether to add bottom safe area padding to the small screen offline indicator. */ diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index aac699855248..a68e3037bbb2 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -73,19 +73,16 @@ type ScreenWrapperProps = Omit & | PlatformStackNavigationProp | PlatformStackNavigationProp; - /** A unique ID to find the screen wrapper in tests */ testID: string; /** Returns a function as a child to pass insets to or a node to render without insets */ children: ReactNode | ((props: ScreenWrapperChildrenProps) => ReactNode); - /** Additional styles to add */ style?: StyleProp; /** Whether to disable the safe area padding for (nested) offline indicators */ disableOfflineIndicatorSafeAreaPadding?: boolean; - /** Settings for the focus trap */ focusTrapSettings?: FocusTrapForScreenProps['focusTrapSettings']; /** Called when navigated Screen's transition is finished. It does not fire when user exit the page. */ diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index 2980d62f6ec0..49792e8d05c5 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -17,13 +17,11 @@ type ScrollOffsetContextValue = { /** Save scroll offset of FlashList on given screen */ saveScrollOffset: (route: PlatformStackRouteProp, scrollOffset: number) => void; - /** Get scroll offset value for given screen */ getScrollOffset: (route: PlatformStackRouteProp) => number | undefined; /** Save scroll index of FlashList on given screen */ saveScrollIndex: (route: PlatformStackRouteProp, scrollIndex: number) => void; - /** Get scroll index value for given screen */ getScrollIndex: (route: PlatformStackRouteProp) => number | undefined; /** Clean scroll offsets of screen that aren't anymore in the state */ @@ -31,7 +29,6 @@ type ScrollOffsetContextValue = { }; type ScrollOffsetContextProviderProps = { - /** Actual content wrapped by this component */ children: React.ReactNode; }; diff --git a/src/components/ScrollView.tsx b/src/components/ScrollView.tsx index 089a25c399a8..7c4abf7ed09a 100644 --- a/src/components/ScrollView.tsx +++ b/src/components/ScrollView.tsx @@ -8,13 +8,8 @@ import React from 'react'; import {ScrollView as RNScrollView} from 'react-native'; type ScrollViewProps = RNScrollViewProps & { - /** Whether to add bottom safe area padding to the content. */ addBottomSafeAreaPadding?: boolean; - - /** Whether to add bottom safe area padding to the content. */ addOfflineIndicatorBottomSafeAreaPadding?: boolean; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 70d624570308..8e296959a048 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -37,6 +37,7 @@ function ChatSearchView({ columns, canSelectMultiple, isActionColumnWide, + columnSizeOptions, isMobileSelectionModeEnabled, SearchTableHeader: searchTableHeader, tableHeaderVisible, @@ -95,6 +96,7 @@ function ChatSearchView({ columns={columns} type={type} isActionColumnWide={isActionColumnWide} + columnSizeOptions={columnSizeOptions} isHeaderVisible={!!searchTableHeader} dataKey={data} isKeyboardShown={isKeyboardShown} diff --git a/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx b/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx index a8df569c5b58..dab1e261420c 100644 --- a/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx +++ b/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx @@ -43,6 +43,9 @@ function DeferredAutocompleteList(props: SearchAutocompleteListProps) { shouldStyleAsTable onLayout={markLayoutComplete} speed={CONST.TIMING.SKELETON_ANIMATION_SPEED} + // At this speed the shimmer's first sweep starts 750ms in, and the list replaces this placeholder + // well before that, so it paints flat either way. + shouldAnimate={false} /> ); } diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index ea8875307121..60a319602e58 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -41,8 +41,11 @@ function ExpenseFlatSearchView({ columns, canSelectMultiple, isActionColumnWide, + columnSizeOptions, isAttendeesEnabledForMovingPolicy, nonPersonalAndWorkspaceCards, + policyCategories, + policyTags, isMobileSelectionModeEnabled, SearchTableHeader: searchTableHeader, tableHeaderVisible, @@ -135,8 +138,13 @@ function ExpenseFlatSearchView({ columns={columns} type={type} isActionColumnWide={isActionColumnWide} + columnSizeOptions={columnSizeOptions} isHeaderVisible={!!searchTableHeader} dataKey={data} + data={data} + nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} + policyCategories={policyCategories} + policyTags={policyTags} isKeyboardShown={isKeyboardShown} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} containerStyle={containerStyle} diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index 3f1459c89b6d..7bc9566cb34a 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -98,6 +98,7 @@ function ExpenseGroupedSearchView({ columns, canSelectMultiple, isActionColumnWide, + columnSizeOptions, isAttendeesEnabledForMovingPolicy, nonPersonalAndWorkspaceCards, isMobileSelectionModeEnabled, @@ -307,6 +308,7 @@ function ExpenseGroupedSearchView({ columns={columns} type={type} isActionColumnWide={isActionColumnWide} + columnSizeOptions={columnSizeOptions} isHeaderVisible={!!searchTableHeader} dataKey={data} isKeyboardShown={isKeyboardShown} diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index dee210044181..45ca76d7c7f7 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -43,6 +43,7 @@ function ExpenseReportSearchView({ columns, canSelectMultiple, isActionColumnWide, + columnSizeOptions, isMobileSelectionModeEnabled, SearchTableHeader: searchTableHeader, tableHeaderVisible, @@ -130,8 +131,10 @@ function ExpenseReportSearchView({ columns={columns} type={type} isActionColumnWide={isActionColumnWide} + columnSizeOptions={columnSizeOptions} isHeaderVisible={!!searchTableHeader} dataKey={data} + data={data} isKeyboardShown={isKeyboardShown} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} containerStyle={containerStyle} diff --git a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx index dbb73536792f..165b2d6f952f 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx @@ -1,5 +1,5 @@ import AmountWithoutCurrencyInput from '@components/AmountWithoutCurrencyInput'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ScrollView from '@components/ScrollView'; import type {SearchAmountFilterKeys, SearchAmountValues} from '@components/Search/types'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/DateFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/DateFilterContent.tsx index b98c43b9eb88..9f2ac912259b 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/DateFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/DateFilterContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import DateFilterBase from '@components/Search/FilterComponents/DateFilterBase'; import type {DateFilterBaseHandle} from '@components/Search/FilterComponents/DateFilterBase'; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx index affa7acd43ef..63001fbc257d 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx @@ -38,6 +38,12 @@ type FilterListProps = FilterItemCallbacks & { selectedFilter?: SearchFilter['key']; style?: StyleProp; contentContainerStyle?: StyleProp; + + /** Called as the cursor travels over the list. Web only - it reports the movement inside a row, which `onHoverIn` only reports entering. */ + onPointerMove?: (event: {clientX: number; clientY: number}) => void; + + /** Called when the cursor leaves the list, so the consumer can settle whatever it was still waiting on for the last row. Web only. */ + onPointerLeave?: (event: {clientX: number}) => void; }; type FilterItemProps = FilterItemCallbacks & { @@ -102,7 +108,7 @@ function FilterItem({filterKey, isSelected, onPress, onHoverIn, onFocus}: Filter ); } -function FilterList({type, policyID, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress}: FilterListProps) { +function FilterList({type, policyID, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress, onPointerMove, onPointerLeave}: FilterListProps) { const styles = useThemeStyles(); const typeFiltersKeys = useAdvancedSearchFilters(type, policyID); @@ -111,6 +117,8 @@ function FilterList({type, policyID, selectedFilter, style, contentContainerStyl style={[style]} contentContainerStyle={[contentContainerStyle]} showsVerticalScrollIndicator={false} + onMouseMove={onPointerMove} + onMouseLeave={onPointerLeave} > {typeFiltersKeys.map((section, index) => ( diff --git a/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx index 54ee83e84b66..9172573b83cb 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ReportFieldBase from '@components/Search/FilterComponents/ReportField'; import type {ReportFieldHandle} from '@components/Search/FilterComponents/ReportField'; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index bf92c39e5811..5facf618718b 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import type {ReportFieldTextKey, SearchTextFilterKeys} from '@components/Search/types'; diff --git a/src/components/Search/FilterComponents/DateFilterBase.tsx b/src/components/Search/FilterComponents/DateFilterBase.tsx index 727b9d4d132e..20531a8e62db 100644 --- a/src/components/Search/FilterComponents/DateFilterBase.tsx +++ b/src/components/Search/FilterComponents/DateFilterBase.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -62,7 +62,6 @@ type DateFilterBaseProps = { shouldShowButtonsOnlyWithDateModifier?: boolean; /** Whether to render the built-in HeaderWithBackButton. Defaults to true. */ shouldShowHeader?: boolean; - /** The ref handle */ ref?: React.Ref; style?: StyleProp; }; diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index 9c84edb1f014..7469bf089ca2 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -59,25 +59,21 @@ function isCustomDateModifier(dateModifier: SearchDateModifier | null): dateModi } type DatePresetFilterBaseHandle = { - /** Gets date values */ getDateValues: () => SearchDateValues; /** Gets the formatted range display text for current date values */ getRangeDisplayText: () => string; - /** Clears date values */ clearDateValues: () => void; /** Sets the date value of the selected date modifier to the ephemeral date value (the selected date in calendar) */ setDateValueOfSelectedDateModifier: () => void; - /** Clears the date value of the selected date modifier */ clearDateValueOfSelectedDateModifier: () => void; /** Restores the Range value to what it was when Range mode was entered, discarding any unsaved ephemeral picks */ restoreRangeToEntrySnapshot: () => void; - /** Resets date values to the provided defaults */ resetDateValuesToDefault: () => void; /** Validates the selected date modifier input */ @@ -85,16 +81,9 @@ type DatePresetFilterBaseHandle = { }; type DatePresetFilterBaseProps = { - /** Default date values */ defaultDateValues: SearchDateValues; - - /** Selected date modifier */ selectedDateModifier: SearchDateModifier | null; - - /** Callback when a date modifier is selected */ onSelectDateModifier: (dateModifier: SearchDateModifier | null) => void; - - /** The date presets */ presets?: SearchDatePreset[]; /** Whether to show the "Custom date" (On/After/Before) option. Defaults to true. */ @@ -103,16 +92,12 @@ type DatePresetFilterBaseProps = { /** Whether the search advanced filters form Onyx data is loading or not */ isSearchAdvancedFiltersFormLoading?: boolean; - /** Callback when date values change */ onDateValuesChange?: (dateValues: SearchDateValues) => void; - - /** Callback when range validation error changes */ onRangeValidationErrorChange?: (shouldShowRangeError: boolean) => void; /** Force vertical stacking of calendars in range picker */ forceVerticalCalendars?: boolean; - /** The ref handle */ ref: Ref; }; diff --git a/src/components/Search/FilterComponents/ExportedToSelector.tsx b/src/components/Search/FilterComponents/ExportedToSelector.tsx index fb85971cfb5b..2a83f0d3beb5 100644 --- a/src/components/Search/FilterComponents/ExportedToSelector.tsx +++ b/src/components/Search/FilterComponents/ExportedToSelector.tsx @@ -43,6 +43,7 @@ function ExportedToSelector({value = [], policyID, selectionListTextInputStyle, 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', 'Table', 'TablePencil', diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 7f458edfa5ab..ccc0c2d49e09 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -4,6 +4,7 @@ import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMembe import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import type {TextInputOptions} from '@components/SelectionList/types'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useFilteredOptions from '@hooks/useFilteredOptions'; @@ -12,7 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; -import useSortedActions from '@hooks/useSortedActions'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import {searchInServer} from '@libs/actions/Report'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -43,11 +44,16 @@ const defaultListOptions = { }; function getSelectedOptionData(option: Option & Pick): OptionData { - return {...option, isSelected: true, keyForList: option.keyForList ?? option.reportID}; + return { + ...option, + isSelected: true, + keyForList: option.keyForList ?? option.reportID, + }; } function InSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: InSelectorProps) { const {translate, dateFnsLocale} = useLocalize(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const personalDetails = usePersonalDetails(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {options, isLoading} = useFilteredOptions({ @@ -59,9 +65,14 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); + const [loginList] = useOnyx(ONYXKEYS.LOGINS, { + selector: expensifyLoginsSelector, + }); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const sortedActions = useSortedActions(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; + const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; + const lastActions = sortedReportActionsData?.lastActions; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -74,7 +85,10 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const privateIsArchivedMap = usePrivateIsArchivedMap(); const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { + selector: isTrackIntentUserSelector, + }); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const buildReportOption = (id: string, isSelected: boolean): OptionData => { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`]; @@ -84,11 +98,18 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle ...getSelectedOptionData( createOptionFromReport({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, report: {...reportData, reportID: id}, personalDetails, privateIsArchived, + rules, policy: reportPolicy, sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserLogin: currentUserEmail, conciergeReportID, reportAttributesDerived, isTrackIntentUser, @@ -102,7 +123,20 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const alternateText = getAlternateText( report, {}, - {dateFnsLocale, isReportArchived, personalDetails, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID, isTrackIntentUser}, + { + dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + isReportArchived, + personalDetails, + policy, + reportAttributesDerived, + policyTags: reportPolicyTags, + conciergeReportID, + isTrackIntentUser, + translate, + rules, + }, ); return {...report, alternateText}; }; @@ -119,6 +153,8 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle ? defaultListOptions : getSearchOptions({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, options, draftComments, betas: undefined, @@ -130,16 +166,29 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle personalDetails, policyCollection: allPolicies, sortedActions, + currentUserLogin: currentUserEmail, conciergeReportID, isTrackIntentUser, translate, + rules, }).options; - const chatOptions = filterAndOrderOptions(defaultOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, { - dateFnsLocale, - selectedOptions, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - }); + const chatOptions = filterAndOrderOptions( + defaultOptions, + cleanSearchTerm, + countryCode, + loginList, + currentUserEmail, + currentUserAccountID, + personalDetails, + { + dateFnsLocale, + convertToDisplayString, + selectedOptions, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + }, + rules, + ); const sections: SelectionListSections = []; diff --git a/src/components/Search/FilterComponents/ListFilterContent.tsx b/src/components/Search/FilterComponents/ListFilterContent.tsx index aa6f69970b37..3bf19e429523 100644 --- a/src/components/Search/FilterComponents/ListFilterContent.tsx +++ b/src/components/Search/FilterComponents/ListFilterContent.tsx @@ -1,13 +1,15 @@ import type {Filter, SearchAmountFilterKeys, SearchDateFilterKeys, SearchFilterCommonProps, SearchTextFilterKeys} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {isFilterNegatable} from '@libs/SearchQueryUtils'; -import {getMultiSelectFilterOptions, getSingleSelectFilterOptions} from '@libs/SearchUIUtils'; +import {getHasOptions, getMultiSelectFilterOptions, getSingleSelectFilterOptions} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; @@ -59,6 +61,10 @@ type MultiSelectListFilterContentProps = SearchFilterCommonProps & { + type: SearchDataTypes | undefined; +}; + function SingleSelectListFilterContent({baseFilterKey, value, selectionListStyle, footer, onChange}: SingleSelectListFilterContentProps) { const {translate} = useLocalize(); const items = getSingleSelectFilterOptions(baseFilterKey, translate); @@ -75,8 +81,54 @@ function SingleSelectListFilterContent({baseFilterKey, value, selectionListStyle ); } +/** + * HAS is split out so other multi-select filters do not subscribe to the hot policy collections. + * Availability is computed in render (not a POLICY selector that closes over categories) so a late + * POLICY_CATEGORIES load still recomputes submitted-violation for migrated Control workspaces. + */ +function HasMultiSelectListFilterContent({value = [], type = CONST.SEARCH.DATA_TYPES.EXPENSE, selectionListStyle, footer, onChange}: HasMultiSelectListFilterContentProps) { + const {translate} = useLocalize(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const selectedValues = value as string[]; + // Include already-selected values even when the matching workspace feature is off, otherwise + // toggling another option would call onChange without them and clear the saved/query selection. + const items = getHasOptions(translate, type, { + policies: policies ?? {}, + policyCategories, + selectedValues, + }); + const multiSelectValues = items.filter((item) => selectedValues.includes(item.value)); + + return ( + { + onChange(selectedItems.map((item) => item.value)); + }} + /> + ); +} + function MultiSelectListFilterContent({baseFilterKey, value = [], type = CONST.SEARCH.DATA_TYPES.EXPENSE, selectionListStyle, footer, onChange}: MultiSelectListFilterContentProps) { const {translate} = useLocalize(); + + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS) { + return ( + + ); + } + const items = getMultiSelectFilterOptions(baseFilterKey, type, translate); const multiSelectValues = items.filter((item) => (value as string[]).includes(item.value)); diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 03f00ce98341..24db44ce0860 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -35,13 +35,11 @@ type MultiSelectItem = { }; type MultiSelectProps = SearchFilterCommonProps>> & { - /** The list of all items to show up in the list */ items: Array>; /** Whether the search input should be displayed. */ isSearchable?: boolean; - /** Custom height for each item in the list */ itemHeight?: number; /** Search input placeholder. Defaults to 'common.search' when not provided. */ @@ -50,7 +48,6 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Whether the data for the popover is loading */ loading?: boolean; - /** Whether to show the loading placeholder */ shouldShowLoadingPlaceholder?: boolean; }; diff --git a/src/components/Search/FilterComponents/NegatableFilter.tsx b/src/components/Search/FilterComponents/NegatableFilter.tsx index ea997f3784de..be224e55b5d7 100644 --- a/src/components/Search/FilterComponents/NegatableFilter.tsx +++ b/src/components/Search/FilterComponents/NegatableFilter.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/Search/FilterComponents/RangeDatePicker.tsx b/src/components/Search/FilterComponents/RangeDatePicker.tsx index 2d59b5c34273..818747334749 100644 --- a/src/components/Search/FilterComponents/RangeDatePicker.tsx +++ b/src/components/Search/FilterComponents/RangeDatePicker.tsx @@ -12,16 +12,9 @@ import React from 'react'; import {View} from 'react-native'; type RangeDatePickerProps = { - /** The "From" date value */ fromValue?: string; - - /** The "To" date value */ toValue?: string; - - /** Callback when "From" date is selected */ onFromSelected: (date: string) => void; - - /** Callback when "To" date is selected */ onToSelected: (date: string) => void; /** Force vertical stacking of calendars */ diff --git a/src/components/Search/FilterComponents/SingleSelect.tsx b/src/components/Search/FilterComponents/SingleSelect.tsx index 2ceb55945f54..d46ac97fb381 100644 --- a/src/components/Search/FilterComponents/SingleSelect.tsx +++ b/src/components/Search/FilterComponents/SingleSelect.tsx @@ -23,7 +23,6 @@ type SingleSelectItem = { }; type SingleSelectProps = SearchFilterCommonProps | undefined> & { - /** The list of all items to show up in the list */ items: Array>; /** Whether the search input should be displayed */ @@ -35,7 +34,6 @@ type SingleSelectProps = SearchFilterCommonProps | undefi /** Whether SelectionList of popup should stay mounted when popup is not visible. */ shouldShowList?: boolean; - /** Custom height for each item in the list */ itemHeight?: number; /** Whether the popover keeps a fixed height instead of growing with its content */ diff --git a/src/components/Search/FilterComponents/UserSelector.tsx b/src/components/Search/FilterComponents/UserSelector.tsx index 73d8f5cf404d..b6e516f665dd 100644 --- a/src/components/Search/FilterComponents/UserSelector.tsx +++ b/src/components/Search/FilterComponents/UserSelector.tsx @@ -47,7 +47,8 @@ function UserSelector({value = [], isNegatable, policyID, selectionListTextInput return acc; }, new Set()); - const expensifyTeamExclusions = getExpensifyTeamExclusions(personalDetails, policies, currentUserPersonalDetails.email); + // getExpensifyTeamExclusions walks every personal detail, so it is skipped while the selector withholds its options. + const expensifyTeamExclusions = ready ? getExpensifyTeamExclusions(personalDetails, policies, currentUserPersonalDetails.email) : CONST.EMPTY_OBJECT; // Snapshot the pre-selected accountIDs from when the filter first opened so they can be floated to the // top on first render without repinning rows that are toggled afterwards. diff --git a/src/components/Search/FilterDropdowns/ActionButtons.tsx b/src/components/Search/FilterDropdowns/ActionButtons.tsx index 64a1755f7f28..76936bc330d7 100644 --- a/src/components/Search/FilterDropdowns/ActionButtons.tsx +++ b/src/components/Search/FilterDropdowns/ActionButtons.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Search/FilterDropdowns/BasePopup.tsx b/src/components/Search/FilterDropdowns/BasePopup.tsx index 49b08cd08c76..4ab4b42fd48b 100644 --- a/src/components/Search/FilterDropdowns/BasePopup.tsx +++ b/src/components/Search/FilterDropdowns/BasePopup.tsx @@ -14,6 +14,7 @@ import ActionButtons from './ActionButtons'; type BasePopupProps = React.PropsWithChildren & { label?: string; showLabel?: boolean; + shouldShowActionButtons?: boolean; applySentryLabel: string; resetSentryLabel?: string; style?: StyleProp; @@ -22,7 +23,7 @@ type BasePopupProps = React.PropsWithChildren & { onBackButtonPress?: () => void; }; -function BasePopup({children, label, applySentryLabel, resetSentryLabel, showLabel, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { +function BasePopup({children, label, applySentryLabel, resetSentryLabel, showLabel, shouldShowActionButtons = true, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -42,13 +43,15 @@ function BasePopup({children, label, applySentryLabel, resetSentryLabel, showLab shouldDisplayLabel && {label} )} {children} - + {shouldShowActionButtons && ( + + )} ); } diff --git a/src/components/Search/FilterDropdowns/CurrencyPopup.tsx b/src/components/Search/FilterDropdowns/CurrencyPopup.tsx index f03291f61d1b..54ec5e5ed256 100644 --- a/src/components/Search/FilterDropdowns/CurrencyPopup.tsx +++ b/src/components/Search/FilterDropdowns/CurrencyPopup.tsx @@ -12,10 +12,7 @@ type CurrencyPopupProps = { /** The label to show when in an overlay on mobile */ label?: string; - /** Function to call when the back button is pressed */ onBackButtonPress?: () => void; - - /** Function to call to close the overlay */ closeOverlay: () => void; /** Function to call when a currency is selected */ diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index 025df17f38be..2d1a8006b7bc 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -28,19 +28,10 @@ type DateSelectPopupProps = { /** The label to show when in an overlay on mobile */ label?: string; - /** The current date values */ value: SearchDateValues; - - /** The date presets */ presets?: SearchDatePreset[]; - - /** Additional style props */ style?: StyleProp; - - /** Function to call when changes are applied */ onChange: (value: SearchDateValues) => void; - - /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; /** Function to set the popover width dynamically */ diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index fea0dfa50996..4b9970af916f 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -1,6 +1,6 @@ import CompactMenuContext from '@components/CompactMenuContext'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScrollView from '@components/ScrollView'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import type {SearchQueryJSON} from '@components/Search/types'; @@ -80,47 +80,42 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP return ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_SORT_BY} + value={`${translate(getSearchColumnTranslationKey(sortByValue))} ${CONST.DOT_SEPARATOR} ${translate(`search.filters.sortOrder.${sortOrderValue}`)}`} /> {(isExpenseType || isTripType) && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_BY} + value={groupByValue ? translate(`search.filters.groupBy.${groupByValue}`) : undefined} /> )} {!!groupBy && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_CURRENCY} + value={groupCurrencyValue} /> )} {isExpenseType && !!groupByValue && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_VIEW} + value={viewValue ? translate(`search.view.${viewValue}`) : undefined} /> )} {isExpenseType && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_LIMIT} + value={limitValue} /> )} {shouldShowColumnsButton && ( diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index 4df72f22731e..6e78372ee4ea 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CaretWrapper from '@components/CaretWrapper'; import Icon from '@components/Icon'; import Text from '@components/Text'; @@ -23,22 +23,14 @@ import FilterPopupButton from './FilterPopupButton'; type DropdownButtonProps = WithSentryLabel & Omit & { - /** The label to display on the select */ label: string; - - /** The selected value(s) if any */ value: string | string[] | null; /** Whether to use medium size button instead of small */ medium?: boolean; - /** Button inner styles */ innerStyles?: StyleProp; - - /** Button label style */ labelStyle?: StyleProp; - - /** Caret wrapper style */ caretWrapperStyle?: StyleProp; onClosePress?: () => void; }; diff --git a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx index 2b05cb4970a7..9a0e8096edc0 100644 --- a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx +++ b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx @@ -36,19 +36,11 @@ type ButtonComponentProps = { }; type FilterPopupButtonProps = { - /** The viewport's offset */ viewportOffsetTop: number; - - /** Wrapper style for the outer view */ wrapperStyle?: StyleProp; - popoverWidth?: number; popoverAnchorAlignment?: AnchorAlignment; - - /** The component to render in the popover */ PopoverComponent: (props: PopoverComponentProps) => ReactNode; - - /** The component to render as the button */ renderButton: (props: ButtonComponentProps) => ReactNode; }; diff --git a/src/components/Search/FilterDropdowns/GroupByPopup.tsx b/src/components/Search/FilterDropdowns/GroupByPopup.tsx index 40daea3d5362..c7f3e99fe4d7 100644 --- a/src/components/Search/FilterDropdowns/GroupByPopup.tsx +++ b/src/components/Search/FilterDropdowns/GroupByPopup.tsx @@ -22,18 +22,10 @@ type GroupByPopupItem = { }; type GroupByPopupProps = { - /** The grouped options to show in the list */ sections: GroupBySection[]; - - /** The currently selected item */ value: GroupByPopupItem | null; - onBackButtonPress: () => void; - - /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; - - /** Function to call when changes are applied */ onChange: (item: GroupByPopupItem | null) => void; }; diff --git a/src/components/Search/FilterDropdowns/ListPopup.tsx b/src/components/Search/FilterDropdowns/ListPopup.tsx index b4f9c36f304d..4c38b92db059 100644 --- a/src/components/Search/FilterDropdowns/ListPopup.tsx +++ b/src/components/Search/FilterDropdowns/ListPopup.tsx @@ -14,13 +14,14 @@ import type {PopoverComponentProps} from './FilterPopupButton'; import BasePopup from './BasePopup'; type ListPopupProps = Pick & { + isDefault: boolean; values: Partial | undefined; label: string; closeOverlay: PopoverComponentProps['closeOverlay']; updateFilterForm: (value: Partial) => void; }; -function ListPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: ListPopupProps) { +function ListPopup({baseFilterKey, isDefault, values, label, updateFilterForm, closeOverlay}: ListPopupProps) { const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); const [value, setValue] = useState(initialValue); const [isNegated, setIsNegated] = useState(initialIsNegated); @@ -34,6 +35,7 @@ function ListPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay = { - /** Whether to show the label in the popup or not */ showLabel?: boolean; /** The label to show when in an overlay on mobile */ label: string; - /** The list of all items to show up in the list */ items: Array>; - - /** The currently selected items */ value: Array>; - - /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; - - /** Function to call when changes are applied */ onChange: (item: Array>) => void; /** Whether the search input should be displayed. */ diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/ListFilterContentPopupWrapper.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/ListFilterContentPopupWrapper.tsx index bb02513672fa..f850c4e346ed 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/ListFilterContentPopupWrapper.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/ListFilterContentPopupWrapper.tsx @@ -1,3 +1,4 @@ +import LoadingIndicator from '@components/LoadingIndicator'; import type {ListFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; import ListFilterContent from '@components/Search/FilterComponents/ListFilterContent'; @@ -6,12 +7,22 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isFilterNegatable} from '@libs/SearchQueryUtils'; import React, {useState} from 'react'; +import {View} from 'react-native'; -function ListFilterContentPopupWrapper({baseFilterKey, value, isNegated: initialIsNegated, type, policyID, onChange}: ListFilterContentWrapperProps) { +function ListFilterContentPopupWrapper({baseFilterKey, value, isNegated: initialIsNegated, type, policyID, ready, onChange}: ListFilterContentWrapperProps) { const styles = useThemeStyles(); const [isNegated, setIsNegated] = useState(initialIsNegated); const isFilterKeyNegatable = isFilterNegatable(baseFilterKey); + // The filter is its own loading state, so its controls do not render behind the spinner. + if (ready === false) { + return ( + + + + ); + } + return ( ; + + /** The filter values each mounted instance was built from. A revisit compares against it. */ + formAtMount: Partial>>; + + /** The filter values each content was last shown with. It goes on rendering with them while hidden. */ + formWhileHidden: Partial>>; + + /** How many times each content has been remounted. Part of its key, so a bump replaces the instance. */ + contentVersions: Partial>; + + /** The deferred filters now allowed to derive. One left out stands as its own loading state. */ + readyFilters: Array; +}; + +const filterComponents = { + List: ListFilterContentPopupWrapper, + Text: TextInputFilterContentPopupWrapper, + Amount: AmountFilterContentPopupWrapper, + Date: DateFilterContentPopupWrapper, + ReportField: ReportFieldFilterContentPopupWrapper, +} as const; + +/** The filter the popover opens on. */ +const INITIAL_FILTER = CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE; + +/** The filters whose contents are derived from the whole contact list, the one derivation heavy enough to be felt. */ +const DEFERRED_CONTENT_FILTERS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE, +]); + +type MountedFilterContentProps = { + /** The filter whose content this is. */ + filterKey: SearchFilter['key']; + + /** The filter values the content reads. */ + values: Partial | undefined; + + /** Whether the content may derive itself, or has to stand as its own loading state for now. */ + ready: boolean; + + /** Called with the values a change in the content produces. */ + onChange: (values: Partial) => void; +}; + +// React Compiler caches the whole `mountedFilters.map()` in one slot, so every element is rebuilt on each change. Each +// content sits in its own component, so a hover ends the render at the boundary of those whose props did not change. +// `onChange` changes with the form, so an edit still re-renders them all. +function MountedFilterContent({filterKey, values, ready, onChange}: MountedFilterContentProps) { + const styles = useThemeStyles(); + + return ( + + + + ); +} + function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {windowHeight} = useWindowDimensions(); - const [selectedFilter, setSelectedFilter] = useState(CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE); - const filterContentRef = useRef(null); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - + const filterContentRef = useRef(null); + const [mountedFilterState, setMountedFilterState] = useState(() => ({ + activeFilter: INITIAL_FILTER, + mountedFilters: [INITIAL_FILTER], + formAtMount: {[INITIAL_FILTER]: searchAdvancedFiltersForm}, + formWhileHidden: {}, + contentVersions: {}, + readyFilters: [], + })); + const {activeFilter, mountedFilters, formWhileHidden, contentVersions, readyFilters} = mountedFilterState; const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); + // Shows a filter's content. A visited one stays mounted, so a return reveals it rather than building it again. + const showFilter = (filterKey: SearchFilter['key']) => { + setMountedFilterState((currentState) => { + if (currentState.activeFilter === filterKey) { + return currentState; + } + + const isMounted = currentState.mountedFilters.includes(filterKey); + // A content decides its selection pinning on mount, so one whose values moved on since it was built is + // remounted rather than revealed. Only the values that content reads count. + const isStale = isMounted && hasFilterContentValuesChanged(filterKey, currentState.formAtMount[filterKey], searchAdvancedFiltersForm); + const isBuilt = !isMounted || isStale; + + return { + activeFilter: filterKey, + mountedFilters: isMounted ? currentState.mountedFilters : [...currentState.mountedFilters, filterKey], + formAtMount: isBuilt ? {...currentState.formAtMount, [filterKey]: searchAdvancedFiltersForm} : currentState.formAtMount, + // The filter being left keeps the values it had while shown, so hiding it costs no render. + formWhileHidden: {...currentState.formWhileHidden, [currentState.activeFilter]: searchAdvancedFiltersForm}, + contentVersions: isStale ? {...currentState.contentVersions, [filterKey]: (currentState.contentVersions[filterKey] ?? 0) + 1} : currentState.contentVersions, + // A remounted content derives itself from scratch, so it waits for the cursor again like a new one. + readyFilters: isStale ? currentState.readyFilters.filter((key) => key !== filterKey) : currentState.readyFilters, + }; + }); + }; + + /** Lets the shown filter derive what it withheld. It reads the filter from the updater, so a wait outlives its render. */ + const markShownFilterReady = () => { + setMountedFilterState((currentState) => { + if (!DEFERRED_CONTENT_FILTERS.has(currentState.activeFilter) || currentState.readyFilters.includes(currentState.activeFilter)) { + return currentState; + } + + return {...currentState, readyFilters: [...currentState.readyFilters, currentState.activeFilter]}; + }); + }; + + // Elapses once the cursor comes to rest on a row. + const {invoke: debouncedMarkShownFilterReady, cancel: cancelReadyWait} = useDebounceWithControls(markShownFilterReady, CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY); + + const hoverFilter = (filterKey: SearchFilter['key']) => { + showFilter(filterKey); + debouncedMarkShownFilterReady(); + }; + + const {trackMovement: trackPointerMovement, stopTracking} = usePointerMovement(CONST.SEARCH.HOVER_INTENT_REST_RADIUS_PX, debouncedMarkShownFilterReady); + + // The direction comes from the last tracked position rather than the exit point, because `SafeTriangle` covers the + // path to the content with an overlay and the cursor leaves the list above it as often as through its edge. + const stopTrackingPointer = (event: {clientX: number}) => { + const lastMovement = stopTracking(); + + if (lastMovement && event.clientX <= lastMovement.x) { + return; + } + + cancelReadyWait(); + markShownFilterReady(); + }; + + // Moving the focus is deliberate and never passes over rows on the way, so nothing is withheld from it. + const focusFilter = (filterKey: SearchFilter['key']) => { + cancelReadyWait(); + showFilter(filterKey); + markShownFilterReady(); + }; + + const mayDeriveContent = (filterKey: SearchFilter['key']) => !DEFERRED_CONTENT_FILTERS.has(filterKey) || readyFilters.includes(filterKey); + return ( @@ -45,26 +197,31 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps style={[styles.typeFiltersPopupContainer]} type={searchAdvancedFiltersForm?.type} policyID={getFilterNegatableValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, searchAdvancedFiltersForm)} - selectedFilter={selectedFilter} - onHoverIn={setSelectedFilter} - onFocus={setSelectedFilter} + selectedFilter={activeFilter} + onHoverIn={hoverFilter} + onPointerMove={trackPointerMovement} + onPointerLeave={stopTrackingPointer} + onFocus={focusFilter} /> - + {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/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index da4a5592ee52..827e2eeaf189 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -12,24 +12,15 @@ import React, {useCallback, useState} from 'react'; import BasePopup from './BasePopup'; type SingleSelectPopupProps = { - /** Whether to show the label in the popup or not */ showLabel?: boolean; /** The label to show when in an overlay on mobile */ label?: string; - /** The list of all items to show up in the list */ items: Array>; - - /** The currently selected item */ value: SingleSelectItem | undefined; - onBackButtonPress?: () => void; - - /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; - - /** Function to call when changes are applied */ onChange: (item: SingleSelectItem | undefined) => void; /** Whether the search input should be displayed */ @@ -42,8 +33,6 @@ type SingleSelectPopupProps = { defaultValue?: string; style?: StyleProp; - - /** Custom styles for the SelectionList */ selectionListStyle?: SelectionListStyle; /** Custom height for each item in the list. Overrides the default row height and adjusts the popover size accordingly. */ diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx index ab5d17990869..698ad4331211 100644 --- a/src/components/Search/FilterDropdowns/SortByPopup.tsx +++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ListFilterWrapper from '@components/Search/FilterComponents/ListFilterViewWrapper'; import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; import {useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; @@ -109,11 +109,10 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPres hasHeader extraHeight={variables.optionRowHeight + DIVIDER_HEIGHT} > - void; /** Callback invoked when the user submits the input */ onSubmit?: () => void; - /** Whether the input is full width */ isFullWidth: boolean; - - /** Whether the input is disabled */ disabled?: boolean; - - /** Whether the offline message should be shown */ shouldShowOfflineMessage?: boolean; /** Callback to call when the input gets focus */ @@ -57,7 +51,6 @@ type SearchAutocompleteInputProps = { /** Callback to call when the input gets blur */ onBlur?: () => void; - /** Any additional styles to apply */ wrapperStyle?: ViewStyle; /** Any additional styles to apply when input is focused */ @@ -76,10 +69,7 @@ type SearchAutocompleteInputProps = { /** Map of autocomplete suggestions. Required for highlighting to work properly */ substitutionMap: SubstitutionMap; - /** Whether the focus should be delayed */ shouldDelayFocus?: boolean; - - /** Reference to the outer element */ ref?: ForwardedRef; } & Pick; diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index dfa2ab55100e..d09bf8829f5e 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -1,4 +1,4 @@ -import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {usePersonalDetails, useCardList, useWorkspaceCardList} from '@components/OnyxListItemProvider'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import BareUserListItem from '@components/SelectionList/ListItem/BareUserListItem'; @@ -8,6 +8,7 @@ import type {Section, SelectionListWithSectionsHandle} from '@components/Selecti import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; @@ -18,7 +19,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportAttributes from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSortedActions from '@hooks/useSortedActions'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; @@ -76,7 +77,6 @@ type SearchAutocompleteListProps = { /** Any extra sections that should be displayed in the router list. */ getAdditionalSections?: GetAdditionalSectionsCallback; - /** Callback to call when an item is clicked/selected */ onListItemPress: (item: OptionData | SearchQueryItem) => void; /** Whether to subscribe to KeyboardShortcut arrow keys events */ @@ -91,7 +91,6 @@ type SearchAutocompleteListProps = { /** Map of display values to actual IDs for filters (e.g. workspace name -> policy ID). Used to exclude by ID when multiple options share the same name. */ autocompleteSubstitutions?: SubstitutionMap; - /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -180,6 +179,7 @@ function SearchAutocompleteList({ }: SearchAutocompleteListProps) { const styles = useThemeStyles(); const {translate, localeCompare, formatPhoneNumber, dateFnsLocale} = useLocalize(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const contentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ addOfflineIndicatorBottomSafeAreaPadding: true, @@ -189,13 +189,20 @@ function SearchAutocompleteList({ const [betas] = useOnyx(ONYXKEYS.BETAS); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const reportAttributes = useReportAttributes(); + const cardList = useCardList(); + const workspaceCardList = useWorkspaceCardList(); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [recentSearches, recentSearchesMetadata] = useOnyx(ONYXKEYS.RECENT_SEARCHES); const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); + const [loginList] = useOnyx(ONYXKEYS.LOGINS, { + selector: expensifyLoginsSelector, + }); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const sortedActions = useSortedActions(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; + const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; + const lastActions = sortedReportActionsData?.lastActions; const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); @@ -203,6 +210,7 @@ function SearchAutocompleteList({ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; // hasEffectiveInputQuery reflects the immediate input (used to hide recent searches the moment the user types). @@ -217,7 +225,9 @@ function SearchAutocompleteList({ const currentUserAccountID = currentUserPersonalDetails.accountID; const expensifyIcons = useMemoizedLazyExpensifyIcons(['History', 'MagnifyingGlass']); const taxRates = useMemo(() => getAllTaxRates(policies), [policies]); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { + selector: isTrackIntentUserSelector, + }); const { options: listOptions, @@ -255,6 +265,8 @@ function SearchAutocompleteList({ } return getSearchOptions({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, options: listOptions, draftComments, betas: betas ?? [], @@ -274,10 +286,19 @@ function SearchAutocompleteList({ currentUserEmail, policyCollection: policies, personalDetails, + reportAttributesDerived: reportAttributes, sortedActions, + transactionThreadIDs, + lastActions, + currentUserLogin: currentUserEmail, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, conciergeReportID, isTrackIntentUser, translate, + rules, }).options; }, [ listOptions, @@ -291,11 +312,21 @@ function SearchAutocompleteList({ currentUserEmail, policies, personalDetails, + reportAttributes, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, conciergeReportID, isTrackIntentUser, translate, dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, ]); const [isInitialRender, setIsInitialRender] = useState(true); @@ -466,13 +497,17 @@ function SearchAutocompleteList({ // debounce below so they don't fire a server request on every keystroke. const hasUpstreamDebounce = inputQueryValue !== undefined; - const debounceHandleSearch = useDebounce(() => { - if (!handleSearch || !autocompleteQueryWithoutFilters) { - return; - } + const debounceHandleSearch = useDebounce( + () => { + if (!handleSearch || !autocompleteQueryWithoutFilters) { + return; + } - handleSearch(autocompleteQueryWithoutFilters); - }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + handleSearch(autocompleteQueryWithoutFilters); + }, + CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME, + {maxWait: CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME}, + ); useEffect(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { @@ -499,7 +534,10 @@ function SearchAutocompleteList({ }; if (searchQueryItems && searchQueryItems.length > 0) { - pushSection({data: searchQueryItems as AutocompleteListItem[], sectionIndex: sectionIndex++}); + pushSection({ + data: searchQueryItems as AutocompleteListItem[], + sectionIndex: sectionIndex++, + }); } const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); @@ -512,7 +550,11 @@ function SearchAutocompleteList({ } if (!hasEffectiveInputQuery && recentSearchesData && recentSearchesData.length > 0) { - pushSection({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); + pushSection({ + title: translate('search.recentSearches'), + data: recentSearchesData as AutocompleteListItem[], + sectionIndex: sectionIndex++, + }); } const nextStyledRecentReports = recentReportsOptions.map((option) => { @@ -541,7 +583,11 @@ function SearchAutocompleteList({ // No active (debounced) query yet: single "Recent chats" section. This also covers the debounce window // right after the user starts typing, so we keep recent chats visible instead of flashing search rows. if (!isLoadingOptions) { - pushSection({title: translate('search.recentChats'), data: nextStyledRecentReports, sectionIndex: sectionIndex++}); + pushSection({ + title: translate('search.recentChats'), + data: nextStyledRecentReports, + sectionIndex: sectionIndex++, + }); } else { pushSection({ title: translate('search.recentChats'), @@ -567,7 +613,11 @@ function SearchAutocompleteList({ localRows.sort((a, b) => (frozenLocalRank.get(getStableRankKey(a) ?? '') ?? 0) - (frozenLocalRank.get(getStableRankKey(b) ?? '') ?? 0)); if (localRows.length > 0 || !isLoadingOptions) { - pushSection({title: translate('search.recentChats'), data: localRows, sectionIndex: sectionIndex++}); + pushSection({ + title: translate('search.recentChats'), + data: localRows, + sectionIndex: sectionIndex++, + }); } else { // Options are still loading and no local results matched — show a skeleton so the // user sees feedback instead of a bare section header. @@ -610,10 +660,18 @@ function SearchAutocompleteList({ }; }); - pushSection({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); + pushSection({ + title: translate('search.suggestions'), + data: autocompleteData, + sectionIndex: sectionIndex++, + }); } - return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; + return { + sections: nextSections, + styledRecentReports: nextStyledRecentReports, + suggestionsCount: nextSuggestionsCount, + }; }, [ hasEffectiveInputQuery, hasActiveSearchResults, @@ -790,7 +848,9 @@ function SearchAutocompleteList({ }} shouldSingleExecuteRowSelect ref={setListRef} - initialScrollIndex={0} + // Index 0 pins the wide layout to the top, where `initiallyFocusedItemKey` resolves to a row below the + // "Recent searches" section. The narrow layout focuses no row, so it has no scroll target. + initialScrollIndex={shouldUseNarrowLayout ? undefined : 0} initiallyFocusedItemKey={!shouldUseNarrowLayout ? defaultFocusedKey : undefined} shouldHighlightInitiallyFocusedItem={!shouldUseNarrowLayout} shouldScrollToFocusedIndex={!isInitialRender} diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 3b615bac6a98..33544ce6bc74 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -104,10 +104,15 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { allReports, searchData, } = useSearchBulkActions({queryJSON}); - const currentSelectedPolicyID = selectedPolicyIDs?.at(0); const currentSelectedReportID = selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0); - const currentPolicy = usePolicy(currentSelectedPolicyID); const [selectedIOUReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentSelectedReportID}`); + const currentSelectedPolicyID = + selectedIOUReport?.policyID ?? + searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${currentSelectedReportID}`]?.policyID ?? + selectedReports.find((report) => report.reportID === currentSelectedReportID)?.policyID ?? + Object.values(selectedTransactions).find((transaction) => transaction.reportID === currentSelectedReportID)?.policyID ?? + selectedPolicyIDs?.at(0); + const currentPolicy = usePolicy(currentSelectedPolicyID); const isCurrentSelectedExpenseReport = isExpenseReport(currentSelectedReportID); const pendingPaymentAdditionalDataRef = useRef(undefined); @@ -168,21 +173,32 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { }); const allMatchingItemsCount = currentSearchResults?.search?.count; + // For expense-report searches `count` is the number of expenses, not reports, so it can't label the Reports tab. + // The matching-report total across all pages is server-provided. `selectedTransactions` only holds the loaded + // page, so it can't be derived locally. Read it from the snapshot here. + const allMatchingReportsCount = currentSearchResults?.search?.reportCount; + const relevantAllMatchingCount = isExpenseReportType ? allMatchingReportsCount : allMatchingItemsCount; const hasSearchErrors = Object.keys(currentSearchResults?.errors ?? {}).length > 0; // The server count is the only source for how many items "select all" covers, so keep the button loading until it // arrives. Offline or on error it never will, so fall back to the count of the items we do have selected. - const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof allMatchingItemsCount !== 'number' && !isOffline && !hasSearchErrors; + const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof relevantAllMatchingCount !== 'number' && !isOffline && !hasSearchErrors; // Excluded items only map onto the server count for expenses. For expense reports an excluded transaction doesn't - // necessarily drop its whole report from the results, so the server count is used as-is there. + // necessarily drop its whole report from the results, so the report count is used as-is there. let selectedAllMatchingItemsCount: number; - if (typeof allMatchingItemsCount !== 'number') { + if (isExpenseReportType) { + // Show the matching-report total once it lands. Before then, or while offline, fall back to the loaded-page report count. + selectedAllMatchingItemsCount = typeof allMatchingReportsCount === 'number' ? allMatchingReportsCount : selectedItemsCount; + } else if (typeof allMatchingItemsCount !== 'number') { selectedAllMatchingItemsCount = selectedItemsCount; } else { selectedAllMatchingItemsCount = isExpenseType ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : allMatchingItemsCount; } - const selectionButtonText = translate('workspace.common.selected', { - count: areAllMatchingItemsSelected ? selectedAllMatchingItemsCount : selectedItemsCount, - }); + const shouldShowAllMatchingItemsSelected = isExpenseType && areAllMatchingItemsSelected && Object.keys(excludedTransactions).length === 0; + const selectionButtonText = shouldShowAllMatchingItemsSelected + ? translate('search.exportAll.allMatchingItemsSelected') + : translate('workspace.common.selected', { + count: areAllMatchingItemsSelected ? selectedAllMatchingItemsCount : selectedItemsCount, + }); return ( <> diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index f5df6f65b5c4..6ca93fcfe79f 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -21,7 +21,6 @@ import SearchLineChart from './SearchLineChart'; import SearchPieChart from './SearchPieChart'; type SearchChartViewProps = { - /** The current search query JSON */ queryJSON: Readonly; /** The view type (bar, etc.) */ @@ -33,7 +32,6 @@ type SearchChartViewProps = { /** Grouped transaction data from search results */ data: GroupedItem[]; - /** Whether data is loading */ isLoading?: boolean; }; diff --git a/src/components/Search/SearchColumnWidthsContext.tsx b/src/components/Search/SearchColumnWidthsContext.tsx new file mode 100644 index 000000000000..5b7de3696c51 --- /dev/null +++ b/src/components/Search/SearchColumnWidthsContext.tsx @@ -0,0 +1,109 @@ +import useStyleUtils from '@hooks/useStyleUtils'; + +import type {GetReportTableColumnStylesParams} from '@styles/utils'; + +import type {ViewStyle} from 'react-native'; + +import React, {createContext, useContext} from 'react'; + +import type {SearchColumnSizing} from './hooks/useSearchColumnWidths'; +import type {SearchColumnType} from './types'; + +/** + * How the Search table's dynamically sized columns share its free space. Columns absent from this map keep the flex or + * fixed width they are styled with, which is also the whole map's state when dynamic sizing doesn't apply. + */ +type SearchColumnWidths = Partial>; + +type SearchColumnSizingState = { + /** What each measured column was sized to. */ + columnWidths: SearchColumnWidths; + + /** + * The columns the sized table lays out, which is not every column rendered underneath it: a grouped table's rows can + * expand to show their transactions, and those inner rows have columns of their own that this sizing says nothing + * about. They are left exactly as they are styled. + */ + sizedColumns: Set; + + /** + * Which columns take their wider variant across the whole table. A row that renders one of these columns without + * saying which variant it wants gets the table's answer, rather than defaulting to the narrow one and standing a + * column short of the heading above it. + */ + columnOptions: GetReportTableColumnStylesParams; +}; + +/** + * Defaults to sizing nothing, because the components that read this are shared well beyond the Search table: the same + * row and heading render in the Money Request report, the unreported expense list, and the duplicate and merge + * transaction pages, none of which mount the provider. Those keep the widths they are styled with. + */ +const SearchColumnWidthsContext = createContext({columnWidths: {}, sizedColumns: new Set(), columnOptions: {}}); + +function SearchColumnWidthsProvider({columnWidths, sizedColumns, columnOptions, children}: React.PropsWithChildren) { + return {children}; +} + +/** + * Returns the Search table's column style function, with any measured width applied on top. + * + * The header and every row resolve their columns through this, so a column is measured in both or in neither and the + * two stay aligned. + */ +function useSearchColumnStyles(): (columnName: SearchColumnType, options?: GetReportTableColumnStylesParams) => ViewStyle { + const StyleUtils = useStyleUtils(); + const {columnWidths, sizedColumns, columnOptions} = useContext(SearchColumnWidthsContext); + + const isSizingColumns = Object.keys(columnWidths).length > 0; + + return (columnName, options = {}) => { + // Only for the table's own columns, and only where the caller didn't answer: an inner table rendered below this + // one sizes its columns from its own rows, and a caller that names a variant knows something this doesn't. + // + // Only while this table is being sized, too. The table-wide variants exist to stop a sized row and its heading + // disagreeing about a width, so with nothing sized they answer a question nobody asked - and answering it does + // harm. These variants are decided across the whole search, while a group expanded inside it decides its own + // from its own transactions and passes them to its heading and its rows. Filling the gaps from the search-wide + // set leaves a row on one answer and its heading on the other, which slides every column after it sideways. + const columnStyles = StyleUtils.getReportTableColumnStyles(columnName, isSizingColumns && sizedColumns.has(columnName) ? {...columnOptions, ...options} : options); + const sizing = columnWidths[columnName]; + + if (!sizing) { + // `flex: 1` expands to a zero basis, which discards any width declared beside it: the column starts from + // nothing and shares the leftover space, ending up neither its declared width nor what its content needs. + // That declared width is what it was sized to fit, so pin it there exactly. Not narrower, since a truncated + // amount reads as a different amount rather than as a truncation. Not wider either, since the spare room + // belongs to the free-text columns. Same reasoning as the existing `shouldRemoveTotalColumnFlex`. + // + // Only for a column of the sized table itself. A column belonging to some inner table rendered below it was + // never part of what was measured, so pinning it would hold it to a width chosen for a different layout. + if (isSizingColumns && sizedColumns.has(columnName) && typeof columnStyles.width === 'number' && columnStyles.flex !== undefined) { + return {...columnStyles, flex: undefined, flexGrow: 0, flexShrink: 0, flexBasis: columnStyles.width, minWidth: columnStyles.width}; + } + + return columnStyles; + } + + // A hugging column is sized to its content and pinned there, the same way a column with a declared width is. + if (sizing.shouldHug) { + return {...columnStyles, flex: undefined, flexGrow: 0, flexShrink: 0, flexBasis: sizing.contentWidth, minWidth: sizing.contentWidth, width: undefined}; + } + + // The resolved width is the column's starting size, not its final one: it still grows and shrinks from there. + // + // That is what the model already asks for. A column settled at its content width is settled by the resolver and + // reaches this with nothing left to share, while the columns splitting the remainder were each given an equal + // slice of it - so growing them all by one unit keeps that slice equal and simply hands out whatever the budget + // was short by. Pinning them instead would leave that difference as a band of empty space at the end of the + // row, which is what the rows' own padding and chrome, spread across several differently shaped row + // components, make impossible to predict to the pixel from here. + // + // Shrinking is the same argument in the other direction, floored at the width below which the column stops + // being readable. `flex` is cleared because the base style sets it on exactly these columns, and leaving both + // it and the properties below would make which one wins depend on emission order. + return {...columnStyles, flex: undefined, flexGrow: 1, flexShrink: 1, flexBasis: sizing.width ?? 0, minWidth: sizing.minWidth, width: undefined}; + }; +} + +export {SearchColumnWidthsProvider, useSearchColumnStyles}; diff --git a/src/components/Search/SearchContextDefinitions.ts b/src/components/Search/SearchContextDefinitions.ts index e723e31dc141..2c358f8ed8b3 100644 --- a/src/components/Search/SearchContextDefinitions.ts +++ b/src/components/Search/SearchContextDefinitions.ts @@ -5,6 +5,7 @@ import CONST from '@src/CONST'; import React from 'react'; import type { + QueryFilterKey, SearchQueryActionsValue, SearchQueryContextValue, SearchResultsActionsValue, @@ -23,12 +24,16 @@ const defaultSearchQueryContext: SearchQueryContextValue = { currentSimilarSearchHash: -1, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), suggestedSearches: {} as Record, shouldResetSearchQuery: false, }; const defaultSearchQueryActions: SearchQueryActionsValue = { setShouldResetSearchQuery: () => {}, + setCurrentSearchKey: () => {}, + resetSearchKey: () => {}, }; const EMPTY_TRANSACTIONS_BY_REPORT_ID: SearchResultsContextValue['currentSearchTransactionsByReportID'] = new Map(); diff --git a/src/components/Search/SearchFilterPageFooterButtons.tsx b/src/components/Search/SearchFilterPageFooterButtons.tsx index 4df6e1531af6..6721b4ffe0a8 100644 --- a/src/components/Search/SearchFilterPageFooterButtons.tsx +++ b/src/components/Search/SearchFilterPageFooterButtons.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index a726f5faffa9..c18b7a1719df 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -23,10 +23,7 @@ type BaseSearchListProps = Pick< | 'stickyHeaderConfig' | 'overrideItemLayout' > & { - /** The data to display in the list */ data: SearchListItem[]; - - /** The function to render each item in the list */ renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element; /** The columns that might change to trigger re-render via extraData */ @@ -35,16 +32,12 @@ type BaseSearchListProps = Pick< /** The transactions that might trigger re-render via extraData */ newTransactions: Transaction[]; - /** The length of the flattened items in the list */ flattenedItemsLength: number; /** The callback, which is run when a row is pressed */ onSelectRow: (item: SearchListItem) => void; - /** The ref to the list */ ref: RefObject | null>; - - /** The function to scroll to an index */ scrollToIndex?: (index: number, animated?: boolean) => void; /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */ diff --git a/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx index a44547cc35a6..5bde795ce7c3 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index cae0bcc3191a..ea05c23ec7ae 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -52,6 +52,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined; const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID); @@ -143,6 +144,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab chatReportActions, delegateAccountID, isTrackIntentUser, + rules, }); return; } @@ -180,6 +182,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab delegateAccountID, isTrackIntentUser, conciergeChat, + rules, }); }; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/index.tsx b/src/components/Search/SearchList/ListItem/ActionCell/index.tsx index 3ecfdb1a25b9..84e4c49a6caf 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/index.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/index.tsx @@ -1,5 +1,5 @@ -import Button from '@components/ButtonComposed'; -import LinkButton from '@components/ButtonComposed/composed/LinkButton'; +import Button from '@components/Button'; +import LinkButton from '@components/Button/composed/LinkButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index b11cf330ece4..5f1fc3611a3e 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -52,7 +52,6 @@ type ColumnStyleKey = | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER; type BaseListItemHeaderProps = { - /** The group item being rendered */ item: BaseGroupListItemType; /** The display name to show for this group */ @@ -64,7 +63,6 @@ type BaseListItemHeaderProps = { /** The column style key for sizing (e.g., CATEGORY, MERCHANT) */ columnStyleKey: ColumnStyleKey; - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; /** Whether this section items disabled for selection */ @@ -79,7 +77,6 @@ type BaseListItemHeaderProps = { /** Whether only some transactions are selected */ isIndeterminate?: boolean; - /** Callback for when the down arrow is clicked */ onDownArrowClick?: () => void; /** Whether the down arrow is expanded */ diff --git a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx index 3f1be480b122..7b8ce7c47b66 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -26,16 +26,12 @@ import TextCell from './TextCell'; import TotalCell from './TotalCell'; type CardListItemHeaderProps = { - /** The card currently being looked at */ card: TransactionCardGroupListItemType; - - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; - /** Whether the item is focused */ isFocused?: boolean; /** Whether selecting multiple transactions at once is allowed */ @@ -47,7 +43,6 @@ type CardListItemHeaderProps = { /** Whether only some transactions are selected */ isIndeterminate?: boolean; - /** Callback for when the down arrow is clicked */ onDownArrowClick?: () => void; /** Whether the down arrow is expanded */ diff --git a/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx index 2cc316c4b31c..be234954cac6 100644 --- a/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx @@ -12,7 +12,6 @@ import type {TransactionCategoryGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type CategoryListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The category currently being looked at */ category: TransactionCategoryGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index c8e98b61be6f..3475ae313640 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -115,6 +115,7 @@ function ExpenseReportListItemInner({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); const currentUserDetails = useCurrentUserPersonalDetails(); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Fetch live policy categories from Onyx to sync violations at render time const [parentPolicy] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(reportItem.policyID)}`); @@ -126,6 +127,7 @@ function ExpenseReportListItemInner({ policy: parentPolicy, report: parentReport, isTrackIntentUser, + rules, }); const searchData = currentSearchResults?.data; @@ -307,6 +309,7 @@ function ExpenseReportListItemInner({ // whole TRANSACTION_VIOLATIONS collection, so the Approve action reads live data without re-rendering // every row on unrelated violation changes. allViolations: liveViolationsForSnapshotTransactions, + rules, conciergeChat, }); }, [ @@ -350,6 +353,7 @@ function ExpenseReportListItemInner({ delegateAccountID, isTrackIntentUser, liveViolationsForSnapshotTransactions, + rules, conciergeChat, shouldShowMarkAsDoneCopy, ]); diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx index dcdfece02e35..fc6e0dac7211 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -21,7 +21,7 @@ function ExpenseReportListItemRowNarrow({item, onCheckboxPress = () => {}, canSe const {amount: amountText, date: formattedDate, expenseCountText} = getExpenseReportRowDisplayValues(item, {translate, dateFnsLocale, convertToDisplayString}); return ( - + {!!canSelectMultiple && ( ), [CONST.SEARCH.TABLE_COLUMNS.DATE]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.SUBMITTED]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.APPROVED]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: ( - + {!!item.firstApproverAccountID && ( ), [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.PAID_BY]: ( - + {!!item.paidByAccountID && ( ), [CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.STATUS]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.PAID_STATUS]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.TITLE]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.FROM]: ( - + {!!item.from && ( ), [CONST.SEARCH.TABLE_COLUMNS.TO]: ( - + {!!item.to && ( ), [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE_TOTAL]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.TOTAL]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.AMOUNT_DEBITED]: ( - + {!!debitedAmount && !!debitedCurrency && ( ), [CONST.SEARCH.TABLE_COLUMNS.AMOUNT_REIMBURSED]: ( - + {!!creditedAmount && !!creditedCurrency && ( ), [CONST.SEARCH.TABLE_COLUMNS.REPORT_ID]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.ACTION]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME]: ( - + !!icon); + const integrationIconStyle = StyleUtils.getAvatarBorderStyle(CONST.AVATAR_SIZE.XXX_SMALL, CONST.AVATAR_SHAPE.CIRCLE); return ( @@ -115,14 +106,83 @@ function ExportedIconCell({reportActions}: ExportedIconCellProps) { size={CONST.ICON_SIZE.SMALL} /> )} - {integrationIcons.map(({name, src}) => ( + {isExportedToNetsuite && ( - ))} + )} + {isExportedToXero && ( + + )} + {isExportedToIntacct && ( + + )} + {(isExportedToQuickbooksOnline || isExportedToQuickbooksDesktop) && ( + + )} + {isExportedToIntuitEnterpriseSuite && ( + + )} + {isExportedToCertinia && ( + + )} + {isExportedToRillet && ( + + )} + {isExportedToDualEntry && ( + + )} + {isExportedToCampfire && ( + + )} + {isExportedToBillCom && ( + + )} + {isExportedToZenefits && ( + + )} ); } diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 4d6b97365f37..052ea50924c4 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; @@ -361,7 +360,7 @@ function GroupHeader({ disabled={isDisabled && !isItemSelected} sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_GROUP_LIST_ITEM} accessibilityLabel={item.text ?? ''} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} isNested hoverStyle={[!isExpanded && !item.isDisabled && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} diff --git a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx index 6b16c65d9c60..da2b4cfa1a76 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -23,10 +23,7 @@ import TextCell from './TextCell'; import TotalCell from './TotalCell'; type MemberListItemHeaderProps = { - /** The member currently being looked at */ member: TransactionMemberGroupListItemType; - - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; /** Whether this section items disabled for selection */ @@ -41,7 +38,6 @@ type MemberListItemHeaderProps = { /** Whether only some transactions are selected */ isIndeterminate?: boolean; - /** Callback for when the down arrow is clicked */ onDownArrowClick?: () => void; /** Whether the down arrow is expanded */ @@ -50,7 +46,6 @@ type MemberListItemHeaderProps = { /** The visible columns for the header */ columns?: SearchColumnType[]; - /** Whether the screen is large */ isLargeScreenWidth?: boolean; }; diff --git a/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx index 130aab246418..af7550055fa4 100644 --- a/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx @@ -10,7 +10,6 @@ import type {TransactionMerchantGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type MerchantListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The merchant currently being looked at */ merchant: TransactionMerchantGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx index 2b6a03131f20..0ee4a9944f86 100644 --- a/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx @@ -10,7 +10,6 @@ import type {TransactionMonthGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type MonthListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The month group currently being looked at */ month: TransactionMonthGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx b/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx index e8f553757da7..d537a929d167 100644 --- a/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx @@ -10,7 +10,6 @@ import type {TransactionQuarterGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type QuarterListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The quarter group currently being looked at */ quarter: TransactionQuarterGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index d33277694099..7b4026a58234 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -55,19 +55,13 @@ import TotalCell from './TotalCell'; import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow'; type ReportListItemHeaderProps = SearchListActionProps & { - /** The report currently being looked at */ report: TransactionReportGroupListItemType; - - /** Callback to fire when the item is pressed */ onSelectRow: (item: TItem, event?: ModifiedMouseEvent) => void; - - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; - /** Whether the item is focused */ isFocused?: boolean; /** Whether selecting multiple transactions at once is allowed */ @@ -79,21 +73,16 @@ type ReportListItemHeaderProps = SearchListActionProps & /** Whether only some transactions are selected */ isIndeterminate?: boolean; - /** Callback for when the down arrow is clicked */ onDownArrowClick?: () => void; /** Whether the down arrow is expanded */ isExpanded?: boolean; - /** Whether the item is hovered */ isHovered?: boolean; }; type FirstRowReportHeaderProps = { - /** The report currently being looked at */ report: TransactionReportGroupListItemType; - - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; /** Whether this section items disabled for selection */ @@ -114,7 +103,6 @@ type FirstRowReportHeaderProps = { /** Whether only some transactions are selected */ isIndeterminate?: boolean; - /** Callback for when the down arrow is clicked */ onDownArrowClick?: () => void; /** Whether the down arrow is expanded */ @@ -289,10 +277,12 @@ function ReportListItemHeaderInner({ `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(chatReport?.reportID ?? snapshotReport?.chatReportID ?? snapshotReport.parentReportID)}`, ); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy: parentPolicy, report: parentReport, isTrackIntentUser, + rules, }); const reportTransactionIDs = (reportItem.transactions ?? []).map((transaction) => transaction.transactionID); @@ -351,6 +341,7 @@ function ReportListItemHeaderInner({ delegateAccountID, isTrackIntentUser, allViolations, + rules, conciergeChat, }); }; diff --git a/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx new file mode 100644 index 000000000000..12b3fe29f425 --- /dev/null +++ b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx @@ -0,0 +1,92 @@ +import RadioButton from '@components/RadioButton'; +import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; +import type {ListItemProps} from '@components/SelectionList/ListItem/types'; +import type {ListItem} from '@components/SelectionList/types'; + +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {ExpenseReportListItemType} from './types'; + +import ExpenseReportListItemRowNarrow from './ExpenseReportListItemRow/ExpenseReportListItemRowNarrow'; +import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow'; + +function SearchMergeReportsListItem({item, isFocused, showTooltip, onSelectRow, onFocus, shouldSyncFocus, isLastItem, isFirstItem}: ListItemProps) { + const reportItem = item as unknown as ExpenseReportListItemType; + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const isSelected = item.isSelected ?? false; + + const listItemPressableStyle = [ + styles.selectionListPressableItemWrapper, + isSelected && styles.activeComponentBG, + styles.mh0, + styles.noBorderRadius, + isFirstItem && [styles.tableTopRadius, styles.overflowHidden], + isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]; + + const listItemWrapperStyle = [styles.flex1, styles.userSelectNone, styles.flexColumn, styles.alignItemsStretch]; + + const selectRow = () => { + onSelectRow(item); + }; + + return ( + + {() => ( + + + + + + + + )} + + ); +} + +export default SearchMergeReportsListItem; diff --git a/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx b/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx index 90199442339d..5d07dceeff40 100644 --- a/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx @@ -10,7 +10,6 @@ import type {TransactionTagGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type TagListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The tag currently being looked at */ tag: TransactionTagGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx b/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx index f8bb61821447..1ad6d2e91157 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx @@ -1,6 +1,6 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {useSession} from '@components/OnyxListItemProvider'; import TextWithTooltip from '@components/TextWithTooltip'; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index cffca4b65636..1b0f426bf33f 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import LinkButton from '@components/ButtonComposed/composed/LinkButton'; +import LinkButton from '@components/Button/composed/LinkButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 7e0c97c122a9..1f57088073b1 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -1,5 +1,4 @@ import AnimatedCollapsible from '@components/AnimatedCollapsible'; -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; import {useSearchResultsContext} from '@components/Search/SearchContext'; @@ -202,6 +201,10 @@ function TransactionGroupListItemImpl({ paddingVertical: variables.tableGroupRowPaddingVertical, ...(isLastItem ? styles.tableBottomRadius : {}), }, + // A selected row paints an opaque background here, on top of the rounded wrapper below, so the outer + // corners have to be rounded on this element too or the list's top/bottom corners look square. + !isLargeScreenWidth && isFirstItem && styles.tableTopRadius, + isLastItem && styles.tableBottomRadius, isItemSelected && styles.activeComponentBG, ]; const pressableRef = useRef(null); @@ -517,7 +520,7 @@ function TransactionGroupListItemImpl({ disabled={isDisabled && !isItemSelected} sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_GROUP_LIST_ITEM} accessibilityLabel={item.text ?? ''} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} isNested hoverStyle={[!isExpanded && !item.isDisabled && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} @@ -534,11 +537,7 @@ function TransactionGroupListItemImpl({ styles.userSelectNone, isLargeScreenWidth ? [StyleUtils.getSearchTableGroupRowBorderStyle(isFirstItem, isLastItem, isItemSelected), isLastItem && styles.overflowHidden] - : [ - isFirstItem && [styles.tableTopRadius, styles.overflowHidden], - isLastItem && [styles.tableBottomRadius, styles.overflowHidden], - !isLastItem && StyleUtils.getSelectedBorderBottomStyle(isItemSelected), - ], + : [isFirstItem && styles.tableTopRadius, isLastItem && styles.tableBottomRadius, !isLastItem && StyleUtils.getSelectedBorderBottomStyle(isItemSelected)], ]} > {({hovered}) => ( diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index 1cf050060a45..697eed254b32 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; @@ -128,7 +127,7 @@ function TransactionListItemNarrow({ onPress={handleOnPress} disabled={isDisabled && !isSelected} accessibilityLabel={item.text ?? ''} - role={!isDeletedTransaction ? getButtonRole(true) : 'none'} + role={!isDeletedTransaction ? CONST.ROLE.BUTTON : 'none'} isNested hoverStyle={[!item.isDisabled && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index cde862f91b96..d94d0c3d3cf4 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; @@ -160,7 +159,7 @@ function TransactionListItemWide({ onPress={handleOnPress} disabled={isDisabled && !isSelected} accessibilityLabel={item.text ?? ''} - role={!isDeletedTransaction ? getButtonRole(true) : 'none'} + role={!isDeletedTransaction ? CONST.ROLE.BUTTON : 'none'} isNested onMouseDown={handleOnMouseDown} onHoverIn={handleOnHoverIn} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index a42303f8f7ba..027c89231056 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -153,6 +153,7 @@ function TransactionListItemInner({ chatReportPolicyID: chatReport?.policyID, }); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const liveTransactionItem = useLiveRowCapabilities({ item: transactionItem, @@ -180,6 +181,7 @@ function TransactionListItemInner({ policy: rowPolicy ?? liveTransactionItem.policy, report: liveTransactionItem.report, isTrackIntentUser, + rules, }); const onyxViolations = (transactionViolationsForRow ?? []).filter( @@ -250,6 +252,7 @@ function TransactionListItemInner({ delegateAccountID, isTrackIntentUser, allViolations, + rules, conciergeChat, }); }; diff --git a/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx index 333ecc406abb..0495556bd3f3 100644 --- a/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx @@ -10,7 +10,6 @@ import type {TransactionWeekGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type WeekListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The week group currently being looked at */ week: TransactionWeekGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index 648cef0226e4..33b110d19a06 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -33,10 +33,7 @@ import TextCell from './TextCell'; import TotalCell from './TotalCell'; type WithdrawalIDListItemHeaderProps = { - /** The withdrawal ID currently being looked at */ withdrawalID: TransactionWithdrawalIDGroupListItemType; - - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; /** Whether this section items disabled for selection */ @@ -51,7 +48,6 @@ type WithdrawalIDListItemHeaderProps = { /** Whether only some transactions are selected */ isIndeterminate?: boolean; - /** Callback for when the down arrow is clicked */ onDownArrowClick?: () => void; /** Whether the down arrow is expanded */ diff --git a/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx b/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx index 65fbf9b0e0ce..48ddbe289ebd 100644 --- a/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx @@ -10,7 +10,6 @@ import type {TransactionYearGroupListItemType} from './types'; import BaseListItemHeader from './BaseListItemHeader'; type YearListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { - /** The year group currently being looked at */ year: TransactionYearGroupListItemType; }; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index d372f7447df1..be2a1a230185 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -45,7 +45,6 @@ import type {ValueOf} from 'type-fest'; type SearchListActionProps = { /** The last payment method used per policy */ lastPaymentMethod?: OnyxEntry; - /** The user's personal policy ID */ personalPolicyID?: string; /** Billing grace period end dates for workspace owners (shared across all list items) */ userBillingGracePeriodEnds?: OnyxCollection; @@ -54,7 +53,6 @@ type SearchListActionProps = { }; type ChatListItemProps = ListItemProps & { - /** The report data */ report?: Report; }; @@ -210,7 +208,6 @@ type TransactionGroupListItemType = ListItem & { /** Whether the report has a single transaction */ isOneTransactionReport?: boolean; - /** The hash of the query to get the transactions data */ transactionsQueryJSON?: SearchQueryJSON; /** Whether the report has visible violations for user */ @@ -256,7 +253,6 @@ type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupe /** The date of the report's first approval (created date of the earliest APPROVED/FORWARDED report action) */ firstApproved?: string; - /** The avatar of the first approver */ firstApproverAvatar?: AvatarSource; /** Account ID of the first approver (actor on the earliest APPROVED/FORWARDED report action) */ @@ -274,7 +270,6 @@ type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupe /** Final and formatted "paid by" value used for displaying and sorting */ formattedPaidBy?: string; - /** Whether the status field should be shown in a pending state */ shouldShowStatusAsPending?: boolean; /** @@ -351,7 +346,6 @@ type TaskListItemProps = ListItemProps & { /** Whether the item's action is loading */ isLoading?: boolean; - /** All the data of the report collection */ allReports?: OnyxCollection; }; @@ -402,7 +396,6 @@ type ReportActionListItemType = ListItem & /** Key used internally by React */ keyForList: string; - /** The name of the report */ reportName: string; }; @@ -424,13 +417,11 @@ type TransactionMemberGroupListItemType = TransactionGroupListItemType & {groupe }; type TransactionMonthGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.MONTH} & SearchMonthGroup & { - /** Final and formatted "month" value used for displaying */ formattedMonth: string; /** Compact "month" value used where space is tight, e.g. chart axis labels */ shortFormattedMonth: string; - /** Key used for sorting */ sortKey: number; }; @@ -458,7 +449,6 @@ type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy }; type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.WEEK} & SearchWeekGroup & { - /** Final and formatted "week" value used for displaying */ formattedWeek: string; /** Compact "week" value used where space is tight, e.g. chart axis labels */ @@ -466,21 +456,16 @@ type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedB }; type TransactionYearGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.YEAR} & SearchYearGroup & { - /** Final and formatted "year" value used for displaying */ formattedYear: string; - - /** Key used for sorting */ sortKey: number; }; type TransactionQuarterGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.QUARTER} & SearchQuarterGroup & { - /** Final and formatted "quarter" value used for displaying */ formattedQuarter: string; /** Compact "quarter" value used where space is tight, e.g. chart axis labels */ shortFormattedQuarter: string; - /** Sort key for sorting */ sortKey: number; }; @@ -493,7 +478,6 @@ type TransactionListItemProps = ListItemProps & nonPersonalAndWorkspaceCards?: CardList; /** All policies' tag lists, drilled from the list level so each row can resolve its policy's tags without an Onyx subscription per row */ policyTags?: OnyxCollection; - /** Callback to undelete a transaction */ onUndelete?: (transaction: Transaction) => void; }; @@ -506,7 +490,6 @@ type TransactionGroupListItemProps = ListItemProps void; }; diff --git a/src/components/Search/SearchList/ListItem/useLiveRowCapabilities.ts b/src/components/Search/SearchList/ListItem/useLiveRowCapabilities.ts index 762c6d7862ca..5e33da641b73 100644 --- a/src/components/Search/SearchList/ListItem/useLiveRowCapabilities.ts +++ b/src/components/Search/SearchList/ListItem/useLiveRowCapabilities.ts @@ -54,6 +54,7 @@ function useLiveRowCapabilities(params: UseLiveRowCapabil const [liveReportActions] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`); const [liveReportMetadata] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(reportID)}`); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); if (!enabled || !snapshotData) { return item; @@ -70,6 +71,7 @@ function useLiveRowCapabilities(params: UseLiveRowCapabil currentUserDetails.accountID ?? CONST.DEFAULT_NUMBER_ID, bankAccountList, liveReportMetadata, + rules, liveActionsArray, transactionsForReport, ); diff --git a/src/components/Search/SearchListViewLayout.tsx b/src/components/Search/SearchListViewLayout.tsx index cce9d9b5c4bb..4636b829d904 100644 --- a/src/components/Search/SearchListViewLayout.tsx +++ b/src/components/Search/SearchListViewLayout.tsx @@ -1,13 +1,22 @@ +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {GetReportTableColumnStylesParams} from '@styles/utils'; -import React from 'react'; +import type {CardList, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; + +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; + +import React, {useState} from 'react'; import {View} from 'react-native'; +import type {SearchListItem} from './SearchList/ListItem/types'; import type {SearchColumnType, SearchQueryJSON} from './types'; +import useSearchColumnWidths from './hooks/useSearchColumnWidths'; import HorizontalTableScroll from './primitives/HorizontalTableScroll'; +import {SearchColumnWidthsProvider} from './SearchColumnWidthsContext'; type SearchListViewLayoutProps = { /** Columns rendered in the table (drives the min-width for horizontal scroll). */ @@ -19,6 +28,9 @@ type SearchListViewLayoutProps = { /** Whether the action column uses its wider variant. */ isActionColumnWide: boolean; + /** Which columns render in their wider variant, decided across the whole search and shared with the column header. */ + columnSizeOptions: GetReportTableColumnStylesParams; + /** Whether a column header is present (gates horizontal scroll). */ isHeaderVisible: boolean; @@ -31,9 +43,20 @@ type SearchListViewLayoutProps = { /** The bottom safe-area padding style applied when the keyboard is hidden. */ safeAreaPaddingBottomStyle: StyleProp; - /** Outer container style for the list wrapper. */ containerStyle: StyleProp; + /** The rendered transactions, so the free-text columns can be sized from them. Left out, columns keep their widths. */ + data?: SearchListItem[]; + + /** The viewer's non-personal and workspace cards, so the card column can be sized from the names it renders. */ + nonPersonalAndWorkspaceCards?: CardList; + + /** Every policy's categories, so the category GL code column can be sized from the codes it renders. */ + policyCategories?: OnyxCollection; + + /** Every policy's tag lists, so the tag GL code column can be sized from the codes it renders. */ + policyTags?: OnyxCollection; + /** The list and any header/modal blocks, composed by the view (e.g. SelectionTopBar, BaseSearchList, long-press menu). */ children: React.ReactNode; }; @@ -47,25 +70,102 @@ function SearchListViewLayout({ columns, type, isActionColumnWide, + columnSizeOptions, isHeaderVisible, dataKey, isKeyboardShown, safeAreaPaddingBottomStyle, containerStyle, + data, + nonPersonalAndWorkspaceCards, + policyCategories, + policyTags, children, }: SearchListViewLayoutProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const [tableWidth, setTableWidth] = useState(0); + + const handleTableLayout = (event: LayoutChangeEvent) => { + setTableWidth(event.nativeEvent.layout.width); + }; + + const columnWidths = useSearchColumnWidths({ + columns, + data: data ?? [], + tableWidth, + // A header is only rendered in the table layout. Narrow layouts render rows as cards with no columns to size. + isEnabled: isHeaderVisible && !!data, + columnSizeOptions, + measurementContext: {nonPersonalAndWorkspaceCards, policyCategories, policyTags}, + }); + + // The scroller decides whether to scroll by summing what each column refuses to shrink below, which it otherwise has + // to estimate. Every minimum it can be told exactly is resolved here instead: a dynamic column measured its own, and + // any other column that declares a width is never laid out narrower than it. The estimates run several columns 70px + // or more over their real width, so a table left on them reserves room it never uses, scrolls before it has run out + // of space, and then stretches its columns across the width it wrongly claimed. + // + // Only for a table that is being sized. Correcting the scroller's arithmetic changes how wide the table lays out, + // and a view that isn't sized has rows built for the widths it already had - a grouped view nests a whole table of + // its own inside its rows, tuned against the old numbers. Handing those views a better answer to a question this + // branch isn't yet answering for them moves their columns for no gain. + const isSizingColumns = Object.keys(columnWidths).length > 0; + + const columnMinWidths: Partial> = {}; + + // The columns this table lays out, so the sizing below is applied to those and to nothing else that renders under it. + const sizedColumns = new Set(columns); + + // What each column would rather have than its minimum. Only the dynamic columns differ between the two: everything + // else is pinned to one width, so it wants exactly what it is never squeezed below. + const columnContentWidths: Partial> = {}; + + for (const column of columns) { + const sizing = columnWidths[column]; + + if (sizing) { + columnMinWidths[column] = sizing.minWidth; + columnContentWidths[column] = sizing.contentWidth; + continue; + } + + const declaredWidth = StyleUtils.getReportTableColumnStyles(column, columnSizeOptions).width; + + // A column styled with flex alone declares no width, so the scroller keeps estimating that one. + if (typeof declaredWidth === 'number') { + columnMinWidths[column] = declaredWidth; + columnContentWidths[column] = declaredWidth; + } + } return ( - - {children} - + + + {children} + + + ); } diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index ec55d3cbbeb7..a10b046ded9c 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx index 1cb67da2f538..3acc5f7abfba 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; @@ -72,6 +72,7 @@ function SearchActionsBarCreateButton() { 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); @@ -89,6 +90,7 @@ function SearchActionsBarCreateButton() { allBetas, isTrackIntentUser, getCurrencyDecimals, + rules, false, shouldDismissEmptyReportsConfirmation, ); @@ -100,7 +102,7 @@ function SearchActionsBarCreateButton() { ); }); }, - [currentUserPersonalDetails, hasViolations, defaultChatEnabledPolicy, isASAPSubmitBetaEnabled, allBetas, isTrackIntentUser, getCurrencyDecimals], + [currentUserPersonalDetails, hasViolations, defaultChatEnabledPolicy, isASAPSubmitBetaEnabled, allBetas, isTrackIntentUser, getCurrencyDecimals, rules], ); const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({ diff --git a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx index dfe004f699ca..3315b1b0c135 100644 --- a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import FilterPopupButton from '@components/Search/FilterDropdowns/FilterPopupButton'; diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx index 89ded4f2cc97..5760b08daadb 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx @@ -12,7 +12,7 @@ import {FlatList} from 'react-native'; import type {FilterItem} from './useSearchFiltersBar'; import SearchFilterBar from './SearchFilterBar'; -import SearchFiltersClearButton from './SearchFiltersClearButton'; +import SearchFiltersResetButton from './SearchFiltersResetButton'; import useSearchFiltersBar from './useSearchFiltersBar'; type SearchFiltersBarNarrowProps = { @@ -22,7 +22,7 @@ type SearchFiltersBarNarrowProps = { function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { const styles = useThemeStyles(); const scrollRef = useRef>(null); - const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON); + const {filters, hasErrors, shouldShowFiltersBarLoading, shouldShowResetFilters, resetFilters} = useSearchFiltersBar(queryJSON); const adjustScroll = (info: {distanceFromEnd: number}) => { // Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504): @@ -59,7 +59,7 @@ function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { renderItem={renderFilterItem} onEndReached={adjustScroll} onEndReachedThreshold={0.75} - ListFooterComponent={filters.length > 0 ? : undefined} + ListFooterComponent={shouldShowResetFilters ? : undefined} /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx index cc54717a739c..34179ef8c480 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx @@ -4,7 +4,7 @@ import SearchFiltersSkeleton from '@components/Skeletons/SearchFiltersSkeleton'; import React from 'react'; import SearchFilterBar from './SearchFilterBar'; -import SearchFiltersClearButton from './SearchFiltersClearButton'; +import SearchFiltersResetButton from './SearchFiltersResetButton'; import useSearchFiltersBar from './useSearchFiltersBar'; type SearchFiltersBarWideProps = { @@ -12,7 +12,7 @@ type SearchFiltersBarWideProps = { }; function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) { - const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON); + const {filters, hasErrors, shouldShowFiltersBarLoading, shouldShowResetFilters, resetFilters} = useSearchFiltersBar(queryJSON); if (hasErrors) { return null; @@ -30,7 +30,7 @@ function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) { item={item} /> ))} - {filters.length > 0 && } + {shouldShowResetFilters && } ); } diff --git a/src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx b/src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx similarity index 66% rename from src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx rename to src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx index 6376bae4c5a2..6f8414f201cc 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx @@ -11,32 +11,32 @@ import CONST from '@src/CONST'; import React from 'react'; -type SearchFiltersClearButtonProps = { +type SearchFiltersResetButtonProps = { onPress: () => void; }; -function SearchFiltersClearButton({onPress}: SearchFiltersClearButtonProps) { +function SearchFiltersResetButton({onPress}: SearchFiltersResetButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['RotateLeft']); return ( - {translate('common.clear')} + {translate('common.reset')} ); } -export default SearchFiltersClearButton; +export default SearchFiltersResetButton; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderCommon.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderCommon.tsx new file mode 100644 index 000000000000..ac20d1781521 --- /dev/null +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderCommon.tsx @@ -0,0 +1,34 @@ +import TopBar from '@components/Navigation/TopBar'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; + +import useActiveSavedSearch from '@hooks/useActiveSavedSearch'; +import useLocalize from '@hooks/useLocalize'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; + +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +import getSearchPageHeaderTitle from './getSearchPageHeaderTitle'; + +type SearchPageHeaderCommonProps = { + queryJSONType: SearchDataTypes; + shouldShowLoadingBar?: boolean; +}; + +function SearchPageHeaderCommon({queryJSONType, shouldShowLoadingBar}: SearchPageHeaderCommonProps) { + const {translate} = useLocalize(); + const typeMenuSections = useSearchTypeMenuSections(); + const {currentSearchKey} = useSearchQueryContext(); + const selectedItem = typeMenuSections.flatMap((section) => section.menuItems).find((item) => item.key === currentSearchKey); + const activeSavedSearch = useActiveSavedSearch(); + const title = getSearchPageHeaderTitle({translate, type: queryJSONType, activeSavedSearch, selectedItem}); + + return ( + + ); +} + +export default SearchPageHeaderCommon; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx index a7fb1c7671b4..ec6970e40954 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx @@ -1,14 +1,10 @@ -import TopBar from '@components/Navigation/TopBar'; import type {SearchQueryJSON} from '@components/Search/types'; -import useLocalize from '@hooks/useLocalize'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; - import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import React from 'react'; -import getSearchPageHeaderTitle from './getSearchPageHeaderTitle'; +import SearchPageHeaderCommon from './SearchPageHeaderCommon'; type SearchPageHeaderNarrowProps = { queryJSON: SearchQueryJSON; @@ -17,21 +13,14 @@ type SearchPageHeaderNarrowProps = { }; function SearchPageHeaderNarrow({queryJSON, shouldShowLoadingBar = false, isMobileSelectionModeEnabled}: SearchPageHeaderNarrowProps) { - const {translate} = useLocalize(); - const {typeMenuSections, activeItemIndex, activeSavedSearch} = useSearchTypeMenuSections(queryJSON); - const selectedItem = activeItemIndex >= 0 ? typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex) : undefined; - - const title = getSearchPageHeaderTitle({translate, type: queryJSON.type, activeSavedSearch, selectedItem}); - if (isMobileSelectionModeEnabled) { return ; } return ( - ); } diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx index 1acb6d1d7581..5def4ef20f54 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx @@ -1,31 +1,15 @@ -import TopBar from '@components/Navigation/TopBar'; import type {SearchQueryJSON} from '@components/Search/types'; -import useLocalize from '@hooks/useLocalize'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; - import React from 'react'; -import getSearchPageHeaderTitle from './getSearchPageHeaderTitle'; +import SearchPageHeaderCommon from './SearchPageHeaderCommon'; type SearchPageHeaderWideProps = { queryJSON: SearchQueryJSON; }; function SearchPageHeaderWide({queryJSON}: SearchPageHeaderWideProps) { - const {translate} = useLocalize(); - const {typeMenuSections, activeItemIndex, activeSavedSearch} = useSearchTypeMenuSections(queryJSON); - const selectedItem = activeItemIndex >= 0 ? typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex) : undefined; - - const title = getSearchPageHeaderTitle({translate, type: queryJSON.type, activeSavedSearch, selectedItem}); - - return ( - - ); + return ; } export default SearchPageHeaderWide; diff --git a/src/components/Search/SearchPageHeader/SearchSaveButton.tsx b/src/components/Search/SearchPageHeader/SearchSaveButton.tsx index cc449f1ec9c8..bd742255dc47 100644 --- a/src/components/Search/SearchPageHeader/SearchSaveButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchSaveButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; diff --git a/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts b/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts index 8c157982b04b..afa0ebb2edad 100644 --- a/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts +++ b/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts @@ -12,10 +12,10 @@ type SearchPageHeaderTitleDeps = { /** The `type` of the current search query, used for the data-type fallbacks */ type: SearchDataTypes | undefined; - /** The saved search the current query maps to, if any (from `useSearchTypeMenuSections`) */ + /** The saved search the current query maps to, if any */ activeSavedSearch: SaveSearchItem | undefined; - /** The matched suggested-search menu item, if any (only pass when `activeItemIndex >= 0`) */ + /** The matched suggested-search menu item, if any */ selectedItem: SearchTypeMenuItem | undefined; }; diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index ecfbbd72163c..dad5542ab987 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -5,8 +5,8 @@ import ListPopup from '@components/Search/FilterDropdowns/ListPopup'; import ReportFieldPopup from '@components/Search/FilterDropdowns/ReportFieldPopup'; import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; -import {useSearchResultsContext} from '@components/Search/SearchContext'; -import type {ReportFieldKey, SearchFilterKey, SearchQueryJSON} from '@components/Search/types'; +import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; +import type {SearchFilterKey, SearchQueryJSON} from '@components/Search/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; @@ -16,7 +16,8 @@ import {shouldShowInitialCategoryFilterLoading} from '@hooks/useSearchFilterSync import {close} from '@libs/actions/Modal'; import {setSearchContext} from '@libs/actions/Search'; -import {getAdvancedFiltersToReset, removeNegation} from '@libs/SearchQueryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildQueryStringWithResetFilters, hasFiltersChangedFromDefault, removeNegation} from '@libs/SearchQueryUtils'; import {FILTER_VIEW_MAP, isAmountFilterKey, isDateFilterKey, isReportFieldKey, isTextFilterKey, mapFiltersFormToLabelValueList, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; @@ -35,29 +36,31 @@ import DatePickerFilterPopup from './DatePickerFilterPopup'; type FilterItem = WithSentryLabel & { PopoverComponent: (props: PopoverComponentProps) => ReactNode; - onClosePress: () => void; + onClosePress: (() => void) | undefined; }; type UseSearchFiltersBarResult = { filters: Array; hasErrors: boolean; shouldShowFiltersBarLoading: boolean; - clearFilters: () => void; + shouldShowResetFilters: boolean; + resetFilters: () => void; }; type FilterPopupProps = { baseFilterKey: SearchFilter['key']; + isDefault: boolean; searchAdvancedFiltersForm: Partial; closeOverlay: () => void; setPopoverWidth: PopoverComponentProps['setPopoverWidth']; updateFilterForm: (values: Partial) => void; }; -function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey | ReportFieldKey) { +function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey) { return `Search-Filter-${filterKey}`; } -function FilterPopup({baseFilterKey, searchAdvancedFiltersForm, closeOverlay, setPopoverWidth, updateFilterForm}: FilterPopupProps) { +function FilterPopup({baseFilterKey, isDefault, searchAdvancedFiltersForm, closeOverlay, setPopoverWidth, updateFilterForm}: FilterPopupProps) { const {translate} = useLocalize(); const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); @@ -128,6 +131,7 @@ function FilterPopup({baseFilterKey, searchAdvancedFiltersForm, closeOverlay, se return ( ({ + (filterKey, isDefault): FilterItem => ({ PopoverComponent: ({closeOverlay, setPopoverWidth}) => ( ), sentryLabel: getFilterSentryLabel(filterKey), - onClosePress: () => { - if (isAmountFilterKey(filterKey)) { - const equalToKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`; - const greaterThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`; - const lessThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`; - updateFilterQueryParams({[equalToKey]: undefined, [greaterThanKey]: undefined, [lessThanKey]: undefined}); - return; - } - - if (isDateFilterKey(filterKey)) { - const onKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.ON}`; - const beforeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.BEFORE}`; - const afterKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`; - const rangeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.RANGE}`; - updateFilterQueryParams({[onKey]: undefined, [beforeKey]: undefined, [afterKey]: undefined, [rangeKey]: undefined}); - return; - } - - if (filterKey === CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX) { - const formValues = Object.keys(searchAdvancedFiltersForm).reduce((acc, curr) => { - if (isReportFieldKey(curr)) { - acc[curr] = undefined; - } - return acc; - }, {} as Partial); - updateFilterQueryParams(formValues); - return; - } - - updateFilterQueryParams({[filterKey]: undefined}); - }, + onClosePress: isDefault + ? undefined + : () => { + if (isAmountFilterKey(filterKey)) { + const equalToKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`; + const greaterThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`; + const lessThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`; + updateFilterQueryParams({[equalToKey]: undefined, [greaterThanKey]: undefined, [lessThanKey]: undefined}); + return; + } + + if (isDateFilterKey(filterKey)) { + const onKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.ON}`; + const beforeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.BEFORE}`; + const afterKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`; + const rangeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.RANGE}`; + updateFilterQueryParams({[onKey]: undefined, [beforeKey]: undefined, [afterKey]: undefined, [rangeKey]: undefined}); + return; + } + + if (filterKey === CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX) { + const formValues = Object.keys(searchAdvancedFiltersForm).reduce((acc, curr) => { + if (isReportFieldKey(curr)) { + acc[curr] = undefined; + } + return acc; + }, {} as Partial); + updateFilterQueryParams(formValues); + return; + } + + updateFilterQueryParams({[filterKey]: undefined}); + }, }), ); - const clearFilters = () => { - setFilterQueryParams(getAdvancedFiltersToReset(searchAdvancedFiltersForm ?? {})); + const resetFilters = () => { + if (!currentSearchQueryJSON) { + return; + } + + Navigation.setParams({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); setSearchContext(false); }; const isCategoryFilterLoading = shouldShowInitialCategoryFilterLoading(queryJSON, areCategoriesLoaded, isLoadingCategories, isOffline); @@ -209,7 +222,9 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes filters, hasErrors: Object.keys(currentSearchResults?.errors ?? {}).length > 0 && !isOffline, shouldShowFiltersBarLoading: shouldShowFiltersBarLoading || isCategoryFilterLoading, - clearFilters, + shouldShowResetFilters: + currentDefaultSearchQueryJSON && currentSearchQueryJSON ? hasFiltersChangedFromDefault(currentSearchQueryJSON, currentDefaultSearchQueryJSON) : filters.length > 0, + resetFilters, }; } diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index e045589f5336..c0fdf8096e9c 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -6,17 +6,19 @@ import usePreviousDefined from '@hooks/usePreviousDefined'; import useRootNavigationState from '@hooks/useRootNavigationState'; import {getDeepestFocusedScreen} from '@libs/Navigation/Navigation'; -import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; -import {getSuggestedSearches, getSuggestedSearchesVisibility} from '@libs/SearchUIUtils'; +import {buildSearchQueryJSON, buildSearchQueryString, doesQueryMatchDefaultFilterKeysAndType} from '@libs/SearchQueryUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; +import {getLastSearchQuery, getSuggestedSearches, savedSearchIDToSearchKey, getSuggestedSearchesVisibility} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import {defaultExpensifyCardSelector} from '@src/selectors/Card'; import type {NavigationState} from '@react-navigation/routers'; import {useNavigation} from '@react-navigation/native'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {SearchQueryActionsValue, SearchQueryContextValue} from './types'; @@ -26,6 +28,11 @@ type SearchQueryProviderProps = { children: React.ReactNode; }; +const typeToGenericKey: Record = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, +}; + function selectSearchQueryParam(state: NavigationState | undefined) { const focused = getDeepestFocusedScreen(state); return focused?.name === SCREENS.SEARCH.ROOT ? (focused.params?.q as string | undefined) : undefined; @@ -48,30 +55,107 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { useLoadSearchCategoryData({shouldLoad: shouldLoadCategoryData}); const {defaultCardFeed, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); + const [defaultExpensifyCardID] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: (card) => defaultExpensifyCardSelector(card)?.id}); const {accountID, email} = useCurrentUserPersonalDetails(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const defaultCardFeedID = defaultCardFeed?.id; - // Only policy IDs are needed so Top Spenders matches the type menu hash; card feeds aren't used for that eligibility. - const topSpendersPolicyIDs = useMemo(() => getSuggestedSearchesVisibility(email, {}, policies, undefined).topSpendersPolicyIDs, [email, policies]); - const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeedID, undefined, topSpendersPolicyIDs, activeExpensifyCardFeedID); + const {shouldShowExpensifyCard} = getSuggestedSearchesVisibility(email, {}, policies, undefined); + const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeedID ?? defaultExpensifyCardID, shouldShowExpensifyCard, activeExpensifyCardFeedID); const currentSearchHash = currentSearchQueryJSON?.hash ?? -1; const currentSimilarSearchHash = currentSearchQueryJSON?.similarSearchHash ?? -1; - const currentSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; + const [prevCurrentSearchHash, setPrevCurrentSearchHash] = useState(currentSearchHash); + + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [shouldResetSearchQuery, setShouldResetSearchQuery] = useState(false); + const getSearchKeyForQuery = (queryJSON = currentSearchQueryJSON) => { + const suggestedSearchKey = Object.values(suggestedSearches).find((search) => { + const lastSearchFilterQuery = getLastSearchQuery(searchFilters, search.key); + const lastSearchFilter = lastSearchFilterQuery ? buildSearchQueryJSON(lastSearchFilterQuery) : undefined; + return search.similarSearchHash === queryJSON?.similarSearchHash || lastSearchFilter?.similarSearchHash === queryJSON?.similarSearchHash; + })?.key; + if (suggestedSearchKey) { + return suggestedSearchKey; + } + + const savedSearchID = Object.keys(savedSearches ?? {}).find((id) => { + const savedSearchQuery = savedSearches?.[id].query; + const lastSavedSearchQuery = getLastSearchQuery(searchFilters, savedSearchIDToSearchKey(id)); + + return ( + (savedSearchQuery ? buildSearchQueryJSON(savedSearchQuery)?.hash === queryJSON?.hash : false) || + (lastSavedSearchQuery ? buildSearchQueryJSON(lastSavedSearchQuery)?.hash === queryJSON?.hash : false) + ); + }); + + if (savedSearchID) { + return savedSearchIDToSearchKey(savedSearchID); + } + + return queryJSON?.type ? typeToGenericKey[queryJSON.type] : undefined; + }; + + const [currentSearchKey, setCurrentSearchKey] = useState(getSearchKeyForQuery); + // Search key can be undefined when the query is not bound to any search key (e.g., query with type of chat). + // `null` means there is no pending current search key. + const [pendingCurrentSearchKey, setPendingCurrentSearchKey] = useState(null); + + const currentDefaultSearchQueryString = currentSearchKey ? suggestedSearches[currentSearchKey]?.searchQuery : undefined; + const currentDefaultSearchQueryJSON = currentDefaultSearchQueryString ? buildSearchQueryJSON(currentDefaultSearchQueryString) : undefined; + const currentDefaultSearchQueryFilterKeys = new Set(currentDefaultSearchQueryJSON?.flatFilters.map((filter) => filter.key)); + + const resetSearchKey = (queryJSON = currentSearchQueryJSON) => { + const searchKey = getSearchKeyForQuery(queryJSON); + if (queryJSON?.hash !== currentSearchHash) { + setPendingCurrentSearchKey(searchKey); + } else { + setCurrentSearchKey(searchKey); + } + }; + + if (currentSearchHash !== prevCurrentSearchHash) { + setPrevCurrentSearchHash(currentSearchHash); + + if (pendingCurrentSearchKey !== null) { + setCurrentSearchKey(pendingCurrentSearchKey); + setPendingCurrentSearchKey(null); + } + // Every time the query changes, we invalidate the currentSearchKey if the new query doesn't have the default filters + // from the currently selected search key query or the type is different. For example, the "Card statements" suggested + // search default filters are Feed and Posted. When the query changes (by removing Posted), the search key becomes invalid, + // it's not a "Card statements" search anymore. This can happen when accessing the page through a link/deeplink. + else if (!doesQueryMatchDefaultFilterKeysAndType(currentSearchQueryJSON, currentDefaultSearchQueryJSON)) { + resetSearchKey(); + } + } + const queryValue: SearchQueryContextValue = { currentSearchHash, currentSimilarSearchHash, currentSearchKey, currentSearchQueryJSON, + currentDefaultSearchQueryJSON, + currentDefaultSearchQueryFilterKeys, suggestedSearches, shouldResetSearchQuery, }; const queryActionsValue: SearchQueryActionsValue = { setShouldResetSearchQuery, + setCurrentSearchKey: (key, pendingQuery) => { + // We pending the update of the currentSearchKey to be updated later at the same time with the + // currentSearchQueryJSON so the consumer won't see mismatch value between search key and query JSON. + const pending = pendingQuery !== undefined && buildSearchQueryJSON(pendingQuery)?.hash !== currentSearchHash; + if (pending) { + setPendingCurrentSearchKey(key); + } else { + setCurrentSearchKey(key); + } + }, + resetSearchKey, }; return ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 7c3733131375..4a4826889dd0 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -10,9 +10,11 @@ import {isSearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQu import type {SearchQueryString} from '@components/Search/types'; import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; +import useIsSupportalSession from '@hooks/useIsSupportalSession'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -21,7 +23,7 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRootNavigationState from '@hooks/useRootNavigationState'; -import useSortedActions from '@hooks/useSortedActions'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import useThemeStyles from '@hooks/useThemeStyles'; import {scrollToRight} from '@libs/InputUtils'; @@ -35,14 +37,14 @@ import {getReportAction} from '@libs/ReportActionsUtils'; import {isHiddenForCurrentUser, isOneOnOneChat} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getAutocompleteQueryWithComma, getTrimmedUserSearchQueryPreservingComma} from '@libs/SearchAutocompleteUtils'; -import {buildUserReadableQueryString, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; -import {navigateToAndOpenReport, searchInServer} from '@userActions/Report'; +import {navigateToAndOpenReport, searchInServer, searchUserInServer} from '@userActions/Report'; import {setSearchContext} from '@userActions/Search'; import CONST from '@src/CONST'; @@ -78,21 +80,33 @@ type SearchRouterProps = { ref?: React.Ref; }; +function searchForReportsAndUsersInServer(searchInput: string) { + searchInServer(searchInput); + searchUserInServer(searchInput); +} + function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDisplayed, ref}: SearchRouterProps) { const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const styles = useThemeStyles(); - const {setShouldResetSearchQuery} = useSearchQueryActions(); + const {setShouldResetSearchQuery, resetSearchKey} = useSearchQueryActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [betas] = useOnyx(ONYXKEYS.BETAS); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [searchContext] = useOnyx(ONYXKEYS.SEARCH_CONTEXT); + const isSupportalSession = useIsSupportalSession(); const personalDetails = usePersonalDetails(); - const sortedActions = useSortedActions(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; + const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; + const lastActions = sortedReportActionsData?.lastActions; const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass', 'ConciergeAvatar']); @@ -100,7 +114,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const {query: pendingInitialQuery, isFromSearchPageSearchButton} = peekPendingRouterState(); - const {currentSearchQueryJSON} = useSearchQueryContext(); + const {currentSearchQueryJSON, currentSearchHash} = useSearchQueryContext(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); @@ -218,11 +232,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const option = createOptionFromReport({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, report: contextualReport, personalDetails, privateIsArchived: contextualReportNVP, + rules, policy: contextualReportPolicy, sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserLogin: currentUserPersonalDetails.login, conciergeReportID, reportAttributesDerived: reportAttributes, config: { @@ -294,11 +315,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla contextualReport, personalDetails, sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserPersonalDetails.login, contextualReportNVP, contextualReportPolicy, reportAttributes, isTrackIntentUser, dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, ], ); @@ -371,6 +399,10 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla backHistory(() => { onRouterClose(); setSearchContext(true); + const updatedQueryJSON = buildSearchQueryJSON(updatedQuery); + if (currentSearchHash !== updatedQueryJSON?.hash) { + resetSearchKey(updatedQueryJSON); + } Navigation.navigate( ROUTES.SEARCH_ROOT.getRoute({query: updatedQuery, rawQuery: shouldSkipAmountConversion || !isFromSearchPageSearchButton ? undefined : queryWithSubstitutions}), ); @@ -379,7 +411,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, currentUserAccountID, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery, isFromSearchPageSearchButton, policies], + [ + autocompleteSubstitutions, + currentUserAccountID, + currentSearchHash, + onRouterClose, + setAutocompleteQueryValue, + setTextInputValue, + setShouldResetSearchQuery, + resetSearchKey, + isFromSearchPageSearchButton, + policies, + ], ); const onListItemPress = useCallback( @@ -456,6 +499,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, betas, conciergeChat, + isSupportalSession, shouldDismissModal: false, }); } @@ -476,6 +520,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, betas, conciergeChat, + isSupportalSession, contextualPoliciesMap, contextualReportsMap, askConcierge, @@ -532,7 +577,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla shouldShowOfflineMessage wrapperStyle={styles.searchRouterBorder} wrapperFocusedStyle={styles.borderColorFocus} - isSearchingForReports={!!isSearchingForReports} + isSearchingForReports={!!isSearchingForReports || !!isSearchingForUsers} selection={selection} substitutionMap={autocompleteSubstitutions} ref={textInputRef} @@ -542,7 +587,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla return textComparison; } + const sortTextComparison = localeCompare(toMatchKey(firstItem.sortText), toMatchKey(secondItem.sortText)); + if (sortTextComparison !== 0) { + return sortTextComparison; + } + return localeCompare(firstItem.keyForList ?? '', secondItem.keyForList ?? ''); }); } @@ -85,7 +91,7 @@ function buildNavigationSuggestions(query: string, sources: NavigationSuggestion isNavigationIntentOnly || (shouldMatchExactDestination ? matchesNavigationQueryExactly(matchQuery, ...(item.matchTerms ?? [])) : matchesNavigationQuery(matchQuery, ...(item.matchTerms ?? []))), ) - .map(({matchTerms, ...item}) => ({ + .map(({matchTerms, sortText, ...item}) => ({ ...item, searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.NAVIGATE, })) diff --git a/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx b/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx index 590e8d542345..6b6845d60791 100644 --- a/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx +++ b/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx @@ -1,7 +1,7 @@ import useFilteredOptions from '@hooks/useFilteredOptions'; import useOnyx from '@hooks/useOnyx'; import useReportAttributes from '@hooks/useReportAttributes'; -import useSortedActions from '@hooks/useSortedActions'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import {Scheduler} from '@libs/Scheduler'; import type {IdleTask} from '@libs/Scheduler'; @@ -35,12 +35,12 @@ function SearchRouterOptionsWarmer({onDone}: SearchRouterOptionsWarmerProps) { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const reportAttributes = useReportAttributes(); - const sortedActions = useSortedActions(); + const sortedReportActionsData = useSortedReportActionsData(); const churnVersionRef = useRef(0); useEffect(() => { churnVersionRef.current += 1; - }, [reports, policies, personalDetails, reportAttributes, sortedActions]); + }, [reports, policies, personalDetails, reportAttributes, sortedReportActionsData]); useEffect(() => { let attempts = 0; diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index 108b2cbc6626..1645cc01a10d 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -4,6 +4,7 @@ import useCreateReport from '@hooks/useCreateReport'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsSupportalSession from '@hooks/useIsSupportalSession'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -11,15 +12,16 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import {showSupportalPermissionDenied} from '@libs/actions/App'; import {startDistanceRequest, startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; import {createNewReport, startNewChat} from '@libs/actions/Report'; +import {WRITE_COMMANDS} from '@libs/API/types'; import getIconForAction from '@libs/getIconForAction'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; -import Permissions from '@libs/Permissions'; // 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 {generateReportID} from '@libs/ReportUtils'; @@ -94,6 +96,7 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI const {isBetaEnabled} = usePermissions(); const {isOffline} = useNetwork(); const {isRestrictedPolicyCreation} = usePreferredPolicy(); + const isSupportalSession = useIsSupportalSession(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reportID] = useState(() => generateReportID()); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); @@ -109,11 +112,12 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI 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 = Permissions.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL, allBetas); + const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const primaryContactMethod = primaryLogin ?? sessionEmail ?? ''; const isTravelEnabled = !isBlockedFromSpotnanaTravel && @@ -157,6 +161,7 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI allBetas, isTrackIntentUser, getCurrencyDecimals, + rules, false, shouldDismissEmptyReportsConfirmation, ); @@ -216,7 +221,15 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI text: translate('sidebarScreen.fabNewChat'), icon: icons.ChatBubble, matchTerms: chatMatchTerms, - action: () => replaceTopmostModalWithAction(() => interceptAnonymousUser(startNewChat)), + action: () => + replaceTopmostModalWithAction(() => { + // Support agents cannot create chats on a user's behalf, so block before the selector opens. + if (isSupportalSession) { + showSupportalPermissionDenied({command: WRITE_COMMANDS.OPEN_REPORT}); + return; + } + interceptAnonymousUser(startNewChat); + }), keyForList: 'create_chat', }, { diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 6fed52de7802..f2de8cd3e40b 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -1,22 +1,31 @@ /** - * Builds the top-level, Spend, Domain, Account, and Create navigation suggestions shown in the Search Router. + * Builds the top-level, Spend, Workspace, Domain, Account, and Create navigation suggestions shown in the Search Router. */ +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import getSearchTabRoute from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; -import {useSearchSelectionActions} from '@components/Search/SearchContext'; +import {useSearchQueryActions, useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import TextWithIconCell from '@components/Search/SearchList/ListItem/TextWithIconCell'; +import TextWithTooltip from '@components/TextWithTooltip'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import useThemeStyles from '@hooks/useThemeStyles'; +import navigateToDomainRouteWithSidebarSync from '@libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync'; +import navigateToWorkspaceSettingsRoute from '@libs/Navigation/helpers/navigateToWorkspaceSettingsRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {shouldShowPolicy} from '@libs/PolicyUtils'; import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; -import {SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; -import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; +import {getLastSearchQuery, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; +import type {SearchKey, SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import navigationRef from '@navigation/navigationRef'; @@ -24,6 +33,7 @@ import getDomainMenuItems, {DOMAIN_MENU_ICON_NAMES} from '@pages/domain/getDomai import type {DomainMenuIconMap} from '@pages/domain/getDomainMenuItems'; import useSettingsNavigationMenuData from '@pages/settings/useSettingsNavigationMenuData'; import type {MenuData, MenuSection} from '@pages/settings/useSettingsNavigationMenuData'; +import getWorkspaceMenuItems from '@pages/workspace/getWorkspaceMenuItems'; import variables from '@styles/variables'; @@ -34,13 +44,17 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {isAdminSelector} from '@src/selectors/Domain'; -import type {Domain} from '@src/types/onyx'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; +import {emailSelector} from '@src/selectors/Session'; +import type * as OnyxTypes from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ReactNode} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; import {Str} from 'expensify-common'; import React from 'react'; +import {View} from 'react-native'; import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; @@ -50,7 +64,31 @@ import useCreateNavigationSuggestions from './useCreateNavigationSuggestions'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Globe' | 'Gear', IconAsset>; type SpendNavigationIcons = Record; -const SEARCH_ROUTER_ICON_NAMES = ['Home', 'Inbox', 'ReceiptMultiple', 'Building', 'Globe', 'Gear', ...DOMAIN_MENU_ICON_NAMES, ...SEARCH_TYPE_MENU_ICON_NAMES] as const; +const SEARCH_ROUTER_ICON_NAMES = [ + 'Home', + 'Inbox', + 'ReceiptMultiple', + 'Building', + 'Globe', + 'Gear', + ...DOMAIN_MENU_ICON_NAMES, + ...SEARCH_TYPE_MENU_ICON_NAMES, + 'Users', + 'Hashtag', + 'Sync', + 'Briefcase', + 'Tag', + 'Coins', + 'Workflows', + 'Feed', + 'Car', + 'LuggageWithLines', + 'ExpensifyCard', + 'Clock', + 'InvoiceGeneric', + 'Bolt', + 'Bot', +] as const; // Saved searches are user-defined searches, not canned destinations, so they are excluded from go-to navigation suggestions. const SAVED_SEARCHES_SECTION_PATH = 'search.savedSearchesMenuItemTitle'; @@ -76,17 +114,74 @@ type BuildSpendNavigationItemsParams = { rightElement: ReactNode; getItemText: (item: SearchTypeMenuItem) => string; getDestinationText: (destination: string) => string; - onSelect: (searchQuery: string) => void; + onSelect: (searchKey: SearchKey, searchQuery: string) => void; }; +type BuildWorkspaceNavigationItemsParams = { + /** Policies considered when building accessible Workspace destinations. */ + policies: OnyxCollection; + + /** Categories used by the shared Workspace menu visibility rules. */ + policyCategories: OnyxCollection; + + /** Login used to evaluate Workspace membership and permissions. */ + currentUserLogin: string | undefined; + + /** Icons consumed by the shared Workspace menu source. */ + icons: Parameters[0]['icons']; + + /** Whether pending offline state should be considered by Workspace visibility rules. */ + isOffline: boolean; + + isVendorMatchingBetaEnabled: boolean; + + /** Whether navigation should use the narrow-layout Workspace flow. */ + shouldUseNarrowLayout: boolean; + + /** Formats monetary values required by shared Workspace menu items. */ + convertToDisplayString: Parameters[0]['convertToDisplayString']; + + /** Resolves the localized label for a Workspace menu item. */ + getItemText: (item: ReturnType[number]) => string; + + /** Formats a Workspace label as navigation destination text. */ + getDestinationText: (destination: string) => string; +}; + +type WorkspaceIdentityCellProps = { + /** The policy used to identify the workspace suggestion */ + policy: OnyxTypes.Policy; +}; + +function WorkspaceIdentityCell({policy}: WorkspaceIdentityCellProps) { + const styles = useThemeStyles(); + + return ( + + + + + ); +} + type BuildDomainNavigationItemsParams = { - domains: Array; + domains: Array; currentUserAccountID: number; icons: DomainMenuIconMap; getItemText: (translationKey: TranslationPaths) => string; getDestinationText: (destination: string) => string; getDomainContext: (domainName: string) => ReactNode; - onSelect: (route: Route) => void; + onSelect: (route: Route, domainAccountID: number) => void; }; type BuildAccountNavigationItemsParams = { @@ -160,7 +255,7 @@ function buildSpendNavigationItems({sections, icons, rightElement, getItemText, return { text: getDestinationText(itemText), singleIcon: icons[item.icon], - action: () => onSelect(item.searchQuery), + action: () => onSelect(item.key, item.searchQuery), keyForList: `spend_${item.key}`, rightElement, matchTerms: [itemText], @@ -169,6 +264,49 @@ function buildSpendNavigationItems({sections, icons, rightElement, getItemText, ); } +function buildWorkspaceNavigationItems({ + policies, + policyCategories, + currentUserLogin, + icons, + isOffline, + isVendorMatchingBetaEnabled, + shouldUseNarrowLayout, + convertToDisplayString, + getItemText, + getDestinationText, +}: BuildWorkspaceNavigationItemsParams): NavigationSuggestionSourceItem[] { + return Object.values(policies ?? {}) + .filter( + (policy): policy is OnyxTypes.Policy => + !!policy?.id && !policy.isJoinRequestPending && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && shouldShowPolicy(policy, isOffline, currentUserLogin), + ) + .flatMap((policy) => { + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`], + isVendorMatchingBetaEnabled, + convertToDisplayString, + }); + + return items.map((item) => { + const itemText = getItemText(item); + return { + text: getDestinationText(itemText), + singleIcon: item.icon, + action: () => navigateToWorkspaceSettingsRoute(item.getRoute(), policy.id, shouldUseNarrowLayout, item.screenName), + keyForList: `workspace_${policy.id}_${item.screenName}`, + rightElement: , + matchTerms: item.screenName === SCREENS.WORKSPACE.PROFILE ? [itemText, policy.name] : [itemText], + sortText: policy.name, + }; + }); + }); +} + function buildDomainNavigationItems({ domains, currentUserAccountID, @@ -179,7 +317,7 @@ function buildDomainNavigationItems({ onSelect, }: BuildDomainNavigationItemsParams): NavigationSuggestionSourceItem[] { const isCurrentUserDomainAdmin = isAdminSelector(currentUserAccountID); - const canAdministerDomain = (domain: Domain | null | undefined): domain is Domain => + const canAdministerDomain = (domain: OnyxTypes.Domain | null | undefined): domain is OnyxTypes.Domain => !!domain?.accountID && !!domain.email && domain.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isCurrentUserDomainAdmin(domain); return domains.filter(canAdministerDomain).flatMap((domain) => { @@ -191,7 +329,7 @@ function buildDomainNavigationItems({ return { text: getDestinationText(itemText), singleIcon: item.icon, - action: () => onSelect(item.route), + action: () => onSelect(item.route, domain.accountID), keyForList: `domain_${domain.accountID}_${item.screenName}`, rightElement: domainContext, matchTerms: [itemText, domainName], @@ -224,13 +362,22 @@ function buildAccountNavigationItems({sections, rightElement, getItemText, getDe function useNavigationSuggestions(query: string, shouldWatchForApprovals = true): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); + const {convertToDisplayString} = useCurrencyListActions(); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const createItems = useCreateNavigationSuggestions(query); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {clearSelectedTransactions} = useSearchSelectionActions(); - const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals); + const typeMenuSections = useSearchTypeMenuSections(shouldWatchForApprovals); + const {setCurrentSearchKey} = useSearchQueryActions(); const {accountMenuItemsData, generalMenuItemsData} = useSettingsNavigationMenuData(); const topLevelItems = buildTopLevelNavigationItems({ @@ -243,7 +390,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) account: translate('initialSettingsPage.account'), }, icons, - getSpendRoute: () => getSearchTabRoute(navigationRef.getRootState(), lastSearchParams), + getSpendRoute: () => getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuerySelector(searchFilters)), getDestinationText: (destination) => getGoToText(translate, destination), }); @@ -261,7 +408,21 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) ), getItemText: (item) => translate(item.translationPath), getDestinationText: (destination) => getGoToText(translate, destination), - onSelect: (searchQuery) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions), + onSelect: (searchKey, searchQuery) => + navigateToCannedSpendSearch(searchKey, searchQuery, getLastSearchQuery(searchFilters, searchKey), clearSelectedTransactions, setCurrentSearchKey), + }); + + const workspaceItems = buildWorkspaceNavigationItems({ + policies: allPolicies, + policyCategories, + currentUserLogin, + icons, + isOffline: !!isOffline, + isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), + shouldUseNarrowLayout, + convertToDisplayString, + getItemText: (item) => translate(item.translationKey), + getDestinationText: (destination) => getGoToText(translate, destination), }); const domainItems = buildDomainNavigationItems({ @@ -279,7 +440,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) textStyle={[styles.textLabelSupporting, styles.label]} /> ), - onSelect: (route) => Navigation.navigate(route), + onSelect: (route, domainAccountID) => navigateToDomainRouteWithSidebarSync(route, domainAccountID, shouldUseNarrowLayout), }); const accountItems = buildAccountNavigationItems({ @@ -297,8 +458,8 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) getDestinationText: (destination) => getGoToText(translate, destination), }); - return buildNavigationSuggestions(query, [topLevelItems, spendItems, domainItems, accountItems, createItems], localeCompare); + return buildNavigationSuggestions(query, [topLevelItems, spendItems, workspaceItems, domainItems, accountItems, createItems], localeCompare); } export default useNavigationSuggestions; -export {buildTopLevelNavigationItems, buildSpendNavigationItems, buildDomainNavigationItems, buildAccountNavigationItems}; +export {buildTopLevelNavigationItems, buildSpendNavigationItems, buildWorkspaceNavigationItems, buildDomainNavigationItems, buildAccountNavigationItems}; diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 0daa952154c3..7c95f0d1cee2 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -60,15 +60,16 @@ function getTransactionCount(transactionKeys: string[], transactions: SelectedTr }, 0); } -function getTransactionTotal(transactions: SelectedTransactionInfo[]): number { - return transactions.reduce((total, transaction) => total - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); -} - // The live default-currency figure a row contributes to the footer total (also what the footer falls back to before a // conversion arrives). The footer stamps each conversion against this value and compares it on every render, so an // inline edit that moves it is detected and the cached conversion is fetched again. +// Sources are expense-signed (the negation of the displayed amount), so callers sum them with `total - source`. function getEntrySource(entry: SelectedTransactionInfo): number { - return entry.groupAmount ?? -Math.abs(entry.amount); + return entry.groupAmount ?? -entry.displayAmount; +} + +function getTransactionTotal(transactions: SelectedTransactionInfo[]): number { + return transactions.reduce((total, transaction) => total - getEntrySource(transaction), 0); } // Every selected row needs a fresh cached conversion for the target currency before the selected total can be shown @@ -105,7 +106,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const {isOffline} = useNetwork(); const activePolicy = useActivePolicy(); // The server converts search figures to the active policy's currency when the query carries no explicit target. @@ -215,7 +216,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } const group: unknown = data[key]; if (group && typeof group === 'object' && 'total' in group && typeof group.total === 'number') { - sources[key] = -Math.abs(group.total); + sources[key] = -group.total; } } return sources; @@ -482,7 +483,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { convertedAmount = convertedTransactions?.[transaction.transaction.transactionID]?.[selectedCurrency]; } } - return acc - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount)); + return acc - (convertedAmount ?? getEntrySource(transaction)); }, 0); } @@ -500,7 +501,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } else if (transactionID) { convertedAmount = convertedTransactions?.[transactionID]?.[selectedCurrency]; } - return total - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount)); + return total - (convertedAmount ?? getEntrySource(transaction)); }, 0) : 0; return { diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index 6e2126122870..5f3dd40d2b9b 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -109,10 +109,13 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { excludedTransactions[key] = transaction; } } - for (const key of Object.keys(selectedTransactions)) { + for (const [key, transaction] of Object.entries(selectedTransactions)) { if (!Object.hasOwn(prevState.selectedTransactions, key) && Object.hasOwn(excludedTransactions, key)) { delete excludedTransactions[key]; } + if (!Object.hasOwn(prevState.selectedTransactions, key) && transaction.isSelectedViaGroup && transaction.groupKey) { + delete excludedTransactions[transaction.groupKey]; + } } } else if (!areAllMatchingItemsSelected) { excludedTransactions = {}; diff --git a/src/components/Search/SearchStaticList.tsx b/src/components/Search/SearchStaticList.tsx index 65eedb771dc4..5e40b2e5ec41 100644 --- a/src/components/Search/SearchStaticList.tsx +++ b/src/components/Search/SearchStaticList.tsx @@ -86,6 +86,7 @@ function SearchStaticList({ const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [showPendingExpensePlaceholder, setShowPendingExpensePlaceholder] = useState( () => hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) || Navigation.getIsFullscreenPreInsertedUnderRHP(), @@ -109,6 +110,7 @@ function SearchStaticList({ translate, formatPhoneNumber, bankAccountList: undefined, + rules, conciergeReportID, convertToDisplayString, reportAttributesDerivedValue: undefined, diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 84aaa4962959..92f54079fce9 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -41,10 +41,7 @@ type SearchWriteActionsProviderProps = { /** The live TRANSACTION collection, subscribed by `` and passed down. */ transactions: OnyxCollection; - /** Whether mobile selection mode is on. */ isMobileSelectionModeEnabled: boolean; - - /** The search data type. */ type: SearchDataTypes; /** Grouped meaning either a group-by view or the expense-report view. */ @@ -85,7 +82,6 @@ type ReconcileSelectionParams = { /** Login (email or phone) of the current user */ currentUserLogin: string; - /** Account ID of the current user */ currentUserAccountID: number; /** The current user's self-DM report, used as the parent for unreported (track) expenses */ @@ -125,6 +121,7 @@ function useReconcileSelectionWithData({ }: ReconcileSelectionParams) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {applySelection} = useSearchSelectionActions(); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); useEffect(() => { if (!isFocused) { @@ -204,6 +201,7 @@ function useReconcileSelectionWithData({ selfDMReport, allowNegativeAmount: true, parentReport: itemParentReport, + rules, }); const liveSelectionEntry: SelectedTransactionInfo = { @@ -248,6 +246,7 @@ function useReconcileSelectionWithData({ selfDMReport, allowNegativeAmount: true, parentReport: itemParentReport, + rules, }); const liveSelectionEntry: SelectedTransactionInfo = { @@ -394,6 +393,7 @@ function SearchWriteActionsProvider({ const selfDMReport = useSelfDMReport(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {applySelection} = useSearchSelectionActions(); const searchResultsData = searchResults?.data; @@ -425,6 +425,7 @@ function SearchWriteActionsProvider({ outstandingReportsByPolicyID, selfDMReport, parentReport: itemParentReport, + rules, }); if (areItemsGrouped && isGroupedItemArray(filteredData)) { @@ -515,6 +516,7 @@ function SearchWriteActionsProvider({ selfDMReport, allowNegativeAmount: true, parentReport: itemParentReport, + rules, }); return [key, {...entry, groupKey: item.keyForList, isSelectedViaGroup: !!item.keyForList}]; }), @@ -563,6 +565,7 @@ function SearchWriteActionsProvider({ selfDMReport, allowNegativeAmount: true, parentReport: itemParentReport, + rules, }); entries.push([key, {...entry, groupKey: item.keyForList, isSelectedViaGroup: !!item.keyForList}]); } @@ -595,6 +598,7 @@ function SearchWriteActionsProvider({ selfDMReport, allowNegativeAmount: true, parentReport: itemParentReport, + rules, }), ); } diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx index 21daa1256854..4127da9436e9 100644 --- a/src/components/Search/SortableTableHeader.tsx +++ b/src/components/Search/SortableTableHeader.tsx @@ -13,6 +13,7 @@ import {View} from 'react-native'; import type {SearchColumnType, SearchSortBy, SortOrder, TableColumnSize} from './types'; +import {useSearchColumnStyles} from './SearchColumnWidthsContext'; import SortableHeaderText from './SortableHeaderText'; type ColumnConfig = { @@ -66,6 +67,7 @@ function SortableTableHeader({ }: SearchTableHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const getSearchColumnStyles = useSearchColumnStyles(); const {translate} = useLocalize(); return ( @@ -101,7 +103,7 @@ function SortableTableHeader({ sentryLabel={CONST.SENTRY_LABEL.SEARCH.SORTABLE_HEADER} innerContainerStyle={canEdit && styles.editableCellHeader} containerStyle={[ - StyleUtils.getReportTableColumnStyles(columnName, { + getSearchColumnStyles(columnName, { isDateColumnWide: dateColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, isSubmittedColumnWide: submittedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, isApprovedColumnWide: approvedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, diff --git a/src/components/Search/TaskSearchView.tsx b/src/components/Search/TaskSearchView.tsx index a3aa2ce448fc..30f17ea54fcc 100644 --- a/src/components/Search/TaskSearchView.tsx +++ b/src/components/Search/TaskSearchView.tsx @@ -36,6 +36,7 @@ function TaskSearchView({ columns, canSelectMultiple, isActionColumnWide, + columnSizeOptions, isMobileSelectionModeEnabled, SearchTableHeader: searchTableHeader, tableHeaderVisible, @@ -93,6 +94,7 @@ function TaskSearchView({ columns={columns} type={type} isActionColumnWide={isActionColumnWide} + columnSizeOptions={columnSizeOptions} isHeaderVisible={!!searchTableHeader} dataKey={data} isKeyboardShown={isKeyboardShown} diff --git a/src/components/Search/hooks/useSearchColumnWidths.ts b/src/components/Search/hooks/useSearchColumnWidths.ts new file mode 100644 index 000000000000..f399b00b22ab --- /dev/null +++ b/src/components/Search/hooks/useSearchColumnWidths.ts @@ -0,0 +1,243 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchColumnType} from '@components/Search/types'; +import type {DynamicColumnConstraints} from '@components/Table/calculateDynamicColumnWidths'; +import calculateDynamicColumnWidths from '@components/Table/calculateDynamicColumnWidths'; + +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {SearchColumnMeasurementContext} from '@libs/getSearchColumnContentToMeasure'; +import getSearchColumnContentToMeasure, { + DYNAMICALLY_SIZED_SEARCH_COLUMNS, + getSearchColumnEditButtonReserve, + getSearchColumnExtraWidth, + HUGGED_SEARCH_COLUMNS, + SEARCH_COLUMN_HEADER_TRANSLATION_KEYS, +} from '@libs/getSearchColumnContentToMeasure'; +import measureTextWidth, {canMeasureText} from '@libs/measureTextWidth'; +import createWidestTextMeasurer from '@libs/measureTextWidth/widestTextMeasurer'; +import {getSearchTableRowInsetWidth} from '@libs/SearchUIUtils'; + +import {textVariants} from '@styles/typography'; +import type {GetReportTableColumnStylesParams} from '@styles/utils'; +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +const {MIN_FREE_TEXT_COLUMN_WIDTH, MAX_FREE_TEXT_COLUMN_WIDTH} = CONST.TABLES.DYNAMIC_COLUMNS; + +/** How wide a dynamically sized column was resolved to, and how far it may be squeezed before the table scrolls. */ +type SearchColumnSizing = { + /** + * The width the column is laid out at, or `undefined` when every column fits an equal share and they all stay + * equal, which is what the columns are already styled to do. + */ + width: number | undefined; + + /** Width the column is never squeezed below, so its header stays readable however narrow the table gets. */ + minWidth: number; + + /** Width the column's content wants, used to lay the columns out once the table has given up on fitting them. */ + contentWidth: number; + + /** Whether the column is sized to its content exactly, rather than sharing the row's spare space with the others. */ + shouldHug: boolean; +}; + +/** + * Measures how wide a column's header renders, or `null` when the platform can't measure text. + * + * Uses the bold weight and reserves the sort arrow even when unsorted, so sorting a column never truncates its heading. + */ +function measureHeaderLabelWidth(column: SearchColumnType, translate: LocalizedTranslate, sortIconWidth: number): number | null { + const translationKey = SEARCH_COLUMN_HEADER_TRANSLATION_KEYS[column]; + + if (!translationKey) { + return 0; + } + + // The heading's own variant, so the measurement follows the typography scale rather than a copy of it. + const width = measureTextWidth(translate(translationKey), {fontSize: textVariants.microStrong.fontSize, fontWeight: '700'}); + + return width === null ? null : Math.ceil(width + sortIconWidth); +} + +type UseSearchColumnWidthsParams = { + /** Every column the table renders, in order. */ + columns: SearchColumnType[]; + + /** The table's rows, all of them, so widths don't shift as rows scroll in. A row of a kind the table can't read + * text from measures as empty and leaves its columns on their existing widths. */ + data: SearchListItem[]; + + /** Measured width of the area the table renders into, including the rows' own margin and padding. */ + tableWidth: number; + + /** Whether dynamic sizing should run. Callers pass `false` on narrow layouts, where rows render as cards. */ + isEnabled: boolean; + + /** Which columns render in their wider variant, so the fixed columns are subtracted at the width they really take. */ + columnSizeOptions?: GetReportTableColumnStylesParams; + + /** Data some columns need to resolve their text, read once at the list level rather than per row. */ + measurementContext?: SearchColumnMeasurementContext; +}; + +/** + * Sizes the Search table's free-text columns from their content, instead of every one taking an equal share of what the + * fixed columns leave over. Today that leaves a column of empty descriptions holding the same room as one of long + * merchant names. + * + * The widths come from `calculateDynamicColumnWidths`, the same resolver the Members table uses, so every dynamically + * sized table in the app behaves identically: equal columns while everything fits, then a column that can't fit an + * equal share taking exactly its content while the rest split the remainder equally, then a squeeze toward the + * minimums, and only then a scroll. The per-table part is what to measure and what each column's minimum is. + * + * Returns an empty map to leave the columns as they are: sizing is off, text can't be measured (native), or the table + * hasn't been measured yet. + */ +function useSearchColumnWidths({ + columns, + data, + tableWidth, + isEnabled, + columnSizeOptions, + measurementContext, +}: UseSearchColumnWidthsParams): Partial> { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const noColumnSizing: Partial> = {}; + + // Checked before anything else, so native never walks the data to gather text that it can't measure anyway. + if (!isEnabled || tableWidth <= 0 || !canMeasureText()) { + return noColumnSizing; + } + + const dynamicColumns = columns.filter((column) => DYNAMICALLY_SIZED_SEARCH_COLUMNS.has(column)); + + if (dynamicColumns.length === 0) { + return noColumnSizing; + } + + let fixedColumnsWidth = 0; + + for (const column of columns) { + if (DYNAMICALLY_SIZED_SEARCH_COLUMNS.has(column)) { + continue; + } + + const declaredWidth = StyleUtils.getReportTableColumnStyles(column, columnSizeOptions ?? {}).width; + + // A column styled with flex alone declares no width, so it can't be subtracted from the budget and the whole + // table keeps its existing layout rather than being sized from a budget that is wrong. + if (typeof declaredWidth !== 'number') { + return noColumnSizing; + } + + fixedColumnsWidth += declaredWidth; + } + + // What the dynamic columns share: the table, less what the row spends on anything that is not a column, less the + // columns pinned to a width of their own. The inset comes from the same helper the horizontal scroller subtracts, + // so the two cannot disagree about whether the columns fit. + // + // It does not have to be exact. The resolved widths are applied as a flex basis that still grows and shrinks, so an + // inset a few px out shows as the columns sharing a little more or less room rather than as dead space at the end + // of the row. What it does decide is which of the four states the table is in, and "close" is enough for that. + const availableWidth = tableWidth - getSearchTableRowInsetWidth(columns.length) - fixedColumnsWidth; + + if (availableWidth <= 0) { + return noColumnSizing; + } + + const constraints: DynamicColumnConstraints[] = []; + const contentWidths: number[] = []; + + // A measurer per column, filled in one pass over the rows rather than one pass per column, so the row array is + // walked once however many columns the table shows. + const measurersByColumn = new Map(dynamicColumns.map((column) => [column, createWidestTextMeasurer()])); + + for (const item of data) { + for (const [column, measurer] of measurersByColumn) { + for (const content of getSearchColumnContentToMeasure(column, item, translate, measurementContext)) { + measurer.add(content.text, content.font); + } + } + } + + for (const [column, measurer] of measurersByColumn) { + const widestContentWidth = measurer.getWidestWidth(); + const headerLabelWidth = measureHeaderLabelWidth(column, translate, variables.iconSizeExtraSmall + styles.gap1.gap); + + if (widestContentWidth === null || headerLabelWidth === null) { + return noColumnSizing; + } + + // A short value in an editable cell sits under the edit button that appears on hover, so it is the one case + // where the button has to be reserved for. Measured from the text alone, before the cell's own padding, since + // that padding is what the value is inset by rather than room it has to spare. + const extraWidth = getSearchColumnExtraWidth(column) + getSearchColumnEditButtonReserve(column, widestContentWidth); + + // A column has to fit its header as well as its cells, so the heading is part of what its content needs rather + // than a separate floor. That also keeps a column of empty cells identifiable instead of collapsing. + const contentWidth = Math.max(Math.ceil(widestContentWidth + extraWidth), headerLabelWidth); + + contentWidths.push(contentWidth); + + // A column holding a fixed-size element is pinned to its content: it takes no share of the spare room and is + // never squeezed, so its three constraints are the same number and the resolver settles it there immediately. + if (HUGGED_SEARCH_COLUMNS.has(column)) { + constraints.push({contentWidth, minWidth: contentWidth, maxWidth: contentWidth}); + continue; + } + + constraints.push({ + contentWidth, + // Squeezed no further than a readable width, or its own content when that is narrower, so a short value + // isn't inflated to the minimum. Never below the header, which would leave the column unidentifiable. + minWidth: Math.max(Math.min(contentWidth, MIN_FREE_TEXT_COLUMN_WIDTH + extraWidth), headerLabelWidth), + // Uncapped while the table still fits, so a long value gets the room when the room is there instead of + // stopping at a cap and leaving the space unused. Capping is only for the scrolling case below. + maxWidth: Number.POSITIVE_INFINITY, + }); + } + + const {widths, shouldScrollHorizontally} = calculateDynamicColumnWidths(constraints, availableWidth); + + const columnSizing: Partial> = {}; + + for (const [index, column] of dynamicColumns.entries()) { + const constraint = constraints.at(index); + const contentWidth = contentWidths.at(index) ?? 0; + + if (!constraint) { + continue; + } + + const shouldHug = HUGGED_SEARCH_COLUMNS.has(column); + + // Past the point where the columns fit, a column is sized to its content but no wider than the cap: the table + // scrolls either way, and letting one unusually long value set the width would push every column after it out + // of view for the sake of a single row. Below the cap nothing is capped at all, which is why this is applied + // here rather than as the constraint the resolver sees. + const scrolledWidth = shouldHug ? contentWidth : Math.max(Math.min(contentWidth, MAX_FREE_TEXT_COLUMN_WIDTH + getSearchColumnExtraWidth(column)), constraint.minWidth); + + columnSizing[column] = { + shouldHug, + minWidth: constraint.minWidth, + contentWidth: shouldScrollHorizontally ? scrolledWidth : contentWidth, + // An empty result means every column fits an equal share, so they are left to divide the row equally, which + // is state one of the model and exactly what the columns are already styled to do. + width: shouldScrollHorizontally ? scrolledWidth : widths.at(index), + }; + } + + return columnSizing; +} + +export default useSearchColumnWidths; +export type {SearchColumnSizing}; diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index 5d0b15f615f3..0abce285b94b 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -19,8 +19,11 @@ import {shouldShowAttendees} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import type {PolicyCategories, PolicyTagLists} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {OnyxCollection} from 'react-native-onyx'; + import {useMemo} from 'react'; import useLiveFilteredReportActions from './useLiveFilteredReportActions'; @@ -60,6 +63,10 @@ type SearchSnapshotResult = { hasLoadedAllTransactions: boolean; /** True while the cached optimistic row is being re-injected across a snapshot-replacement gap. */ hasCachedOptimisticItem: boolean; + /** Every policy's categories, already read here for sorting and reused to size the category GL code column. */ + policyCategories: OnyxCollection; + /** Every policy's tag lists, already read here for sorting and reused to size the tag GL code column. */ + policyTags: OnyxCollection; } & Pick< OptimisticTrackingReturn, 'showPendingExpensePlaceholder' | 'shouldDeferHeavySearchWork' | 'setShouldDeferHeavySearchWork' | 'hasPendingWriteOnMountRef' | 'skipDeferralOnFocusRef' | 'rearmTracking' @@ -119,6 +126,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Inject an optimistically-created transaction the server has not indexed yet so its row mounts // immediately. @@ -193,6 +201,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans translate, formatPhoneNumber, bankAccountList, + rules, groupBy: validGroupBy, reportActions: exportReportActions, currentSearch: currentSearchKey, @@ -226,6 +235,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans translate, formatPhoneNumber, bankAccountList, + rules, validGroupBy, exportReportActions, currentSearchKey, @@ -273,6 +283,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans currentAccountID: accountID, currentUserEmail: email ?? '', bankAccountList, + rules, translate, formatPhoneNumber, isActionLoadingSet, @@ -298,6 +309,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans accountID, email, bankAccountList, + rules, translate, localeCompare, formatPhoneNumber, @@ -414,6 +426,8 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, + policyCategories, + policyTags, }; } diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 1a1e171dc90d..80e444b76e06 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -1,10 +1,11 @@ +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; -import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; +import {buildFilterQueryWithSortDefaults, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,6 +14,8 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { const {translate} = useLocalize(); + const {resetSearchKey} = useSearchQueryActions(); + const {currentSearchHash} = useSearchQueryContext(); const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -49,6 +52,13 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } + if (values.type && searchAdvancedFiltersForm.type !== values.type) { + const newQueryJSON = buildSearchQueryJSON(queryString); + if (currentSearchHash !== newQueryJSON?.hash) { + resetSearchKey(newQueryJSON); + } + } + Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 4cd448cc5001..5a3aecbe6dc4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -50,6 +50,7 @@ import { isTransactionListItemType, isTransactionReportGroupListItemType, isTransactionSearchType, + searchKeyToSavedSearchID, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -69,6 +70,8 @@ import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; +import type {GetReportTableColumnStylesParams} from '@styles/utils'; + import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -189,10 +192,17 @@ function Search({ const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); const isExpenseAllMatchingSelection = type === CONST.SEARCH.DATA_TYPES.EXPENSE && areAllMatchingItemsSelected; const isAllMatchingItemsCountMissing = isExpenseAllMatchingSelection && typeof searchResults?.search?.count !== 'number'; - const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); + const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); const shouldCalculateTotals = (areAllMatchingItemsSelected && !isExpenseAllMatchingSelection) || shouldCalculateExpenseTotals; 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 + // report count, not the expense `count` (which is always present). Treat a missing reportCount as the missing + // total so the totals retry below still fires. Otherwise a snapshot that has `count` but no `reportCount` + // (e.g. persisted before the field shipped, or a colliding non-totals response) would never fetch it and the + // button would stay loading forever. + const isRequiredAllMatchingTotalMissing = + isExpenseReportType && areAllMatchingItemsSelected ? typeof searchResults?.search?.reportCount !== 'number' : searchResults?.search?.count === undefined; useEffect(() => { if (searchRequestOffset === offset) { @@ -205,14 +215,20 @@ function Search({ // Retrying a failed page always resets pagination to the first page, so totals eligibility // must be evaluated as if we're on the first page rather than the (possibly paginated) offset. - const shouldCalculateTotalsOnRetry = useSearchShouldCalculateTotals(currentSearchKey, hash, true, areAllMatchingItemsSelected); + const shouldCalculateTotalsOnRetry = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const searchListRef = useRef | null>(null); - const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); + const savedSearchSelector = useCallback( + (searches: OnyxEntry) => { + const savedSearchID = searchKeyToSavedSearchID(currentSearchKey); + return savedSearchID ? searches?.[savedSearchID] : undefined; + }, + [currentSearchKey], + ); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { selector: savedSearchSelector, }); @@ -262,6 +278,8 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, + policyCategories, + policyTags, } = useSearchSnapshot({ queryJSON, searchResults, @@ -401,10 +419,6 @@ function Search({ const shouldRetrySearchWithTotalsOrGroupedRef = useRef(false); - // `isLoading` has to stay out of the effect deps below, or every completed search would start another, - // so a page requested while one was in flight is remembered here and fired once it resolves. - const pendingSearchOffsetRef = useRef(undefined); - useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT; @@ -427,12 +441,9 @@ function Search({ } if (searchResults?.search?.isLoading) { - if (validGroupBy || (shouldCalculateTotals && searchResults?.search?.count === undefined)) { + if (validGroupBy || (shouldCalculateTotals && isRequiredAllMatchingTotalMissing)) { shouldRetrySearchWithTotalsOrGroupedRef.current = true; } - if (offset > 0) { - pendingSearchOffsetRef.current = offset; - } return; } @@ -458,7 +469,6 @@ function Search({ return; } - pendingSearchOffsetRef.current = undefined; handleSearch({ queryJSON, searchKey: currentSearchKey, @@ -470,22 +480,22 @@ function Search({ // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, hasErrors, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy, searchRequestOffset]); + }, [handleSearch, hasErrors, isOffline, offset, queryJSON, shouldCalculateTotals, validGroupBy, searchRequestOffset]); useEffect(() => { if (!shouldRetrySearchWithTotalsOrGroupedRef.current || searchResults?.search?.isLoading || (!shouldCalculateTotals && !validGroupBy)) { return; } - // If count is already present, the latest response already contains totals and we can skip the re-query. + // If the required total is already present, the latest response already contains totals and we can skip the + // re-query (for expense-report select-all that means reportCount, not the always-present expense count). // If we show grouped values we want to retry search either way, the data may be outdated e.g. after deleting an expense. - if (!validGroupBy && searchResults?.search?.count !== undefined) { + if (!validGroupBy && !isRequiredAllMatchingTotalMissing) { shouldRetrySearchWithTotalsOrGroupedRef.current = false; return; } shouldRetrySearchWithTotalsOrGroupedRef.current = false; - pendingSearchOffsetRef.current = undefined; handleSearch({ queryJSON, searchKey: currentSearchKey, @@ -500,41 +510,13 @@ function Search({ queryJSON, currentSearchKey, searchResults?.search?.count, + isRequiredAllMatchingTotalMissing, searchResults?.search?.isLoading, shouldCalculateTotals, validGroupBy, searchRequestOffset, ]); - useEffect(() => { - if (pendingSearchOffsetRef.current !== offset || searchResults?.search?.isLoading || !searchResults?.search?.hasMoreResults || !isFocused || isOffline || hasErrors) { - return; - } - - pendingSearchOffsetRef.current = undefined; - handleSearch({ - queryJSON, - searchKey: currentSearchKey, - offset: searchRequestOffset, - shouldCalculateTotals, - prevReportsLength: filteredDataLength, - isLoading: false, - }); - }, [ - filteredDataLength, - handleSearch, - hasErrors, - isFocused, - isOffline, - offset, - queryJSON, - currentSearchKey, - searchResults?.search?.isLoading, - searchResults?.search?.hasMoreResults, - shouldCalculateTotals, - searchRequestOffset, - ]); - useEffect(() => { if (!isSearchResultsEmpty || prevIsSearchResultEmpty) { return; @@ -841,13 +823,79 @@ function Search({ } }, [hasErrors, queryJSON, searchResults, shouldResetSearchQuery, setShouldResetSearchQuery]); + // `isLoading` has to stay out of the search effect's deps, or every completed search would start another, + // and onEndReached only fires on the edge, so a page we cannot fetch yet is remembered here rather than + // dropped. Stays set until that page actually shows up in the snapshot. + const wantedOffsetRef = useRef(undefined); + const fetchMoreResults = useCallback(() => { - if (!isFocused || !searchResults?.search?.hasMoreResults || shouldShowLoadingState || shouldShowLoadingMoreItems || offset > allDataLength - CONST.SEARCH.RESULTS_PAGE_SIZE) { + if (!searchResults?.search?.hasMoreResults) { + wantedOffsetRef.current = undefined; + return; + } + + // A first-page response replaces the snapshot rather than appending to it, so deriving the next page + // from `offset` instead of the snapshot's own cursor drifts the moment one lands mid-pagination. + const serverOffset = searchResults?.search?.offset ?? 0; + if (!isFocused || shouldShowLoadingState || serverOffset > allDataLength - CONST.SEARCH.RESULTS_PAGE_SIZE) { return; } - setOffset((prev) => prev + CONST.SEARCH.RESULTS_PAGE_SIZE); - }, [isFocused, searchResults?.search?.hasMoreResults, shouldShowLoadingMoreItems, shouldShowLoadingState, offset, allDataLength]); + const nextOffset = serverOffset + CONST.SEARCH.RESULTS_PAGE_SIZE; + wantedOffsetRef.current = nextOffset; + // Offline, the request would only fail and leave an error on the snapshot. Hold the page until reconnect. + if (searchResults?.search?.isLoading || isOffline) { + return; + } + + // Dispatched here rather than left to the offset effect: after a first-page response `nextOffset` can + // equal the offset we already hold, and that effect only runs on a change. search() dedupes the pair. + setOffset(nextOffset); + handleSearch({ + queryJSON, + searchKey: currentSearchKey, + offset: nextOffset, + shouldCalculateTotals, + prevReportsLength: filteredDataLength, + isLoading: false, + }); + }, [ + isFocused, + isOffline, + searchResults?.search?.hasMoreResults, + searchResults?.search?.isLoading, + searchResults?.search?.offset, + shouldShowLoadingState, + allDataLength, + handleSearch, + queryJSON, + currentSearchKey, + shouldCalculateTotals, + filteredDataLength, + ]); + + // Ask again for a page that never arrived, either because a search was still running when the list hit + // its end or because a first-page response replaced it. Both leave the request with nothing to retry it. + useEffect(() => { + const serverOffset = searchResults?.search?.offset ?? 0; + // A first-page response that lands after the page it displaces drags the cursor back below the page we + // hold, after that page's arrival already cleared the intent. The list has not moved, so arm it again. + if (wantedOffsetRef.current === undefined && searchResults?.search?.hasMoreResults && serverOffset < offset) { + wantedOffsetRef.current = offset; + } + + const wantedOffset = wantedOffsetRef.current; + if (wantedOffset === undefined || searchResults?.search?.isLoading) { + return; + } + + if (serverOffset >= wantedOffset) { + wantedOffsetRef.current = undefined; + return; + } + + fetchMoreResults(); + }, [fetchMoreResults, offset, searchResults?.search?.hasMoreResults, searchResults?.search?.isLoading, searchResults?.search?.offset]); const onLayoutBase = useCallback(() => { hasHadFirstLayout.current = true; @@ -1214,6 +1262,20 @@ function Search({ /> ) : undefined; + // The same flags the column header above is built from, so a row and its heading can't disagree about how wide a + // column is. Read once here because they are decided across the whole search, not from the rows currently loaded. + const columnSizeOptions: GetReportTableColumnStylesParams = { + isActionColumnWide: isTask || hasDeletedTransaction, + isDateColumnWide: shouldShowYearCreated, + isSubmittedColumnWide: shouldShowYearSubmitted, + isApprovedColumnWide: shouldShowYearApproved, + isPostedColumnWide: shouldShowYearPosted, + isExportedColumnWide: shouldShowYearExported, + isWithdrawnColumnWide: shouldShowYearWithdrawn, + isAmountColumnWide: shouldShowAmountInWideColumn, + isTaxAmountColumnWide: shouldShowTaxAmountInWideColumn, + }; + const commonViewProps: CommonSearchViewProps = { ref: searchListRef, queryJSON, @@ -1233,6 +1295,7 @@ function Search({ newTransactions, hasLoadedAllTransactions, isActionColumnWide: isTask || hasDeletedTransaction, + columnSizeOptions, }; let searchListContent: React.JSX.Element; @@ -1242,6 +1305,8 @@ function Search({ {...commonViewProps} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} + policyCategories={policyCategories} + policyTags={policyTags} /> ); } else if (isTransactionListView) { diff --git a/src/components/Search/primitives/HorizontalTableScroll.tsx b/src/components/Search/primitives/HorizontalTableScroll.tsx index 211c49d15f3c..08492424d323 100644 --- a/src/components/Search/primitives/HorizontalTableScroll.tsx +++ b/src/components/Search/primitives/HorizontalTableScroll.tsx @@ -34,6 +34,23 @@ type HorizontalTableScrollProps = { /** Re-restores the saved horizontal offset whenever this value changes (typically the list data). */ dataKey: unknown; + + /** Known minimums for the columns that have one, replacing the estimates `getTableMinWidth` assumes for them. */ + columnMinWidths?: Partial>; + + /** + * What each column wants rather than the least it can take. Used to size the scrolled content, so that once the + * table scrolls its columns are laid out from what they hold instead of staying squeezed to the widths that only a + * table short of room would have needed. + */ + columnContentWidths?: Partial>; + + /** + * Measured width of the area the table lays out into. The page is inset from the window, so the window is wider and + * comparing against it would leave the table overflowing its container without ever scrolling. Falls back to the + * window width until the first layout. + */ + availableWidth?: number; }; /** @@ -41,11 +58,20 @@ type HorizontalTableScrollProps = { * the saved horizontal offset across query changes (before paint, to avoid a visible shift). Extracted * from SearchList so ExpenseFlatSearchView can reuse it. */ -function HorizontalTableScroll({children, columns, type, isActionColumnWide, isHeaderVisible, dataKey}: HorizontalTableScrollProps) { +function HorizontalTableScroll({children, columns, type, isActionColumnWide, isHeaderVisible, dataKey, columnMinWidths, columnContentWidths, availableWidth}: HorizontalTableScrollProps) { const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); - const minTableWidth = getTableMinWidth(columns, type, isActionColumnWide); - const shouldScrollHorizontally = isHeaderVisible && minTableWidth > windowWidth; + // The row chrome is counted only alongside real column widths, which is the same thing as saying only for a table + // whose columns were sized. Left off, this is the arithmetic every view had before any of them were sized. + const isSizingColumns = !!columnMinWidths; + const minTableWidth = getTableMinWidth(columns, type, isActionColumnWide, columnMinWidths, isSizingColumns); + const tableWidth = availableWidth && availableWidth > 0 ? availableWidth : windowWidth; + + // Whether to scroll is decided on the minimums, so the table only gives up on fitting once it truly cannot. The + // scrolled content is then laid out from what the columns want, since there is no longer any room to save by + // squeezing them, and never narrower than the space already available. + const shouldScrollHorizontally = isHeaderVisible && minTableWidth > tableWidth; + const contentTableWidth = Math.max(getTableMinWidth(columns, type, isActionColumnWide, columnContentWidths, isSizingColumns), minTableWidth, tableWidth); const horizontalScrollViewRef = useRef(null); @@ -71,7 +97,7 @@ function HorizontalTableScroll({children, columns, type, isActionColumnWide, isH horizontal showsHorizontalScrollIndicator style={styles.flex1} - contentContainerStyle={{width: minTableWidth}} + contentContainerStyle={{width: contentTableWidth}} contentOffset={{x: savedHorizontalScrollOffset, y: 0}} onScroll={handleHorizontalScroll} scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 40da64005c12..4c511a65d25d 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -1,11 +1,14 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {GetReportTableColumnStylesParams} from '@styles/utils'; + +import type {CardList, PolicyCategories, PolicyTagLists, Transaction} from '@src/types/onyx'; import type React from 'react'; import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {SearchColumnType, SearchQueryJSON} from './types'; @@ -35,7 +38,13 @@ type CommonSearchViewProps = { /** Whether the action column uses its wider variant. */ isActionColumnWide: boolean; - /** Whether mobile selection mode is on. */ + /** + * Which columns render in their wider variant, decided across the whole search rather than per row: a date carrying + * a year, an amount long enough to need the room. The column header already sizes itself from these, so the rows + * have to read the same ones or they line up against a header of a different width. + */ + columnSizeOptions: GetReportTableColumnStylesParams; + isMobileSelectionModeEnabled: boolean; /** The column header element (undefined on narrow layouts). */ @@ -59,16 +68,12 @@ type CommonSearchViewProps = { /** Fires when the list scrolls near its end (router's fetchMoreResults). */ onEndReached: () => void; - /** Fires on the list's first layout and on layout changes. */ onLayout: () => void; /** Scroll handler forwarded to the list. */ onScroll?: (event: NativeSyntheticEvent) => void; - /** Content container style for the list. */ contentContainerStyle: StyleProp; - - /** Outer container style for the list wrapper. */ containerStyle: StyleProp; /** Imperative handle for highlight-driven scrolling, set by the router. */ @@ -82,6 +87,12 @@ type TransactionViewExtras = { /** Non-personal and workspace cards for row rendering (subscribed once by the router). */ nonPersonalAndWorkspaceCards?: CardList; + + /** Every policy's categories, used to size the category GL code column from the codes it renders. */ + policyCategories?: OnyxCollection; + + /** Every policy's tag lists, used to size the tag GL code column from the codes it renders. */ + policyTags?: OnyxCollection; }; export type {CommonSearchViewProps, TransactionViewExtras}; diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 4b643ba6b42e..dab026020114 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -4,7 +4,7 @@ import {isTransactionListItemType, isTransactionReportGroupListItemType} from '@ import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isExpenseUnreported, isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -24,7 +24,6 @@ type MapTransactionItemToSelectedEntryParams = { /** Email of the current user */ currentUserLogin: string; - /** Account ID of the current user */ currentUserAccountID: number; /** Report name-value pairs collection, used for the change-report eligibility archived check */ @@ -41,6 +40,9 @@ type MapTransactionItemToSelectedEntryParams = { /** The row's parent report, used for split eligibility */ parentReport: OnyxEntry | undefined; + + /** Approval workflow rules, used for split eligibility */ + rules: OnyxCollection; }; /** @@ -59,8 +61,9 @@ function mapTransactionItemToSelectedEntry({ selfDMReport, allowNegativeAmount, parentReport, + rules, }: MapTransactionItemToSelectedEntryParams): [string, SelectedTransactionInfo] { - const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID); + const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID, rules); const canRejectRequest = item.report ? canRejectReportAction(item.report, currentUserAccountID, item.policy) : false; const amount = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount; const isUnreported = isExpenseUnreported(item); @@ -75,7 +78,7 @@ function mapTransactionItemToSelectedEntry({ canHold: canHoldRequest, isHeld: isOnHold(item), canUnhold: canUnholdRequest, - canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, currentUserLogin, currentUserAccountID, item.policy, parentReport), + canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, currentUserLogin, currentUserAccountID, rules, item.policy, parentReport), hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, canChangeReport: canEditFieldOfMoneyRequest({ reportAction: item.reportAction, @@ -85,6 +88,7 @@ function mapTransactionItemToSelectedEntry({ report: item.report, policy: item.policy, reportNameValuePairs, + rules, }), action: item.action, groupCurrency: item.groupCurrency, @@ -93,6 +97,7 @@ function mapTransactionItemToSelectedEntry({ reportID: item.reportID, policyID: item.policyID, amount: allowNegativeAmount ? amount : Math.abs(amount), + displayAmount: item.formattedTotal, groupAmount: item.groupAmount, currency: item.currency, isFromOneTransactionReport: isOneTransactionReport(item.report), @@ -122,6 +127,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, amount: item.totalDisplaySpend ?? item.total ?? 0, + displayAmount: item.totalDisplaySpend ?? 0, currency, ...(currency ? {groupCurrency: currency} : {}), }, @@ -146,6 +152,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, amount: item.total ?? 0, + displayAmount: item.total ?? 0, currency, ...(currency ? {groupCurrency: currency} : {}), }, @@ -168,7 +175,6 @@ type PrepareTransactionsListParams = { /** Email of the current user */ currentUserLogin: string; - /** Account ID of the current user */ currentUserAccountID: number; /** Report name-value pairs collection, used for the change-report eligibility archived check */ @@ -182,6 +188,9 @@ type PrepareTransactionsListParams = { /** The row's parent report, used for split eligibility */ parentReport: OnyxEntry | undefined; + + /** Approval workflow rules, used for split eligibility */ + rules: OnyxCollection; }; /** @@ -199,6 +208,7 @@ function prepareTransactionsList({ outstandingReportsByPolicyID, selfDMReport, parentReport, + rules, }: PrepareTransactionsListParams) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; @@ -217,6 +227,7 @@ function prepareTransactionsList({ selfDMReport, allowNegativeAmount: false, parentReport, + rules, }); return { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index a255ff30f41a..73b599f5478e 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -69,9 +69,12 @@ type SelectedTransactionInfo = { /** The policyID tied to the report the transaction is reported on */ policyID: string | undefined; - /** The transaction amount */ + /** The transaction amount as a magnitude, used for bulk pay. Signed only on the reconcile path. */ amount: number; + /** The signed amount the row displays */ + displayAmount: number; + /** The transaction currency */ currency: string; @@ -189,12 +192,16 @@ type SearchQueryContextValue = { currentSimilarSearchHash: number; currentSearchKey: SearchKey | undefined; currentSearchQueryJSON: Readonly | undefined; + currentDefaultSearchQueryJSON: SearchQueryJSON | undefined; + currentDefaultSearchQueryFilterKeys: Set; suggestedSearches: Record; shouldResetSearchQuery: boolean; }; type SearchQueryActionsValue = { setShouldResetSearchQuery: (shouldReset: boolean) => void; + setCurrentSearchKey: (searchKey: SearchKey, pendingQuery?: string) => void; + resetSearchKey: (queryJSON: SearchQueryJSON | undefined) => void; }; type SearchResultsContextValue = { @@ -346,23 +353,23 @@ type SearchAmountFilterKeys = | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT_REIMBURSED; type SearchAmountValues = Record, string | undefined>; -type SearchFilterKey = - | SyntaxFilterKey - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW; - type UserFriendlyKey = ValueOf; type UserFriendlyValue = ValueOf; +type QueryFilterKey = SyntaxFilterKey | ReportFieldTextKey; type QueryFilters = Array<{ - key: SearchFilterKey; + key: QueryFilterKey; filters: QueryFilter[]; }>; +type SearchFilterKey = + | QueryFilterKey + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT; + type RawFilterKey = SyntaxFilterKey | ValueOf; type RawQueryFilter = { @@ -457,7 +464,6 @@ type SearchChartProps = { /** Callback when a chart item is pressed - receives the filter query to apply */ onItemPress?: (filterQuery: string) => void; - /** Whether data is loading */ isLoading?: boolean; /** Currency unit with font fallback support */ @@ -508,6 +514,7 @@ export type { QueryFilter, Filter, QueryFilters, + QueryFilterKey, SyntaxFilterKey, RawQueryFilter, SearchFilterKey, diff --git a/src/components/Section/IconSection.tsx b/src/components/Section/IconSection.tsx index 3f518d4e9d2d..fc61cf60281b 100644 --- a/src/components/Section/IconSection.tsx +++ b/src/components/Section/IconSection.tsx @@ -14,10 +14,7 @@ import {View} from 'react-native'; type IconSectionProps = { icon?: IconAsset; iconContainerStyles?: StyleProp; - /** The width of the icon. */ width?: number; - - /** The height of the icon. */ height?: number; }; diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index 1b97c265b8d7..b8d7f9a61449 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -1,8 +1,6 @@ import ImageSVG from '@components/ImageSVG'; import Lottie from '@components/Lottie'; import type DotLottieAnimation from '@components/LottieAnimations/types'; -import type {MenuItemWithLink} from '@components/MenuItemList'; -import MenuItemList from '@components/MenuItemList'; import Text from '@components/Text'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -32,9 +30,6 @@ const CARD_LAYOUT = { } as const; type SectionProps = Partial & { - /** An array of props that are passed to individual MenuItem components */ - menuItems?: MenuItemWithLink[]; - /** The text to display in the title of the section */ title?: string; @@ -47,7 +42,6 @@ type SectionProps = Partial & { /** Card layout that affects icon positioning, margins, sizes */ cardLayout?: ValueOf; - /** Whether the subtitle should have a muted style */ subtitleMuted?: boolean; /** Customize the Section container */ @@ -65,10 +59,7 @@ type SectionProps = Partial & { /** Customize the Section container */ childrenStyles?: StyleProp; - /** Customize the Icon container */ iconContainerStyles?: StyleProp; - - /** Customize the Central pane container */ centralPaneContainerStyle?: StyleProp; /** Whether the section is in the central pane of the layout */ @@ -80,13 +71,8 @@ type SectionProps = Partial & { /** The background color to apply in the upper half of the screen. */ illustrationBackgroundColor?: string; - /** Customize the Illustration container */ illustrationContainerStyle?: StyleProp; - - /** Styles to apply to illustration component */ illustrationStyle?: StyleProp; - - /** Padding for content on large screens */ contentPaddingOnLargeScreens?: {padding: number}; /** Overlay content to display on top of animation */ @@ -95,13 +81,8 @@ type SectionProps = Partial & { /** The component to display in the title of the section */ renderSubtitle?: () => ReactNode; - /** The component to display custom title */ renderTitle?: () => ReactNode; - - /** The width of the icon. */ iconWidth?: number; - - /** The height of the icon. */ iconHeight?: number; /** Banner to display at the top of the section */ @@ -115,7 +96,6 @@ function Section({ icon, cardLayout = CARD_LAYOUT.ICON_ON_RIGHT, iconContainerStyles, - menuItems, subtitle, subtitleStyles, subtitleTextStyles, @@ -229,8 +209,6 @@ function Section({ )} {children} - - {!!menuItems && } ); diff --git a/src/components/SectionSubtitleHTML.tsx b/src/components/SectionSubtitleHTML.tsx index bb1c87b06d7d..b1091522852a 100644 --- a/src/components/SectionSubtitleHTML.tsx +++ b/src/components/SectionSubtitleHTML.tsx @@ -15,10 +15,7 @@ type SectionSubtitleHTMLProps = { /** Whether the subtitle text should be muted */ subtitleMuted?: boolean; - /** Optional link press handler */ onLinkPress?: ComponentProps['onLinkPress']; - - /** Optional wrapper style */ wrapperStyle?: StyleProp; }; diff --git a/src/components/SelectionButton.tsx b/src/components/SelectionButton.tsx index 8915d14b4759..a4664da47224 100644 --- a/src/components/SelectionButton.tsx +++ b/src/components/SelectionButton.tsx @@ -37,7 +37,6 @@ type BaseSelectionButtonProps = Partial & /** Additional styles to add to the indicator container */ containerStyle?: StyleProp; - /** Callback that is called when mousedown is triggered. */ onMouseDown?: MouseEventHandler; /** The size of the indicator container */ @@ -49,10 +48,7 @@ type BaseSelectionButtonProps = Partial & /** The size of the caret (checkmark) */ caretSize?: number; - /** An accessibility label */ accessibilityLabel: string; - - /** An accessibility hint */ accessibilityHint?: string; /** stop propagation of the mouse down event */ @@ -61,16 +57,12 @@ type BaseSelectionButtonProps = Partial & /** Whether the button should be selected when pressing Enter key */ shouldSelectOnPressEnter?: boolean; - /** Additional styles to add to the outer wrapper */ wrapperStyle?: StyleProp; /** Used to locate this view in end-to-end tests. */ testID?: string; - /** Reference to the outer element */ ref?: ForwardedRef; - - /** Tab index */ tabIndex?: 0 | -1; /** Whether the button is accessible to screen readers */ diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 5386cb09849a..daaf6f8bc419 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -248,12 +248,26 @@ function BaseSelectionListImpl({ selectRow(focusedOption); }; + const hasSelectedItems = dataDetails.selectedOptions.length > 0; + const isFooterConfirmEnabled = confirmButtonOptions?.isFooterConfirmEnabled ?? hasSelectedItems; + const isCustomFooterConfirmEnabled = isFooterConfirmEnabled && confirmButtonOptions?.isDisabled !== true && confirmButtonOptions?.isFooterConfirmEnterKeyEnabled !== false; + // Whether Enter should trigger an enabled confirm button instead of the list. + // Footer renders footerContent in place of the built-in button, so the two paths are mutually + // exclusive; custom footers count only if they are Enter-capable and enabled. Owners can + // override the enabled state when selection persists outside the currently rendered rows. + const hasEnabledEnterConfirm = + (!footerContent && !!confirmButtonOptions?.showButton && !confirmButtonOptions?.isDisabled) || (!!footerContent && !!confirmButtonOptions?.onConfirm && isCustomFooterConfirmEnabled); + // Whether the focused row should handle plain Enter. + // Enter selects the row when keyboard navigation/search is active, propagation should stop, + // or there is no enabled Enter-capable confirm control that should handle the keypress instead. + const shouldSelectOnEnter = isKeyboardNavigating || !!syncedSearchValue?.trim() || !hasEnabledEnterConfirm || shouldStopPropagation; + useSelectionListShortcuts({ selectFocusedItem: selectFocusedOption, getFocusedOption: () => focusedOption, confirmButtonOptions, isActive: isFocused, - focusedIndex, + focusedIndex: shouldSelectOnEnter ? focusedIndex : -1, disableKeyboardShortcuts, shouldStopPropagation, shouldBubble: !focusedOption, @@ -323,6 +337,7 @@ function BaseSelectionListImpl({ shouldSyncFocus={!isTextInputFocusedRef.current && isKeyboardNavigating} shouldDisableHoverStyle={shouldDisableHoverStyle} shouldShowRightCaret={shouldShowRightCaret} + isFirstItem={index === 0} isLastItem={index === data.length - 1} shouldPreventEnterKeySubmit={!disableKeyboardShortcuts} selectionButtonPosition={selectionButtonPosition} diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx index 9699590ea49c..d1ba2d9b2e5c 100644 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseListItem.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -54,7 +53,7 @@ function BaseListItem({ shouldShowRightCaret = false, accessible, accessibilityLabel, - accessibilityRole = getButtonRole(true), + accessibilityRole = CONST.ROLE.BUTTON, shouldUseOptionRole, isSelected, forwardedFSClass, diff --git a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx index 45f92e160512..02f5362311f4 100644 --- a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx @@ -39,9 +39,11 @@ function BaseSelectListItem({ isFocusVisible, accessibilityRole, selectionButtonPosition, + leftElement, }: BaseSelectListItemProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const rowLeftElement = leftElement ?? item.leftElement; const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; const indentsLength = (item.text?.length ?? 0) - (fullTitle?.length ?? 0); const paddingLeft = Math.floor(indentsLength / CONST.INDENTS.length) * styles.ml3.marginLeft; @@ -82,7 +84,7 @@ function BaseSelectListItem({ selectionButtonPosition={selectionButtonPosition} > <> - {!!item.leftElement && item.leftElement} + {rowLeftElement} = Omit['singleExecution']; titleStyles?: StyleProp; titleContainerStyles?: StyleProp; + isFirstItem?: boolean; isLastItem?: boolean; shouldHighlightSelectedItem?: boolean; shouldPreventEnterKeySubmit?: boolean; @@ -58,6 +59,7 @@ function ListItemRenderer({ shouldShowRightCaret, selectionButtonPosition, errorRowStyles, + isFirstItem, isLastItem, shouldPreventEnterKeySubmit = true, }: ListItemRendererProps) { @@ -113,6 +115,7 @@ function ListItemRenderer({ shouldDisableHoverStyle={shouldDisableHoverStyle} shouldShowRightCaret={shouldShowRightCaret} selectionButtonPosition={selectionButtonPosition} + isFirstItem={isFirstItem} isLastItem={isLastItem} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx index b51b49e69453..32a82659a750 100644 --- a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx @@ -1,12 +1,10 @@ -import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {Icon} from '@src/types/onyx/OnyxCommon'; import React from 'react'; -import {View} from 'react-native'; import type {ListItem, MultiSelectListItemProps} from './types'; @@ -38,15 +36,19 @@ function MultiSelectListItem({ const styles = useThemeStyles(); const icon = item.icons?.at(0); - const itemWithAvatar = { - ...item, - leftElement: icon ? : item.leftElement, - }; const computedWrapperStyle = [icon ? [styles.pv0, styles.mnh13] : styles.optionRowCompact, wrapperStyle]; return ( + ) : undefined + } isFocused={isFocused} isFocusVisible={isFocusVisible} showTooltip={showTooltip} @@ -69,17 +71,4 @@ function MultiSelectListItem({ ); } -function AvatarLeftElement({icon}: {icon: Icon}) { - const styles = useThemeStyles(); - - return ( - - - - ); -} - export default MultiSelectListItem; diff --git a/src/components/SelectionList/ListItem/SplitListItem.tsx b/src/components/SelectionList/ListItem/SplitListItem.tsx index 20d23ba87706..2f659f6cef38 100644 --- a/src/components/SelectionList/ListItem/SplitListItem.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem.tsx @@ -15,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedFullCategoryName} from '@libs/CategoryUtils'; import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils'; +import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -146,7 +147,7 @@ function SplitListItem({ diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx index b08ae4a69342..54f028ffd0ad 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -22,7 +22,6 @@ type SplitAmountInputProps = { formattedOriginalAmount: string; /** The width of the input content area. */ contentWidth: number; - /** Callback invoked when the split expense value changes. */ onSplitExpenseValueChange: (value: string) => void; /** Callback invoked when the input receives focus. */ focusHandler: () => void; diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitListItemInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitListItemInput.tsx index f1a5013020ab..1de0c0d8aee8 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitListItemInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitListItemInput.tsx @@ -19,7 +19,6 @@ type SplitListItemInputProps = { formattedOriginalAmount: string; /** The draft percentage value while the user is editing. */ percentageDraft?: string; - /** Callback invoked when the split expense value changes. */ onSplitExpenseValueChange: (value: string) => void; /** State setter for the percentage draft value. */ setPercentageDraft: React.Dispatch>; diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitPercentageInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitPercentageInput.tsx index 0b923c9150af..b03d61f67ea7 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitPercentageInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitPercentageInput.tsx @@ -19,7 +19,6 @@ type SplitPercentageInputProps = { contentWidth: number; /** The draft percentage value while the user is editing. */ percentageDraft: string | undefined; - /** Callback invoked when the split expense value changes. */ onSplitExpenseValueChange: (value: string) => void; /** State setter for the percentage draft value. */ setPercentageDraft: React.Dispatch>; diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index 58b68a558345..8fba42ddf76a 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -1,5 +1,4 @@ -import UserAvatar from '@components/Avatar/UserAvatar'; -import TextWithTooltip from '@components/TextWithTooltip'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -7,11 +6,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {areEmailsFromSamePrivateDomain} from '@libs/LoginUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; -import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {ListItem, UserSelectionListItemProps} from './types'; @@ -42,25 +40,18 @@ function UserSelectionListItem({ const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {formatPhoneNumber, translate} = useLocalize(); - const userHandle = useMemo(() => { - const login = item.login ?? ''; + const login = item.login ?? ''; - // If the emails are not in the same private domain, we just return the users email - if (!areEmailsFromSamePrivateDomain(login, currentUserPersonalDetails.login ?? '')) { - return formatPhoneNumber(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); - // Otherwise, the emails are a part of the same private domain, so we can remove the domain and just show username - return login.split('@').at(0); - }, [currentUserPersonalDetails.login, item.login, formatPhoneNumber]); + const userDisplayName = getDisplayNameForParticipant({ + accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, + formatPhoneNumber, + translate, + }); - const userDisplayName = useMemo(() => { - return getDisplayNameForParticipant({ - accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, - formatPhoneNumber, - translate, - }); - }, [formatPhoneNumber, item.accountID, translate]); + const icon = item.icons?.at(0); return ( ({ shouldSyncFocus={shouldSyncFocus} > - {!!item.icons?.length && ( - - - - )} + {!!icon && } - {!!userHandle && ( - )} diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 0f8ba1f1f0c2..599021bef35f 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -33,10 +33,7 @@ import type UserListItem from './UserListItem'; import type UserSelectionListItem from './UserSelectionListItem'; type ListItem = { - /** Text to display */ text?: string; - - /** Alternate text to display */ alternateText?: string | null; /** Custom node rendered in place of the alternate text (e.g. a description containing an inline link). Takes precedence over `alternateText` when set. */ @@ -51,13 +48,11 @@ type ListItem = { /** Key used internally by React */ keyForList: K; - /** Whether this option is selected */ isSelected?: boolean; /** Whether the option can show both selected and error indicators */ canShowSeveralIndicators?: boolean; - /** Whether the checkbox should be disabled */ isDisabledCheckbox?: boolean; /** Whether this option is disabled for selection */ @@ -66,28 +61,19 @@ type ListItem = { /** Whether to hide the selection button (radio/checkbox) entirely, e.g. for structural parent rows that only provide hierarchy context */ shouldHideSelectionButton?: boolean; - /** Whether this item should be interactive at all */ isInteractive?: boolean; /** List title is bold by default. Use this props to customize it */ isBold?: boolean; - /** User accountID */ accountID?: number | null; - - /** User login */ login?: string | null; - - /** Element to show on the left side of the item */ leftElement?: ReactNode; - - /** Element to show on the right side of the item */ rightElement?: ReactNode; /** Icons for the user (can be multiple if it's a Workspace) */ icons?: Icon[]; - /** Errors that this user may contain */ errors?: Errors; /** The type of action that's pending */ @@ -101,19 +87,10 @@ type ListItem = { /** Represents the index of the option within the section it came from */ index?: number; - /** ID of the report */ reportID?: string; - - /** ID of the policy */ policyID?: string; - - /** ID of the group */ groupID?: string; - - /** ID of the category */ categoryID?: string; - - /** Whether this option should show subscript */ shouldShowSubscript?: boolean | null; /** Whether to wrap long text up to 2 lines */ @@ -122,13 +99,11 @@ type ListItem = { /** Whether to wrap the alternate text up to 2 lines */ isAlternateTextMultilineSupported?: boolean; - /** The search value from the selection list */ searchText?: string | null; /** What text to show inside the badge (if none present the badge will be omitted) */ badgeText?: string; - /** Whether the brick road indicator should be shown */ brickRoadIndicator?: BrickRoad | '' | null; /** Element to render below the ListItem */ @@ -146,7 +121,6 @@ type ListItem = { /** The style to override the default appearance */ itemStyle?: StyleProp; - /** Styles applied to the item title */ titleStyles?: StyleProp; /** Boolean whether to display the right icon */ @@ -163,34 +137,18 @@ type CommonListItemProps = { /** Whether this item is focused (for arrow key controls) */ isFocused?: boolean; - /** Whether this item is disabled */ isDisabled?: boolean | null; - - /** Whether this item should show Tooltip */ showTooltip: boolean; /** Whether to use the Checkbox (multiple selection) instead of the Checkmark (single selection) */ canSelectMultiple?: boolean; - /** Callback to fire when the item is pressed */ onSelectRow: (item: TItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** Callback to fire when an error is dismissed */ onDismissError?: (item: TItem) => void; - - /** Styles for the pressable component */ pressableStyle?: StyleProp; - - /** Styles for the pressable component wrapper view */ pressableWrapperStyle?: StyleProp>; - - /** Styles for the wrapper view */ wrapperStyle?: StyleProp; - - /** Styles for the container view */ containerStyle?: StyleProp; - - /** Styles applied for the error row of the list item */ errorRowStyles?: StyleProp; /** Whether to wrap long text up to 2 lines */ @@ -199,13 +157,11 @@ type CommonListItemProps = { /** Whether to wrap the alternate text up to 2 lines */ isAlternateTextMultilineSupported?: boolean; - /** Number of lines to show for alternate text */ alternateTextNumberOfLines?: number; /** Number of lines to show for title text when multiline is supported */ titleNumberOfLines?: number; - /** Handles what to do when the item is focused */ onFocus?: ListItemFocusEventHandler; /** @@ -215,7 +171,6 @@ type CommonListItemProps = { */ isFocusVisible?: boolean; - /** Callback to fire when the item is long pressed */ onLongPressRow?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; /** Accessibility role for the list item (e.g. 'checkbox' for multi-select options so screen readers announce checked state) */ @@ -242,7 +197,6 @@ type ExtendedTargetedEvent = TargetedEvent & { }; type TRightHandSideComponent = { - /** Component to display on the right side */ rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactNode | null | undefined) | ReactNode | null; }; @@ -250,16 +204,12 @@ type ListItemProps = CommonListItemProps & { /** The section list item */ item: TItem; - /** Callback to fire when the selection button is pressed */ onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; - /** Additional styles to apply to text */ style?: StyleProp; - - /** Is item hovered */ isHovered?: boolean; /** Prevent the submission of the list item when enter key is pressed */ @@ -271,34 +221,18 @@ type ListItemProps = CommonListItemProps & { */ shouldSyncFocus?: boolean; - /** Whether to show RBR */ shouldDisplayRBR?: boolean; /** Boolean whether to display the right icon */ shouldShowRightCaret?: boolean; - /** Styles applied for the title */ titleStyles?: StyleProp; - - /** Styles applied for the title container of the list item */ titleContainerStyles?: StyleProp; - - /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; - - /** Index of the item in the list */ index?: number; - - /** Callback when the input inside the item is focused (if input exists) */ onInputFocus?: (item: TItem) => void; - - /** Callback when the input inside the item is blurred (if input exists) */ onInputBlur?: (e: BlurEvent) => void; - - /** Callback when the hold menu should be opened */ onHoldMenuOpen?: HoldMenuCallback; - - /** Whether to disable the hover style of the item */ shouldDisableHoverStyle?: boolean; /** Whether the network is offline */ @@ -331,22 +265,17 @@ type BaseListItemProps = CommonListItemProps & /** Overrides the row's screen-reader name. Defaults to the item's derived label when omitted. */ accessibilityLabel?: string; shouldPreventEnterKeySubmit?: boolean; - /** Additional style object for the error row */ errorRowStyles?: StyleProp; FooterComponent?: ReactElement; children?: ReactElement> | ((hovered: boolean) => ReactElement>); shouldSyncFocus?: boolean; hoverStyle?: StyleProp; - /** Whether to show RBR */ shouldDisplayRBR?: boolean; /** Test ID of the component. Used to locate this view in end-to-end tests. */ testID?: string; /** Whether to show the right caret icon */ shouldShowRightCaret?: boolean; - /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; - - /** Whether to disable the hover style of the item */ shouldDisableHoverStyle?: boolean; /** @@ -363,7 +292,6 @@ type SpendRuleListItemType = ListItem & { /** The cards that the spend rule applies to */ summary: string; - /** The summary parts for the spend rule */ summaryParts: SpendRuleSummaryPart[]; /** A list of relevant tokens for searching for specific spend rules */ @@ -372,7 +300,6 @@ type SpendRuleListItemType = ListItem & { /** Props for SelectableListItem, which extends the composed ListItem pressable with selection button support. */ type SelectableListItemProps = Omit, 'containerStyle'> & { - /** Callback to fire when the selection button is pressed */ onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; /** Which side of the row to render the selection button on */ @@ -381,7 +308,6 @@ type SelectableListItemProps = Omit = ListItemProps; type SpendRuleListItemProps = ListItemProps; -type BaseSelectListItemProps = ListItemProps; +type BaseSelectListItemProps = ListItemProps & { + /** Element rendered before the text column. Falls back to `item.leftElement` when omitted. */ + leftElement?: ReactNode; +}; type SingleSelectListItemProps = ListItemProps; @@ -450,7 +378,7 @@ type TravelDomainListItemProps = SelectableListItemProps } >; -type UserSelectionListItemProps = UserListItemProps; +type UserSelectionListItemProps = ListItemProps; export type { SpendRuleListItemType, diff --git a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx index 206b609a26ea..63e31ca987c4 100644 --- a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx +++ b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {BaseListItemProps, ListItem} from '@components/SelectionList/ListItem/types'; @@ -80,7 +79,7 @@ function ListItemPressable({ shouldDisableHoverStyle, accessible, accessibilityLabel, - accessibilityRole = getButtonRole(true), + accessibilityRole = CONST.ROLE.BUTTON, shouldUseOptionRole, isSelected, shouldShowTooltip, diff --git a/src/components/SelectionList/ListItemComposed/index.tsx b/src/components/SelectionList/ListItemComposed/index.tsx index 0f69d62bba8a..ae052f823a92 100644 --- a/src/components/SelectionList/ListItemComposed/index.tsx +++ b/src/components/SelectionList/ListItemComposed/index.tsx @@ -19,6 +19,7 @@ * `ListItemComposed` to avoid the name collision. */ import ListItemPressable from './ListItemPressable'; +import ListItemCompactAvatar from './primitives/ListItemCompactAvatar'; import ListItemRBRIndicator from './primitives/ListItemRBRIndicator'; import ListItemRightCaret from './primitives/ListItemRightCaret'; import ListItemSubtitle from './primitives/ListItemSubtitle'; @@ -31,6 +32,7 @@ const ListItem = Object.assign(ListItemPressable, { RBRIndicator: ListItemRBRIndicator, TextColumn: ListItemTextColumn, RightCaret: ListItemRightCaret, + CompactAvatar: ListItemCompactAvatar, }); export default ListItem; diff --git a/src/components/SelectionList/ListItemComposed/primitives/ListItemCompactAvatar.tsx b/src/components/SelectionList/ListItemComposed/primitives/ListItemCompactAvatar.tsx new file mode 100644 index 000000000000..588d3e244a97 --- /dev/null +++ b/src/components/SelectionList/ListItemComposed/primitives/ListItemCompactAvatar.tsx @@ -0,0 +1,35 @@ +import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type ListItemCompactAvatarProps = { + /** Pre-resolved icon entry to render (typically the first entry of item.icons) */ + icon: Icon; + + /** Additional styles merged onto the avatar container */ + style?: StyleProp; +}; + +/** A small avatar in a fixed-size container, used by compact selection and suggestion rows. */ +function ListItemCompactAvatar({icon, style}: ListItemCompactAvatarProps) { + const styles = useThemeStyles(); + + return ( + + + + ); +} + +export default ListItemCompactAvatar; diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 501e3e25be01..c341c3cafb9a 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -36,6 +36,12 @@ function getItemType(item: FlattenedItem): ValueOf> | null>(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, flattenedData); const {containerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown); @@ -200,12 +210,26 @@ function BaseSelectionListWithSectionsImpl({ const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; + const hasSelectedItems = selectedItems.length > 0; + const isFooterConfirmEnabled = confirmButtonOptions?.isFooterConfirmEnabled ?? hasSelectedItems; + const isCustomFooterConfirmEnabled = isFooterConfirmEnabled && confirmButtonOptions?.isDisabled !== true && confirmButtonOptions?.isFooterConfirmEnterKeyEnabled !== false; + // Whether Enter should trigger an enabled confirm button instead of the list. + // Footer renders footerContent in place of the built-in button, so the two paths are mutually + // exclusive; custom footers count only if they are Enter-capable and enabled. Owners can + // override the enabled state when selection persists outside the currently rendered rows. + const hasEnabledEnterConfirm = + (!footerContent && !!confirmButtonOptions?.showButton && !confirmButtonOptions?.isDisabled) || (!!footerContent && !!confirmButtonOptions?.onConfirm && isCustomFooterConfirmEnabled); + // Whether the focused row should handle plain Enter. + // Enter selects the row when keyboard navigation/search is active, propagation should stop, + // or there is no enabled Enter-capable confirm control that should handle the keypress instead. + const shouldSelectOnEnter = isKeyboardNavigating || !!syncedSearchValue?.trim() || !hasEnabledEnterConfirm || shouldStopPropagation; + useSelectionListShortcuts({ selectFocusedItem, getFocusedOption: getFocusedItem, confirmButtonOptions, isActive: isScreenFocused, - focusedIndex, + focusedIndex: shouldSelectOnEnter ? focusedIndex : -1, disableKeyboardShortcuts, shouldStopPropagation, shouldBubble: itemsCount > 0 && !getFocusedItem(), @@ -333,7 +357,8 @@ function BaseSelectionListWithSectionsImpl({ ref={listRef} extraData={flattenedData.length} getItemType={getItemType} - initialScrollIndex={initialScrollIndex ?? initialFocusedIndex} + initialScrollIndex={targetScrollIndex} + overrideProps={FLASH_LIST_OVERRIDE_PROPS} keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index adabf7c0fb6b..7d06ff046892 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -7,10 +7,7 @@ import type {ReactElement, ReactNode} from 'react'; import type {LayoutChangeEvent} from 'react-native'; type Section = { - /** Title of the section */ title?: string; - - /** Custom header to display */ customHeader?: ReactElement; /** Array of items in the section */ @@ -28,25 +25,15 @@ type Section = { * Extends BaseSelectionListProps with section-specific features. */ type SelectionListWithSectionsProps = BaseSelectionListProps & { - /** Reference to the SelectionList component */ ref?: React.Ref; - - /** Array of sections to display in the list */ sections: Array>; /** Index to scroll to initially (when different from the initially focused item) */ initialScrollIndex?: number; - /** Custom content to display in the header */ customHeaderContent?: ReactNode; - - /** Whether to hide the keyboard when scrolling the list */ shouldHideKeyboardOnScroll?: boolean; - - /** Callback to fire when the list is scrolled */ onScroll?: () => void; - - /** Callback to fire when the list layout changes */ onLayout?: (event: LayoutChangeEvent) => void; /** Whether to prevent auto-scrolling to the first index when selecting an item in multi-select mode */ diff --git a/src/components/SelectionList/components/Footer.tsx b/src/components/SelectionList/components/Footer.tsx index b2c7f949f87a..353353e1f2d2 100644 --- a/src/components/SelectionList/components/Footer.tsx +++ b/src/components/SelectionList/components/Footer.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; diff --git a/src/components/SelectionList/components/ListHeader.tsx b/src/components/SelectionList/components/ListHeader.tsx index 5a791500429c..d39f7fcd1d29 100644 --- a/src/components/SelectionList/components/ListHeader.tsx +++ b/src/components/SelectionList/components/ListHeader.tsx @@ -21,10 +21,7 @@ type ListHeaderProps = { /** Custom header content to render instead of the default select all header */ customListHeader?: React.ReactNode; - /** Whether multiple items can be selected */ canSelectMultiple: boolean; - - /** Styles for the list header wrapper */ headerStyle?: StyleProp; /** Styles for the "Select all" text (merged after textStrong) */ @@ -33,10 +30,7 @@ type ListHeaderProps = { /** Function called when the select all button is pressed */ onSelectAll: () => void; - /** Whether to show 'Select all' button */ shouldShowSelectAllButton: boolean; - - /** Whether to prevent default focus when selecting rows */ shouldPreventDefaultFocusOnSelectRow?: boolean; /** Custom accessibility label for the select all checkbox, providing context about what is being selected */ diff --git a/src/components/SelectionList/components/ListSelectionButton.tsx b/src/components/SelectionList/components/ListSelectionButton.tsx index 131958f285fe..ff5682f4abbf 100644 --- a/src/components/SelectionList/components/ListSelectionButton.tsx +++ b/src/components/SelectionList/components/ListSelectionButton.tsx @@ -14,16 +14,9 @@ type ListSelectionButtonProps = { /** The item to render the selection button for */ item: TItem; - /** Callback to fire when the item is pressed */ onSelectRow: (item: TItem) => void; - - /** Custom accessibility label */ accessibilityLabel?: string; - - /** Whether the button is disabled */ disabled?: boolean; - - /** Additional styles */ style?: StyleProp; /** Additional styles for the checkbox/radio indicator */ @@ -35,7 +28,6 @@ type ListSelectionButtonProps = { /** Paint the checkmark on press before the parent's selection update lands. Opt-in for pages that defer that update. */ shouldUseOptimisticSelection?: boolean; - /** Test ID */ testID?: string; /** Tab index for the button, pass -1 to remove it from the tab order */ diff --git a/src/components/SelectionList/components/SelectionListEmptyState.tsx b/src/components/SelectionList/components/SelectionListEmptyState.tsx index 112e386062d7..697471fc1cc8 100644 --- a/src/components/SelectionList/components/SelectionListEmptyState.tsx +++ b/src/components/SelectionList/components/SelectionListEmptyState.tsx @@ -3,15 +3,10 @@ import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import React from 'react'; type SelectionListEmptyStateProps = { - /** Whether to show the loading placeholder */ shouldShowLoadingPlaceholder: boolean | undefined; - /** Custom component to render while data is loading */ customLoadingPlaceholder?: React.JSX.Element; - /** Whether to use the user skeleton view */ shouldUseUserSkeletonView?: boolean; - /** Whether to show the empty list content */ shouldShowListEmptyContent: boolean; - /** Custom content to display when the list is empty */ listEmptyContent: React.JSX.Element | null | undefined; }; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 270c36fbe957..5f670a8fe088 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -22,7 +22,6 @@ import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; type TextInputProps = { - /** Reference to the BaseTextInput component */ ref?: React.RefObject | null; /** Configuration options for the text input including label, placeholder, validation, etc. */ @@ -31,7 +30,6 @@ type TextInputProps = { /** */ accessibilityLabel?: string; - /** Whether the text input is loading */ isLoading?: boolean; /** The number of items in the data array, used to determine submit behavior */ @@ -40,22 +38,17 @@ type TextInputProps = { /** Callback function called when the text input is submitted */ onSubmit?: () => void; - /** Function called when a key is pressed in the text input */ onKeyPress?: (event: TextInputKeyPressEvent) => void; /** Function called when the text input focus changes */ onFocusChange: (focused: boolean) => void; - /** Whether to show the text input */ shouldShowTextInput?: boolean; - - /** Whether to show the loading placeholder */ shouldShowLoadingPlaceholder?: boolean; /** Whether to show the loading indicator for new options */ isLoadingNewOptions?: boolean; - /** Function to focus text input component */ focusTextInput: () => void; }; diff --git a/src/components/SelectionList/hooks/useSearchFocusSync.ts b/src/components/SelectionList/hooks/useSearchFocusSync.ts index 12976ba40718..ce22c05ec065 100644 --- a/src/components/SelectionList/hooks/useSearchFocusSync.ts +++ b/src/components/SelectionList/hooks/useSearchFocusSync.ts @@ -11,7 +11,6 @@ type UseSearchFocusSyncParams = { /** Array of items (filtered data) */ data: TData[]; - /** Count of currently selected options */ selectedOptionsCount: number; /** Function to check if an item is selected */ @@ -23,10 +22,7 @@ type UseSearchFocusSyncParams = { /** Whether focus index should be updated when selection changes */ shouldUpdateFocusedIndex: boolean; - /** Function to scroll to an index */ scrollToIndex: (index: number, animated?: boolean) => void; - - /** Function to set the focused index */ setFocusedIndex: (index: number) => void; /** The first focusable index in the list (useful when index 0 is a header). Defaults to 0. */ diff --git a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts index d895052d437f..8b7867a7302c 100644 --- a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts +++ b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts @@ -6,7 +6,6 @@ type UseSelectedItemFocusSyncParams = { /** Array of items to search in */ data: TData[]; - /** Key of the item to focus initially */ initiallyFocusedItemKey: string | null | undefined; /** Function to check if an item is selected */ @@ -18,7 +17,6 @@ type UseSelectedItemFocusSyncParams = { /** Current search value - if present, don't sync focus */ searchValue: string | undefined; - /** Function to set the focused index */ setFocusedIndex: (index: number, shouldScrollHint?: boolean) => void; }; diff --git a/src/components/SelectionList/hooks/useSelectionListScroll.ts b/src/components/SelectionList/hooks/useSelectionListScroll.ts index 5165585e73c5..612b3a234274 100644 --- a/src/components/SelectionList/hooks/useSelectionListScroll.ts +++ b/src/components/SelectionList/hooks/useSelectionListScroll.ts @@ -32,7 +32,12 @@ function useSelectionListScroll(listRef: RefObject = { - /** Component to render for each list item */ ListItem: ValidListItem; - - /** Key of the item to focus initially */ initiallyFocusedItemKey?: string; - - /** Called when a row is pressed */ onSelectRow: (item: TItem) => void; - - /** Whether this is a multi-select list */ canSelectMultiple?: boolean; - - /** Custom content to display in the footer */ footerContent?: React.ReactNode; - - /** Custom content to display in the footer of list component */ listFooterContent?: React.JSX.Element | null | undefined; - - /** Whether to show the loading placeholder */ shouldShowLoadingPlaceholder?: boolean; - - /** Component to display on the right side of each item */ rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; - - /** Whether tooltips should be shown */ shouldShowTooltips?: boolean; - - /** Custom content to display in the header of list component. */ customListHeaderContent?: React.JSX.Element | null; - - /** Called when a selection button is pressed */ onSelectionButtonPress?: (item: TItem) => void; - - /** Callback to fire when an error is dismissed */ onDismissError?: (item: TItem) => void; - - /** Whether to prevent default focus on row selection */ shouldPreventDefaultFocusOnSelectRow?: boolean; /** Whether to single execution onRowSelect to avoid double clicks on mobile app */ @@ -64,28 +39,16 @@ type BaseSelectionListProps = { /** Search value used for focus synchronization. Defaults to textInputOptions.value */ searchValueForFocusSync?: string; - /** Whether to show the text input */ shouldShowTextInput?: boolean; - - /** Whether new options are loading */ isLoadingNewOptions?: boolean; - - /** Custom content to display when the list is empty */ listEmptyContent?: React.JSX.Element | null | undefined; - - /** Whether to show the empty list content */ shouldShowListEmptyContent?: boolean; - - /** Whether to add bottom safe area padding */ addBottomSafeAreaPadding?: boolean; - - /** Styles for the list */ style?: SelectionListStyle; /** Whether to debounce scrolling on focused item change */ shouldDebounceScrolling?: boolean; - /** Whether to scroll to the focused item */ shouldScrollToFocusedIndex?: boolean; /** Whether to scroll to the focused item on mount. When false, the list stays at the top to keep header content visible */ @@ -94,7 +57,6 @@ type BaseSelectionListProps = { /** Whether to visually highlight the initially focused item before any keyboard interaction */ shouldHighlightInitiallyFocusedItem?: boolean; - /** Whether keyboard shortcuts should be disabled */ disableKeyboardShortcuts?: boolean; /** Whether to stop automatic propagation on pressing enter key */ @@ -115,7 +77,6 @@ type BaseSelectionListProps = { /** Whether to ignore focus events */ shouldIgnoreFocus?: boolean; - /** Called when the list is scrolled and the user begins dragging */ onScrollBeginDrag?: () => void; /** Configuration for the confirm button */ @@ -124,16 +85,12 @@ type BaseSelectionListProps = { /** Whether to clear the text input when a row is selected */ shouldClearInputOnSelect?: boolean; - /** Whether hover style should be disabled */ shouldDisableHoverStyle?: boolean; - - /** Whether to set the hover style */ setShouldDisableHoverStyle?: React.Dispatch>; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; - /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; }; @@ -144,22 +101,17 @@ type BaseSelectionListProps = { */ type SelectionListProps = Partial & BaseSelectionListProps & { - /** Array of items to display in the list */ data: TItem[]; - - /** Reference to the SelectionList component */ ref?: React.Ref>; /** Called when "Select All" button is pressed */ onSelectAll?: () => void; - /** Callback to fire when the item is long pressed */ onLongPressRow?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; /** Custom header content to render instead of the default select all header */ customListHeader?: React.ReactNode; - /** Custom component to render while data is loading */ customLoadingPlaceholder?: React.JSX.Element; /** Number of lines to show for alternate text */ @@ -168,10 +120,7 @@ type SelectionListProps = Partial & /** Array of selected item keys */ selectedItems?: readonly string[]; - /** Function that determines if an item is selected */ isSelected?: (item: TItem) => boolean; - - /** Whether the whole list is disabled */ isDisabled?: boolean; /** Whether the layout is narrow */ @@ -183,7 +132,6 @@ type SelectionListProps = Partial & /** Whether to show the vertical scroll indicator */ showScrollIndicator?: boolean; - /** Whether to use the user skeleton view */ shouldUseUserSkeletonView?: boolean; /** Whether to show the right caret icon */ @@ -197,7 +145,6 @@ type SelectionListProps = Partial & }; type SelectionListStyle = { - /** Styles for the list */ listStyle?: StyleProp; /** Styles for the content container of the list (scrolls with content) */ @@ -206,28 +153,16 @@ type SelectionListStyle = { /** Styles for the list footer content container */ listFooterContentStyle?: StyleProp; - /** Styles for the list container */ containerStyle?: StyleProp; - - /** Styles for the title of the list item */ listItemTitleStyles?: StyleProp; - - /** Styles for the list item wrapper */ listItemWrapperStyle?: StyleProp; - - /** Styles for the list header wrapper */ listHeaderWrapperStyle?: StyleProp; /** Styles for the default "Select all" label in the list header (merged after textStrong) */ listHeaderSelectAllTextStyle?: StyleProp; - /** Styles for the title container of the list item */ listItemTitleContainerStyles?: StyleProp; - - /** Styles for the error row of the list item */ listItemErrorRowStyles?: StyleProp; - - /** Styles for the section titles */ sectionTitleStyles?: StyleProp; }; @@ -235,10 +170,7 @@ type TextInputOptions = { /** Called when the text input value changes */ onChangeText?: (text: string) => void; - /** Label */ label?: string; - - /** Current value */ value?: string; /** Hint text to display below */ @@ -256,7 +188,6 @@ type TextInputOptions = { /** Input mode e.g., 'text', 'email', 'numeric') */ inputMode?: InputModeOptions; - /** Error text to display below */ errorText?: string; /** Whether the text input should be shown as a header inside list */ @@ -280,7 +211,6 @@ type TextInputOptions = { headerMessageStyle?: StyleProp; }; - /** Reference to the text input component */ ref?: Ref; }; @@ -305,13 +235,24 @@ type ConfirmButtonOptions = { * Defaults to large for backwards compatibility. */ confirmButtonSize?: 'large' | 'medium' | 'small'; + + /** + * Whether a custom footer confirm control can handle a plain Enter key on the current platform. + * Defaults to `true` — footers built with an unconditional `` are Enter-capable everywhere. + * Pass `false` when the footer is intentionally unable to handle Enter on a platform. + */ + isFooterConfirmEnterKeyEnabled?: boolean; + + /** + * Whether a custom footer confirm control is currently rendered and enabled. + * Defaults to inferring the state from the rendered rows. Pass the authoritative state when the footer's + * enabled state depends on selection that may not be reflected in the currently rendered rows. + */ + isFooterConfirmEnabled?: boolean; }; type SelectionListHandle = { - /** Scrolls to and highlights the specified items */ scrollAndHighlightItem: (items: string[]) => void; - - /** Scrolls to the item at the specified index */ scrollToIndex: (index: number) => void; /** Updates the focused index and optionally scrolls to it */ @@ -320,24 +261,17 @@ type SelectionListHandle = { /** Scrolls to the focused input on SplitExpensePage */ scrollToFocusedInput: (item: TItem) => void; - /** Sets the focus to the textInput component */ focusTextInput: () => void; }; type DataDetailsType = { - /** Array of all items in the list */ data: TItem[]; - - /** Array of currently selected items */ selectedOptions: TItem[]; - - /** Whether all selectable items are selected */ allSelected: boolean; /** Whether some (but not all) selectable items are selected */ someSelected: boolean; - /** Array of indexes for disabled items */ disabledIndexes: number[]; /** Array of indexes for items disabled for arrow key navigation */ diff --git a/src/components/SelectionScreen.tsx b/src/components/SelectionScreen.tsx index e0adfc17c950..31800aa68543 100644 --- a/src/components/SelectionScreen.tsx +++ b/src/components/SelectionScreen.tsx @@ -36,16 +36,9 @@ type SelectionScreenProps = { /** Used to set the testID for tests */ displayName: string; - /** Title of the selection component */ title?: TranslationPaths; - - /** Custom content to display in the header */ headerContent?: React.ReactNode; - - /** Content to display if the list is empty */ listEmptyContent?: React.JSX.Element | null; - - /** Custom content to display in the footer of list component. */ listFooterContent?: React.JSX.Element | null; /** Sections for the section list */ @@ -54,19 +47,13 @@ type SelectionScreenProps = { /** Renderer for every item in the list. Defaults to SingleSelectListItem. */ ListItem?: typeof SingleSelectListItem | typeof SingleSelectWithAvatarListItem; - /** The style is applied for the wrap component of list item */ listItemWrapperStyle?: StyleProp; /** Item `keyForList` to focus initially */ initiallyFocusedOptionKey?: string | undefined; - /** Callback to fire when a row is pressed */ onSelectRow: (item: SelectorType) => void; - - /** Callback to fire when back button is pressed */ onBackButtonPress?: () => void; - - /** The current policyID */ policyID?: string; /** Defines which types of access should be verified */ @@ -78,16 +65,12 @@ type SelectionScreenProps = { /** Whether or not to block user from accessing the page */ shouldBeBlocked?: boolean; - /** Name of the current connection */ connectionName: ConnectionName; /** The type of action that's pending */ pendingAction?: OnyxCommon.PendingAction | null; - /** The errors to display */ errors?: OnyxCommon.Errors | ReceiptErrors | null; - - /** Additional style object for the error row */ errorRowStyles?: StyleProp; /** A function to run when the X button next to the error is clicked */ @@ -102,7 +85,6 @@ type SelectionScreenProps = { /** Whether to update the focused index on a row select */ shouldUpdateFocusedIndex?: boolean; - /** Whether to show the text input */ shouldShowTextInput?: boolean; /** Whether to allow each row's title to wrap onto multiple lines instead of truncating */ diff --git a/src/components/SettlementAccountSelector.tsx b/src/components/SettlementAccountSelector.tsx index 6ba51b18ef02..6a1dcddc0543 100644 --- a/src/components/SettlementAccountSelector.tsx +++ b/src/components/SettlementAccountSelector.tsx @@ -18,10 +18,7 @@ import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem' type BankAccountListItem = ListItem & {value: number | undefined}; type SettlementAccountSelectorProps = { - /** Bank account list items to display */ listOptions: BankAccountListItem[]; - - /** Callback when an account is selected */ onSelectAccount: (value: number) => void; /** Optional callback for "Add new bank account" - if provided, the option will be shown */ @@ -30,10 +27,7 @@ type SettlementAccountSelectorProps = { /** Whether to show the "Add new bank account" option */ showAddNewAccountOption?: boolean; - /** Optional custom header content */ customHeaderContent?: React.ReactElement; - - /** Initial key to focus */ initiallyFocusedItemKey?: string; }; diff --git a/src/components/SettlementButton/AnimatedSettlementButton.tsx b/src/components/SettlementButton/AnimatedSettlementButton.tsx index 50eb7ad6d176..68c7404cf2da 100644 --- a/src/components/SettlementButton/AnimatedSettlementButton.tsx +++ b/src/components/SettlementButton/AnimatedSettlementButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -31,10 +31,9 @@ type AnimatedSettlementButtonProps = SettlementButtonProps & { shouldAddTopMargin?: boolean; canIOUBePaid: boolean; - // Whether this is a DEW approval that needs backend validation before showing "Approved" + /** Whether this is a DEW approval that needs backend validation before showing "Approved" */ isDEWApproval?: boolean; - // The report id for which the button is displayed reportID?: string; }; diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 5d69422a4bfc..935f7108944f 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -12,6 +12,7 @@ import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -137,6 +138,7 @@ function SettlementButton({ const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID); const activePolicy = usePolicy(activePolicyID); const activeAdminPolicies = useActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const reportID = iouReport?.reportID; const personalPolicy = usePolicy(personalPolicyID); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -403,6 +405,7 @@ function SettlementButton({ betas, isSelfTourViewed, hasActiveAdminPolicies: !!activeAdminPolicies.length, + hasOwnedPaidPolicy, policyName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), }).policyID; }; diff --git a/src/components/SettlementButton/types.ts b/src/components/SettlementButton/types.ts index 278911548abf..b5f507ebfbd7 100644 --- a/src/components/SettlementButton/types.ts +++ b/src/components/SettlementButton/types.ts @@ -47,31 +47,23 @@ type SettlementButtonProps = WithSentryLabel & { /** The IOU/Expense report we are paying */ iouReport?: OnyxEntry; - /** Should we show the payment options? */ shouldHidePaymentOptions?: boolean; /** The policyID of the report we are paying */ policyID: string | undefined; - /** Additional styles to add to the component */ style?: StyleProp; - - /** Additional styles to add to the component wrapper */ wrapperStyle?: StyleProp; - - /** Additional styles to add to the component when it's disabled */ disabledStyle?: StyleProp; /** Total money amount in form */ formattedAmount?: string; - /** The size of the button */ size?: ValueOf; /** Route for the Add Debit Card screen for a given navigation stack */ addDebitCardRoute?: Route; - /** Whether the button should be disabled */ isDisabled?: boolean; /** Whether the button should stay visually normal even when disabled. */ @@ -86,7 +78,6 @@ type SettlementButtonProps = WithSentryLabel & { /** The anchor alignment of the popover menu for KYC wall popover */ kycWallAnchorAlignment?: AnchorAlignment; - /** Whether the personal bank account option should be shown */ shouldShowPersonalBankAccountOption?: boolean; /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ @@ -95,10 +86,7 @@ type SettlementButtonProps = WithSentryLabel & { /** Whether to use keyboard shortcuts for confirmation or not */ useKeyboardShortcuts?: boolean; - /** Whether we only show pay elsewhere button */ onlyShowPayElsewhere?: boolean; - - /** Whether to use short form for the button */ shouldUseShortForm?: boolean; /** Whether we the report has only held expenses */ diff --git a/src/components/SidePanel/SidePanelButton/SidePanelButtonBase.tsx b/src/components/SidePanel/SidePanelButton/SidePanelButtonBase.tsx index 1236940a8f63..0dbd33e39ca7 100644 --- a/src/components/SidePanel/SidePanelButton/SidePanelButtonBase.tsx +++ b/src/components/SidePanel/SidePanelButton/SidePanelButtonBase.tsx @@ -21,7 +21,7 @@ function SidePanelButtonBase({style}: SidePanelButtonProps) { const {translate} = useLocalize(); const {shouldHideHelpButton} = useSidePanelState(); const {openSidePanel} = useSidePanelActions(); - const {ConciergeAvatar} = useMemoizedLazyExpensifyIcons(['ConciergeAvatar']); + const {Concierge} = useMemoizedLazyExpensifyIcons(['Concierge']); if (shouldHideHelpButton) { return null; @@ -36,10 +36,8 @@ function SidePanelButtonBase({style}: SidePanelButtonProps) { onPress={openSidePanel} > diff --git a/src/components/SidePanel/SidePanelButton/index.native.tsx b/src/components/SidePanel/SidePanelButton/index.native.tsx index 600318912567..4eba0cdc5e25 100644 --- a/src/components/SidePanel/SidePanelButton/index.native.tsx +++ b/src/components/SidePanel/SidePanelButton/index.native.tsx @@ -7,6 +7,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSidePanelState from '@hooks/useSidePanelState'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat} from '@userActions/Report'; @@ -21,10 +22,11 @@ import type SidePanelButtonProps from './types'; function SidePanelButton({style}: SidePanelButtonProps) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); const {shouldHideHelpButton} = useSidePanelState(); const {accountID: currentUserAccountID = CONST.DEFAULT_NUMBER_ID} = useCurrentUserPersonalDetails(); - const {ConciergeAvatar} = useMemoizedLazyExpensifyIcons(['ConciergeAvatar']); + 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}); @@ -43,9 +45,8 @@ function SidePanelButton({style}: SidePanelButtonProps) { onPress={() => navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas)} > diff --git a/src/components/SidePanel/SidePanelModal/index.tsx b/src/components/SidePanel/SidePanelModal/index.tsx index cd6817940d7c..7df699dad6d0 100644 --- a/src/components/SidePanel/SidePanelModal/index.tsx +++ b/src/components/SidePanel/SidePanelModal/index.tsx @@ -27,7 +27,7 @@ import type SidePanelModalProps from './types'; function SidePanelModal({children, sidePanelTranslateX, closeSidePanel, shouldHideSidePanelBackdrop}: SidePanelModalProps) { const styles = useThemeStyles(); const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const {paddingTop, paddingBottom} = useSafeAreaPaddings(); + const {paddingTop, paddingBottom, paddingLeft, paddingRight} = useSafeAreaPaddings(); const [isRHPVisible = false] = useOnyx(ONYXKEYS.MODAL, {selector: isRHPVisibleSelector}); const uniqueModalId = ComposerFocusManager.getId(); @@ -85,7 +85,7 @@ function SidePanelModal({children, sidePanelTranslateX, closeSidePanel, shouldHi styles.sidePanelContent, styles.sidePanelContentWidth(shouldUseNarrowLayout), styles.sidePanelContentBorderWidth(isExtraLargeScreenWidth), - {transform: [{translateX: sidePanelTranslateX.current}], paddingTop, paddingBottom}, + {transform: [{translateX: sidePanelTranslateX.current}], paddingTop, paddingBottom, paddingLeft, paddingRight}, ]} > {children} diff --git a/src/components/SidePanel/SidePanelOverlay.tsx b/src/components/SidePanel/SidePanelOverlay.tsx index 10ac49232d28..2bafdd0d05a7 100644 --- a/src/components/SidePanel/SidePanelOverlay.tsx +++ b/src/components/SidePanel/SidePanelOverlay.tsx @@ -1,19 +1,15 @@ -import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils'; import {PressableWithoutFeedback} from '@components/Pressable'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; - import React from 'react'; -import Animated, {Keyframe} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; type SidePanelOverlayProps = { /** Whether the Side Panel is displayed over RHP */ shouldBeVisible: boolean; - /** Callback fired when pressing the backdrop */ onBackdropPress: () => void; }; @@ -21,15 +17,8 @@ function SidePanelOverlay({shouldBeVisible, onBackdropPress}: SidePanelOverlayPr const styles = useThemeStyles(); const {translate} = useLocalize(); - const CustomFadeIn = new Keyframe(getModalInAnimation('fadeIn')).duration(CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN); - const CustomFadeOut = new Keyframe(getModalOutAnimation('fadeOut')).duration(CONST.MODAL.ANIMATION_TIMING.DEFAULT_OUT); - return ( - + ; }; diff --git a/src/components/SpendRules/SpendRuleRestrictionTypeToggle.tsx b/src/components/SpendRules/SpendRuleRestrictionTypeToggle.tsx index 949c4b1f1f2a..bac765d2fc97 100644 --- a/src/components/SpendRules/SpendRuleRestrictionTypeToggle.tsx +++ b/src/components/SpendRules/SpendRuleRestrictionTypeToggle.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/SpendRules/SpendRuleRestrictionTypeToggleRevamp.tsx b/src/components/SpendRules/SpendRuleRestrictionTypeToggleRevamp.tsx index ee868aa8fba2..98ee17fcf0ce 100644 --- a/src/components/SpendRules/SpendRuleRestrictionTypeToggleRevamp.tsx +++ b/src/components/SpendRules/SpendRuleRestrictionTypeToggleRevamp.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import Text from '@components/Text'; diff --git a/src/components/SpendRules/SpendRulesSection.tsx b/src/components/SpendRules/SpendRulesSection.tsx deleted file mode 100644 index f384a1ba8666..000000000000 --- a/src/components/SpendRules/SpendRulesSection.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import ActivityIndicator from '@components/ActivityIndicator'; -import Badge from '@components/Badge'; -import Icon from '@components/Icon'; -import MenuItem from '@components/MenuItem'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import SearchBar from '@components/SearchBar'; -import Section from '@components/Section'; -import Text from '@components/Text'; - -import useConfirmModal from '@hooks/useConfirmModal'; -import useDefaultFundID from '@hooks/useDefaultFundID'; -import useExpensifyCardRules from '@hooks/useExpensifyCardRulesList'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchResults from '@hooks/useSearchResults'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; -import Navigation from '@libs/Navigation/Navigation'; -import tokenizedSearch from '@libs/tokenizedSearch'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -import type {TupleToUnion} from 'type-fest'; - -import React, {useEffect} from 'react'; -import {View} from 'react-native'; - -type SpendRulesSectionProps = { - policyID: string; - canWriteRules: boolean; - showReadOnlyModal: () => void; -}; - -function SpendRulesSection({policyID, canWriteRules, showReadOnlyModal}: SpendRulesSectionProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const theme = useTheme(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock', 'Plus']); - const {showConfirmModal} = useConfirmModal(); - const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardProtectionIllustration']); - const defaultFundID = useDefaultFundID(policyID); - const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); - const {cardRules, isLoadingCardRules} = useExpensifyCardRules(policyID); - - const filterCardRules = (cardRule: TupleToUnion, searchInput: string) => { - const results = tokenizedSearch([cardRule], searchInput, (option) => option.searchTokens); - return results.length > 0; - }; - - const [inputValue, setInputValue, filteredCardRules] = useSearchResults(cardRules, filterCardRules); - - useEffect(() => { - if (!defaultFundID || defaultFundID === CONST.DEFAULT_NUMBER_ID) { - return; - } - if (expensifyCardSettings?.isLoading) { - return; - } - if (expensifyCardSettings?.hasOnceLoaded) { - return; - } - - openPolicyExpensifyCardsPage(policyID, defaultFundID); - }, [defaultFundID, expensifyCardSettings?.hasOnceLoaded, expensifyCardSettings?.isLoading, policyID]); - - const showBuiltInProtectionModal = () => { - showConfirmModal({ - image: illustrations.ExpensifyCardProtectionIllustration, - imageStyles: [styles.w100], - shouldFitImageToContainer: true, - title: translate('workspace.rules.spendRules.builtInProtectionModal.title'), - titleStyles: [styles.textHeadlineH1], - titleContainerStyles: [styles.mb3], - prompt: translate('workspace.rules.spendRules.builtInProtectionModal.description'), - promptStyles: [styles.mb1], - shouldShowCancelButton: false, - confirmText: translate('common.buttonConfirm'), - innerContainerStyle: shouldUseNarrowLayout ? undefined : StyleUtils.getWidthStyle(variables.wideConfirmModalWidth), - }); - }; - - const blockLabel = translate('workspace.rules.spendRules.block'); - const defaultRuleTitle = translate('workspace.rules.spendRules.defaultRuleTitle'); - const descriptionLabel = translate('workspace.rules.spendRules.defaultRuleDescription'); - - const renderSectionTitle = () => ( - - {translate('workspace.rules.spendRules.title')} - - - ); - - const menuItemBody = ( - - - - - {defaultRuleTitle} - - - - - - {descriptionLabel} - - - - ); - - return ( -
- {cardRules.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - - )} - - {isLoadingCardRules ? ( - - - - ) : ( - filteredCardRules.map((rule) => ( - - Navigation.navigate(ROUTES.RULES_SPEND_EDIT.getRoute(policyID, rule.ruleID))} - wrapperStyle={[styles.borderedContentCard, styles.mt2, styles.ph4, styles.pv4]} - titleComponent={ - - {rule.summaryParts.map((part) => ( - - - - {part.text} - - - ))} - - {rule.cardSummary} - - - } - /> - - )) - )} - { - if (!canWriteRules) { - showReadOnlyModal(); - return; - } - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); - }} - /> -
- ); -} - -SpendRulesSection.displayName = 'SpendRulesSection'; - -export default SpendRulesSection; diff --git a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx index 2ab8be63f260..3005ac87591a 100644 --- a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx +++ b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx @@ -25,13 +25,11 @@ import React, {useState} from 'react'; import {View} from 'react-native'; type SpendRulesCurrencyBaseProps = { - /** The currently selected currencies */ currencies: string[]; /** The settlement currency of the currently selected cards */ settlementCurrency: string; - /** Handle the currencies changing */ onCurrenciesChange: (currencies: string[]) => void; }; diff --git a/src/components/SplashScreenHider/types.ts b/src/components/SplashScreenHider/types.ts index 0f0dff825e4c..baeeaacb1634 100644 --- a/src/components/SplashScreenHider/types.ts +++ b/src/components/SplashScreenHider/types.ts @@ -4,7 +4,6 @@ type SplashScreenHiderProps = { /** Splash screen has been hidden */ onHide: () => void; - /** Whether the splash screen should be hidden */ shouldHideSplash: boolean; }; diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 0167c1ce086f..7dfe8c26ed9d 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -22,19 +22,13 @@ import React, {useMemo} from 'react'; type State = keyof typeof COMMON_CONST.STATES; type StateSelectorModalProps = { - /** Whether the modal is visible */ isVisible: boolean; /** Function to call when the user closes the business type selector modal */ onClose: () => void; - /** Label to display on field */ label: string; - - /** State selected */ currentState: string; - - /** Function to call when the user selects a state */ onStateSelected: (value: Option) => void; /** Function to call when the user presses on the modal backdrop */ diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index a7f301f144f8..6555109f7bba 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -15,7 +15,6 @@ import StateSelectorModal from './StateSelectorModal'; type State = keyof typeof COMMON_CONST.STATES; type StatePickerProps = { - /** Current value of the selected item */ value?: string; /** Callback when the list item is selected */ diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx index 02a046088dfc..40c3d8c71369 100644 --- a/src/components/StateSelector.tsx +++ b/src/components/StateSelector.tsx @@ -25,22 +25,17 @@ type StateSelectorProps = { /** Form error text. e.g when no state is selected */ errorText?: string; - /** Current selected state */ value?: State | ''; /** Callback to call when the input changes */ onInputChange?: (value: string) => void; - /** Label to display on field */ label?: string; - - /** Any additional styles to apply */ wrapperStyle?: MenuItemProps['wrapperStyle']; /** Callback to call when the picker modal is dismissed */ onBlur?: () => void; - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index c5f17e4ce127..8b2b58487337 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -11,16 +11,9 @@ import Badge from './Badge'; import Tooltip from './Tooltip'; type StatusBadgeProps = { - /** Status text to display */ text: string; - - /** Background color for the status badge */ backgroundColor?: ColorValue; - - /** Text color for the status badge */ textColor?: ColorValue; - - /** Additional badge styles */ badgeStyles?: StyleProp; /** Text to display in a tooltip shown on hover of the badge. The tooltip is omitted when empty. */ diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx index 49fbbc0a5e3d..53b73985c40b 100644 --- a/src/components/SubStepForms/AddressStep.tsx +++ b/src/components/SubStepForms/AddressStep.tsx @@ -49,10 +49,7 @@ function getStringFormValue(values: type AddressStepProps = SubPageProps & ForwardedFSClassProps & { - /** The ID of the form */ formID: TFormID; - - /** The title of the form */ formTitle: string; /** The disclaimer informing that PO box is not allowed */ @@ -61,46 +58,19 @@ type AddressStepProps = SubPageProp /** The validation function to call when the form is submitted */ customValidate?: (values: FormOnyxValues) => FormInputErrors; - /** A function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - - /** Fields list of the form */ stepFields: Array>; - - /** The IDs of the input fields */ inputFieldsIDs: AddressInputIDs; - - /** The default values for the form */ defaultValues: AddressValues; - - /** Should show help links */ shouldShowHelpLinks?: boolean; - - /** Indicates if country selector should be displayed */ shouldDisplayCountrySelector?: boolean; - - /** Indicates if state selector should be displayed */ shouldDisplayStateSelector?: boolean; - - /** Label for the state selector */ stateSelectorLabel?: string; - - /** The title of the state selector modal */ stateSelectorModalHeaderTitle?: string; - - /** The title of the state selector search input */ stateSelectorSearchInputTitle?: string; - - /** Callback to be called when the country is changed */ onCountryChange?: (country: unknown) => void; - - /** Translation key of street field */ streetTranslationKey?: TranslationPaths; - - /** Indicates if country can be changed by user */ shouldAllowCountryChange?: boolean; - - /** Indicates if zip code format should be validated */ shouldValidateZipCodeFormat?: boolean; /** Whether to show the Patriot Act help link (EnablePayments-only) */ diff --git a/src/components/SubStepForms/AgreementsFullStep.tsx b/src/components/SubStepForms/AgreementsFullStep.tsx index e0fbd45b3a98..e018062a2c24 100644 --- a/src/components/SubStepForms/AgreementsFullStep.tsx +++ b/src/components/SubStepForms/AgreementsFullStep.tsx @@ -10,7 +10,7 @@ import UploadFile from '@components/UploadFile'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getFieldRequiredErrors, isRequiredFulfilled} from '@libs/ValidationUtils'; +import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import requiresDocusignStep from '@pages/ReimbursementAccount/NonUSD/utils/requiresDocusignStep'; @@ -47,21 +47,19 @@ type AgreementsFullStepProps = { /** Default values for inputs */ defaultValues: Partial, boolean>>; - /** The ID of the form */ formID: TFormID; /** Input IDs for field in the form */ inputIDs: { - provideTruthfulInformation: FormOnyxKeys; - agreeToTermsAndConditions: FormOnyxKeys; - consentToPrivacyNotice: FormOnyxKeys; - authorizedToBindClientToAgreement: FormOnyxKeys; + provideTruthfulInformation: Extract, string>; + agreeToTermsAndConditions: Extract, string>; + consentToPrivacyNotice: Extract, string>; + authorizedToBindClientToAgreement: Extract, string>; }; /** Indicates that action is being processed */ isLoading: boolean; - /** Handles back button press */ onBackButtonPress: () => void; /** Handles submit button press */ @@ -70,7 +68,6 @@ type AgreementsFullStepProps = { /** Currency of related account */ currency: string; - /** Array of step names */ stepNames?: readonly string[]; /** Index of currently active step in header */ @@ -104,7 +101,12 @@ function AgreementsFullStep({ const isDocusignStepRequired = requiresDocusignStep(currency); const stepFields = useMemo(() => { - const fields = [inputIDs.authorizedToBindClientToAgreement, inputIDs.provideTruthfulInformation, inputIDs.agreeToTermsAndConditions, inputIDs.consentToPrivacyNotice]; + const fields: Array> = [ + inputIDs.authorizedToBindClientToAgreement, + inputIDs.provideTruthfulInformation, + inputIDs.agreeToTermsAndConditions, + inputIDs.consentToPrivacyNotice, + ]; if (bankStatementInputID) { fields.push(bankStatementInputID); } @@ -150,19 +152,19 @@ function AgreementsFullStep({ (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, stepFields, translate); - if (!isRequiredFulfilled(values[inputIDs.authorizedToBindClientToAgreement] as string)) { + if (Object.hasOwn(errors, inputIDs.authorizedToBindClientToAgreement)) { errors[inputIDs.authorizedToBindClientToAgreement] = translate('agreementsStep.error.authorized'); } - if (!isRequiredFulfilled(values[inputIDs.provideTruthfulInformation] as string)) { + if (Object.hasOwn(errors, inputIDs.provideTruthfulInformation)) { errors[inputIDs.provideTruthfulInformation] = translate('agreementsStep.error.certify'); } - if (!isRequiredFulfilled(values[inputIDs.agreeToTermsAndConditions] as string)) { + if (Object.hasOwn(errors, inputIDs.agreeToTermsAndConditions)) { errors[inputIDs.agreeToTermsAndConditions] = translate('common.error.acceptTerms'); } - if (!isRequiredFulfilled(values[inputIDs.consentToPrivacyNotice] as string)) { + if (Object.hasOwn(errors, inputIDs.consentToPrivacyNotice)) { errors[inputIDs.consentToPrivacyNotice] = translate('agreementsStep.error.consent'); } @@ -194,7 +196,7 @@ function AgreementsFullStep({ ({ ({ ({ )} - +
); diff --git a/src/components/SubStepForms/CountryFullStep.tsx b/src/components/SubStepForms/CountryFullStep.tsx index 3d53fbc51f24..729881b39983 100644 --- a/src/components/SubStepForms/CountryFullStep.tsx +++ b/src/components/SubStepForms/CountryFullStep.tsx @@ -3,7 +3,7 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import FormHelpMessage from '@components/FormHelpMessage'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import PushRowWithModal from '@components/PushRowWithModal'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; @@ -35,19 +35,13 @@ import {View} from 'react-native'; const {COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; type CountryFullStepProps = { - /** Handles back button press */ onBackButtonPress: () => void; - - /** Array of step names */ stepNames: readonly string[]; /** Handles submit button press */ onSubmit: () => void; - /** ID of current policy */ policyID: string | undefined; - - /** Whether the user is coming from the expensify card */ isComingFromExpensifyCard?: boolean; }; @@ -139,10 +133,9 @@ function CountryFullStep({onBackButtonPress, stepNames, onSubmit, policyID, isCo submitFlexEnabled={!showNoPolicyError} > {translate('countryStep.confirmBusinessBank')} - {!!policyID && ( diff --git a/src/components/SubStepForms/DateOfBirthStep.tsx b/src/components/SubStepForms/DateOfBirthStep.tsx index 563dab80d77c..ae2cce347291 100644 --- a/src/components/SubStepForms/DateOfBirthStep.tsx +++ b/src/components/SubStepForms/DateOfBirthStep.tsx @@ -20,28 +20,19 @@ import React, {useCallback} from 'react'; type DateOfBirthStepProps = SubPageProps & ForwardedFSClassProps & { - /** The ID of the form */ formID: TFormID; - - /** The title of the form */ formTitle: string; /** The validation function to call when the form is submitted */ customValidate?: (values: FormOnyxValues) => FormInputErrors; - /** A function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - - /** Fields list of the form */ stepFields: Array>; - - /** The ID of the date of birth input */ dobInputID: string; /** The default value for the date of birth input */ dobDefaultValue: string; - /** Optional footer component */ footerComponent?: React.ReactNode; /** Whether to show the Patriot Act help link (EnablePayments-only) */ diff --git a/src/components/SubStepForms/DocusignFullStep.tsx b/src/components/SubStepForms/DocusignFullStep.tsx index 9a5a85153230..7813ddb5d059 100644 --- a/src/components/SubStepForms/DocusignFullStep.tsx +++ b/src/components/SubStepForms/DocusignFullStep.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormRef} from '@components/Form/types'; @@ -26,16 +26,12 @@ type DocusignFullStepProps = { /** Default value for file upload input */ defaultValue: FileObject[]; - /** The ID of the form */ formID: TFormID; - - /** ID of the input in the form */ inputID: FormOnyxKeys; /** Indicates that action is being processed */ isLoading: boolean; - /** Handles back button press */ onBackButtonPress: () => void; /** Handles submit button press */ @@ -44,7 +40,6 @@ type DocusignFullStepProps = { /** Currency of related account */ currency: string; - /** Array of step names */ stepNames?: readonly string[]; /** Index of currently active step in header */ diff --git a/src/components/SubStepForms/FullNameStep.tsx b/src/components/SubStepForms/FullNameStep.tsx index 3a78a27a065f..d7da3de78a1b 100644 --- a/src/components/SubStepForms/FullNameStep.tsx +++ b/src/components/SubStepForms/FullNameStep.tsx @@ -22,10 +22,7 @@ import {View} from 'react-native'; type FullNameStepProps = SubPageProps & ForwardedFSClassProps & { - /** The ID of the form */ formID: TFormID; - - /** The title of the form */ formTitle: string; /** Subtitle text shown below the title */ @@ -34,25 +31,15 @@ type FullNameStepProps = SubPagePro /** The validation function to call when the form is submitted */ customValidate?: (values: FormOnyxValues) => FormInputErrors; - /** A function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - - /** Fields list of the form */ stepFields: Array>; - - /** The ID of the first name input */ firstNameInputID: string; - - /** The ID of the last name input */ lastNameInputID: string; - - /** The default values for the form */ defaultValues: { firstName: string; lastName: string; }; - /** Should show the help link or not */ shouldShowHelpLinks?: boolean; /** Custom label of the first name input */ diff --git a/src/components/SubStepForms/PushRowFieldsStep.tsx b/src/components/SubStepForms/PushRowFieldsStep.tsx index 695c46bd6a61..7991bdb3f5b7 100644 --- a/src/components/SubStepForms/PushRowFieldsStep.tsx +++ b/src/components/SubStepForms/PushRowFieldsStep.tsx @@ -24,15 +24,9 @@ type PushRowField = { }; type PushRowFieldsStepProps = SubPageProps & { - /** The ID of the form */ formID: TFormID; - - /** Title of the form */ formTitle: string; - - /** A function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - pushRowFields: Array>; }; diff --git a/src/components/SubStepForms/RegistrationNumberStep.tsx b/src/components/SubStepForms/RegistrationNumberStep.tsx index e8fafc07694a..f17b2f17acd9 100644 --- a/src/components/SubStepForms/RegistrationNumberStep.tsx +++ b/src/components/SubStepForms/RegistrationNumberStep.tsx @@ -24,13 +24,8 @@ import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; type RegistrationNumberStepProps = SubPageProps & { - /** The ID of the form */ formID: TFormID; - - /** A function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - - /** The ID of the form input */ inputID: FormOnyxKeys; /** The default values for the input */ diff --git a/src/components/SubStepForms/SingleFieldStep.tsx b/src/components/SubStepForms/SingleFieldStep.tsx index 8541a88951b4..11ebeb59f7de 100644 --- a/src/components/SubStepForms/SingleFieldStep.tsx +++ b/src/components/SubStepForms/SingleFieldStep.tsx @@ -25,10 +25,7 @@ import {View} from 'react-native'; type SingleFieldStepProps = SubPageProps & ForwardedFSClassProps & { - /** The ID of the form */ formID: TFormID; - - /** The title of the form */ formTitle: string; /** The disclaimer to show below the form title */ @@ -37,25 +34,15 @@ type SingleFieldStepProps = SubPage /** The validation function to call when the form is submitted */ validate: (values: FormOnyxValues) => FormInputErrors; - /** A function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - - /** The ID of the form input */ inputId: string; - - /** The label of the input */ inputLabel: string; - - /** The mode of the input */ inputMode?: InputModeOptions; /** The default values for the form */ defaultValue: string; - /** Whether to show help links */ shouldShowHelpLinks?: boolean; - - /** Max length of the field */ maxLength?: number; /** Should the submit button be enabled when offline */ diff --git a/src/components/SubStepForms/YesNoStep.tsx b/src/components/SubStepForms/YesNoStep.tsx index 1523b6b62172..84fd11f03b53 100644 --- a/src/components/SubStepForms/YesNoStep.tsx +++ b/src/components/SubStepForms/YesNoStep.tsx @@ -22,10 +22,7 @@ type YesNoStepProps = { /** The default value of the radio button */ defaultValue: boolean; - /** Callback when the value is selected */ onSelectedValue: (value: boolean) => void; - - /** The style of the submit button */ submitButtonStyles?: StyleProp; /** Whether the submit button should be flex enabled, allowing it to grow and fill available space */ diff --git a/src/components/SubscriptionPlanDowngradeBlocked.tsx b/src/components/SubscriptionPlanDowngradeBlocked.tsx index 87424b5f1ed7..eb374b6bf7a6 100644 --- a/src/components/SubscriptionPlanDowngradeBlocked.tsx +++ b/src/components/SubscriptionPlanDowngradeBlocked.tsx @@ -11,7 +11,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import FixedFooter from './FixedFooter'; import Text from './Text'; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts index b6a4f11ed8d0..1a7a5683af3d 100644 --- a/src/components/SwipeableView/types.ts +++ b/src/components/SwipeableView/types.ts @@ -8,7 +8,6 @@ type SwipeableViewProps = { /** Callback to fire when the user swipes down on the child content */ onSwipeDown: () => void; - /** Additional styles applied to the wrapping view */ style?: StyleProp; }; diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 57724f18675d..9fd7f341813a 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -21,10 +21,7 @@ type SwitchProps = { /** Callback to fire when the switch is toggled */ onToggle: (isOn: boolean) => void; - /** Accessibility label for the switch */ accessibilityLabel: string; - - /** Whether the switch is disabled */ disabled?: boolean; /** Whether the switch is mid-flight (an optimistic update is pending). Shows a spinner and blocks interaction. */ diff --git a/src/components/SymbolButton.tsx b/src/components/SymbolButton.tsx index 7dd7949eae1f..5b7a1532a749 100644 --- a/src/components/SymbolButton.tsx +++ b/src/components/SymbolButton.tsx @@ -19,13 +19,8 @@ type SymbolButtonProps = { /** Symbol of the input */ symbol: string; - /** Function to call when symbol button is pressed */ onSymbolButtonPress: () => void; - - /** Whether the symbol button is pressable or not */ isSymbolPressable?: boolean; - - /** Style for the symbol button */ textStyle?: StyleProp; }; diff --git a/src/components/TabSelector/TabLabel.tsx b/src/components/TabSelector/TabLabel.tsx index cdbe5a58aa34..72964060cbc9 100644 --- a/src/components/TabSelector/TabLabel.tsx +++ b/src/components/TabSelector/TabLabel.tsx @@ -11,7 +11,6 @@ import React from 'react'; import {Animated, StyleSheet, View} from 'react-native'; type TabLabelProps = { - /** Title of the tab */ title?: string; /** Animated opacity value while the label is in inactive state */ @@ -23,7 +22,6 @@ type TabLabelProps = { /** Whether an icon is present - used to conditionally apply margin */ hasIcon?: boolean; - /** Text style */ textStyle?: StyleProp; }; diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index fdb9ef5dc5d3..5fbaa9b3e26f 100644 --- a/src/components/TabSelector/types.ts +++ b/src/components/TabSelector/types.ts @@ -10,16 +10,12 @@ import type {Ref} from 'react'; import type {Animated, StyleProp, View, ViewStyle} from 'react-native'; type TabSelectorProps = MaterialTopTabBarProps & { - /* Callback fired when tab is pressed */ onTabPress?: (name: string) => void; - - /* Callback fired when tab is long pressed */ onLongTabPress?: (key: string) => void; /** Callback to register focus trap container element */ onFocusTrapContainerElementChanged?: (element: HTMLElement | null) => void; - /** Whether to show the label when the tab is inactive */ shouldShowLabelWhenInactive?: boolean; /** Whether tabs should have equal width */ @@ -36,7 +32,6 @@ type TabSelectorBaseItem = WithSentryLabel & { /** Localized title to display. */ title: string; - /** Test identifier used to find elements in tests. */ testID?: string; /** Text to display on the badge on the tab. */ @@ -54,7 +49,6 @@ type TabSelectorBaseItem = WithSentryLabel & { /** Called instead of selecting the tab when it is disabled. Keeps the tab pressable so it can explain why it is locked. */ disabledAction?: () => void | Promise; - /** Pending action for the tab. */ pendingAction?: PendingAction; /** Optional ref forwarded to this tab's pressable element, e.g. to anchor a popover to this specific tab. */ @@ -69,25 +63,15 @@ type TabSelectorBaseItem = WithSentryLabel & { }; type TabSelectorBaseProps = { - /** Tabs to render. */ tabs: Array>; - - /** Key of the currently active tab. */ activeTabKey: K | undefined; - - /** Called when a tab is pressed with its key. */ onTabPress?: (key: K) => void; - - /* Callback fired when tab is long pressed */ onLongTabPress?: (key: K) => void; - - /* Callback fired when active tab is pressed */ onActiveTabPress?: (key: K) => void; /** Animated position from a navigator (optional). */ position?: Animated.AnimatedInterpolation; - /** Whether to show the label when the tab is inactive. */ shouldShowLabelWhenInactive?: boolean; /** Whether tabs should have equal width. */ @@ -98,19 +82,16 @@ type TabSelectorBaseProps = { }; type TabSelectorItemProps = WithSentryLabel & { - /** Key of the tab */ tabKey: string; /** Function to call when onPress */ onPress?: () => void; - /* Callback fired when tab is long pressed */ onLongPress?: () => void; /** Icon to display on tab */ icon?: IconAsset; - /** Title of the tab */ title?: string; /** Animated background color value for the tab button */ @@ -125,10 +106,7 @@ type TabSelectorItemProps = WithSentryLabel & { /** Whether this tab is active */ isActive?: boolean; - /** Whether to show the label when the tab is inactive */ shouldShowLabelWhenInactive?: boolean; - - /** Test identifier used to find elements in tests */ testID?: string; /** Whether tabs should have equal width */ @@ -149,7 +127,6 @@ type TabSelectorItemProps = WithSentryLabel & { /** Called instead of selecting the tab when it is disabled. Keeps the tab pressable so it can explain why it is locked. */ disabledAction?: () => void | Promise; - /** Pending action for the tab. */ pendingAction?: PendingAction; /** Optional ref forwarded to the tab's pressable element. */ @@ -157,14 +134,7 @@ type TabSelectorItemProps = WithSentryLabel & { }; type AnimationConfigBase = { - /** - * The number of routes. - */ routesLength: number; - - /** - * The index of the current tab. - */ tabIndex: number; /** @@ -172,21 +142,11 @@ type AnimationConfigBase = { */ affectedTabs: number[]; - /** - * The animated position interpolation. - */ position: Animated.AnimatedInterpolation | undefined; - - /** - * Whether the tab is active. - */ isActive: boolean; }; type GetBackgroundColorConfig = AnimationConfigBase & { - /** - * The theme colors. - */ theme: ThemeColors; }; diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index c0e18dccbdb3..e21873f50acf 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -73,7 +73,6 @@ type TableContextValue | undefined; - /** Currently active filter values. */ activeFilters: Partial>; /** Currently active sorting configuration. */ @@ -85,7 +84,6 @@ type TableContextValue void; }; @@ -69,11 +68,10 @@ function TableDisplaySettingsSortByPopup({columns, pendingSorting, setPendingSor hasHeader extraHeight={variables.optionRowHeight + DIVIDER_HEIGHT} > - - ); diff --git a/src/components/Table/TableFilterBar/TableFilterTrigger.tsx b/src/components/Table/TableFilterBar/TableFilterTrigger.tsx index f37faed7edac..18830e53471a 100644 --- a/src/components/Table/TableFilterBar/TableFilterTrigger.tsx +++ b/src/components/Table/TableFilterBar/TableFilterTrigger.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import FilterPopupButton from '@components/Search/FilterDropdowns/FilterPopupButton'; diff --git a/src/components/Table/TableFilterBar/index.tsx b/src/components/Table/TableFilterBar/index.tsx index a69a169fff80..3c50ca27c1d4 100644 --- a/src/components/Table/TableFilterBar/index.tsx +++ b/src/components/Table/TableFilterBar/index.tsx @@ -1,5 +1,5 @@ import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; -import SearchFiltersClearButton from '@components/Search/SearchPageHeader/SearchFiltersClearButton'; +import SearchFiltersResetButton from '@components/Search/SearchPageHeader/SearchFiltersResetButton'; import {useTableContext} from '@components/Table/TableContext'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -18,11 +18,11 @@ type TableFilterBarProps = PropsWithChildren<{ /** Label and accessibility label for the search input. */ label: string; - /** Whether to show a "Clear" button that resets all active filters. */ - shouldShowClearFiltersButton?: boolean; + /** Whether to show a "Reset" button that resets all active filters. */ + shouldShowResetFiltersButton?: boolean; }>; -export default function TableFilterBar({label, shouldShowClearFiltersButton, children}: TableFilterBarProps) { +export default function TableFilterBar({label, shouldShowResetFiltersButton, children}: TableFilterBarProps) { const styles = useThemeStyles(); const {filterConfig, tableMethods, activeFilters, onSearchStringChange, columns, narrowLayoutSortColumn, originalDataLength, shouldUseNarrowTableLayout} = useTableContext(); @@ -48,11 +48,11 @@ export default function TableFilterBar({label, shouldShowClearFiltersButton, chi }; }); - const clearAllFilters = () => { + const resetFilters = () => { for (const filter of appliedFilters) { tableMethods.updateFilter({key: filter.key, value: []}); } - // Also clear the search input so the Clear button resets both the filters and the search text. + // Also clear the search input so the Reset button resets both the filters and the search text. tableMethods.updateSearchString(''); onSearchStringChange?.(''); }; @@ -69,7 +69,7 @@ export default function TableFilterBar({label, shouldShowClearFiltersButton, chi onClosePress={filter.onClosePress} /> ))} - {!!shouldShowClearFiltersButton && } + {!!shouldShowResetFiltersButton && } ); diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index efc6e1b6f3ce..1c91306ebbf5 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -17,7 +17,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {GestureResponderEvent, PressableStateCallbackType} from 'react-native'; +import type {GestureResponderEvent, PressableStateCallbackType, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; @@ -37,10 +37,7 @@ type TableRowProps = Omit = (item: DataType, value * Methods exposed by the table to control filtering. */ type FilteringMethods = { - /** Callback to update a filter value. */ updateFilter: (params: {key: FilterKey; value: string[]}) => void; - - /** Callback to get the active filters. */ getActiveFilters: () => Record; }; diff --git a/src/components/Table/middlewares/searching.ts b/src/components/Table/middlewares/searching.ts index 790166823b74..a91248c27acc 100644 --- a/src/components/Table/middlewares/searching.ts +++ b/src/components/Table/middlewares/searching.ts @@ -29,10 +29,7 @@ type UseSearchingProps = { * @template T - The type of items in the data array. */ type SearchingMethods = { - /** Callback to update the search string. */ updateSearchString: (value: string) => void; - - /** Callback to get the active search string. */ getActiveSearchString: () => string; }; diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index af5f361964f7..93075fd5431d 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -16,13 +16,11 @@ import {useCallback, useEffect, useState} from 'react'; import type {MiddlewareHookResult} from './types'; type UseSelectionProps = { - /** The data being used in the table */ data: DataType[]; /** The number of non-disabled items in the original (pre-search/filter) data */ originalSelectableCount: number; - /** The list of selected keys */ selectedKeys: string[]; /** The list of actively applied filters */ diff --git a/src/components/Table/middlewares/sorting.ts b/src/components/Table/middlewares/sorting.ts index 6b601c2f7fd8..7b9b27221dcc 100644 --- a/src/components/Table/middlewares/sorting.ts +++ b/src/components/Table/middlewares/sorting.ts @@ -40,7 +40,6 @@ type SortingMethods = { /** Callback to update the sorting configuration. */ updateSorting: (value: SetStateAction>) => void; - /** Callback to toggle sorting for a specific column. */ toggleColumnSorting: (columnKey?: ColumnKey) => void; /** Callback to get the active sorting configuration. */ diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 42a78d699981..8ddd9d9ca673 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -253,7 +253,6 @@ type TableProps>; - /** Callback when an option is selected */ onRowSelectionChange?: (selectedRowKeys: string[]) => void; /** Optional callback fired when the active search string changes. */ diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts index 0d5ad824338f..a57c6e53ad85 100644 --- a/src/components/Table/useDynamicColumnWidths.ts +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -1,19 +1,19 @@ import useThemeStyles from '@hooks/useThemeStyles'; import measureTextWidth, {canMeasureText} from '@libs/measureTextWidth'; +import createWidestTextMeasurer from '@libs/measureTextWidth/widestTextMeasurer'; +import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import {useMemo} from 'react'; - import type {DynamicColumnConstraints} from './calculateDynamicColumnWidths'; import type {TableColumn, TableData} from './types'; import calculateDynamicColumnWidths from './calculateDynamicColumnWidths'; -const {MEASURED_CANDIDATES_PER_COLUMN, MIN_FREE_TEXT_COLUMN_WIDTH} = CONST.TABLES.DYNAMIC_COLUMNS; +const {MIN_FREE_TEXT_COLUMN_WIDTH} = CONST.TABLES.DYNAMIC_COLUMNS; type UseDynamicColumnWidthsParams = { /** Column configuration for the table. */ @@ -45,43 +45,18 @@ function measureColumnContentWidth(); + const measurer = createWidestTextMeasurer(); for (const item of data) { for (const content of dynamicSizing.getContentToMeasure(item)) { - if (!content.text) { - continue; - } - - const fontKey = `${content.fontSize ?? ''}|${content.fontWeight ?? ''}`; - const existingTexts = textsByFont.get(fontKey); - - if (existingTexts) { - existingTexts.texts.push(content.text); - } else { - textsByFont.set(fontKey, {fontSize: content.fontSize, fontWeight: content.fontWeight, texts: [content.text]}); - } + measurer.add(content.text, {fontSize: content.fontSize, fontWeight: content.fontWeight}); } } - let widestContentWidth = 0; - - for (const {fontSize, fontWeight, texts} of textsByFont.values()) { - // Only the widest string can decide the column's width, and character count is a good (if imperfect) proxy for - // rendered width, so just the longest few strings are measured. - const candidates = [...texts].sort((first, second) => second.length - first.length).slice(0, MEASURED_CANDIDATES_PER_COLUMN); - - for (const text of candidates) { - const width = measureTextWidth(text, {fontSize, fontWeight}); - - if (width === null) { - return null; - } + const widestContentWidth = measurer.getWidestWidth(); - widestContentWidth = Math.max(widestContentWidth, width); - } + if (widestContentWidth === null) { + return null; } // Rounded up because the widths end up as whole px grid tracks. Rounding a fraction down would leave a column @@ -94,7 +69,7 @@ function measureColumnContentWidth): {gridTemplateColumns: string[] | undefined; scrollWidth: number | undefined} { const styles = useThemeStyles(); - // This `useMemo` is load-bearing rather than redundant: it is the only hook call here, so without it the React - // Compiler sees a plain function instead of a hook and memoizes nothing (both compilers report `no-components`). - // The measurement below would then re-run on every render, including on every keystroke in the table's search box. - return useMemo(() => { - const noDynamicWidths = {gridTemplateColumns: undefined, scrollWidth: undefined}; + const noDynamicWidths = {gridTemplateColumns: undefined, scrollWidth: undefined}; - // Checked before anything else, so native never walks the data to gather text that it can't measure anyway. - if (!isEnabled || tableWidth <= 0 || !canMeasureText()) { + // Checked before anything else, so native never walks the data to gather text that it can't measure anyway. + if (!isEnabled || tableWidth <= 0 || !canMeasureText()) { + return noDynamicWidths; + } + + const dynamicColumns: Array> = []; + let fixedColumnsWidth = 0; + + for (const column of columns) { + // A column with a percentage or other non-numeric width can't be subtracted from the budget, so the whole + // table keeps its static tracks rather than being laid out from a wrong budget. + if (column.width !== undefined && typeof column.width !== 'number') { return noDynamicWidths; } - const dynamicColumns: Array> = []; - let fixedColumnsWidth = 0; - - for (const column of columns) { - // A column with a percentage or other non-numeric width can't be subtracted from the budget, so the whole - // table keeps its static tracks rather than being laid out from a wrong budget. - if (column.width !== undefined && typeof column.width !== 'number') { - return noDynamicWidths; - } - - if (typeof column.width === 'number') { - fixedColumnsWidth += column.width; - } else { - dynamicColumns.push(column); - } + if (typeof column.width === 'number') { + fixedColumnsWidth += column.width; + } else { + dynamicColumns.push(column); } + } - if (dynamicColumns.length === 0) { - return noDynamicWidths; - } + if (dynamicColumns.length === 0) { + return noDynamicWidths; + } - const selectionColumnWidth = hasSelectionColumn ? variables.tableCheckboxColumnWidth : 0; - const totalColumnCount = columns.length + (hasSelectionColumn ? 1 : 0); - const totalGapWidth = Math.max(totalColumnCount - 1, 0) * styles.gap3.gap; - const rowChromeWidth = (styles.mh5.marginHorizontal + styles.ph3.paddingHorizontal) * 2; - const availableWidth = tableWidth - rowChromeWidth - totalGapWidth - fixedColumnsWidth - selectionColumnWidth; + const selectionColumnWidth = hasSelectionColumn ? variables.tableCheckboxColumnWidth : 0; + const totalColumnCount = columns.length + (hasSelectionColumn ? 1 : 0); + const totalGapWidth = Math.max(totalColumnCount - 1, 0) * styles.gap3.gap; + const rowChromeWidth = (styles.mh5.marginHorizontal + styles.ph3.paddingHorizontal) * 2; + const availableWidth = tableWidth - rowChromeWidth - totalGapWidth - fixedColumnsWidth - selectionColumnWidth; - if (availableWidth <= 0) { - return noDynamicWidths; - } + if (availableWidth <= 0) { + return noDynamicWidths; + } - const constraints: DynamicColumnConstraints[] = []; - - for (const column of dynamicColumns) { - const contentWidth = measureColumnContentWidth(column, data); - const headerLabelWidth = measureHeaderLabelWidth(column.label, variables.iconSizeExtraSmall + styles.ml1.marginLeft); - - // Text measurement is unavailable (native), so the table keeps its static, content-independent tracks. - if (contentWidth === null || headerLabelWidth === null) { - return noDynamicWidths; - } - - // A column has to fit its header label as well as its cells, so the label is part of what its content needs - // rather than a separate floor. - const columnContentWidth = Math.max(contentWidth, headerLabelWidth); - - // A column holding a known, short set of values is never squeezed below its content, so it never truncates. - // A free-text column is squeezed no further than a readable width, or its content when that is narrower. - const readableWidth = MIN_FREE_TEXT_COLUMN_WIDTH + (column.dynamicSizing?.extraWidth ?? 0); - const defaultMinWidth = column.dynamicSizing?.shouldFitContent ? columnContentWidth : Math.min(columnContentWidth, readableWidth); - - constraints.push({ - contentWidth: columnContentWidth, - minWidth: column.dynamicSizing?.minWidth ?? defaultMinWidth, - // Uncapped by default, so the table scrolls rather than truncating. A cap also can't be derived from the - // available width without breaking the sizing: a column capped at its equal share looks like it fits in - // one, so the columns would be left equal and the long column would stay truncated. Columns that should - // truncate rather than widen the table set `maxWidth` themselves. - maxWidth: column.dynamicSizing?.maxWidth ?? Number.POSITIVE_INFINITY, - }); - } + const constraints: DynamicColumnConstraints[] = []; - const {widths, shouldScrollHorizontally} = calculateDynamicColumnWidths(constraints, availableWidth); + for (const column of dynamicColumns) { + const contentWidth = measureColumnContentWidth(column, data); + const headerLabelWidth = measureHeaderLabelWidth(column.label, variables.iconSizeExtraSmall + styles.ml1.marginLeft); - // The columns fit equally, which is exactly what the static `1fr` tracks already do. - if (widths.length === 0) { + // Text measurement is unavailable (native), so the table keeps its static, content-independent tracks. + if (contentWidth === null || headerLabelWidth === null) { return noDynamicWidths; } - // Keyed by column rather than tracked with a running index, so the tracks can be built without mutating a counter - // from inside the mapping callback (which the React Compiler can't compile). - const widthByColumnKey = new Map(); - for (const [index, column] of dynamicColumns.entries()) { - widthByColumnKey.set(column.key, widths.at(index) ?? 0); - } + // A column has to fit its header label as well as its cells, so the label is part of what its content needs + // rather than a separate floor. + const columnContentWidth = Math.max(contentWidth, headerLabelWidth); + + // A column holding a known, short set of values is never squeezed below its content, so it never truncates. + // A free-text column is squeezed no further than a readable width, or its content when that is narrower. + const readableWidth = MIN_FREE_TEXT_COLUMN_WIDTH + (column.dynamicSizing?.extraWidth ?? 0); + const defaultMinWidth = column.dynamicSizing?.shouldFitContent ? columnContentWidth : Math.min(columnContentWidth, readableWidth); + + constraints.push({ + contentWidth: columnContentWidth, + minWidth: column.dynamicSizing?.minWidth ?? defaultMinWidth, + // Uncapped by default, so the table scrolls rather than truncating. A cap also can't be derived from the + // available width without breaking the sizing: a column capped at its equal share looks like it fits in + // one, so the columns would be left equal and the long column would stay truncated. Columns that should + // truncate rather than widen the table set `maxWidth` themselves. + maxWidth: column.dynamicSizing?.maxWidth ?? Number.POSITIVE_INFINITY, + }); + } - const gridTemplateColumns = columns.map((column) => (typeof column.width === 'number' ? `${column.width}px` : `${widthByColumnKey.get(column.key) ?? 0}px`)); + const {widths, shouldScrollHorizontally} = calculateDynamicColumnWidths(constraints, availableWidth); - if (!shouldScrollHorizontally) { - return {gridTemplateColumns, scrollWidth: undefined}; - } + // The columns fit equally, which is exactly what the static `1fr` tracks already do. + if (widths.length === 0) { + return noDynamicWidths; + } + + // Keyed by column rather than tracked with a running index, so the tracks can be built without mutating a counter + // from inside the mapping callback (which the React Compiler can't compile). + const widthByColumnKey = new Map(); + for (const [index, column] of dynamicColumns.entries()) { + widthByColumnKey.set(column.key, widths.at(index) ?? 0); + } + + const gridTemplateColumns = columns.map((column) => (typeof column.width === 'number' ? `${column.width}px` : `${widthByColumnKey.get(column.key) ?? 0}px`)); + + if (!shouldScrollHorizontally) { + return {gridTemplateColumns, scrollWidth: undefined}; + } - // The rows are wider than the table, so the caller scrolls them horizontally at exactly the width they need. This - // adds back all of the row chrome subtracted above, margin included: the rows keep their horizontal margin inside - // the scrolled content, so leaving it out would make the content container too narrow and clip the rows' trailing - // edge at the end of the scroll. - const scrollWidth = widths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + rowChromeWidth; + // The rows are wider than the table, so the caller scrolls them horizontally at exactly the width they need. This + // adds back all of the row chrome subtracted above, margin included: the rows keep their horizontal margin inside + // the scrolled content, so leaving it out would make the content container too narrow and clip the rows' trailing + // edge at the end of the scroll. + const scrollWidth = widths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + rowChromeWidth; - return {gridTemplateColumns, scrollWidth}; - }, [columns, data, tableWidth, isEnabled, hasSelectionColumn, styles.mh5.marginHorizontal, styles.ph3.paddingHorizontal, styles.gap3.gap, styles.ml1.marginLeft]); + return {gridTemplateColumns, scrollWidth}; } export default useDynamicColumnWidths; diff --git a/src/components/Tables/AgentsTable/AgentsTableRow.tsx b/src/components/Tables/AgentsTable/AgentsTableRow.tsx index 2562e2d68103..4cbd4510aee0 100644 --- a/src/components/Tables/AgentsTable/AgentsTableRow.tsx +++ b/src/components/Tables/AgentsTable/AgentsTableRow.tsx @@ -1,5 +1,5 @@ import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import type {TableRow} from '@components/Table'; import Table from '@components/Table'; @@ -24,10 +24,7 @@ type AgentsTableRowProps = { /** Data about the agent (wrapped by the table, so it also carries row state such as `selected`) */ item: TableRow; - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; }; diff --git a/src/components/Tables/AgentsTable/index.tsx b/src/components/Tables/AgentsTable/index.tsx index ea10e1085fb1..d23fa6926caf 100644 --- a/src/components/Tables/AgentsTable/index.tsx +++ b/src/components/Tables/AgentsTable/index.tsx @@ -38,8 +38,6 @@ type AgentRowData = TableData & { type AgentsTableProps = { ref?: React.Ref> | undefined; - - /** The list of agents to render as rows */ agents: AgentRowData[]; /** Content rendered above the table header inside the scrollable list */ @@ -51,7 +49,6 @@ type AgentsTableProps = { /** Keys of the currently selected rows */ selectedKeys: string[]; - /** Called with the updated selected row keys when the selection changes */ onRowSelectionChange: (selectedRowKeys: string[]) => void; }; diff --git a/src/components/Tables/DomainAdminsTable/DomainAdminRequestsTableRow.tsx b/src/components/Tables/DomainAdminsTable/DomainAdminRequestsTableRow.tsx new file mode 100644 index 000000000000..da20f0e6da2e --- /dev/null +++ b/src/components/Tables/DomainAdminsTable/DomainAdminRequestsTableRow.tsx @@ -0,0 +1,108 @@ +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; +import Button from '@components/Button'; +import Table from '@components/Table'; +import {getCellAccessibilityProps, shouldUseTableSemantics} from '@components/Table/tableAccessibility'; +import {useTableContext} from '@components/Table/TableContext'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {DomainAdminRequestRowData} from '.'; + +type DomainAdminRequestsTableRowProps = { + /** Data about the pending adminship request */ + item: DomainAdminRequestRowData; + + /** The index of the row relative to all other rows */ + rowIndex: number; +}; + +export default function DomainAdminRequestsTableRow({item, rowIndex}: DomainAdminRequestsTableRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowTableLayout} = useTableContext(); + + const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); + + const avatarSize = shouldUseNarrowTableLayout ? CONST.AVATAR_SIZE.DEFAULT : CONST.AVATAR_SIZE.SMALL; + const accessibilityLabel = [item.name, item.email].filter(Boolean).join(', '); + const isRequestPending = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + const actionButtons = ( + + + + + ); + + return ( + + + + + + + + + + {!shouldUseNarrowTableLayout && ( + + {actionButtons} + + )} + + ); +} diff --git a/src/components/Tables/DomainAdminsTable/DomainAdminsTableGroupHeaderRow.tsx b/src/components/Tables/DomainAdminsTable/DomainAdminsTableGroupHeaderRow.tsx new file mode 100644 index 000000000000..49e8756dde3f --- /dev/null +++ b/src/components/Tables/DomainAdminsTable/DomainAdminsTableGroupHeaderRow.tsx @@ -0,0 +1,39 @@ +import Table from '@components/Table'; +import {getCellAccessibilityProps, shouldUseTableSemantics} from '@components/Table/tableAccessibility'; +import {useTableContext} from '@components/Table/TableContext'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {DomainAdminGroupHeaderRowData} from '.'; + +type DomainAdminsTableGroupHeaderRowProps = { + /** Data about the group header */ + item: DomainAdminGroupHeaderRowData; + + /** The index of the row relative to all other rows */ + rowIndex: number; +}; + +export default function DomainAdminsTableGroupHeaderRow({item, rowIndex}: DomainAdminsTableGroupHeaderRowProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowTableLayout} = useTableContext(); + + const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); + + return ( + + + {item.label} + + + ); +} diff --git a/src/components/Tables/DomainAdminsTable/DomainAdminsTableRow.tsx b/src/components/Tables/DomainAdminsTable/DomainAdminsTableRow.tsx index 0f58dceec608..99e4af12be46 100644 --- a/src/components/Tables/DomainAdminsTable/DomainAdminsTableRow.tsx +++ b/src/components/Tables/DomainAdminsTable/DomainAdminsTableRow.tsx @@ -3,6 +3,7 @@ import Badge from '@components/Badge'; import Icon from '@components/Icon'; import Table from '@components/Table'; import {getCellAccessibilityProps, shouldUseTableSemantics} from '@components/Table/tableAccessibility'; +import {useTableContext} from '@components/Table/TableContext'; import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -20,21 +21,16 @@ import {View} from 'react-native'; import type {DomainAdminRowData} from '.'; type DomainAdminsTableRowProps = { - /** Data about the domain admin */ item: DomainAdminRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ - shouldUseNarrowTableLayout: boolean; }; -export default function DomainAdminsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: DomainAdminsTableRowProps) { +export default function DomainAdminsTableRow({item, rowIndex}: DomainAdminsTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + const {shouldUseNarrowTableLayout} = useTableContext(); const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); diff --git a/src/components/Tables/DomainAdminsTable/index.tsx b/src/components/Tables/DomainAdminsTable/index.tsx index 0232f4efd17b..724c303ebcc9 100644 --- a/src/components/Tables/DomainAdminsTable/index.tsx +++ b/src/components/Tables/DomainAdminsTable/index.tsx @@ -3,23 +3,47 @@ import Table from '@components/Table'; import useDomainHighlightOnReturn from '@hooks/useDomainHighlightOnReturn'; import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; 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, {useRef} from 'react'; +import DomainAdminRequestsTableRow from './DomainAdminRequestsTableRow'; +import DomainAdminsTableGroupHeaderRow from './DomainAdminsTableGroupHeaderRow'; import DomainAdminsTableRow from './DomainAdminsTableRow'; type DomainAdminsTableColumnKey = 'admin' | 'actions'; +type DomainAdminGroupHeaderRowData = TableData & { + rowType: typeof CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER; + groupOrder: number; + label: string; +}; + +type DomainAdminRequestRowData = TableData & { + rowType: typeof CONST.DOMAIN.ADMINS.ROW_TYPE.REQUEST; + groupOrder: number; + accountID: number; + name: string; + email: string; + canApprove: boolean; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; + approve: () => void; + deny: () => void; + dismissError: () => void; +}; + type DomainAdminRowData = TableData & { + rowType: typeof CONST.DOMAIN.ADMINS.ROW_TYPE.ADMIN; + groupOrder: number; accountID: number; name: string; email: string; @@ -30,19 +54,19 @@ type DomainAdminRowData = TableData & { dismissError: () => void; }; +type DomainAdminsTableRowData = DomainAdminGroupHeaderRowData | DomainAdminRequestRowData | DomainAdminRowData; + type DomainAdminsTableProps = { domainAccountID: number; admins: DomainAdminRowData[]; + requests: DomainAdminRequestRowData[]; }; -export default function DomainAdminsTable({domainAccountID, admins}: DomainAdminsTableProps) { +export default function DomainAdminsTable({domainAccountID, admins, requests}: DomainAdminsTableProps) { const {translate, localeCompare} = useLocalize(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const tableRef = useRef>(null); + const tableRef = useRef>(null); useDomainHighlightOnReturn(domainAccountID, 'admins', tableRef); - const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; - const domainAdminsTableColumns: Array> = [ { key: 'admin', @@ -57,33 +81,87 @@ export default function DomainAdminsTable({domainAccountID, admins}: DomainAdmin }, ]; - const compareTableItems: CompareItemsCallback = (item1, item2, activeSorting) => { + const tableData: DomainAdminsTableRowData[] = + requests.length > 0 + ? [ + { + keyForList: 'group-requests', + rowType: CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER, + groupOrder: CONST.DOMAIN.ADMINS.GROUP_ORDER.REQUESTS, + label: translate('domain.admins.requests'), + }, + ...requests, + { + keyForList: 'group-admins', + rowType: CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER, + groupOrder: CONST.DOMAIN.ADMINS.GROUP_ORDER.ADMINS, + label: translate('domain.admins.title'), + }, + ...admins, + ] + : admins; + + const compareTableItems: CompareItemsCallback = (item1, item2, activeSorting) => { + if (item1.groupOrder !== item2.groupOrder) { + return item1.groupOrder - item2.groupOrder; + } + if (item1.rowType === CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER || item2.rowType === CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER) { + return 0; + } + const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; return localeCompare(item1.name, item2.name) * orderMultiplier; }; - const isTableItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + const isTableItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + if (item.rowType === CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER) { + const groupRows = tableData.filter( + (row): row is DomainAdminRequestRowData | DomainAdminRowData => row.rowType !== CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER && row.groupOrder === item.groupOrder, + ); + return tokenizedSearch(groupRows, searchValue, (option) => [option.name, option.email]).length > 0; + } + const results = tokenizedSearch([item], searchValue, (option) => [option.name, option.email]); return results.length > 0; }; - const renderTableItem = ({item, index}: ListRenderItemInfo) => ( - - ); + const renderTableItem = ({item, index}: ListRenderItemInfo) => { + if (item.rowType === CONST.DOMAIN.ADMINS.ROW_TYPE.GROUP_HEADER) { + return ( + + ); + } + + if (item.rowType === CONST.DOMAIN.ADMINS.ROW_TYPE.REQUEST) { + return ( + + ); + } + + return ( + + ); + }; const tableHeaderComponent = ; return ( item.rowType} initialSortColumn="admin" title={translate('domain.admins.title')} keyExtractor={(item) => item.keyForList} @@ -96,4 +174,4 @@ export default function DomainAdminsTable({domainAccountID, admins}: DomainAdmin ); } -export type {DomainAdminRowData, DomainAdminsTableColumnKey}; +export type {DomainAdminGroupHeaderRowData, DomainAdminRequestRowData, DomainAdminRowData, DomainAdminsTableColumnKey, DomainAdminsTableRowData}; diff --git a/src/components/Tables/DomainGroupsTable/DomainGroupsTableRow.tsx b/src/components/Tables/DomainGroupsTable/DomainGroupsTableRow.tsx index bde18a5b0de7..4da05edb94f5 100644 --- a/src/components/Tables/DomainGroupsTable/DomainGroupsTableRow.tsx +++ b/src/components/Tables/DomainGroupsTable/DomainGroupsTableRow.tsx @@ -34,13 +34,8 @@ type DomainGroupRowData = TableData & { }; type DomainGroupsTableRowProps = { - /** Data about the domain group */ item: DomainGroupRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; }; diff --git a/src/components/Tables/DomainListTable/DomainListTableRow.tsx b/src/components/Tables/DomainListTable/DomainListTableRow.tsx index 60a37b65e945..00795c23366b 100644 --- a/src/components/Tables/DomainListTable/DomainListTableRow.tsx +++ b/src/components/Tables/DomainListTable/DomainListTableRow.tsx @@ -16,6 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import React from 'react'; @@ -35,7 +36,7 @@ export default function DomainListTableRow({item, rowIndex, shouldUseNarrowTable const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Globe', 'ArrowRight']); + const icons = useMemoizedLazyExpensifyIcons(['Globe', 'ArrowRight', 'DotIndicator']); const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); @@ -57,6 +58,13 @@ export default function DomainListTableRow({item, rowIndex, shouldUseNarrowTable }); } + const brickRoadIndicator = !!item.brickRoadIndicator && ( + + ); + const VerifiedDomainBadge = ( + {brickRoadIndicator} + {threeDotMenuItems.length > 0 && ( void; }; diff --git a/src/components/Tables/RoomMembersTable/RoomMembersTableRow.tsx b/src/components/Tables/RoomMembersTable/RoomMembersTableRow.tsx index d518cab1374b..0fe5f06a2ac9 100644 --- a/src/components/Tables/RoomMembersTable/RoomMembersTableRow.tsx +++ b/src/components/Tables/RoomMembersTable/RoomMembersTableRow.tsx @@ -17,10 +17,7 @@ import {View} from 'react-native'; import type {RoomMemberRowData} from '.'; type RoomMembersTableRowProps = { - /** The room member item for the row */ item: RoomMemberRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; }; diff --git a/src/components/Tables/RoomMembersTable/index.tsx b/src/components/Tables/RoomMembersTable/index.tsx index 6b3596098485..2f813183c889 100644 --- a/src/components/Tables/RoomMembersTable/index.tsx +++ b/src/components/Tables/RoomMembersTable/index.tsx @@ -1,5 +1,5 @@ import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; -import Table from '@components/Table'; +import Table, {composeTableListHeader} from '@components/Table'; import useLocalize from '@hooks/useLocalize'; @@ -31,8 +31,12 @@ type RoomMemberRowData = TableData & { }; type RoomMembersTableProps = { + /** Imperative handle for controlling table search state */ ref?: React.Ref>; + /** Page-level content rendered above the table header inside the scrollable list */ + headerComponent?: React.ReactElement; + /** The rows to render in the table */ members: RoomMemberRowData[]; @@ -42,14 +46,13 @@ type RoomMembersTableProps = { /** The list of selected row keys */ selectedKeys: string[]; - /** Callback when the set of selected rows changes */ onRowSelectionChange: (selectedRowKeys: string[]) => void; /** Callback when the active search string changes */ onSearchStringChange?: (searchString: string) => void; }; -export default function RoomMembersTable({ref, members, selectionEnabled, selectedKeys, onRowSelectionChange, onSearchStringChange}: RoomMembersTableProps) { +export default function RoomMembersTable({ref, headerComponent, members, selectionEnabled, selectedKeys, onRowSelectionChange, onSearchStringChange}: RoomMembersTableProps) { const {translate, localeCompare} = useLocalize(); const columns: Array> = [ @@ -80,6 +83,8 @@ export default function RoomMembersTable({ref, members, selectionEnabled, select /> ); + const tableHeaderComponent = composeTableListHeader(headerComponent, ); + return (
- + {tableHeaderComponent} diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index e6cbcc1f3562..5add15fae021 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -20,13 +20,8 @@ import {View} from 'react-native'; import type {WorkspaceCategoryTableRowData} from '.'; type WorkspaceCategoriesTableRowProps = { - /** Data about the category */ item: WorkspaceCategoryTableRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; /** Whether the GL Code column is visible on web screens or not */ diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx index e17525b89607..38af51ec52bd 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx @@ -27,13 +27,8 @@ import type {WorkspaceCompanyCardTableItemData} from './WorkspaceCompanyCardsTab type WorkspaceCompanyCardBulkActionType = 'unassign' | 'viewTransactions' | 'exportCSV'; type WorkspaceCompanyCardsTableControlsProps = { - /** Current policy id */ policyID: string; - - /** Domain or workspace account ID */ domainOrWorkspaceAccountID: number; - - /** Bank name */ bankName: UseCompanyCardsResult['bankName']; /** Whether the current member can edit company cards */ diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx index d5a5420d87de..8c047c2eb841 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx @@ -1,5 +1,5 @@ import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FeedSelector from '@components/FeedSelector'; import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; @@ -36,10 +36,7 @@ import getShouldShowBrokenConnectionError from './getShouldShowBrokenConnectionE const FEED_SELECTOR_SKELETON_WIDTH = 289; type WorkspaceCompanyCardsTableHeaderButtonsProps = { - /** Current policy id */ policyID: string; - - /** Currently selected feed */ feedName: CompanyCardFeedWithDomainID; /** Whether the feed is loading */ @@ -48,7 +45,6 @@ type WorkspaceCompanyCardsTableHeaderButtonsProps = { /** Whether the current member can edit company cards */ canWriteCompanyCards: boolean; - /** Card feed icon */ CardFeedIcon: React.ReactNode; }; diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index 220e172c07e5..6e3b79b69747 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -1,5 +1,5 @@ import AccountAvatarWithCardFeed from '@components/Avatar/connected/AccountAvatarWithCardFeed'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import type {TableData} from '@components/Table'; import Table from '@components/Table'; @@ -28,27 +28,15 @@ import {View} from 'react-native'; type WorkspaceCompanyCardTableRowData = TableData & CardAssignmentData & { - /** Whether the card is deleted */ isCardDeleted: boolean; - - /** Whether the card is assigned */ isAssigned: boolean; - - /** Assigned card */ assignedCard?: Card; - - /** On dismiss error callback */ onDismissError?: () => void; }; type WorkspaceCompanyCardTableRowProps = { - /** The workspace company card table item */ item: WorkspaceCompanyCardTableRowData; - - /** Selected card feed */ feedName?: CompanyCardFeedWithDomainID; - - /** Card feed icon element */ CardFeedIcon?: React.ReactNode; /** Whether to disable assign card button */ @@ -57,10 +45,7 @@ type WorkspaceCompanyCardTableRowProps = { /** Whether the current member can edit company cards */ canWriteCompanyCards: boolean; - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; - - /** The index of the row */ rowIndex: number; /** diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index 07acc9c7a728..be9a51bf3846 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -1,5 +1,6 @@ import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CardFeedIcon from '@components/CardFeedIcon'; import ScrollView from '@components/ScrollView'; import Table, {composeTableListHeader} from '@components/Table'; @@ -49,8 +50,6 @@ type WorkspaceCompanyCardsTableHandle = { type WorkspaceCompanyCardsTableProps = { ref?: React.Ref; - - /** Policy ID */ policyID: string; /** Whether the policy is done loading, i.e. its account ID has resolved. Offline this is `true` even without an account ID, since it can never resolve until we reconnect */ @@ -59,10 +58,7 @@ type WorkspaceCompanyCardsTableProps = { /** Whether the company cards page fetch is still expected to land, i.e. no feeds are cached for the workspace yet */ isPageFetchPending: boolean; - /** Domain or workspace account ID */ domainOrWorkspaceAccountID: number; - - /** Company cards */ companyCards: UseCompanyCardsResult; /** Whether to disable assign card button */ @@ -74,13 +70,8 @@ type WorkspaceCompanyCardsTableProps = { /** Whether the narrow-layout selection mode is active */ isSelectionModeEnabled: boolean; - /** On assign card callback */ onAssignCard: (cardID: string, encryptedCardNumber: string) => void; - - /** On reload page callback */ onReloadPage: () => void; - - /** On reload feed callback */ onReloadFeed: () => void; }; @@ -458,12 +449,9 @@ function WorkspaceCompanyCardsTable({ titleStyles={[styles.mb2, styles.mt8]} subtitleStyle={styles.textSupporting} /> - + )} diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index eaa8cc29284b..e805120fedd7 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -20,7 +20,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; -import type {Rate} from '@src/types/onyx/Policy'; +import type {Rate, Unit} from '@src/types/onyx/Policy'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -47,6 +47,7 @@ type WorkspaceDistanceRatesTableRowProps = { shouldUseNarrowTableLayout: boolean; shouldShowDateColumns: boolean; statusLabels: Record; + unit?: Unit; }; function formatDate(dateString: string | null | undefined, dateFnsLocale: DateFnsLocale | undefined): string { @@ -73,7 +74,7 @@ function getRateStatusColors(status: string, theme: ReturnType, } } -function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowDateColumns, statusLabels}: WorkspaceDistanceRatesTableRowProps) { +function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowDateColumns, statusLabels, unit}: WorkspaceDistanceRatesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); @@ -87,7 +88,7 @@ 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 isAutoGeneratedRate = isGovernmentRateUnmodified(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; diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 8ce5dc30427f..481850c8aa32 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -14,6 +14,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Unit} from '@src/types/onyx/Policy'; import type {ListRenderItemInfo} from '@shopify/flash-list'; @@ -28,6 +29,7 @@ type DistanceRatesTableColumnKey = 'status' | 'name' | 'rate' | 'startDate' | 'e type WorkspaceDistanceRatesTableProps = { ratesData: DistanceRateTableItemData[]; policyID: string; + unit?: Unit; selectionEnabled: boolean; selectedKeys: string[]; canWriteDistanceRates: boolean; @@ -42,7 +44,16 @@ const STATUS_ORDER: Record = { [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: 3, }; -function WorkspaceDistanceRatesTable({ratesData, policyID, selectionEnabled, selectedKeys, canWriteDistanceRates, onRowSelectionChange, headerComponent}: WorkspaceDistanceRatesTableProps) { +function WorkspaceDistanceRatesTable({ + ratesData, + policyID, + unit, + selectionEnabled, + selectedKeys, + canWriteDistanceRates, + onRowSelectionChange, + headerComponent, +}: WorkspaceDistanceRatesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Plus']); @@ -144,6 +155,7 @@ function WorkspaceDistanceRatesTable({ratesData, policyID, selectionEnabled, sel shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} shouldShowDateColumns={hasAnyDateBound} statusLabels={statusLabels} + unit={unit} /> ); diff --git a/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx b/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx index 55872885f466..de0d69719442 100644 --- a/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx +++ b/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx @@ -16,14 +16,19 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; import {View} from 'react-native'; +type ExpenseDefaultsSection = ValueOf; + type ExpenseDefaultTableItem = TableData & { ruleID: string; - isMerchantType: boolean; + section: ExpenseDefaultsSection; isRename: boolean; groupID?: string; typeLabel: string; @@ -36,6 +41,18 @@ type ExpenseDefaultTableItem = TableData & { action: () => void; }; +const SECTION_HEADER_TRANSLATION_KEYS = { + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES]: 'workspace.rules.spendRules.categories', + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS]: 'workspace.rules.spendRules.merchants', + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES]: 'workspace.rules.spendRules.merchantTypes', +} as const satisfies Record; + +const SECTION_SENTRY_LABELS = { + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES]: CONST.SENTRY_LABEL.WORKSPACE.RULES.CATEGORY_TAX_RULE_ITEM, + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS]: CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_ITEM, + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES]: CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_TYPE_RULE_ITEM, +} as const satisfies Record; + type WorkspaceExpenseDefaultsTableRowProps = { item: ExpenseDefaultTableItem; rowIndex: number; @@ -58,10 +75,12 @@ function WorkspaceExpenseDefaultsTableRow({item, rowIndex, shouldUseNarrowTableL const badgeColors = item.isRename ? theme.reportStatusBadge.approved : theme.reportStatusBadge.draft; const prevItem = rowIndex > 0 ? processedData.at(rowIndex - 1) : undefined; - const hasMultipleSections = processedData.some((rule) => rule.isMerchantType) && processedData.some((rule) => !rule.isMerchantType); - const showSectionHeader = hasMultipleSections && (rowIndex === 0 || !!prevItem?.isMerchantType !== !!item.isMerchantType); + const isMerchantType = item.section === CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES; + // A single section stays a flat list. The headers only earn their place once there is more than one group to tell apart. + const hasMultipleSections = new Set(processedData.map((rule) => rule.section)).size > 1; + const showSectionHeader = hasMultipleSections && (rowIndex === 0 || prevItem?.section !== item.section); - const lockIcon = item.isMerchantType ? ( + const lockIcon = isMerchantType ? ( @@ -89,7 +108,7 @@ function WorkspaceExpenseDefaultsTableRow({item, rowIndex, shouldUseNarrowTableL rowIndex={rowIndex} disabled={isDeleting} accessibilityLabel={accessibilityLabel} - sentryLabel={item.isMerchantType ? CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_TYPE_RULE_ITEM : CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_ITEM} + sentryLabel={SECTION_SENTRY_LABELS[item.section]} offlineWithFeedback={{ pendingAction: item.pendingAction, shouldHideOnDelete: false, diff --git a/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx b/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx index b3f8dd438502..b3690dd2ba19 100644 --- a/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx @@ -9,6 +9,8 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; + import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; @@ -19,6 +21,9 @@ import WorkspaceExpenseDefaultsTableRow from './WorkspaceExpenseDefaultsTableRow type ExpenseDefaultsTableColumnKey = 'type' | 'condition' | 'rule' | 'actions'; +/** The order the `Expense defaults` subsections render in, per the design. */ +const SECTION_ORDER = [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES, CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES] as const; + type WorkspaceExpenseDefaultsTableProps = { rulesData: ExpenseDefaultTableItem[]; selectionEnabled: boolean; @@ -62,8 +67,9 @@ function WorkspaceExpenseDefaultsTable({rulesData, selectionEnabled, selectedKey const compareItems: CompareItemsCallback = (a, b, activeSorting) => { const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; - if (a.isMerchantType !== b.isMerchantType) { - return a.isMerchantType ? 1 : -1; + // Sections stay grouped and in a fixed order whatever the column sort is. Sorting only reorders rows inside a section. + if (a.section !== b.section) { + return SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section); } if (activeSorting.columnKey === 'type') { diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx index 37a4371a9886..bcc23d2dc620 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx @@ -26,13 +26,8 @@ import {View} from 'react-native'; import type {WorkspaceExpensifyCardTableRowData} from '.'; type WorkspaceExpensifyCardsTableRowProps = { - /** Data about the Expensify card */ item: WorkspaceExpensifyCardTableRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; }; diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index d1b73a45cbfb..2b4b1eebe3d3 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -51,13 +51,11 @@ type WorkspaceExpensifyCardTableRowData = TableData & { }; type WorkspaceExpensifyCardsTableProps = { - /** Policy ID */ policyID: string; /** Optional page-level content rendered above the card labels that scrolls with the rows */ headerComponent?: ReactElement; - /** List of Expensify cards to display in the table */ cards: WorkspaceExpensifyCardTableRowData[]; /** Whether multi selection is enabled */ @@ -66,7 +64,6 @@ type WorkspaceExpensifyCardsTableProps = { /** The list of selected keys for the table */ selectedKeys: string[]; - /** Callback when row selection changes */ onRowSelectionChange: (selectedRowKeys: string[]) => void; /** Card settings used to display labels and top-level errors */ @@ -81,10 +78,7 @@ type WorkspaceExpensifyCardsTableProps = { /** Optional footer component rendered at the bottom of the scrollable list */ listFooterComponent?: ReactElement; - /** Optional styles for the list footer component */ listFooterComponentStyle?: StyleProp; - - /** Optional styles for the list content container */ listContentContainerStyle?: StyleProp; }; diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx index c56e3d3559c3..7871b3230096 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx @@ -29,13 +29,8 @@ import WorkspaceRowBrickRoadIndicator from './WorkspaceRowBrickRoadIndicator'; import WorkspaceRowThreeDotsMenu from './WorkspaceRowThreeDotsMenu'; type WorkspaceRowProps = { - /** The workspace data */ item: WorkspaceRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; /** Called when the user picks Delete in the row menu, so the page can mount the delete flow */ diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index a726e7176960..504682a3e635 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -127,7 +127,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on const searchBarComponent = ( ); const tableHeaderComponent = composeTableListHeader(headerComponent, searchBarComponent); diff --git a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx index c50ad071509e..313061100b1b 100644 --- a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx +++ b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx @@ -20,13 +20,8 @@ import {View} from 'react-native'; import type {WorkspaceMemberRowData} from '.'; type WorkspaceMembersTableRowProps = { - /** The member item for the row */ item: WorkspaceMemberRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; /** Whether the custom field 1 column is visible on web screens or not */ diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 5108ccbf3f59..dc2f2c29a48a 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -7,6 +7,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {getPolicyApproverLogins, isControlPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; +import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -90,8 +91,8 @@ export default function WorkspaceMembersTable({ // The cell stacks the member's name above their email, so whichever of the two renders wider decides the // column's width. getContentToMeasure: (item) => [ - {text: item.name, fontSize: variables.fontSizeNormal}, - {text: item.email, fontSize: variables.fontSizeLabel}, + {text: item.name, fontSize: fontScale.text}, + {text: item.email, fontSize: fontScale.label}, ], extraWidth: MEMBER_CELL_AVATAR_WIDTH, }, @@ -104,7 +105,7 @@ export default function WorkspaceMembersTable({ key: 'customField1' as const, label: translate('workspace.common.customField1'), dynamicSizing: { - getContentToMeasure: (item: WorkspaceMemberRowData) => (item.employeeUserID ? [{text: item.employeeUserID, fontSize: variables.fontSizeNormal}] : []), + getContentToMeasure: (item: WorkspaceMemberRowData) => (item.employeeUserID ? [{text: item.employeeUserID, fontSize: fontScale.text}] : []), }, }, ] @@ -116,7 +117,7 @@ export default function WorkspaceMembersTable({ key: 'customField2' as const, label: translate('workspace.common.customField2'), dynamicSizing: { - getContentToMeasure: (item: WorkspaceMemberRowData) => (item.employeePayrollID ? [{text: item.employeePayrollID, fontSize: variables.fontSizeNormal}] : []), + getContentToMeasure: (item: WorkspaceMemberRowData) => (item.employeePayrollID ? [{text: item.employeePayrollID, fontSize: fontScale.text}] : []), }, }, ] @@ -126,7 +127,7 @@ export default function WorkspaceMembersTable({ label: translate('common.role'), sortable: true, dynamicSizing: { - getContentToMeasure: (item) => [{text: translate('workspace.common.roleName', item.role), fontSize: variables.fontSizeNormal}], + getContentToMeasure: (item) => [{text: translate('workspace.common.roleName', item.role), fontSize: fontScale.text}], // A role is one of a short, known set of labels, so the column always shows them in full. shouldFitContent: true, }, diff --git a/src/components/Tables/WorkspacePerDiemTable/WorkspacePerDiemTableRow.tsx b/src/components/Tables/WorkspacePerDiemTable/WorkspacePerDiemTableRow.tsx index 847a26ff7797..6efd06b5e97b 100644 --- a/src/components/Tables/WorkspacePerDiemTable/WorkspacePerDiemTableRow.tsx +++ b/src/components/Tables/WorkspacePerDiemTable/WorkspacePerDiemTableRow.tsx @@ -20,10 +20,7 @@ type WorkspacePerDiemTableRowProps = { /** Data about the per diem subrate */ item: PerDiemTableRowData; - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; }; diff --git a/src/components/Tables/WorkspaceReportFieldListValuesTable/WorkspaceReportFieldListValuesTableRow.tsx b/src/components/Tables/WorkspaceReportFieldListValuesTable/WorkspaceReportFieldListValuesTableRow.tsx index d2dc7938482e..c158e88c06a8 100644 --- a/src/components/Tables/WorkspaceReportFieldListValuesTable/WorkspaceReportFieldListValuesTableRow.tsx +++ b/src/components/Tables/WorkspaceReportFieldListValuesTable/WorkspaceReportFieldListValuesTableRow.tsx @@ -18,10 +18,7 @@ import {View} from 'react-native'; import type {ReportFieldListValueRowData} from '.'; type WorkspaceReportFieldListValuesTableRowProps = { - /** The list value item for the row */ item: ReportFieldListValueRowData; - - /** The index of the row relative to all other rows */ rowIndex: number; }; diff --git a/src/components/Tables/WorkspaceReportFieldListValuesTable/index.tsx b/src/components/Tables/WorkspaceReportFieldListValuesTable/index.tsx index 2f1fd106719d..73ffb326d7e2 100644 --- a/src/components/Tables/WorkspaceReportFieldListValuesTable/index.tsx +++ b/src/components/Tables/WorkspaceReportFieldListValuesTable/index.tsx @@ -1,5 +1,5 @@ import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; -import Table from '@components/Table'; +import Table, {composeTableListHeader} from '@components/Table'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -33,13 +33,25 @@ type WorkspaceReportFieldListValuesTableProps = { listValues: ReportFieldListValueRowData[]; selectionEnabled: boolean; selectedKeys: string[]; + /** Content rendered above the table header inside the scrollable list */ + headerComponent?: React.ReactElement; onRowSelectionChange: (selectedRowKeys: string[]) => void; + isInvoicePage: boolean; }; -export default function WorkspaceReportFieldListValuesTable({listValues, selectionEnabled, selectedKeys, onRowSelectionChange}: WorkspaceReportFieldListValuesTableProps) { +export default function WorkspaceReportFieldListValuesTable({ + listValues, + selectionEnabled, + selectedKeys, + headerComponent, + onRowSelectionChange, + isInvoicePage, +}: WorkspaceReportFieldListValuesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['FolderWithPapers']); + const findFieldKey = isInvoicePage ? 'workspace.invoiceFields.findInvoiceField' : 'workspace.reportFields.findReportField'; + const emptyValuesSubtitleKey = isInvoicePage ? 'workspace.invoiceFields.emptyInvoiceFieldsValues.subtitle' : 'workspace.reportFields.emptyReportFieldsValues.subtitle'; const columns: Array> = [ { @@ -88,6 +100,8 @@ export default function WorkspaceReportFieldListValuesTable({listValues, selecti /> ); + const tableHeaderComponent = composeTableListHeader(headerComponent, ); + return (
item.keyForList} onRowSelectionChange={onRowSelectionChange} > - + {isInvoicePage ? : {tableHeaderComponent}} )} - {(item.showEnabledSwitch || item.showRequiredSwitch) && !!handleSwitchToggle && ( + {item.showEnabledSwitch && !!handleSwitchToggle && ( void; onToggleEnabled?: (enabled: boolean) => void; - onToggleRequired?: (required: boolean) => void; onClose: () => void; }; @@ -77,13 +73,10 @@ export default function WorkspaceTagsTable({ const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const {isBetaEnabled} = usePermissions(); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const shouldShowTagCountColumn = isMultiLevelTags && !shouldUseNarrowTableLayout; const shouldShowEnabledColumn = !isMultiLevelTags; - // Required is configured from Rules once the revamp is on, so the rows have no switch to head. - const shouldShowRequiredColumn = isMultiLevelTags && !hasDependentTags && !isBetaEnabled(CONST.BETAS.RULES_REVAMP); const tagTableColumns: Array> = [ { @@ -132,19 +125,6 @@ export default function WorkspaceTagsTable({ }, ] : []), - ...(shouldShowRequiredColumn - ? [ - { - key: 'required' as const, - label: translate('common.required'), - sortable: true, - width: variables.tableSwitchColumnWidth, - styling: { - containerStyles: [styles.justifyContentEnd], - }, - }, - ] - : []), { key: 'actions', label: '', diff --git a/src/components/Tables/WorkspaceTaxesTable/WorkspaceTaxesTableRow.tsx b/src/components/Tables/WorkspaceTaxesTable/WorkspaceTaxesTableRow.tsx index 693c683c09ca..7bd2546f9856 100644 --- a/src/components/Tables/WorkspaceTaxesTable/WorkspaceTaxesTableRow.tsx +++ b/src/components/Tables/WorkspaceTaxesTable/WorkspaceTaxesTableRow.tsx @@ -36,10 +36,7 @@ type WorkspaceTaxesTableRowProps = { /** Data about the tax rate */ item: WorkspaceTaxTableRowData; - /** The index of the row relative to all other rows */ rowIndex: number; - - /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; }; diff --git a/src/components/Tables/WorkspaceVendorsTable/index.tsx b/src/components/Tables/WorkspaceVendorsTable/index.tsx index 2b279496821b..7f3cd7444099 100644 --- a/src/components/Tables/WorkspaceVendorsTable/index.tsx +++ b/src/components/Tables/WorkspaceVendorsTable/index.tsx @@ -1,5 +1,5 @@ import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; -import Table from '@components/Table'; +import Table, {composeTableListHeader} from '@components/Table'; import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; @@ -21,10 +21,14 @@ type WorkspaceVendorTableRowData = TableData & { }; type WorkspaceVendorsTableProps = { + /** Vendor rows to render */ vendors: WorkspaceVendorTableRowData[]; + + /** Page-level content rendered above the table header inside the scrollable list */ + headerComponent?: React.ReactElement; }; -function WorkspaceVendorsTable({vendors}: WorkspaceVendorsTableProps) { +function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -63,6 +67,8 @@ function WorkspaceVendorsTable({vendors}: WorkspaceVendorsTableProps) { ); + const tableHeaderComponent = composeTableListHeader(headerComponent, ); + return (
item.keyForList} > - + {tableHeaderComponent} void; }; -export default function WorkspaceViewTagsTable({tags, hasDependentTags, selectionEnabled, selectedKeys, onRowSelectionChange}: WorkspaceViewTagsTableProps) { +export default function WorkspaceViewTagsTable({tags, hasDependentTags, selectionEnabled, selectedKeys, headerComponent, onRowSelectionChange}: WorkspaceViewTagsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -111,9 +123,7 @@ export default function WorkspaceViewTagsTable({tags, hasDependentTags, selectio /> ); - if (tags.length === 0) { - return null; - } + const tableHeaderComponent = composeTableListHeader(headerComponent, ); return (
item.keyForList} onRowSelectionChange={onRowSelectionChange} > - + {tableHeaderComponent} diff --git a/src/components/TagPicker/TagPickerModal.tsx b/src/components/TagPicker/TagPickerModal.tsx index 671e934cc03e..992ebedaf201 100644 --- a/src/components/TagPicker/TagPickerModal.tsx +++ b/src/components/TagPicker/TagPickerModal.tsx @@ -29,13 +29,11 @@ const DEFAULT_ANCHOR_ALIGNMENT = { }; type TagPickerModalProps = { - /** Callback to close the modal */ onClose: () => void; /** The policy whose tags should be shown */ policyID: string | undefined; - /** Currently selected tag */ selectedTag?: string; /** The current transaction tag of the expense */ diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index f404971afb52..52373ffcbacf 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -37,16 +37,12 @@ type TagPickerProps = { /** The name of tag list we are getting tags for */ tagListName: string; - /** Callback to submit the selected tag */ onSubmit: (selectedTag: Partial) => void; - - /** Should show the selected option that is disabled? */ shouldShowDisabledAndSelectedOption?: boolean; /** Whether the list should be sorted by tag name. default is false */ shouldOrderListByTagName?: boolean; - /** Indicates which tag list index was selected */ tagListIndex: number; /** diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx index 0d06c5b27a4e..0109abb97e51 100644 --- a/src/components/TaskHeaderActionButton.tsx +++ b/src/components/TaskHeaderActionButton.tsx @@ -21,10 +21,9 @@ import {delegateEmailSelector} from '@selectors/Account'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; type TaskHeaderActionButtonProps = { - /** The report currently being looked at */ report: OnyxTypes.Report; }; diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index cdd10966dd27..05a114ea97d9 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -25,21 +25,11 @@ type TaxPickerProps = { /** The selected tax rate of an expense */ selectedTaxRate?: string; - /** ID of the policy */ policyID?: string; - - /** ID of the transaction */ transactionID?: string; - - /** Callback to fire when a tax is pressed */ onSubmit: (tax: TaxRatesOption, shouldClearTax?: boolean) => void; - - /** The action to take */ action?: IOUAction; - - /** The type of IOU */ iouType?: ValueOf; - onDismiss: () => void; /** diff --git a/src/components/TestCrash/index.native.tsx b/src/components/TestCrash/index.native.tsx index 6a93c1b4d541..61646c6676f8 100644 --- a/src/components/TestCrash/index.native.tsx +++ b/src/components/TestCrash/index.native.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import TestToolRow from '@components/TestToolRow'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/TestDrive/TestDriveBanner.tsx b/src/components/TestDrive/TestDriveBanner.tsx index 1cb3d89fbdc7..ec6af2d7fe2f 100644 --- a/src/components/TestDrive/TestDriveBanner.tsx +++ b/src/components/TestDrive/TestDriveBanner.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 8ed2518c25be..44f6e96a2cc0 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,3 +1,4 @@ +import useEnvironment from '@hooks/useEnvironment'; import useIsAgentAccount from '@hooks/useIsAgentAccount'; import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useLocalize from '@hooks/useLocalize'; @@ -5,21 +6,25 @@ import useOnyx from '@hooks/useOnyx'; import {useSidebarOrderedReportsActions} from '@hooks/useSidebarOrderedReports'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isUsingStagingApi} from '@libs/ApiUtils'; +import {getActiveServer} from '@libs/ApiUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network'; import {expireSessionWithDelay, invalidateAuthToken, invalidateCredentials} from '@userActions/Session'; -import {setIsDebugModeEnabled, setShouldShowBranchNameInTitle, setShouldUseStagingServer} from '@userActions/User'; +import {getBackToParam} from '@userActions/TestTool'; +import {setActiveServer, setIsDebugModeEnabled, setShouldShowBranchNameInTitle} from '@userActions/User'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; import {Platform} from 'react-native'; import BiometricsTestToolRow from './BiometricsTestToolRow'; -import Button from './ButtonComposed'; +import Button from './Button'; import QAAuthTestToolRows from './QAAuthTestToolRows'; import SoftKillTestToolRow from './SoftKillTestToolRow'; import Switch from './Switch'; @@ -30,12 +35,13 @@ import Text from './Text'; function TestToolMenu() { const [network] = useOnyx(ONYXKEYS.NETWORK); const [isUsingImportedState] = useOnyx(ONYXKEYS.IS_USING_IMPORTED_STATE); - const [shouldUseStagingServer = isUsingStagingApi()] = useOnyx(ONYXKEYS.SHOULD_USE_STAGING_SERVER); + const [activeServer = getActiveServer()] = useOnyx(ONYXKEYS.ACTIVE_SERVER); const [isDebugModeEnabled = false] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const [shouldShowBranchNameInTitle = false] = useOnyx(ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE); const styles = useThemeStyles(); const {translate} = useLocalize(); const {clearLHNCache} = useSidebarOrderedReportsActions(); + const {isProduction} = useEnvironment(); // Check if the user is authenticated to show options that require authentication const isAuthenticated = useIsAuthenticated(); @@ -116,6 +122,28 @@ function TestToolMenu() { + {/* Allows locally overriding beta feature flags for testing. Not rendered in production because this is not something regular users should reach, and forcing a beta on can leave the app half broken. */} + {!isProduction && ( + + + + )} + {/* Allows testing and revoking biometric multifactor authentication */} {isAgentAccount === false && } @@ -131,8 +159,8 @@ function TestToolMenu() { > setShouldUseStagingServer(!shouldUseStagingServer)} + isOn={activeServer === CONST.SERVER.STAGING} + onToggle={(isOn) => setActiveServer(isOn ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION)} /> )} diff --git a/src/components/Text.tsx b/src/components/Text.tsx index af23c9aa7c96..b88e85733599 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -1,6 +1,6 @@ import useTheme from '@hooks/useTheme'; -import {containsOnlyCustomEmoji} from '@libs/EmojiUtils'; +import {containsOnlyCustomEmoji} from '@libs/CustomEmojiUtils'; import type {TextVariant} from '@styles/typography'; import {textVariants} from '@styles/typography'; @@ -35,19 +35,10 @@ type TextProps = RNTextProps & */ fontSize?: number; - /** The alignment of the text */ textAlign?: TextStyle['textAlign']; - - /** Any children to display */ children: React.ReactNode; - - /** The family of the font to use */ family?: keyof FontUtilsType['fontFamily']['platform']; - - /** Should apply default line height */ shouldUseDefaultLineHeight?: boolean; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/TextBlock.tsx b/src/components/TextBlock.tsx index 0677d849686c..061798680337 100644 --- a/src/components/TextBlock.tsx +++ b/src/components/TextBlock.tsx @@ -9,7 +9,6 @@ import React, {memo, useMemo} from 'react'; import Text from './Text'; type TextBlockProps = { - /** The color of the text */ color?: string; /** Styles to apply to each text word */ diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index a0f3608c8012..07cf3fbce236 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -14,22 +14,17 @@ import type {MaskedTextInputOwnProps} from 'react-native-advanced-input-mask/lib type InputType = 'markdown' | 'mask' | 'default'; type CustomBaseTextInputProps = ForwardedFSClassProps & WithSentryLabel & { - /** Input label */ label?: string; /** Name attribute for the input */ name?: string; - /** Input value */ value?: string; /** Default value - used for non controlled inputs */ defaultValue?: string; - /** Input value placeholder */ placeholder?: string; - - /** Error text to display */ errorText?: string; /** Icon to display in right side of text input */ @@ -41,7 +36,6 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & /** Whether to include padding to the icon container */ includeIconPadding?: boolean; - /** Customize the TextInput container */ textInputContainerStyles?: StyleProp; /** Whether to apply padding to the input, some inputs doesn't require any padding, e.g. Amount input in money request flow */ @@ -50,16 +44,12 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & /** Customizes the touchable wrapper of the TextInput component */ touchableInputWrapperStyle?: StyleProp; - /** Customize the main container */ containerStyles?: StyleProp; - - /** input style */ inputStyle?: StyleProp; /** If present, this prop forces the label to remain in a position where it will not collide with input text */ forceActiveLabel?: boolean; - /** Should the input auto focus? */ autoFocus?: boolean; /** Disable the virtual keyboard */ @@ -101,13 +91,8 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & /** Hint text to display below the TextInput */ hint?: string; - /** Whether the hint should be rendered as HTML */ shouldRenderHintAsHTML?: boolean; - - /** Prefix character */ prefixCharacter?: string; - - /** Suffix character */ suffixCharacter?: string; /** Whether autoCorrect functionality should enable */ @@ -138,13 +123,8 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & /** Should there be an error displayed */ hasError?: boolean; - /** On Press handler */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void; - - /** Should loading state should be displayed */ isLoading?: boolean; - - /** Type of autocomplete */ autoCompleteType?: string; /** List of markdowns that won't be styled as a markdown */ @@ -156,46 +136,25 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & /** Custom parser function for RNMarkdownTextInput */ parser?: (input: string) => MarkdownRange[]; - /** Whether the clear button should be displayed */ shouldShowClearButton?: boolean; /** Whether to apply styles when input is disabled */ shouldUseDisabledStyles?: boolean; - /** Style for the prefix */ prefixStyle?: StyleProp; - - /** Style for the prefix container */ prefixContainerStyle?: StyleProp; - - /** Style for the suffix */ suffixStyle?: StyleProp; - - /** Style for the suffix container */ suffixContainerStyle?: StyleProp; - - /** Style for the loading spinner */ loadingSpinnerStyle?: StyleProp; - - /** Style for the icon container */ iconContainerStyle?: StyleProp; - - /** Style for the clear button */ clearButtonStyle?: StyleProp; - - /** The clear button icon size */ clearButtonIconSize?: number; - - /** The width of inner content */ contentWidth?: number; /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown` */ type?: InputType; - /** The mask of the masked input */ mask?: MaskedTextInputOwnProps['mask']; - - /** Custom notations for the masked input */ customNotations?: MaskedTextInputOwnProps['customNotations']; /** A set of permitted characters for the input */ @@ -222,7 +181,6 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ rightHandSideComponent?: React.ReactNode; - /** Reference to the outer element */ ref?: ForwardedRef; /** When the `disableKeyboard` prop is passed with the value `true`, we need to pass the `navigation` prop from `useNavigation` to ensure that the `disableKeyboard` functionality works correctly when the application is in the background */ diff --git a/src/components/TextInput/TextInputLabel/types.ts b/src/components/TextInput/TextInputLabel/types.ts index 9b467d836fbe..1330ea3d1d58 100644 --- a/src/components/TextInput/TextInputLabel/types.ts +++ b/src/components/TextInput/TextInputLabel/types.ts @@ -1,16 +1,12 @@ import type {SharedValue} from 'react-native-reanimated'; type TextInputLabelProps = { - /** Label */ label: string; /** Label vertical translate */ labelTranslateY: SharedValue; - /** Label scale */ labelScale: SharedValue; - - /** Whether the input is multiline */ isMultiline?: boolean; /** Force the floating label to render on a single line and ellipsize even when the input is multiline */ diff --git a/src/components/TextInput/TextInputMeasurement/types.ts b/src/components/TextInput/TextInputMeasurement/types.ts index 5110612a97d9..9de1749c9ee7 100644 --- a/src/components/TextInput/TextInputMeasurement/types.ts +++ b/src/components/TextInput/TextInputMeasurement/types.ts @@ -10,37 +10,22 @@ type TextInputMeasurementProps = { /** The width to measure */ contentWidth?: number; - /** Whether to auto grow height */ autoGrowHeight?: boolean; - - /** The maximum height for auto grow */ maxAutoGrowHeight?: number; /** The width of the container */ width: number | null; - /** The input style */ inputStyle?: StyleProp; - - /** The input padding left */ inputPaddingLeft?: StyleProp; - - /** Whether to auto grow */ autoGrow?: boolean; - - /** Whether the input is markdown */ isAutoGrowHeightMarkdown?: boolean; /** Horizontal padding and border styles extracted from the container for accurate width measurement */ autoGrowMeasurementStyles?: StyleProp; - /** Callback to set the text input width */ onSetTextInputWidth: (width: number) => void; - - /** Callback to set the text input height */ onSetTextInputHeight: (height: number) => void; - - /** Whether the prefix character padding is calculated */ isPrefixCharacterPaddingCalculated: boolean; }; diff --git a/src/components/TextInputWithSymbol/index.web.tsx b/src/components/TextInputWithSymbol/index.web.tsx index da0bd9579ee1..b8f3658d66be 100644 --- a/src/components/TextInputWithSymbol/index.web.tsx +++ b/src/components/TextInputWithSymbol/index.web.tsx @@ -1,3 +1,5 @@ +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + import type {TextInputSelectionChangeEvent} from 'react-native'; import React, {useRef} from 'react'; @@ -7,32 +9,33 @@ import type {TextInputWithSymbolProps} from './types'; import BaseTextInputWithSymbol from './BaseTextInputWithSymbol'; function TextInputWithSymbol({onSelectionChange = () => {}, ref, ...props}: TextInputWithSymbolProps) { - const textInputRef = useRef(null); + const textInputRef = useRef(null); return ( { - textInputRef.current = element as HTMLFormElement; + textInputRef.current = element; if (!ref) { return; } if (typeof ref === 'function') { - ref(element as HTMLFormElement); + ref(element); return; } // eslint-disable-next-line no-param-reassign - ref.current = element as HTMLFormElement; + ref.current = element; }} onSelectionChange={(event: TextInputSelectionChangeEvent) => { onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end); }} onPress={() => { - const selectionStart = (textInputRef.current?.selectionStart as number) ?? 0; - const selectionEnd = (textInputRef.current?.selectionEnd as number) ?? 0; + const input = textInputRef.current; + const selectionStart = input instanceof HTMLInputElement ? (input.selectionStart ?? 0) : 0; + const selectionEnd = input instanceof HTMLInputElement ? (input.selectionEnd ?? 0) : 0; onSelectionChange(selectionStart, selectionEnd); }} /> diff --git a/src/components/TextInputWithSymbol/types.ts b/src/components/TextInputWithSymbol/types.ts index 897dfa3e0ea7..bbaf35b90b94 100644 --- a/src/components/TextInputWithSymbol/types.ts +++ b/src/components/TextInputWithSymbol/types.ts @@ -7,34 +7,21 @@ import type {KeyboardTypeOptions, NativeSyntheticEvent, StyleProp, TextInputSele import type {ValueOf} from 'type-fest'; type BaseTextInputWithSymbolProps = { - /** Formatted amount */ formattedAmount: string; /** Function to call when amount in text input is changed */ onChangeAmount?: (amount: string) => void; - /** Function to call when symbol button is pressed */ onSymbolButtonPress?: () => void; /** Placeholder value for amount text input */ placeholder: string; - /** Symbol of the input */ symbol: string; - - /** Position of the symbol */ symbolPosition?: ValueOf; - - /** Whether the symbol is pressable */ isSymbolPressable?: boolean; - - /** Whether to hide the symbol */ hideSymbol?: boolean; - - /** Selection Object */ selection?: TextSelection; - - /** Function to call when selection in text input is changed */ onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; /** Function to call to handle key presses in the text input */ @@ -53,25 +40,18 @@ type BaseTextInputWithSymbolProps = { /** Whether to disable native keyboard on mobile */ disableKeyboard?: boolean; - /** Style for the input */ style?: StyleProp; - - /** Style for the container */ containerStyle?: StyleProp; /** Character to be shown before the amount */ prefixCharacter?: string; - /** Style for the prefix */ prefixStyle?: StyleProp; - - /** Style for the prefix container */ prefixContainerStyle?: StyleProp; /** Customizes the touchable wrapper of the TextInput component */ touchableInputWrapperStyle?: StyleProp; - /** Style for the symbol */ symbolTextStyle?: StyleProp; /** Max length for the amount input */ @@ -89,13 +69,11 @@ type BaseTextInputWithSymbolProps = { /** Function to toggle the amount to negative */ toggleNegative?: () => void; - /** Style for the negative symbol */ negativeSymbolStyle?: StyleProp; /** The test ID of TextInput. Used to locate the view in end-to-end tests. */ testID?: string; - /** Determines which keyboard to open */ keyboardType?: KeyboardTypeOptions; /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ diff --git a/src/components/TextLink.tsx b/src/components/TextLink.tsx index ad0bd1225d3b..76455ec27b03 100644 --- a/src/components/TextLink.tsx +++ b/src/components/TextLink.tsx @@ -30,13 +30,8 @@ type PressProps = { type TextLinkProps = (LinkProps | PressProps) & TextProps & { - /** Additional style props */ style?: StyleProp; - - /** Callback that is called when mousedown is triggered */ onMouseDown?: MouseEventHandler; - - /** Whether to suppress the default link style */ suppressDefaultStyle?: boolean; }; diff --git a/src/components/TextPicker/types.ts b/src/components/TextPicker/types.ts index 32a6916a9617..41acfadbabe9 100644 --- a/src/components/TextPicker/types.ts +++ b/src/components/TextPicker/types.ts @@ -10,16 +10,12 @@ type TextSelectorModalProps = { /** Whether the modal is visible */ isVisible: boolean; - /** Current value */ value?: string; - - /** Function to call when the user selects a item */ onValueSelected?: (value: string) => void; /** Function to call when the user closes the modal */ onClose: () => void; - /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; /** Whether to clear the input value when the modal closes */ @@ -28,7 +24,6 @@ type TextSelectorModalProps = { /** The ID used to uniquely identify the input in a Form */ inputID: string; - /** Whether the field is required */ required?: boolean; /** Custom validation function */ @@ -46,10 +41,7 @@ type TextSelectorModalProps = { Omit; type TextPickerProps = { - /** Item to display */ value?: string; - - /** A placeholder value to display */ placeholder?: string; /** Form Error description */ @@ -67,13 +59,11 @@ type TextPickerProps = { /** Text to display under the main menu item */ furtherDetails?: string; - /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; /** The ID used to uniquely identify the input in a Form */ inputID: string; - /** Whether the field is required */ required?: boolean; /** Custom validation function */ @@ -88,7 +78,6 @@ type TextPickerProps = { /** Whether to use strict HTML tag validation regex */ shouldUseStrictHtmlTagValidation?: boolean; - /** Reference to the outer element */ ref?: ForwardedRef; } & Pick & TextProps; diff --git a/src/components/TextWithEllipsis/index.tsx b/src/components/TextWithEllipsis/index.tsx index 5e8e19ea6962..518d7b25a51a 100644 --- a/src/components/TextWithEllipsis/index.tsx +++ b/src/components/TextWithEllipsis/index.tsx @@ -17,7 +17,6 @@ type TextWithEllipsisProps = { /** Styles for leading and trailing text */ textStyle?: StyleProp; - /** Styles for leading text View */ leadingTextParentStyle?: StyleProp; /** Styles for parent View */ diff --git a/src/components/TextWithMiddleEllipsis/index.native.tsx b/src/components/TextWithMiddleEllipsis/index.native.tsx index 12faa3e1bed5..96def5db9fe9 100644 --- a/src/components/TextWithMiddleEllipsis/index.native.tsx +++ b/src/components/TextWithMiddleEllipsis/index.native.tsx @@ -5,13 +5,8 @@ import type {StyleProp, TextStyle} from 'react-native'; import React from 'react'; type TextWithMiddleEllipsisProps = { - /** The text to display */ text: string; - - /** Additional styles */ style?: StyleProp; - - /** Additional text styles */ textStyle?: StyleProp; }; diff --git a/src/components/TextWithMiddleEllipsis/index.tsx b/src/components/TextWithMiddleEllipsis/index.tsx index 4a57df48bdfb..a5a606907633 100644 --- a/src/components/TextWithMiddleEllipsis/index.tsx +++ b/src/components/TextWithMiddleEllipsis/index.tsx @@ -10,13 +10,8 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; type TextWithMiddleEllipsisProps = { - /** The text to display */ text: string; - - /** Additional styles */ style?: StyleProp; - - /** Additional text styles */ textStyle?: StyleProp; }; diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts index c55e27f97c93..1bda032fb8da 100644 --- a/src/components/TextWithTooltip/types.ts +++ b/src/components/TextWithTooltip/types.ts @@ -3,19 +3,13 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {StyleProp, TextStyle} from 'react-native'; type TextWithTooltipProps = ForwardedFSClassProps & { - /** The text to display */ text: string; - - /** Whether to show the tooltip text */ shouldShowTooltip?: boolean; - - /** Additional styles */ style?: StyleProp; /** Custom number of lines for text wrapping */ numberOfLines?: number; - /** TestID of the Text component */ testID?: string; }; diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 261aa98a6eed..4f50116e8a40 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; import type BaseModalProps from '@components/Modal/types'; import type {PopoverMenuItem} from '@components/PopoverMenu'; @@ -185,7 +184,7 @@ function ThreeDotsMenu({ ref={buttonRef} style={[styles.touchableButtonImage, styles.threeDotsMenuIconWidth, iconStyles]} hoverStyle={iconHoverStyle} - role={getButtonRole(isNested)} + role={CONST.ROLE.BUTTON} isNested={isNested} accessibilityLabel={translate(iconTooltip)} sentryLabel={sentryLabel} diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts index 92d8ce9e357e..fc8c0ce0f1d3 100644 --- a/src/components/ThreeDotsMenu/types.ts +++ b/src/components/ThreeDotsMenu/types.ts @@ -33,49 +33,35 @@ type ThreeDotsMenuProps = WithSentryLabel & { /** Whether the trigger icon turns green while the menu is open. Defaults to true. */ shouldChangeFillOnOpen?: boolean; - /** Test ID for the trigger */ testID?: string; - - /** Function to call on icon press */ onIconPress?: (() => void) | ((e?: GestureResponderEvent | KeyboardEvent | undefined) => void); /** menuItems that'll show up on toggle of the popup menu */ menuItems: PopoverMenuItem[]; - /** The anchor alignment of the menu */ anchorAlignment?: AnchorAlignment; /** Whether the popover menu should overlay the current view */ shouldOverlay?: boolean; - /** Whether the menu is disabled */ disabled?: boolean; /** Should we announce the Modal visibility changes? */ shouldSetModalVisibility?: boolean; - /** Function to hide the product training tooltip */ hideProductTrainingTooltip?: () => void; - - /** Tooltip content to render */ renderProductTrainingTooltipContent?: () => React.JSX.Element; - - /** Should we render the tooltip */ shouldShowProductTrainingTooltip?: boolean; /** Is the menu nested? This prop is used to omit html warning when we are nesting a button inside another button */ isNested?: boolean; - /** Ref to the menu */ threeDotsMenuRef?: React.RefObject<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean} | null>; - - /** Whether the menu is focused */ isContainerFocused?: boolean; }; type ThreeDotsMenuWithOptionalAnchorProps = | (ThreeDotsMenuProps & { - /** The anchor position of the menu */ anchorPosition: AnchorPosition; /** A callback to get the anchor position dynamically */ @@ -85,7 +71,6 @@ type ThreeDotsMenuWithOptionalAnchorProps = shouldSelfPosition?: false; }) | (ThreeDotsMenuProps & { - /** The anchor position of the menu */ anchorPosition?: never; /** A callback to get the anchor position dynamically */ @@ -95,7 +80,6 @@ type ThreeDotsMenuWithOptionalAnchorProps = shouldSelfPosition?: false; }) | (ThreeDotsMenuProps & { - /** The anchor position of the menu */ anchorPosition?: never; /** A callback to get the anchor position dynamically */ diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 44790eef7361..96fccc69e533 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -29,37 +29,22 @@ import ImageWithSizeCalculation from './ImageWithSizeCalculation'; const thumbnailDimensionsCache = new Map(); type ThumbnailImageProps = { - /** Source URL for the preview image */ previewSourceURL: string | ImageSourcePropType; - - /** alt text for the image */ altText?: string; - - /** Any additional styles to apply */ style?: StyleProp; /** Whether the image requires an authToken */ isAuthTokenRequired: boolean; - /** Width of the thumbnail image */ imageWidth?: number; - - /** Height of the thumbnail image */ imageHeight?: number; - - /** The size of the loading indicator */ loadingIconSize?: FullScreenLoadingIndicatorIconSize; - - /** The style of the loading indicator */ loadingIndicatorStyles?: StyleProp; /** If the image fails to load – show the provided fallback icon */ fallbackIcon?: IconAsset; - /** The size of the fallback icon */ fallbackIconSize?: number; - - /** The color of the fallback icon */ fallbackIconColor?: string; /** The background color of fallback icon */ @@ -68,10 +53,7 @@ type ThumbnailImageProps = { /** Should the image be resized on load or just fit container */ shouldDynamicallyResize?: boolean; - /** The object position of image */ objectPosition?: ImageObjectPosition; - - /** Whether the image is deleted */ isDeleted?: boolean; /** Callback fired when the image fails to load */ @@ -83,7 +65,6 @@ type ThumbnailImageProps = { /** Callback to be called when the image loads */ onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; - /** The resize mode of the image */ resizeMode?: ImageResizeMode; /** Low-resolution URI shown as a placeholder while the full image loads */ diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 17b372a20c7f..2469f74a23b6 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -17,7 +17,6 @@ import ScreenWrapper from './ScreenWrapper'; import TimePicker from './TimePicker/TimePicker'; type TimeModalPickerProps = { - /** Current value of the selected item */ value?: string; /** Callback when the list item is selected */ @@ -26,10 +25,7 @@ type TimeModalPickerProps = { /** Form Error description */ errorText?: string; - /** Label for the picker */ label: string; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index f6ee710edfea..23d4a17d5de0 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -1,6 +1,6 @@ import AmountTextInput from '@components/AmountTextInput'; import BigNumberPad from '@components/BigNumberPad'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import Text from '@components/Text'; @@ -37,7 +37,6 @@ type TimePickerProps = { /** Whether the time value should be validated */ shouldValidate?: boolean; - /** Whether the time value should be validated for future time only */ shouldValidateFutureTime?: boolean; /** Whether the picker shows hours, minutes, seconds and milliseconds */ diff --git a/src/components/Tooltip/BaseGenericTooltip/types.ts b/src/components/Tooltip/BaseGenericTooltip/types.ts index c36c9fe7c133..7f7381bce208 100644 --- a/src/components/Tooltip/BaseGenericTooltip/types.ts +++ b/src/components/Tooltip/BaseGenericTooltip/types.ts @@ -3,10 +3,7 @@ import type {SharedTooltipProps} from '@components/Tooltip/types'; import type {SharedValue} from 'react-native-reanimated'; type BaseGenericTooltipProps = { - /** Window width */ windowWidth: number; - - /** Tooltip Animation value */ animation: SharedValue; /** The distance between the left side of the wrapper view and the left side of the window */ @@ -15,10 +12,7 @@ type BaseGenericTooltipProps = { /** The distance between the top of the wrapper view and the top of the window */ yOffset: number; - /** The width of the tooltip's target */ targetWidth: number; - - /** The height of the tooltip's target */ targetHeight: number; /** Minimum width for the tooltip */ @@ -35,10 +29,7 @@ type BaseGenericTooltipProps = { /** Handles what to do when hiding the tooltip */ onHideTooltip?: () => void; - /** Whether the tooltip should teleport to the modal layer */ shouldTeleportPortalToModalLayer?: boolean; - - /** Whether it is education tooltip */ isEducationTooltip?: boolean; } & Pick< SharedTooltipProps, diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index 5f3998ece92a..b7a758d207ff 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -33,7 +33,6 @@ type SharedTooltipProps = { /** Unique key of renderTooltipContent to rerender the tooltip when one of the key changes */ renderTooltipContentKey?: string[]; - /** The anchor alignment of the tooltip */ anchorAlignment?: TooltipAnchorAlignment; /** Whether to display tooltip below the wrapped component */ @@ -45,24 +44,14 @@ type SharedTooltipProps = { /** Should render a fullscreen transparent overlay */ shouldUseOverlay?: boolean; - /** Whether the tooltip should teleport to the modal layer */ shouldTeleportPortalToModalLayer?: boolean; - - /** Callback when tooltip is clicked */ onTooltipPress?: (event: GestureResponderEvent | KeyboardEvent | undefined) => void; - - /** Whether to compute horizontal shift for native */ computeHorizontalShiftForNative?: boolean; }; type GenericTooltipState = { - /** Is tooltip visible */ isVisible: boolean; - - /** Show tooltip */ showTooltip: () => void; - - /** Hide tooltip */ hideTooltip: () => void; /** Update the tooltip's target bounding rectangle */ @@ -78,7 +67,6 @@ type GenericTooltipProps = SharedTooltipProps & { /** Whether to ignore TooltipSense activity and always trigger animation */ shouldForceAnimate?: boolean; - /** Whether it is education tooltip */ isEducationTooltip?: boolean; }; @@ -90,7 +78,6 @@ type TooltipProps = ChildrenProps & /** Whether the current screen or component is actively focused via navigation */ isFocused?: boolean; - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx index f396e9641ad8..e40a5310e8ec 100644 --- a/src/components/TransactionItemRow/DataCells/TotalCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx @@ -12,13 +12,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {convertToBackendAmount, convertToFrontendAmountAsString, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {formatToParts} from '@libs/NumberFormatUtils'; import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {isGroupPolicy} from '@libs/PolicyUtils'; -import {isExpenseReport, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils'; -import {getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, isDeletedTransaction, isExpenseUnreported, isScanning} from '@libs/TransactionUtils'; +import {getTransactionDisplayAmount, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils'; +import {getCurrency as getTransactionCurrency, isExpenseUnreported, isScanning} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {Policy, Report} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import React, {useRef, useState} from 'react'; @@ -51,9 +49,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report, const effectiveReport = report ?? transactionItem.report; const effectivePolicy = policy ?? transactionItem.policy; - const isDeleted = isDeletedTransaction(transactionItem); - const isFromExpenseReport = (!isEmptyObject(effectiveReport) && isExpenseReport(effectiveReport)) || isGroupPolicy(effectivePolicy); - const amount = getTransactionAmount(transactionItem, isFromExpenseReport, transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, isDeleted); + const amount = getTransactionDisplayAmount(transactionItem, effectiveReport, effectivePolicy); let amountToDisplay = convertToDisplayString(amount, currency); if (isScanning(transactionItem)) { amountToDisplay = translate('iou.receiptStatusTitle'); diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx index 4d8948ce9a66..8f96ce4932b6 100644 --- a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -32,13 +32,8 @@ import React from 'react'; import {View} from 'react-native'; type TransactionItemRowRBRInnerProps = { - /** Transaction item */ transaction: Transaction; - - /** Transaction violations */ violations?: TransactionViolation[]; - - /** Report item */ report?: Report; /** Styles for the RBR messages container */ diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 67f578d34c43..c149fc6c87ef 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -2,6 +2,7 @@ import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import RadioButton from '@components/RadioButton'; +import {useSearchColumnStyles} from '@components/Search/SearchColumnWidthsContext'; import DeferredActionCell from '@components/Search/SearchList/ListItem/ActionCell/DeferredActionCell'; import AttendeesCell from '@components/Search/SearchList/ListItem/AttendeesCell'; import DateCell from '@components/Search/SearchList/ListItem/DateCell'; @@ -16,6 +17,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -26,7 +28,7 @@ import getBase62ReportID from '@libs/getBase62ReportID'; import {isTaxCodeCustomized, getTagGLCode} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {getReimbursableTotal, isExpenseReport} from '@libs/ReportUtils'; -import {getSubmittedViolationsForTransaction} from '@libs/SearchUIUtils'; +import {getViolationsForTransaction} from '@libs/SearchUIUtils'; import {getShiftKeyFromEvent} from '@libs/shiftRangeSelection'; import { getAmount, @@ -48,6 +50,10 @@ import getFormattedPostedDate from '@libs/TransactionUtils/getFormattedPostedDat import variables from '@styles/variables'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; + +import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; import {View} from 'react-native'; @@ -71,6 +77,10 @@ type TransactionItemRowWideProps = Omit< TransactionItemRowWideComputedData & TransactionItemRowRBRDeferControlProps; +function searchHasFilterSelector(form: OnyxEntry) { + return form?.has; +} + function TransactionItemRowWide({ transactionItem, report, @@ -139,7 +149,9 @@ function TransactionItemRowWide({ const styles = useThemeStyles(); const {translate} = useLocalize(); const StyleUtils = useStyleUtils(); + const getSearchColumnStyles = useSearchColumnStyles(); const theme = useTheme(); + const [hasFilterValues] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: searchHasFilterSelector}); const expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']); const isDeletedTransaction = isDeletedTransactionUtil(transactionItem); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -170,7 +182,7 @@ function TransactionItemRowWide({ return ( - + ); case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: return ( @@ -231,7 +243,7 @@ function TransactionItemRowWide({ return ( @@ -326,7 +338,7 @@ function TransactionItemRowWide({ return ( {getReimbursable(transactionItem) ? translate('common.yes') : translate('common.no')} @@ -335,7 +347,7 @@ function TransactionItemRowWide({ return ( {transactionItem.billable ? translate('common.yes') : translate('common.no')} @@ -344,7 +356,7 @@ function TransactionItemRowWide({ return ( {!!transactionItem.action && ( {!!transactionItem.to && ( {!!transactionItem.from && ( @@ -440,7 +452,7 @@ function TransactionItemRowWide({ return ( {shouldShowAttendees && ( @@ -476,7 +488,7 @@ function TransactionItemRowWide({ return ( {shouldShowAttendees && ( @@ -529,7 +541,7 @@ function TransactionItemRowWide({ return ( @@ -538,7 +550,7 @@ function TransactionItemRowWide({ return ( @@ -547,7 +559,7 @@ function TransactionItemRowWide({ return ( @@ -556,7 +568,7 @@ function TransactionItemRowWide({ return ( @@ -565,7 +577,7 @@ function TransactionItemRowWide({ return ( {shouldHideTaxValueByRequestType ? null : ( @@ -634,7 +646,7 @@ function TransactionItemRowWide({ return ( @@ -656,7 +668,7 @@ function TransactionItemRowWide({ return ( @@ -665,7 +677,7 @@ function TransactionItemRowWide({ return ( @@ -674,7 +686,7 @@ function TransactionItemRowWide({ return ( diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index c002847a45b1..c4c9bd5ad871 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -97,10 +97,12 @@ function TransactionItemRow({ }: TransactionItemRowProps) { const shouldDeferRBR = !shouldSkipDeferRBR; const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, report, isTrackIntentUser, + rules, }); const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/components/TrialPaymentReminderModal.tsx b/src/components/TrialPaymentReminderModal.tsx index 6bc935035088..d62a63f2aa50 100644 --- a/src/components/TrialPaymentReminderModal.tsx +++ b/src/components/TrialPaymentReminderModal.tsx @@ -9,13 +9,12 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import Button from './ButtonComposed'; +import Button from './Button'; import ImageSVG from './ImageSVG'; import Modal from './Modal'; import Text from './Text'; type TrialPaymentReminderModalProps = { - /** Whether the modal is visible */ isVisible: boolean; /** The variant of the modal to display */ @@ -27,10 +26,7 @@ type TrialPaymentReminderModalProps = { /** Countdown time for 'countdown' variant */ countdownTime?: CountdownTime; - /** Called when user presses Close */ onClose: () => void; - - /** Called when user presses Add payment card */ onAddPaymentCard: () => void; }; diff --git a/src/components/TwoFactorAuthForm/types.ts b/src/components/TwoFactorAuthForm/types.ts index 77b13503850c..85dd40731106 100644 --- a/src/components/TwoFactorAuthForm/types.ts +++ b/src/components/TwoFactorAuthForm/types.ts @@ -10,7 +10,6 @@ type BaseTwoFactorAuthFormRef = { }; type TwoFactorAuthFormProps = { - /** Whether to allow using a recovery code */ shouldAllowRecoveryCode?: boolean; /** Translation key for the description rendered when the user is entering a TOTP. Defaults to the disable-2FA prose. */ @@ -37,7 +36,6 @@ type TwoFactorAuthFormProps = { /** Callback that is called when the text input is focused */ onFocus?: () => void; - /** Reference to the outer element */ ref: Ref; }; diff --git a/src/components/UnreadActionIndicator.tsx b/src/components/UnreadActionIndicator.tsx index ef6e6cf170eb..24bd3139b143 100755 --- a/src/components/UnreadActionIndicator.tsx +++ b/src/components/UnreadActionIndicator.tsx @@ -9,10 +9,7 @@ import {View} from 'react-native'; import Text from './Text'; type UnreadActionIndicatorProps = { - /** The ID of the report action */ reportActionID: string | undefined; - - /** Whether we should hide thread divider line */ shouldHideThreadDividerLine?: boolean; }; diff --git a/src/components/UploadFile.tsx b/src/components/UploadFile.tsx index 7f833d4c309e..b602afaa6a53 100644 --- a/src/components/UploadFile.tsx +++ b/src/components/UploadFile.tsx @@ -15,7 +15,7 @@ import React from 'react'; import {View} from 'react-native'; import AttachmentPicker from './AttachmentPicker'; -import Button from './ButtonComposed'; +import Button from './Button'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import {PressableWithFeedback} from './Pressable'; @@ -25,16 +25,9 @@ type UploadFileProps = { /** Text displayed on button when no file is uploaded */ buttonText: string; - /** Name of currently uploaded file */ uploadedFiles: FileObject[]; - - /** Handler that fires when file is selected for upload */ onUpload: (files: FileObject[]) => void; - - /** Handler that fires when file is removed */ onRemove: (fileUri: string) => void; - - /** Array containing accepted file types */ acceptedFileTypes: Array>; /** Styles to be assigned to Container */ @@ -52,7 +45,6 @@ type UploadFileProps = { /** Whether to allow multiple files to be selected. */ fileLimit?: number; - /** The total size limit of the files that can be selected. */ totalFilesSizeLimit?: number; /** The maximum size of a single file that can be selected. */ diff --git a/src/components/UserDetailsTooltip/types.tsx b/src/components/UserDetailsTooltip/types.tsx index e581916a1173..79ddeb537712 100644 --- a/src/components/UserDetailsTooltip/types.tsx +++ b/src/components/UserDetailsTooltip/types.tsx @@ -18,7 +18,6 @@ type FallbackUserDetails = { }; type UserDetailsTooltipProps = ChildrenProps & { - /** User's Account ID */ accountID: number; /** Fallback User Details object used if no accountID */ diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index cf6e8ed9f328..c2b624c59dcd 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -13,12 +13,11 @@ import React from 'react'; import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; -import MenuItemEmptyField from './MenuItem/presets/MenuItemEmptyField'; +import MenuItemField from './MenuItem/presets/MenuItemField'; import MenuItemWithLabel from './MenuItem/presets/MenuItemWithLabel'; import OfflineWithFeedback from './OfflineWithFeedback'; type VacationDelegateSectionProps = { - /** Currently selected vacation delegate (if any) */ vacationDelegate?: BaseVacationDelegate; /** Errors related to setting the vacation delegate */ @@ -81,8 +80,8 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl ) : ( - )} diff --git a/src/components/ValidateCodeActionForm/type.ts b/src/components/ValidateCodeActionForm/type.ts index ac3de897fc97..88ef3dfc2fcc 100644 --- a/src/components/ValidateCodeActionForm/type.ts +++ b/src/components/ValidateCodeActionForm/type.ts @@ -9,25 +9,18 @@ type ValidateCodeActionFormProps = { /** Primary description of the modal */ descriptionPrimary: ReactNode; - /** Primary description styles */ descriptionPrimaryStyles?: TextStyle; /** Secondary description of the modal */ descriptionSecondary?: ReactNode; - /** Secondary description styles */ descriptionSecondaryStyles?: TextStyle; /** The pending action for submitting form */ validatePendingAction?: PendingAction | null; - /** The error of submitting */ validateError?: Errors; - - /** Function is called when submitting form */ handleSubmitForm: (validateCode: string) => void; - - /** Function to clear error of the form */ clearError: () => void; /** Function is called when validate code modal is mounted and on validateCode resend */ @@ -36,22 +29,14 @@ type ValidateCodeActionFormProps = { /** If the validateCode has been resent previously */ hasValidateCodeBeenSent?: boolean; - /** Whether the form is loading or not */ isLoading?: boolean; - - /** Whether to show the skip button */ shouldShowSkipButton?: boolean; - - /** Function to call when the skip button is pressed */ handleSkipButtonPress?: () => void; - - /** Text for submit button */ submitButtonText?: string; /** Skip the call to sendValidateCode fn on initial render */ shouldSkipInitialValidation?: boolean; - /** Ref for validate code form */ ref?: ForwardedRef; }; diff --git a/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx b/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx index 6123e75533dc..47374d2371ea 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx @@ -29,6 +29,7 @@ function ValidateCodeActionContent({ handleSubmitForm, clearError, sendValidateCode, + validateCodeReasonCode, isLoading, threeDotsMenuItems = [], onThreeDotsButtonPress = () => {}, @@ -46,10 +47,12 @@ function ValidateCodeActionContent({ } firstRenderRef.current = false; - // The validateCode is account-level, so skip sending if one was already requested within the resend window (e.g. a page reload) + // The validateCode is account-level, so skip sending if one was already requested within the resend window (e.g. a page reload). + // When this screen declares a reason, only suppress if the recent request was for the same flow. const requestedAt = validateCodeAction?.lastValidateCodeRequestedAt; const sentRecently = !!requestedAt && Date.now() - requestedAt < CONST.REQUEST_CODE_DELAY * CONST.MILLISECONDS_PER_SECOND; - if (sentRecently) { + const sentRecentlyForSameReason = sentRecently && (!validateCodeReasonCode || validateCodeAction?.lastValidateCodeReason === validateCodeReasonCode); + if (sentRecentlyForSameReason) { return; } diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index d8f9e1cb505c..ee897df47c1e 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -52,9 +53,7 @@ type ValidateCodeFormProps = { /** Specifies autocomplete hints for the system, so it can provide autofill */ autoComplete?: AutoCompleteVariant; - /** Forwarded inner ref */ ref?: ForwardedRef; - hasValidateCodeBeenSent?: boolean; /** The pending action of validateCode being sent @@ -69,16 +68,9 @@ type ValidateCodeFormProps = { */ validateCodeActionErrorField: string; - /** The error of submitting */ validateError?: Errors; - - /** Function is called when submitting form */ handleSubmitForm: (validateCode: string) => void; - - /** Styles for the button */ buttonStyles?: StyleProp; - - /** Function to clear error of the form */ clearError: () => void; /** Whether to show the verify button */ @@ -90,13 +82,8 @@ type ValidateCodeFormProps = { /** Function is called when validate code modal is mounted and on validateCode resend */ sendValidateCode: () => void; - /** Whether the form is loading or not */ isLoading?: boolean; - - /** Whether to show skip button */ shouldShowSkipButton?: boolean; - - /** Function to call when skip button is pressed */ handleSkipButtonPress?: () => void; /** Whether the modal is used as a page modal. Used to determine input auto focus timing. */ @@ -421,8 +408,7 @@ function BaseValidateCodeForm({ )} {!hideSubmitButton && ( - + )} diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts index 73a98c1f48f2..b4b37b99beee 100644 --- a/src/components/ValidateCodeActionModal/type.ts +++ b/src/components/ValidateCodeActionModal/type.ts @@ -1,15 +1,11 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ValidateCodeReason} from '@src/types/onyx/VerifyValidateCodeAction'; type ValidateCodeActionContentProps = { - /** Title of the modal */ title: string; - - /** Primary description of the modal */ descriptionPrimary: string; - - /** Secondary description of the modal */ descriptionSecondary?: string | null; /** Function to call when the user closes the modal */ @@ -24,7 +20,6 @@ type ValidateCodeActionContentProps = { /** The errorField name of validateCodeAction.errorFields, e.g. "addLogin" to store the validateCode error when adding a new contact method */ validateCodeActionErrorField: string; - /** Function is called when submitting form */ handleSubmitForm: (validateCode: string) => void; /** Function to clear error of the form */ @@ -33,10 +28,12 @@ type ValidateCodeActionContentProps = { /** Function is called when validate code modal is mounted and on validateCode resend */ sendValidateCode: () => void; + /** When set, a recent request only suppresses the mount-time send if it was for this same reason */ + validateCodeReasonCode?: ValidateCodeReason; + /** Whether the form is loading or not */ isLoading?: boolean; - /** List of menu items for more(three dots) menu */ threeDotsMenuItems?: PopoverMenuItem[]; /** Method to trigger when pressing more options button of the header */ diff --git a/src/components/ValidateCodeInput.tsx b/src/components/ValidateCodeInput.tsx index 9fbecf86e5c1..db490a368f4c 100644 --- a/src/components/ValidateCodeInput.tsx +++ b/src/components/ValidateCodeInput.tsx @@ -70,19 +70,13 @@ type ValidateCodeInputProps = { /** Name attribute for the input */ name?: string; - /** Input value */ value?: string; - - /** Should the input auto focus */ autoFocus?: boolean; - - /** Error text to display */ errorText?: string; /** Specifies autocomplete hints for the system, so it can provide autofill */ autoComplete: AutoCompleteVariant; - /* Should submit when the input is complete */ shouldSubmitOnComplete?: boolean; /** Function to call when the input is changed */ @@ -100,19 +94,13 @@ type ValidateCodeInputProps = { /** Specifies the max length of the input */ maxLength?: number; - /** Specifies if the keyboard should be disabled */ isDisableKeyboard?: boolean; /** Last pressed digit on BigDigitPad */ lastPressedDigit?: string; - /** TestID for test */ testID?: string; - - /** Accessibility label for the input */ accessibilityLabel?: string; - - /** Reference to the outer element */ ref?: ForwardedRef; /** Whether to mask the input characters (display as dots) */ diff --git a/src/components/ValuePicker/types.ts b/src/components/ValuePicker/types.ts index 6c4d40ee3f12..2c800ebf0d3f 100644 --- a/src/components/ValuePicker/types.ts +++ b/src/components/ValuePicker/types.ts @@ -18,13 +18,11 @@ type ValuePickerItem = { /** Custom node rendered in place of the description (e.g. a description containing an inline link) */ alternateTextComponent?: ReactNode; - /** Element to show on the right side of the item */ rightElement?: ReactNode; /** Whether to hide the selection button (radio) entirely */ shouldHideSelectionButton?: boolean; - /** Styles applied to the item title */ titleStyles?: StyleProp; }; @@ -32,16 +30,9 @@ type ValueSelectorModalProps = { /** Whether the modal is visible */ isVisible: boolean; - /** Items to pick from */ items?: ValuePickerItem[]; - - /** The selected item */ selectedItem?: ValuePickerItem; - - /** Label for values */ label?: string; - - /** Function to call when the user selects a item */ onItemSelected?: (item: ValuePickerListItem) => void; /** Function to call when the user closes the modal */ @@ -53,13 +44,8 @@ type ValueSelectorModalProps = { /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; - /** Flag to indicate if the keyboard avoiding view should be enabled */ shouldEnableKeyboardAvoidingView?: boolean; - - /** Whether to add bottom safe area padding */ addBottomSafeAreaPadding?: boolean; - - /** Whether to disable keyboard shortcuts */ disableKeyboardShortcuts?: boolean; /** Number of lines to show for alternate text */ @@ -75,16 +61,9 @@ type ValueSelectionListProps = Pick< }; type ValuePickerProps = ForwardedFSClassProps & { - /** Item to display */ value?: string; - - /** Label of picker */ label?: string; - - /** Items to pick from */ items?: ValuePickerItem[]; - - /** A placeholder value to display */ placeholder?: string; /** Form Error description */ @@ -105,13 +84,8 @@ type ValuePickerProps = ForwardedFSClassProps & { /** Whether to show the selector modal */ shouldShowModal?: boolean; - /** Reference to the outer element */ ref: ForwardedRef; - - /** Whether to add bottom safe area padding */ addBottomSafeAreaPadding?: boolean; - - /** Whether to disable keyboard shortcuts */ disableKeyboardShortcuts?: boolean; /** Number of lines to show for alternate text */ diff --git a/src/components/VideoPlayer/VideoErrorIndicator.tsx b/src/components/VideoPlayer/VideoErrorIndicator.tsx index e6309d686bea..7a0ae1bb6f9f 100644 --- a/src/components/VideoPlayer/VideoErrorIndicator.tsx +++ b/src/components/VideoPlayer/VideoErrorIndicator.tsx @@ -12,7 +12,6 @@ import React from 'react'; import {View} from 'react-native'; type VideoErrorIndicatorProps = { - /** Whether it is a preview or not */ isPreview?: boolean; }; diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx index feef0a91d2a2..40ce107dcd4d 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx @@ -21,7 +21,6 @@ import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimat import {scheduleOnRN} from 'react-native-worklets'; type VolumeButtonProps = { - /** Style for the volume button. */ style?: StyleProp; /** Is button icon small. */ diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/index.tsx index 24688759bee2..edbad0085155 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx @@ -26,28 +26,19 @@ import ProgressBar from './ProgressBar'; import VolumeButton from './VolumeButton'; type VideoPlayerControlsProps = { - /** Duration of a video. */ duration: number; /** Position of progress pointer. */ position: number; - /** Url of a video. */ url: string; - - /** Ref for video player. */ videoPlayerRef: RefObject; - - /** Ref for video view component. */ videoViewRef: RefObject; - - /** Is video playing. */ isPlaying: boolean; /** Defines if component should have small icons and tighter spacing inline. */ small?: boolean; - /** Style of video player controls. */ style?: StyleProp>; /** Function to play and pause the video. */ diff --git a/src/components/VideoPlayer/types.ts b/src/components/VideoPlayer/types.ts index a5c159b1388c..7f25ce333985 100644 --- a/src/components/VideoPlayer/types.ts +++ b/src/components/VideoPlayer/types.ts @@ -26,9 +26,6 @@ type VideoPlayerProps = { */ style?: StyleProp; - /** - * Style applied to the VideoView inside the VideoPlayer component. - */ videoPlayerStyle?: StyleProp; /** @@ -41,9 +38,6 @@ type VideoPlayerProps = { */ videoDuration?: number; - /** - * Whether the VideoPlayer should use a shared video element. - */ shouldUseSharedVideoElement?: boolean; /** @@ -51,9 +45,6 @@ type VideoPlayerProps = { */ shouldUseSmallVideoControls?: boolean; - /** - * Whether the VideoPlayer is currently hovered. - */ isVideoHovered?: boolean; /** @@ -61,9 +52,6 @@ type VideoPlayerProps = { */ shouldUseControlsBottomMargin?: boolean; - /** - * Current status of the video controls. - */ controlsStatus?: ValueOf; /** diff --git a/src/components/VideoPlayerContexts/PlaybackContext/types.ts b/src/components/VideoPlayerContexts/PlaybackContext/types.ts index a699dc60cb46..3921a11196e7 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext/types.ts +++ b/src/components/VideoPlayerContexts/PlaybackContext/types.ts @@ -26,9 +26,6 @@ type OriginalParent = View | HTMLDivElement | null; * Metadata describing a video element instance. */ type VideoElementData = { - /** - * Whether the video player should use a shared video element. - */ shouldUseSharedVideoElement: boolean; /** @@ -68,9 +65,6 @@ type PlaybackStateContextValues = { */ mountedVideoPlayersRef: RefObject; - /** - * Status of the currently used Video Player - */ playerStatus: RefObject; /** @@ -113,9 +107,6 @@ type PlaybackActionsContextValues = { videoElementData: VideoElementData, ) => void; - /** - * Sets the URL of the currently playing video. - */ setCurrentlyPlayingURL: React.Dispatch>; /** @@ -136,9 +127,6 @@ type PlaybackActionsContextValues = { * References and helper methods for managing video playback. */ type PlaybackContextVideoRefs = { - /** - * Resets the player data in the context. - */ resetPlayerData: () => void; /** diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx index 491b89fe1602..02bb28c103c0 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx @@ -26,10 +26,7 @@ type VideoPlayerThumbnailProps = { /** Callback executed on thumbnail press. */ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; - /** Accessibility label for the thumbnail. */ accessibilityLabel: string; - - /** Whether the video is deleted */ isDeleted?: boolean; }; diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index 4858bf3d725a..c806249ec049 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -32,28 +32,16 @@ import {View} from 'react-native'; import VideoPlayerThumbnail from './VideoPlayerThumbnail'; type VideoPlayerPreviewProps = { - /** Url to a video. */ videoUrl: string; - - /** reportID of the video */ reportID: string | undefined; - - /** Dimension of a video. */ videoDimensions: Dimensions; - - /** Duration of a video. */ videoDuration: number; /** Url to a thumbnail image. */ thumbnailUrl?: string; - /** Name of a video file. */ fileName: string; - - /** Callback executed when modal is pressed. */ onShowModalPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; - - /** Whether the video is deleted */ isDeleted?: boolean; }; diff --git a/src/components/WidgetContainer.tsx b/src/components/WidgetContainer.tsx index 096ee90d6e9d..e2a5b075450f 100644 --- a/src/components/WidgetContainer.tsx +++ b/src/components/WidgetContainer.tsx @@ -20,10 +20,7 @@ type WidgetContainerProps = { /** The content to display inside the widget container */ children: ReactNode; - /** Additional styles to pass to the container */ containerStyles?: StyleProp; - - /** The content to display on the right side of the title */ titleRightContent?: ReactNode; }; diff --git a/src/components/WorkspaceConfirmationForm.tsx b/src/components/WorkspaceConfirmationForm.tsx index 2e71c70669c2..61d6787be4d3 100644 --- a/src/components/WorkspaceConfirmationForm.tsx +++ b/src/components/WorkspaceConfirmationForm.tsx @@ -3,9 +3,11 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftValues} from '@libs/actions/FormActions'; +import type {PolicyOwner} from '@libs/actions/Policy/Policy'; import {generateDefaultWorkspaceName, generatePolicyID} from '@libs/actions/Policy/Policy'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import {addErrorMessage} from '@libs/ErrorUtils'; @@ -18,6 +20,7 @@ import {isRequiredFulfilled} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {accountIDSelector, displayNameSelector} from '@src/selectors/PersonalDetails'; import {lastWorkspaceNumberSelector} from '@src/selectors/Policy'; import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; import INPUT_IDS from '@src/types/form/WorkspaceConfirmationForm'; @@ -47,7 +50,7 @@ type WorkspaceConfirmationSubmitFunctionParams = { name: string; currency: string; planType?: PolicyType; - owner?: string; + owner?: PolicyOwner; makeMeAdmin: boolean; avatarFile: File | CustomRNImageManipulatorResult | undefined; policyID: string; @@ -60,13 +63,11 @@ type WorkspaceConfirmationFormProps = { */ policyOwnerEmail?: string; - /** Submit function */ onSubmit: (params: WorkspaceConfirmationSubmitFunctionParams) => void; /** Go back function */ onBackButtonPress?: () => void; - /** Whether bottom safe area padding should be added */ addBottomSafeAreaPadding?: boolean; /** Whether the submit button should display a loading spinner (e.g. while the new workspace is revealed) */ @@ -129,8 +130,9 @@ function WorkspaceConfirmationForm({ const [draftValues] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM_DRAFT); const email = policyOwnerEmail || (session?.email ?? ''); - const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, email); - const defaultWorkspaceName = generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate); + const userDisplayName = usePersonalDetailByLogin(email, displayNameSelector); + const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, email, userDisplayName); + const defaultWorkspaceName = generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate, userDisplayName); const [workspaceNameFirstCharacter, setWorkspaceNameFirstCharacter] = useState(defaultWorkspaceName ?? ''); const userCurrency = draftValues?.currency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD; @@ -140,6 +142,7 @@ function WorkspaceConfirmationForm({ const userOwner = draftValues?.owner ?? defaultOwner; const ownerDisplayName = userOwner; + const ownerAccountID = usePersonalDetailByLogin(userOwner, accountIDSelector); const [makeMeAdmin, setMakeMeAdmin] = useState(true); const currentUserEmail = session?.email ?? ''; @@ -218,7 +221,7 @@ function WorkspaceConfirmationForm({ name: val[INPUT_IDS.NAME], currency: val[INPUT_IDS.CURRENCY], planType: isApprovedAccountant ? val[INPUT_IDS.PLAN_TYPE] : undefined, - owner: isApprovedAccountant ? val[INPUT_IDS.OWNER] : '', + owner: isApprovedAccountant ? {email: val[INPUT_IDS.OWNER], accountID: ownerAccountID} : undefined, makeMeAdmin: isApprovedAccountant && isOwnerDifferentFromCurrentUser ? makeMeAdmin : false, avatarFile, policyID, diff --git a/src/components/WorkspaceEmptyStateSection.tsx b/src/components/WorkspaceEmptyStateSection.tsx index d80109ba7fd0..b4106bcc283f 100644 --- a/src/components/WorkspaceEmptyStateSection.tsx +++ b/src/components/WorkspaceEmptyStateSection.tsx @@ -25,7 +25,6 @@ type WorkspaceEmptyStateSectionProps = { /** The icon to display along with the title */ icon: IconAsset; - /** Additional style for container */ containerStyle?: StyleProp; /** Whether to apply card style to container */ diff --git a/src/components/WorkspacesListRowDisplayName/types.tsx b/src/components/WorkspacesListRowDisplayName/types.tsx index 0744ebc18fc1..433c4bf80ca9 100644 --- a/src/components/WorkspacesListRowDisplayName/types.tsx +++ b/src/components/WorkspacesListRowDisplayName/types.tsx @@ -1,5 +1,4 @@ type WorkspacesListRowDisplayNameProps = { - /** Should the deleted style be applied */ isDeleted: boolean; /** Workspace owner name */ diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx index 40235e171e83..0ad25072f949 100755 --- a/src/components/withKeyboardState.tsx +++ b/src/components/withKeyboardState.tsx @@ -11,7 +11,6 @@ import {KeyboardEvents, useKeyboardHandler} from 'react-native-keyboard-controll import {scheduleOnRN} from 'react-native-worklets'; type KeyboardStateContextValue = { - /** Whether the keyboard is open */ isKeyboardShown: boolean; /** Whether the keyboard is animating or shown */ @@ -23,7 +22,6 @@ type KeyboardStateContextValue = { /** Future or present height of the keyboard in pixels. Available together with isKeyboardActive. */ keyboardActiveHeight: number; - /** Ref to check if the keyboard is animating */ isKeyboardAnimatingRef: RefObject; }; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index b8b6416bcbea..f2b2fb505055 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -8,7 +8,6 @@ import React from 'react'; import {View} from 'react-native'; type WithToggleVisibilityViewProps = { - /** Whether the content is visible. */ isVisible?: boolean; }; diff --git a/src/components/withViewportOffsetTop.tsx b/src/components/withViewportOffsetTop.tsx index c9fe053c3a91..3cf7e798528f 100644 --- a/src/components/withViewportOffsetTop.tsx +++ b/src/components/withViewportOffsetTop.tsx @@ -6,8 +6,10 @@ import type {ComponentType} from 'react'; import React, {useEffect, useState} from 'react'; type ViewportOffsetTopProps = { - // viewportOffsetTop returns the offset of the top edge of the visual viewport from the - // top edge of the layout viewport in CSS pixels, when the visual viewport is resized. + /** + * Offset of the top edge of the visual viewport from the top edge of the layout viewport + * in CSS pixels, when the visual viewport is resized. + */ viewportOffsetTop: number; }; diff --git a/src/hooks/useActiveSavedSearch.ts b/src/hooks/useActiveSavedSearch.ts new file mode 100644 index 000000000000..48b315fdc877 --- /dev/null +++ b/src/hooks/useActiveSavedSearch.ts @@ -0,0 +1,27 @@ +import {useSearchQueryContext} from '@components/Search/SearchContext'; + +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +function useActiveSavedSearch() { + const {isOffline} = useNetwork(); + const {currentSearchKey} = useSearchQueryContext(); + const [activeSavedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { + selector: (savedSearches) => { + const activeSavedSearchID = searchKeyToSavedSearchID(currentSearchKey); + const item = activeSavedSearchID ? savedSearches?.[activeSavedSearchID] : undefined; + if (!item || (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline)) { + return undefined; + } + return item; + }, + }); + return activeSavedSearch; +} + +export default useActiveSavedSearch; diff --git a/src/hooks/useAllTransactionViolations.ts b/src/hooks/useAllTransactionViolations.ts new file mode 100644 index 000000000000..ebe5bfb0b51b --- /dev/null +++ b/src/hooks/useAllTransactionViolations.ts @@ -0,0 +1,19 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {TransactionViolations} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useOnyx from './useOnyx'; + +/** + * Returns all violations for the given transaction without any ownership or visibility calculations unlike useTransactionViolations + */ +function useAllTransactionViolations(transactionID: string | undefined): OnyxEntry { + const [allTransactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getNonEmptyStringOnyxID(transactionID)}`); + + return allTransactionViolations; +} + +export default useAllTransactionViolations; diff --git a/src/hooks/useAnimatedHighlightStyle/index.ts b/src/hooks/useAnimatedHighlightStyle/index.ts index 9496843688ee..19ce06a5753c 100644 --- a/src/hooks/useAnimatedHighlightStyle/index.ts +++ b/src/hooks/useAnimatedHighlightStyle/index.ts @@ -8,7 +8,6 @@ import {Easing, interpolate, interpolateColor, useAnimatedStyle, useSharedValue, import {scheduleOnRN} from 'react-native-worklets'; type Props = { - /** Border radius of the wrapper */ borderRadius?: number; /** Height of the item that is to be faded */ @@ -17,10 +16,7 @@ type Props = { /** Delay before the highlighted item enters */ itemEnterDelay?: number; - /** Duration in which the item enters */ itemEnterDuration?: number; - - /** Delay before the item starts to get highlighted */ highlightStartDelay?: number; /** Duration in which the item gets fully highlighted */ @@ -32,7 +28,6 @@ type Props = { /** Duration in which the item gets fully un-highlighted */ highlightEndDuration?: number; - /** Whether the item should be highlighted */ shouldHighlight: boolean; /** Whether it should return height and border radius styles */ diff --git a/src/hooks/useAssignCard.ts b/src/hooks/useAssignCard.ts index 6a6406d7ea05..7fb7b463de45 100644 --- a/src/hooks/useAssignCard.ts +++ b/src/hooks/useAssignCard.ts @@ -36,13 +36,11 @@ import {usePersonalDetailsByLogins} from './usePersonalDetailByLogin'; import usePolicy from './usePolicy'; type UseAssignCardProps = { - /** The currently selected card feed */ feedName: CompanyCardFeedWithDomainID | undefined; /** The ID of the workspace/policy */ policyID: string; - /** Callback to show/hide the offline modal */ setShouldShowOfflineModal: (shouldShow: boolean) => void; }; diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index c21193acf5fd..36ea3df19431 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -39,6 +39,7 @@ function useAutoCreateSubmitWorkspace() { formatPhoneNumber, isRestrictedPolicyCreation, hasActiveAdminPolicies, + hasOwnedPaidPolicy, onboardingMessages, lastWorkspaceNumber, shouldUseNarrowLayout, @@ -68,7 +69,7 @@ function useAutoCreateSubmitWorkspace() { const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace ? createWorkspace({ - policyOwnerEmail: undefined, + policyOwner: undefined, makeMeAdmin: true, policyName: generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, translate, displayName), policyID: generatePolicyID(), @@ -86,6 +87,7 @@ function useAutoCreateSubmitWorkspace() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; @@ -146,6 +148,7 @@ function useAutoCreateSubmitWorkspace() { onboardingMessages, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, shouldUseNarrowLayout, conciergeChat, ], diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 1f139d0b823c..6db550c83090 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -43,6 +43,7 @@ function useAutoCreateTrackWorkspace() { formatPhoneNumber, isRestrictedPolicyCreation, hasActiveAdminPolicies, + hasOwnedPaidPolicy, onboardingMessages, lastWorkspaceNumber, shouldUseNarrowLayout, @@ -77,7 +78,7 @@ function useAutoCreateTrackWorkspace() { const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace ? createWorkspace({ - policyOwnerEmail: undefined, + policyOwner: undefined, makeMeAdmin: true, policyName: generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, translate, displayName), policyID: generatePolicyID(), @@ -95,6 +96,7 @@ function useAutoCreateTrackWorkspace() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, personalTrackGoal: onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.TRACK_PERSONAL && !!personalTrackGoal ? personalTrackGoal : undefined, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; @@ -163,6 +165,7 @@ function useAutoCreateTrackWorkspace() { onboardingMessages, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, shouldUseNarrowLayout, isBetaEnabled, conciergeChatReportID, diff --git a/src/hooks/useAutocompleteSuggestions.ts b/src/hooks/useAutocompleteSuggestions.ts index 1042e685d2bb..aa37e5fcfffb 100644 --- a/src/hooks/useAutocompleteSuggestions.ts +++ b/src/hooks/useAutocompleteSuggestions.ts @@ -1,4 +1,5 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {useCardList, useWorkspaceCardList} from '@components/OnyxListItemProvider'; import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import {getSubstitutionMapKey, getSubstitutionMapKeyWithIndex} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import type {SearchFilterKey, UserFriendlyKey} from '@components/Search/types'; @@ -11,6 +12,7 @@ import type {OptionList} from '@libs/OptionsListUtils'; import {getSearchOptions} from '@libs/OptionsListUtils'; import {getAllTaxRates, getCleanedTagName, getExpensifyTeamExclusions, shouldShowPolicy} from '@libs/PolicyUtils'; import { + CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteRecentTags, @@ -21,7 +23,7 @@ import { import {getUserFriendlyKey, getUserFriendlyValue} from '@libs/SearchQueryUtils'; import {getDatePresets, getHasOptions} from '@libs/SearchUIUtils'; -import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Beta, CardFeeds, CardList, PersonalDetailsList, Policy} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; @@ -35,12 +37,12 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import type {FeedKeysWithAssignedCards} from './useFeedKeysWithAssignedCards'; -import {useCurrencyListState} from './useCurrencyList'; +import {useCurrencyListActions, useCurrencyListState} from './useCurrencyList'; import useExportedToFilterOptions from './useExportedToFilterOptions'; import useLoadSearchCategoryData from './useLoadSearchCategoryData'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; -import useSortedActions from './useSortedActions'; +import useSortedReportActionsData from './useSortedReportActionsData'; type AutocompleteItemData = { filterKey: UserFriendlyKey; @@ -119,15 +121,22 @@ function useAutocompleteSuggestions({ translate, autocompleteSubstitutions, }: UseAutocompleteSuggestionsParams): AutocompleteItemData[] { - const {localeCompare, dateFnsLocale} = useLocalize(); + const {localeCompare, dateFnsLocale, formatPhoneNumber} = useLocalize(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const sortedActions = useSortedActions(); + const cardList = useCardList(); + const workspaceCardList = useWorkspaceCardList(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; + const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; + const lastActions = sortedReportActionsData?.lastActions; const {currencyList} = useCurrencyListState(); const {exportedToFilterOptions} = useExportedToFilterOptions(); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); @@ -247,6 +256,8 @@ function useAutocompleteSuggestions({ const participants = getSearchOptions({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, options, draftComments, betas: betas ?? [], @@ -266,10 +277,17 @@ function useAutocompleteSuggestions({ currentUserEmail, personalDetails, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, conciergeReportID, excludeFromSuggestionsOnly: memberExclusions, isTrackIntentUser, translate, + rules, }).options.personalDetails.filter((participant) => participant.text && !alreadyAutocompletedKeys.has(participant.text.toLowerCase())); return participants.map((participant) => ({ @@ -288,6 +306,8 @@ function useAutocompleteSuggestions({ const filteredReports = getSearchOptions({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, options, draftComments, betas: betas ?? [], @@ -307,9 +327,16 @@ function useAutocompleteSuggestions({ currentUserEmail, personalDetails, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, conciergeReportID, isTrackIntentUser, translate, + rules, }).options.recentReports.filter((chat) => { if (!chat.text) { return false; @@ -565,7 +592,11 @@ function useAutocompleteSuggestions({ })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS: { - const hasAutocompleteList = getHasOptions(translate, currentType); + // Use {} while policies load so every has: option does not flash before the collection arrives. + const hasAutocompleteList = getHasOptions(translate, currentType, { + policies: policies ?? {}, + policyCategories: allPolicyCategories, + }); const filteredHasValues = hasAutocompleteList.filter((hasValue) => { return hasValue.value.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.has(hasValue.value.toLowerCase()); }); diff --git a/src/hooks/useAvatarMenu.ts b/src/hooks/useAvatarMenu.ts index 3890afa2d214..d5eab60a2d95 100644 --- a/src/hooks/useAvatarMenu.ts +++ b/src/hooks/useAvatarMenu.ts @@ -24,11 +24,8 @@ type UseAvatarMenuParams = { originalFileName?: string; /** Account ID for navigation */ accountID: number; - /** Callback when avatar is removed */ onImageRemoved: () => void; - /** Callback to show avatar crop modal */ showAvatarCropModal: (image: FileObject) => void; - /** Callback to clear errors */ clearError: () => void; }; diff --git a/src/hooks/useBlockDistanceRequest.ts b/src/hooks/useBlockDistanceRequest.ts index b36d8b8a2b87..ebf1ae85c595 100644 --- a/src/hooks/useBlockDistanceRequest.ts +++ b/src/hooks/useBlockDistanceRequest.ts @@ -32,6 +32,9 @@ type UseBlockDistanceRequestParams = { /** Whether the current flow is for any distance request */ isDistanceRequest?: boolean; + + /** Whether an existing distance request is being edited */ + isEditingExistingDistanceRequest?: boolean; }; type PolicyRequiringMapOrGPS = { @@ -69,7 +72,13 @@ const hasHomeAddressSelector = (privatePersonalDetails: OnyxEntry { - if (!policyIDToCheck || !policiesRequiringMapOrGPS?.[policyIDToCheck]) { + if (!policyIDToCheck || !policiesRequiringMapOrGPS?.[policyIDToCheck] || isEditingExistingDistanceRequest) { return; } @@ -91,7 +100,7 @@ function useBlockDistanceRequest({policyID, isManualDistanceRequest = false, isO return 'homeAddressRequired'; } }, - [hasHomeAddress, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, policiesRequiringMapOrGPS], + [hasHomeAddress, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, isEditingExistingDistanceRequest, policiesRequiringMapOrGPS], ); const showBlockModal = useCallback( diff --git a/src/hooks/useBulkDuplicateAction.ts b/src/hooks/useBulkDuplicateAction.ts index c4125492bbb8..65cf87bc2e5d 100644 --- a/src/hooks/useBulkDuplicateAction.ts +++ b/src/hooks/useBulkDuplicateAction.ts @@ -60,6 +60,7 @@ function useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allR const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [targetPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`); const [targetPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy?.id}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const sourcePolicyIDMap: Record = {}; @@ -106,6 +107,7 @@ function useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allR formatPhoneNumber, participantsPolicyTags, conciergeChat, + rules, }); if (onAfterDuplicate) { diff --git a/src/hooks/useBulkDuplicateReportAction.ts b/src/hooks/useBulkDuplicateReportAction.ts index 109999968ea0..ab643679891f 100644 --- a/src/hooks/useBulkDuplicateReportAction.ts +++ b/src/hooks/useBulkDuplicateReportAction.ts @@ -50,6 +50,7 @@ function useBulkDuplicateReportAction({selectedReports, allReports, searchData}: const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const handleDuplicateReports = () => { const activePolicyExpenseChat = getPolicyExpenseChat(currentUserPersonalDetails.accountID, defaultExpensePolicy?.id); @@ -81,6 +82,7 @@ function useBulkDuplicateReportAction({selectedReports, allReports, searchData}: formatPhoneNumber, getCurrencyDecimals, conciergeChat, + rules, }).catch((error: unknown) => { Log.warn('[useBulkDuplicateReportAction] Failed to duplicate the selected reports', {error}); }); diff --git a/src/hooks/useBulkPayOptions.ts b/src/hooks/useBulkPayOptions.ts index 629055fca109..7f64149f8307 100644 --- a/src/hooks/useBulkPayOptions.ts +++ b/src/hooks/useBulkPayOptions.ts @@ -141,10 +141,10 @@ function useBulkPayOptions({ const personalBankAccountList = formattedPaymentMethods.filter((ba) => (ba.accountData as AccountData)?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL); let bulkPayButtonOptions; - if (!selectedReportID || !selectedPolicyID) { - bulkPayButtonOptions = undefined; - } else if (onlyShowPayElsewhere) { + if (onlyShowPayElsewhere) { bulkPayButtonOptions = [paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]]; + } else if (!selectedReportID || !selectedPolicyID) { + bulkPayButtonOptions = undefined; } else { bulkPayButtonOptions = []; diff --git a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts index ea319a33a4a0..628230e571cf 100644 --- a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts +++ b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts @@ -1,4 +1,5 @@ -import {cancelSpanByInstance} from '@libs/telemetry/activeSpans'; +import {cancelSpanByInstance, getSpanID} from '@libs/telemetry/activeSpans'; +import {cancelSendMessagePhases} from '@libs/telemetry/sendMessageSpans'; import CONST from '@src/CONST'; @@ -32,7 +33,11 @@ function useCancelSendMessageSpanOnSkeleton(reportID: string | undefined, skelet return; } // Defer so activeSpans has registered the span (set right after the `startInactiveSpan` that emits this). - queueMicrotask(() => cancelSpanByInstance(span, {[CONST.TELEMETRY.ATTRIBUTE_CANCELED_BY_SKELETON]: skeletonName})); + queueMicrotask(() => { + // Sentry drops descendants that have not ended when the root span does, so phases go first. + cancelSendMessagePhases(getSpanID(span)); + cancelSpanByInstance(span, {[CONST.TELEMETRY.ATTRIBUTE_CANCELED_BY_SKELETON]: skeletonName}); + }); }); }, [reportID, skeletonName]); } diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 489c1e2b710a..f22164d926b5 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -19,6 +19,7 @@ import {useState} from 'react'; import useActivePolicy from './useActivePolicy'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from './useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import useLocalize from './useLocalize'; import useOnboardingMessages from './useOnboardingMessages'; @@ -42,6 +43,7 @@ function useCompleteOnboarding() { const {isBetaEnabled} = usePermissions(); const activePolicy = useActivePolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); @@ -77,9 +79,9 @@ function useCompleteOnboarding() { const {adminsChatReportID, policyID} = shouldCreateWorkspace ? createWorkspace({ - policyOwnerEmail: undefined, + policyOwner: undefined, makeMeAdmin: true, - policyName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), + policyName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate, currentUserPersonalDetails.displayName), policyID: generatePolicyID(), engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, currency: currentUserPersonalDetails?.localCurrencyCode ?? '', @@ -97,6 +99,7 @@ function useCompleteOnboarding() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, conciergeChat, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; diff --git a/src/hooks/useConciergeSessionStartTime.ts b/src/hooks/useConciergeSessionStartTime.ts new file mode 100644 index 000000000000..627c7bde9048 --- /dev/null +++ b/src/hooks/useConciergeSessionStartTime.ts @@ -0,0 +1,18 @@ +import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; + +import useIsInSidePanel from './useIsInSidePanel'; +import useSidePanelState from './useSidePanelState'; + +/** + * Returns the Concierge session start time from the context that actually tracks it: SidePanelStateContext in the + * side panel, ConciergeSessionContext in the main DM. Each is null on the other surface, so always read via this + * hook. Only meaningful in a Concierge chat - callers gate with e.g. isConciergeHiddenHistory. + */ +function useConciergeSessionStartTime(): string | null { + const isInSidePanel = useIsInSidePanel(); + const {sessionStartTime: mainDMSessionStartTime} = useConciergeSessionState(); + const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); + return isInSidePanel ? sidePanelSessionStartTime : mainDMSessionStartTime; +} + +export default useConciergeSessionStartTime; diff --git a/src/hooks/useControlOnlyRuleUpgradeRedirect.ts b/src/hooks/useControlOnlyRuleUpgradeRedirect.ts index 4ac72a16d369..bbb6bdf2bedb 100644 --- a/src/hooks/useControlOnlyRuleUpgradeRedirect.ts +++ b/src/hooks/useControlOnlyRuleUpgradeRedirect.ts @@ -8,7 +8,6 @@ import ROUTES from '@src/ROUTES'; import {useEffect, useRef} from 'react'; import useOnyx from './useOnyx'; -import usePermissions from './usePermissions'; import usePolicy from './usePolicy'; /** @@ -24,13 +23,12 @@ import usePolicy from './usePolicy'; */ function useControlOnlyRuleUpgradeRedirect(policyID: string, backTo?: Route) { const policy = usePolicy(policyID); - const {isBetaEnabled} = usePermissions(); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const isCollect = isCollectPolicy(policy); // Mirrors the feature check in AccessOrNotFoundWrapper. When Rules itself is disabled, that wrapper already // redirects to More features, so redirecting to the upgrade page too would flash it on the way there. - const isRulesFeatureEnabled = arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); + const isRulesFeatureEnabled = arePolicyRulesEnabled(policy, policyCategories); const hasRedirectedToUpgrade = useRef(false); const upgradeBackTo = backTo ?? ROUTES.WORKSPACE_RULES.getRoute(policyID); diff --git a/src/hooks/useCreateNewReport.tsx b/src/hooks/useCreateNewReport.tsx index 30858bc40484..68e27af76643 100644 --- a/src/hooks/useCreateNewReport.tsx +++ b/src/hooks/useCreateNewReport.tsx @@ -27,6 +27,7 @@ function useCreateNewReport() { const [betas] = useOnyx(ONYXKEYS.BETAS); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {getCurrencyDecimals} = useCurrencyListActions(); const hasViolations = hasViolationsUtil(undefined, transactionViolations, accountID ?? CONST.DEFAULT_NUMBER_ID, email ?? ''); @@ -42,11 +43,12 @@ function useCreateNewReport() { betas, isTrackIntentUser, getCurrencyDecimals, + rules, false, shouldDismissEmptyReportsConfirmation, ); }, - [betas, currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policies, isTrackIntentUser, getCurrencyDecimals], + [betas, currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policies, isTrackIntentUser, getCurrencyDecimals, rules], ); } diff --git a/src/hooks/useCurrentReportID.tsx b/src/hooks/useCurrentReportID.tsx index 02c28b470eac..9bb657050914 100644 --- a/src/hooks/useCurrentReportID.tsx +++ b/src/hooks/useCurrentReportID.tsx @@ -16,7 +16,6 @@ type CurrentReportIDActionsContextType = { }; type CurrentReportIDContextProviderProps = { - /** Actual content wrapped by this component */ children: React.ReactNode; /** Optional callback invoked whenever `currentReportID` is explicitly updated. * This is intended only for unit testing, to detect when the hook diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index cd37e03ffad1..40af3fae7cbf 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -7,6 +7,13 @@ import {useEffect, useRef} from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericFunction = (...args: any[]) => void; +type DebounceControls = { + /** Calls the debounced function, restarting the wait. */ + invoke: T; + + cancel: () => void; +}; + type UseDebounceOptions = DebounceSettings & { /** * When true, any pending trailing invocation is flushed on component unmount instead of cancelled. @@ -19,7 +26,7 @@ type UseDebounceOptions = DebounceSettings & { * Non-generic implementation so OXC's React Compiler can memoize the hook. * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -function useDebounceImpl(func: GenericFunction, wait: number, options?: UseDebounceOptions): GenericFunction { +function useDebounceImpl(func: GenericFunction, wait: number, options?: UseDebounceOptions): DebounceControls { const debouncedFnRef = useRef | undefined>(undefined); const {leading, maxWait, trailing = true, shouldExecuteOnUnmount = false} = options ?? {}; @@ -35,7 +42,8 @@ function useDebounceImpl(func: GenericFunction, wait: number, options?: UseDebou }, [shouldExecuteOnUnmount]); useEffect(() => { - const debouncedFn = lodashDebounce(func, wait, {leading, maxWait, trailing}); + // Lodash checks whether `maxWait` is present, not its value, so `{maxWait: undefined}` would cap the wait at `wait`. + const debouncedFn = maxWait === undefined ? lodashDebounce(func, wait, {leading, trailing}) : lodashDebounce(func, wait, {leading, maxWait, trailing}); debouncedFnRef.current = debouncedFn; @@ -44,32 +52,47 @@ function useDebounceImpl(func: GenericFunction, wait: number, options?: UseDebou }; }, [func, wait, leading, maxWait, trailing]); - return (...args: unknown[]) => { - const debouncedFn = debouncedFnRef.current; - - if (debouncedFn) { - debouncedFn(...args); - } + return { + invoke: (...args: unknown[]) => { + debouncedFnRef.current?.(...args); + }, + cancel: () => { + debouncedFnRef.current?.cancel(); + }, }; } +/** + * Same as `useDebounce`, with a `cancel` that drops a pending invocation. See `useDebounce` for the parameters. + * + * Returns an object because OXC's React Compiler fails on a function value whose methods read a ref + * ("Ref type environment did not converge"). + */ +function useDebounceWithControls(func: T, wait: number, options?: UseDebounceOptions): DebounceControls { + return useDebounceImpl(func, wait, options) as DebounceControls; +} + /** * Create and return a debounced function. * * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). * This is especially important in the case of func. To prevent that, pass stable references. * + * Use `useDebounceWithControls` when the wait has to be cancellable. + * * @param func The function to debounce. * @param wait The number of milliseconds to delay. * @param options The options object. * @param options.leading Specify invoking on the leading edge of the timeout. - * @param options.maxWait The maximum time func is allowed to be delayed before it's invoked. + * @param options.maxWait The maximum time func is allowed to be delayed before it's invoked, raised to `wait` when lower. Left out, a burst of calls pushes the wait back without limit. * @param options.trailing Specify invoking on the trailing edge of the timeout. * @param options.shouldExecuteOnUnmount When true, flush pending invocations on unmount instead of cancelling them. * @returns Returns a function to call the debounced function. */ export default function useDebounce(func: T, wait: number, options?: UseDebounceOptions): T { - return useDebounceImpl(func, wait, options) as T; + return useDebounceWithControls(func, wait, options).invoke; } +export {useDebounceWithControls}; + export type {UseDebounceOptions}; diff --git a/src/hooks/useDefaultParticipants.ts b/src/hooks/useDefaultParticipants.ts index 1c71e92f58a4..dcc59a50f155 100644 --- a/src/hooks/useDefaultParticipants.ts +++ b/src/hooks/useDefaultParticipants.ts @@ -29,9 +29,6 @@ type UseDefaultParticipantsParams = { /** The IOU type from the route params. */ iouType?: IOUType; - - /** When false, the hook short-circuits and returns an empty list (the new manual expense flow beta is off). */ - isNewManualExpenseFlowEnabled?: boolean; }; type UseDefaultParticipantsResult = { @@ -52,7 +49,7 @@ type UseDefaultParticipantsResult = { * Shared by `useResetIOUType` (to seed the freshly-rebuilt transaction so the confirmation's auto-assign effect * short-circuits) and `IOURequestStepConfirmation` (to compute the participants it auto-assigns) so both stay in sync. */ -function useDefaultParticipants({sourceReport, transaction, iouType, isNewManualExpenseFlowEnabled = true}: UseDefaultParticipantsParams): UseDefaultParticipantsResult { +function useDefaultParticipants({sourceReport, transaction, iouType}: UseDefaultParticipantsParams): UseDefaultParticipantsResult { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); const personalPolicy = usePersonalPolicy(); @@ -64,15 +61,9 @@ function useDefaultParticipants({sourceReport, transaction, iouType, isNewManual const accountID = currentUserPersonalDetails.accountID; - const isLoading = - isNewManualExpenseFlowEnabled && - (!accountID || isLoadingSelfDMReport || isLoadingOnyxValue(policyCollectionResult, amountOwedResult, userBillingGracePeriodEndsResult, ownerBillingGracePeriodEndResult)); + const isLoading = !accountID || isLoadingSelfDMReport || isLoadingOnyxValue(policyCollectionResult, amountOwedResult, userBillingGracePeriodEndsResult, ownerBillingGracePeriodEndResult); const participants = useMemo(() => { - if (!isNewManualExpenseFlowEnabled) { - return []; - } - const reportParticipants = getMoneyRequestParticipantsFromReport(sourceReport, accountID).filter((participant) => participant.selected); if (reportParticipants.length > 0) { return reportParticipants; @@ -96,7 +87,6 @@ function useDefaultParticipants({sourceReport, transaction, iouType, isNewManual const defaultTargetReport = shouldAutoReport ? getPolicyExpenseChat(accountID, defaultExpensePolicy?.id) : selfDMReport; return getMoneyRequestParticipantsFromReport(defaultTargetReport, accountID).filter((participant) => participant.selected); }, [ - isNewManualExpenseFlowEnabled, sourceReport, accountID, transaction?.isFromGlobalCreate, diff --git a/src/hooks/useDeleteSavedSearch.tsx b/src/hooks/useDeleteSavedSearch.tsx index fb158e3f8df2..2cfed641c953 100644 --- a/src/hooks/useDeleteSavedSearch.tsx +++ b/src/hooks/useDeleteSavedSearch.tsx @@ -1,25 +1,31 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {useSearchQueryContext} from '@components/Search/SearchContext'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import {deleteSavedSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; import {useCallback} from 'react'; import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; export default function useDeleteSavedSearch() { const {translate} = useLocalize(); - const {currentSearchHash} = useSearchQueryContext(); + const {currentSearchKey} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const {showConfirmModal} = useConfirmModal(); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); const handleDeleteSavedSearch = useCallback( - (hash: number) => { + (savedSearchID: string) => { showConfirmModal({ title: translate('search.deleteSavedSearch'), prompt: translate('search.deleteSavedSearchConfirm'), @@ -30,18 +36,16 @@ export default function useDeleteSavedSearch() { if (result.action !== ModalActions.CONFIRM) { return; } - deleteSavedSearch(hash); - - if (hash === currentSearchHash) { - Navigation.navigate( - ROUTES.SEARCH_ROOT.getRoute({ - query: buildCannedSearchQuery(), - }), - ); + deleteSavedSearch(savedSearchID); + + if (savedSearchID === searchKeyToSavedSearchID(currentSearchKey)) { + const query = lastExpensesSearchQuery ?? buildCannedSearchQuery(); + setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.EXPENSES, query); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); } }); }, - [showConfirmModal, translate, currentSearchHash], + [showConfirmModal, translate, currentSearchKey, lastExpensesSearchQuery, setCurrentSearchKey], ); return {showDeleteModal: handleDeleteSavedSearch}; diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 50e7aa32e0d2..42c9e8d4ef48 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -105,6 +105,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const personalPolicy = usePersonalPolicy(); const restrictedActionPolicyID = useRestrictedActionPolicyID(policy); @@ -327,6 +328,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac delegateAccountID, isTrackIntentUser, formatPhoneNumber, + rules, }); } @@ -363,6 +365,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac chatReportID: candidateIOUReport?.reportID, chatReport: candidateIOUReport, chatReportActions: selfDMReportActions, + transactionThreadReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID}`], transactionID, reportAction: action, iouReport: undefined, @@ -392,6 +395,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac transactionID, reportAction: action, transactionThreadReport, + transactionThreadReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`], transactions: duplicateTransactions, violations: duplicateTransactionViolations, iouReport, @@ -450,6 +454,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac formatPhoneNumber, getCurrencyDecimals, getCurrencySymbol, + rules, ], ); diff --git a/src/hooks/useDuplicateTransactionsAndViolations.ts b/src/hooks/useDuplicateTransactionsAndViolations.ts index 178058a7cc65..83d5592c981d 100644 --- a/src/hooks/useDuplicateTransactionsAndViolations.ts +++ b/src/hooks/useDuplicateTransactionsAndViolations.ts @@ -5,6 +5,9 @@ import type {Transaction, TransactionViolation, TransactionViolations} from '@sr import type {OnyxCollection} from 'react-native-onyx'; import {useMemo} from 'react'; +// Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot +// eslint-disable-next-line no-restricted-imports +import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import useOnyx from './useOnyx'; @@ -143,4 +146,38 @@ function useDuplicateTransactionsAndViolations(transactionIDs: string[]): Duplic ); } +/** + * Same as {@link useDuplicateTransactionsAndViolations}, but reads the transaction and violation collections from live + * Onyx rather than the Search snapshot. + * More info: https://github.com/Expensify/App/pull/99279#discussion_r3892786625 + * @param transactionIDs - Array of transaction IDs to check for duplicates. + * @returns - An object containing duplicate transactions and their violations. + */ +function useLiveDuplicateTransactionsAndViolations(transactionIDs: string[]): DuplicateTransactionsAndViolations { + const violationsSelectorMemo = useMemo(() => { + return (allTransactionsViolations: OnyxCollection) => selectViolationsWithDuplicates(transactionIDs, allTransactionsViolations); + }, [transactionIDs]); + + const [duplicateTransactionViolations] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { + selector: violationsSelectorMemo, + }); + + const transactionSelector = useMemo(() => { + return (allTransactions: OnyxCollection) => selectTransactionsWithDuplicates(transactionIDs, allTransactions, duplicateTransactionViolations); + }, [transactionIDs, duplicateTransactionViolations]); + + const [duplicateTransactions] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: transactionSelector, + }); + + return useMemo( + () => ({ + duplicateTransactions, + duplicateTransactionViolations, + }), + [duplicateTransactions, duplicateTransactionViolations], + ); +} + +export {useLiveDuplicateTransactionsAndViolations}; export default useDuplicateTransactionsAndViolations; diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts index 47eb00808e2a..048bfa212ac1 100644 --- a/src/hooks/useDynamicBackPath.ts +++ b/src/hooks/useDynamicBackPath.ts @@ -17,11 +17,13 @@ import useRootNavigationState from './useRootNavigationState'; * If the suffix doesn't match the tail of the current path, returns the path as-is. * * @param dynamicRouteSuffix - The dynamic route pattern to remove from the current URL. + * @param isEnabled - Pass false from a caller that discards the result, to skip the work behind it. Serializing the + * navigation tree and matching every suffix against it runs on each navigation event, for each mounted caller. * @returns The back path for the dynamic route. */ -function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route { +function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix, isEnabled = true): Route { const path = useRootNavigationState((state) => { - if (!state) { + if (!isEnabled || !state) { return undefined; } diff --git a/src/hooks/useEnableGlobalReimbursementsNavigation.ts b/src/hooks/useEnableGlobalReimbursementsNavigation.ts new file mode 100644 index 000000000000..7daaa8382f95 --- /dev/null +++ b/src/hooks/useEnableGlobalReimbursementsNavigation.ts @@ -0,0 +1,67 @@ +/** + * Resolves Enable Global Reimbursements routes for the static settings/wallet flow and the dynamic report/search flow. + */ + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import isDynamicRouteScreen from '@libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteScreen'; +import type {EnableGlobalReimbursementsRouteParams} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; +import { + getDynamicBasePathFromNavigationPath, + getEnableGlobalReimbursementsRootBackPath, + shouldUseDynamicEnableGlobalReimbursementsBase, +} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import type {State} from '@libs/Navigation/types'; + +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; + +import {useRoute} from '@react-navigation/native'; + +import useRootNavigationState from './useRootNavigationState'; + +function useEnableGlobalReimbursementsNavigation() { + const route = useRoute(); + const isDynamic = isDynamicRouteScreen(route.name as Screen); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion -- route.name is string at runtime + const navigationPath = useRootNavigationState((state) => (isDynamic && state ? getPathFromState(state as State) : undefined)); + const dynamicBasePath = isDynamic ? getDynamicBasePathFromNavigationPath(navigationPath) : ''; + + const getBusinessRoute = (bankAccountID: number | string, subPage: string, action?: 'edit', params?: EnableGlobalReimbursementsRouteParams): Route => { + if (isDynamic) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(String(bankAccountID), subPage, action, params), dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), subPage, action, params); + }; + + const getAgreementsRoute = (bankAccountID: number | string, params?: EnableGlobalReimbursementsRouteParams): Route => { + if (isDynamic) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(String(bankAccountID), params), dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID), params); + }; + + const getSignRoute = (bankAccountID: number | string, params?: EnableGlobalReimbursementsRouteParams): Route => { + if (isDynamic) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute(String(bankAccountID), params), dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute(Number(bankAccountID), params); + }; + + const getRootBackPath = (): Route => { + if (isDynamic && shouldUseDynamicEnableGlobalReimbursementsBase(dynamicBasePath)) { + return getEnableGlobalReimbursementsRootBackPath(dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET; + }; + + return { + isDynamic, + getBusinessRoute, + getAgreementsRoute, + getSignRoute, + getRootBackPath, + }; +} + +export default useEnableGlobalReimbursementsNavigation; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index f99e30891480..e5a69a9d36dd 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -21,7 +21,6 @@ import { canEditFieldOfMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, generateReportID, - getAddExpenseDropdownOptions, getPolicyExpenseChat, isDM, isSelfDM, @@ -43,6 +42,7 @@ import { import {getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU/DeleteMoneyRequest'; import {getMoneyRequestParticipantsFromReport, startMoneyRequest} from '@userActions/IOU/MoneyRequest'; +import {getAddExpenseDropdownOptions} from '@userActions/IOU/StartExpenseFlows'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -159,6 +159,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); // Billing keys @@ -217,6 +218,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic outstandingReportsByPolicyID, reportNameValuePairs, transaction: singleTransaction, + rules, }) && canUserPerformWriteActionReportUtils(moneyRequestReport, isChatReportArchived); @@ -288,6 +290,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic formatPhoneNumber, participantsPolicyTags, conciergeChat, + rules, }); } }; @@ -470,6 +473,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic getCurrencyDecimals, participantsPolicyTags: reportDuplicateParticipantsPolicyTags, conciergeChat, + rules, }); }, }, diff --git a/src/hooks/useExpensifyCardFeedsForFeedSelector.ts b/src/hooks/useExpensifyCardFeedsForFeedSelector.ts index 18ee5eb0987b..36219c10eb82 100644 --- a/src/hooks/useExpensifyCardFeedsForFeedSelector.ts +++ b/src/hooks/useExpensifyCardFeedsForFeedSelector.ts @@ -1,12 +1,16 @@ import {getAdminExpensifyCardFeedEntries, partitionExpensifyCardFeedsForSelector} from '@libs/ExpensifyCardFeedSelectorUtils'; -import type {ExpensifyCardFeedEntry} from '@libs/ExpensifyCardFeedSelectorUtils'; +import type {ExpensifyCardFeedEntry, ExpensifyCardFeedProgram} from '@libs/ExpensifyCardFeedSelectorUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; -function useExpensifyCardFeedsForFeedSelector(policyID: string | undefined): { +/** Pass a program set (e.g. TRAVEL_US) to list travel feeds; defaults to the US/GB card feeds. */ +function useExpensifyCardFeedsForFeedSelector( + policyID: string | undefined, + programs?: ExpensifyCardFeedProgram[], +): { primaryFeeds: ExpensifyCardFeedEntry[]; otherFeeds: ExpensifyCardFeedEntry[]; allFeeds: ExpensifyCardFeedEntry[]; @@ -19,7 +23,7 @@ function useExpensifyCardFeedsForFeedSelector(policyID: string | undefined): { if (!policyID) { return {primaryFeeds: [], otherFeeds: [], allFeeds: []}; } - const allFeeds = getAdminExpensifyCardFeedEntries(cardSettingsCollection, policies, domains, currentUserAccountID); + const allFeeds = getAdminExpensifyCardFeedEntries(cardSettingsCollection, policies, domains, currentUserAccountID, programs); const {primary, other} = partitionExpensifyCardFeedsForSelector(allFeeds, policyID); return {primaryFeeds: primary, otherFeeds: other, allFeeds}; } diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 5647acc70bb3..7fae127faeda 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -102,6 +102,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', 'ArrowRight', ]); diff --git a/src/hooks/useExportAgainModal.ts b/src/hooks/useExportAgainModal.ts index 0616b87cd234..4a74651d45ab 100644 --- a/src/hooks/useExportAgainModal.ts +++ b/src/hooks/useExportAgainModal.ts @@ -30,6 +30,18 @@ function useExportAgainModal(reportID: string | undefined, policyID: string | un if (!integrationForExport) { return; } + + // "Mark as exported" only logs a per-report exported action through MarkAsExported and never pushes data + // into the external accounting company, so an already-exported report is simply re-marked. The + // "export again" copy would wrongly warn that the report is about to be exported to e.g. QuickBooks Online. + if (exportType === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { + if (!reportID) { + return; + } + markAsManuallyExported([reportID], integrationForExport, policy); + return; + } + const connectionNameFriendly = getAccountingIntegrationDisplayName(policy, integrationForExport, translate); showConfirmModal({ @@ -45,11 +57,7 @@ function useExportAgainModal(reportID: string | undefined, policyID: string | un if (result.action !== ModalActions.CONFIRM || !reportID) { return; } - if (exportType === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION) { - exportToIntegration(reportID, integrationForExport, policy); - } else if (exportType === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { - markAsManuallyExported([reportID], integrationForExport, policy); - } + exportToIntegration(reportID, integrationForExport, policy); }); }; diff --git a/src/hooks/useExportedToFilterOptions.ts b/src/hooks/useExportedToFilterOptions.ts index 7921309e10cf..c63e0173b534 100644 --- a/src/hooks/useExportedToFilterOptions.ts +++ b/src/hooks/useExportedToFilterOptions.ts @@ -31,7 +31,16 @@ function exportedToPoliciesSelector(policies: OnyxCollection): OnyxColle if (!policy) { continue; } - result[key] = {id: policy.id, name: policy.name, connections: policy.connections, exportLayouts: policy.exportLayouts, outputCurrency: policy.outputCurrency} as Policy; + result[key] = { + id: policy.id, + name: policy.name, + connections: policy.connections, + exportLayouts: policy.exportLayouts, + outputCurrency: policy.outputCurrency, + role: policy.role, + areCompanyCardsEnabled: policy.areCompanyCardsEnabled, + areExpensifyCardsEnabled: policy.areExpensifyCardsEnabled, + } as Policy; } return result; } diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index e924ac2887ab..55cf589537d9 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -6,12 +6,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {useCallback, useMemo, useState} from 'react'; +import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; import useReportAttributes from './useReportAttributes'; -import useSortedActions from './useSortedActions'; +import useSortedReportActionsData from './useSortedReportActionsData'; type UseFilteredOptionsConfig = { /** Maximum number of recent reports to pre-filter and process (default: 500). */ @@ -85,16 +86,25 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportAttributesDerived = useReportAttributes(); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + 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 {convertToDisplayString} = useCurrencyListActions(); // Sorted report actions from the RAM_ONLY_SORTED_REPORT_ACTIONS derived value; a new reference on // every recompute, so it doubles as the report-actions invalidation signal for the option-list cache. - const sortedActions = useSortedActions(); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; + const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; + const lastActions = sortedReportActionsData?.lastActions; + const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const privateIsArchivedMap = usePrivateIsArchivedMap(); @@ -110,9 +120,23 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption reportAttributesDerived, privateIsArchivedMap, allPolicies, - {currentUserAccountID, dateFnsLocale, conciergeReportID, maxRecentReports: reportsLimit, includeP2P, isSearching, deferContactsUntilSearch, locale: preferredLocale}, - undefined, - undefined, + { + currentUserAccountID, + currentUserLogin, + transactionThreadIDs, + lastActions, + dateFnsLocale, + convertToDisplayString, + conciergeReportID, + maxRecentReports: reportsLimit, + includeP2P, + isSearching, + deferContactsUntilSearch, + locale: preferredLocale, + }, + rules, + allPolicyTags, + visibleReportActionsData, isTrackIntentUser, sortedActions, ) @@ -125,15 +149,22 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption privateIsArchivedMap, allPolicies, conciergeReportID, + rules, reportsLimit, includeP2P, isSearching, deferContactsUntilSearch, preferredLocale, isTrackIntentUser, + currentUserLogin, + allPolicyTags, + visibleReportActionsData, sortedActions, + transactionThreadIDs, + lastActions, currentUserAccountID, dateFnsLocale, + convertToDisplayString, ], ); diff --git a/src/hooks/useGetExpensifyCardFromReportAction.ts b/src/hooks/useGetExpensifyCardFromReportAction.ts index b0abf78078de..165e57335564 100644 --- a/src/hooks/useGetExpensifyCardFromReportAction.ts +++ b/src/hooks/useGetExpensifyCardFromReportAction.ts @@ -1,11 +1,7 @@ import {useCardList, useWorkspaceCardList} from '@components/OnyxListItemProvider'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; -import {getOriginalMessage, isCardIssuedAction} from '@libs/ReportActionsUtils'; -import {getTravelBillingFeedID} from '@libs/TravelBillingUtils'; +import {getExpensifyCardFromReportAction} from '@libs/ReportAlternateTextUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, ReportAction} from '@src/types/onyx'; import usePolicy from './usePolicy'; @@ -14,21 +10,8 @@ function useGetExpensifyCardFromReportAction({reportAction, policyID}: {reportAc const allUserCards = useCardList(); const allExpensifyCards = useWorkspaceCardList(); const policy = usePolicy(policyID); - const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; - const cardIssuedActionOriginalMessage = isCardIssuedAction(reportAction) ? getOriginalMessage(reportAction) : undefined; - const cardID = cardIssuedActionOriginalMessage?.cardID ?? CONST.DEFAULT_NUMBER_ID; - if (!isPolicyAdmin(policy)) { - return allUserCards?.[cardID]; - } - - // Issued Expensify Cards live on one of two Onyx keys: regular cards on the 2-segment key, - // Travel Billing cards on the `_TRAVEL_US` variant. Check both. - return ( - allExpensifyCards?.[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`]?.[cardID] ?? - allExpensifyCards?.[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${getTravelBillingFeedID(workspaceAccountID)}`]?.[cardID] ?? - allUserCards?.[cardID] - ); + return getExpensifyCardFromReportAction({reportAction, policy, cardList: allUserCards, workspaceCardList: allExpensifyCards}); } export default useGetExpensifyCardFromReportAction; diff --git a/src/hooks/useHRSyncResultsPage.ts b/src/hooks/useHRSyncResultsPage.ts index aec62a639100..daa846190acc 100644 --- a/src/hooks/useHRSyncResultsPage.ts +++ b/src/hooks/useHRSyncResultsPage.ts @@ -13,15 +13,19 @@ import {isModalActiveSelector} from '@selectors/Modal'; import {useEffect, useEffectEvent, useRef} from 'react'; import useOnyx from './useOnyx'; -import usePrevious from './usePrevious'; /** * Watches an HR provider's sync progress and automatically opens the HR sync results screen - * when the sync transitions to the `JOB_DONE` stage with a result payload. + * when the sync reaches the `JOB_DONE` stage with a result payload. */ function useHRSyncResultsPage(connectionSyncProgress: OnyxEntry, isFocused: boolean) { - const previousSyncProgress = usePrevious(connectionSyncProgress); const pendingSyncResultRef = useRef | null>(null); + + // The backend sends the `JOB_DONE` stage and the result in separate Onyx updates, and it does not + // guarantee their order. Therefore we cannot key the screen on the render that first shows `JOB_DONE`. + // We instead remember that this mount watched a sync run, and we open the result one time for that run. + // A mount that finds a finished sync already in Onyx never saw the run, therefore it opens nothing. + const didWatchSyncRunRef = useRef(false); const [isAnyModalActive] = useOnyx(ONYXKEYS.MODAL, {selector: isModalActiveSelector}); const connectionName = connectionSyncProgress?.connectionName; @@ -37,13 +41,20 @@ function useHRSyncResultsPage(connectionSyncProgress: OnyxEntry { const syncResult = connectionSyncProgress?.result; + const stageInProgress = connectionSyncProgress?.stageInProgress; const isHRConnectionName = CONST.POLICY.CONNECTIONS.HR_CONNECTION_NAMES.some((hrConnectionName) => hrConnectionName === connectionName); - const isHRSyncDoneWithResult = isHRConnectionName && connectionSyncProgress?.stageInProgress === CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE && !!syncResult; - const didTransitionToJobDone = previousSyncProgress?.connectionName === connectionName && previousSyncProgress?.stageInProgress !== CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE; - const didHRSyncComplete = isFocused && isHRSyncDoneWithResult && didTransitionToJobDone; + const isSyncRunning = isHRConnectionName && !!stageInProgress && stageInProgress !== CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE; + + if (isSyncRunning) { + didWatchSyncRunRef.current = true; + } + + const isHRSyncDoneWithResult = isHRConnectionName && stageInProgress === CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE && !!syncResult; + const didHRSyncComplete = isFocused && isHRSyncDoneWithResult && didWatchSyncRunRef.current; if (didHRSyncComplete && syncResult && connectionName) { pendingSyncResultRef.current = {connectionName, result: syncResult}; + didWatchSyncRunRef.current = false; } const pendingSyncResult = pendingSyncResultRef.current; @@ -59,16 +70,7 @@ function useHRSyncResultsPage(connectionSyncProgress: OnyxEntry handle.cancel(); - }, [ - connectionName, - connectionSyncProgress?.result, - connectionSyncProgress?.stageInProgress, - connectionSyncProgress?.timestamp, - isAnyModalActive, - isFocused, - previousSyncProgress?.connectionName, - previousSyncProgress?.stageInProgress, - ]); + }, [connectionName, connectionSyncProgress?.result, connectionSyncProgress?.stageInProgress, connectionSyncProgress?.timestamp, isAnyModalActive, isFocused]); } export default useHRSyncResultsPage; diff --git a/src/hooks/useHasOwnedPaidPolicy.ts b/src/hooks/useHasOwnedPaidPolicy.ts new file mode 100644 index 000000000000..c7d0447e3b47 --- /dev/null +++ b/src/hooks/useHasOwnedPaidPolicy.ts @@ -0,0 +1,18 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import {ownerPoliciesSelector} from '@src/selectors/Policy'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnyx from './useOnyx'; + +/** Whether the current user already owns at least one paid workspace. */ +function useHasOwnedPaidPolicy() { + const {accountID} = useCurrentUserPersonalDetails(); + const selector = (policies: OnyxCollection) => ownerPoliciesSelector(policies, accountID).length > 0; + const [hasOwnedPaidPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); + return !!hasOwnedPaidPolicy; +} + +export default useHasOwnedPaidPolicy; diff --git a/src/hooks/useHasReportAwaitingApproval.ts b/src/hooks/useHasReportAwaitingApproval.ts index 7c0b91aabdce..d34e1ef7914c 100644 --- a/src/hooks/useHasReportAwaitingApproval.ts +++ b/src/hooks/useHasReportAwaitingApproval.ts @@ -24,6 +24,7 @@ function useHasReportAwaitingApproval(shouldWatchForApprovals = true): boolean { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); const [session] = useOnyx(ONYXKEYS.SESSION); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Holds the last computed result so a frozen (inactive) consumer can keep returning it without recomputing. const [frozen, setFrozen] = useState(null); @@ -46,6 +47,7 @@ function useHasReportAwaitingApproval(shouldWatchForApprovals = true): boolean { currentUserAccountID, login: '', areTransactionsLoaded: transactionsMetadata.status === 'loaded', + rules, }); const hasReportAwaitingApproval = reports.length > 0; diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index a5d618fa40af..3331cf0fdee7 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -56,6 +56,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); const {isBetaEnabled} = usePermissions(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const currentUserDetails = useCurrentUserPersonalDetails(); @@ -87,6 +88,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment approveMoneyRequest({ getCurrencyDecimals, expenseReport: currentMoneyRequestReport, + rules, currentUserAccountIDParam: currentUserDetails.accountID, currentUserEmailParam: currentUserDetails.email ?? '', hasViolations, @@ -127,6 +129,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment delegateAccountID, isTrackIntentUser, conciergeChat, + rules, }); } onClose(); diff --git a/src/hooks/useHoldRejectActions.ts b/src/hooks/useHoldRejectActions.ts index fed3e857712f..2029ca413b98 100644 --- a/src/hooks/useHoldRejectActions.ts +++ b/src/hooks/useHoldRejectActions.ts @@ -57,6 +57,7 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const shouldSuppressPromotionalUI = useShouldSuppressPromotionalUI(); const isReportSubmitter = isCurrentUserSubmitter(chatIOUReport); @@ -91,6 +92,7 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); } else if (shouldShowHoldEducationalModal) { onHoldEducationalOpen(); @@ -123,6 +125,7 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); }, }, diff --git a/src/hooks/useHydrateReportsFromSnapshot.ts b/src/hooks/useHydrateReportsFromSnapshot.ts new file mode 100644 index 000000000000..c24a9e82081e --- /dev/null +++ b/src/hooks/useHydrateReportsFromSnapshot.ts @@ -0,0 +1,81 @@ +import type {SelectedReports} from '@components/Search/types'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, SearchResults, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; + +import {useEffect, useRef} from 'react'; +import Onyx from 'react-native-onyx'; + +function useHydrateReportsFromSnapshot( + currentSearchResults: SearchResults | undefined, + allReports: OnyxCollection | undefined, + /** When this parameter is provided, transactions will be hydrated as well. */ + allTransactions?: OnyxCollection, + /** Only merge reports or transactions included in `selectedReports` when this parameter is provided. */ + selectedReports?: SelectedReports[], +) { + const hasHydratedFromAllReports = useRef(false); + const hasHydratedFromAllTransactions = useRef(false); + + useEffect(() => { + const snapshotData = currentSearchResults?.data; + // Guard with `hasHydratedFromAllTransactions` to prevent hydration from re-running when `allTransactions` changes + if (!snapshotData || hasHydratedFromAllTransactions.current) { + return; + } + + const onyxUpdates: Array< + | { + onyxMethod: typeof Onyx.METHOD.MERGE; + key: `${typeof ONYXKEYS.COLLECTION.REPORT}${string}`; + value: Report; + } + | { + onyxMethod: typeof Onyx.METHOD.MERGE; + key: `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; + value: Transaction; + } + > = []; + + const selectedReportIDSet = new Set(selectedReports?.map(({reportID}) => reportID).filter((id) => id) ?? []); + const isReportKey = (key: string): key is `${typeof ONYXKEYS.COLLECTION.REPORT}${string}` => + key.startsWith(ONYXKEYS.COLLECTION.REPORT) && !key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && !key.startsWith(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isTransactionKey = (key: string): key is `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}` => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION); + for (const key of Object.keys(snapshotData)) { + if ((!isReportKey(key) && (!allTransactions || !isTransactionKey(key))) || (isReportKey(key) && hasHydratedFromAllReports.current)) { + continue; + } + + if (allReports?.[key] || allTransactions?.[key]) { + continue; + } + + const value = snapshotData[key]; + if (value && (!selectedReports || selectedReportIDSet.has(value.reportID))) { + onyxUpdates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key, + value, + } as unknown as TupleToUnion); + } + } + + if (onyxUpdates.length > 0) { + Onyx.update(onyxUpdates); + } + + hasHydratedFromAllReports.current = true; + if (allTransactions) { + hasHydratedFromAllTransactions.current = true; + } + // Hydration should only run once on mount using the initial snapshot data + // Include `allTransactions` as a dependency so hydration can occur once it has a value. + // `hasHydratedFromAllTransactions` acts as a guard to ensure hydration only happens once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allTransactions]); +} + +export default useHydrateReportsFromSnapshot; diff --git a/src/hooks/useIndicatorStatus.ts b/src/hooks/useIndicatorStatus.ts index ade0f17cb320..44e390fa839d 100644 --- a/src/hooks/useIndicatorStatus.ts +++ b/src/hooks/useIndicatorStatus.ts @@ -8,7 +8,6 @@ type IndicatorStatusResult = { /** The indicator dot color: danger for errors, success for info or no issues. */ indicatorColor: string; - /** The indicator status. */ status: IndicatorStatus | undefined; /** The policy ID associated with the indicator. */ @@ -19,9 +18,9 @@ function useIndicatorStatus(): IndicatorStatusResult { const theme = useTheme(); const {accountStatus, infoStatus: accountInfoStatus} = useAccountIndicatorChecks(); - const {policyErrorStatus, policyInfoStatus, domainStatus, indicatorPolicyID} = usePolicyIndicatorChecks(); + const {policyErrorStatus, policyInfoStatus, domainErrorStatus, indicatorPolicyID} = usePolicyIndicatorChecks(); - const errorStatus = accountStatus ?? policyErrorStatus ?? domainStatus; + const errorStatus = accountStatus ?? policyErrorStatus ?? domainErrorStatus; const status = errorStatus ?? accountInfoStatus ?? policyInfoStatus; const indicatorColor = errorStatus ? theme.danger : theme.success; diff --git a/src/hooks/useIsInLandscapeMode/index.native.ts b/src/hooks/useIsInLandscapeMode/index.native.ts deleted file mode 100644 index 0f36e84fdade..000000000000 --- a/src/hooks/useIsInLandscapeMode/index.native.ts +++ /dev/null @@ -1,16 +0,0 @@ -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isInLandscapeMode from '@libs/isInLandscapeMode'; - -/** - * Returns whether the device is currently in landscape orientation. - * If component already uses useResponsiveLayout, it will return the value from that hook. - * If component already uses useWindowDimensions, use @libs/isInLandscapeMode instead. - */ -function useIsInLandscapeMode(): boolean { - const {windowWidth, windowHeight} = useWindowDimensions(); - - return isInLandscapeMode(windowWidth, windowHeight); -} - -export default useIsInLandscapeMode; diff --git a/src/hooks/useIsInLandscapeMode/index.ts b/src/hooks/useIsInLandscapeMode/index.ts index 01775a6d2c34..0f36e84fdade 100644 --- a/src/hooks/useIsInLandscapeMode/index.ts +++ b/src/hooks/useIsInLandscapeMode/index.ts @@ -1,8 +1,16 @@ +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import isInLandscapeMode from '@libs/isInLandscapeMode'; + /** - * We only want to change UI according to the landscape mode on native platforms. + * Returns whether the device is currently in landscape orientation. + * If component already uses useResponsiveLayout, it will return the value from that hook. + * If component already uses useWindowDimensions, use @libs/isInLandscapeMode instead. */ function useIsInLandscapeMode(): boolean { - return false; + const {windowWidth, windowHeight} = useWindowDimensions(); + + return isInLandscapeMode(windowWidth, windowHeight); } export default useIsInLandscapeMode; diff --git a/src/hooks/useIsScrollLikelyLayoutTriggered.ts b/src/hooks/useIsScrollLikelyLayoutTriggered.ts index 815043eed8a7..bf58df8d0dd2 100644 --- a/src/hooks/useIsScrollLikelyLayoutTriggered.ts +++ b/src/hooks/useIsScrollLikelyLayoutTriggered.ts @@ -16,6 +16,7 @@ export default function useIsScrollLikelyLayoutTriggered() { const debouncedLowerIsScrollLayoutTriggered = useDebounce( useCallback(() => (isScrollLayoutTriggered.current = false), []), 500, + {maxWait: 500}, ); const raiseIsScrollLayoutTriggered = useCallback(() => { diff --git a/src/hooks/useIsSupportalSession.ts b/src/hooks/useIsSupportalSession.ts new file mode 100644 index 000000000000..0c3d6ad54b3d --- /dev/null +++ b/src/hooks/useIsSupportalSession.ts @@ -0,0 +1,19 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import {isSupportalSessionSelector} from '@selectors/Session'; + +import useOnyx from './useOnyx'; + +/** + * Returns true when the current session is a supportal (support agent) session. + * The subscription is narrowed to the derived boolean so consumers only re-render when the session type actually changes. + * Fails open: while SESSION is still loading this returns false, so a normal user is never briefly blocked from an action. + * The SupportalPermission response middleware remains the backstop for anything that slips through. + */ +function useIsSupportalSession(): boolean { + const [isSupportalSession = false] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); + + return isSupportalSession; +} + +export default useIsSupportalSession; diff --git a/src/hooks/useLastWorkspaceNumber.ts b/src/hooks/useLastWorkspaceNumber.ts index fd00e5a756f1..4fc5a6c6bf28 100644 --- a/src/hooks/useLastWorkspaceNumber.ts +++ b/src/hooks/useLastWorkspaceNumber.ts @@ -1,4 +1,5 @@ import ONYXKEYS from '@src/ONYXKEYS'; +import {displayNameSelector} from '@src/selectors/PersonalDetails'; import {lastWorkspaceNumberSelector} from '@src/selectors/Policy'; import {emailSelector} from '@src/selectors/Session'; import type {Policy} from '@src/types/onyx'; @@ -8,13 +9,15 @@ import type {OnyxCollection} from 'react-native-onyx'; import {useMemo} from 'react'; import useOnyx from './useOnyx'; +import usePersonalDetailByLogin from './usePersonalDetailByLogin'; function useLastWorkspaceNumber(email?: string) { const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const resolvedEmail = email ?? sessionEmail ?? ''; - // Memoize so the POLICY-collection regex scan only re-runs when the resolved email changes, not on every render - // of consumers (e.g. the expense header renders this on every expense). - const selector = useMemo(() => (policies: OnyxCollection) => lastWorkspaceNumberSelector(policies, resolvedEmail), [resolvedEmail]); + const userDisplayName = usePersonalDetailByLogin(resolvedEmail, displayNameSelector); + // Memoize so the POLICY-collection regex scan only re-runs when the resolved email or display name changes, not on + // every render of consumers (e.g. the expense header renders this on every expense). + const selector = useMemo(() => (policies: OnyxCollection) => lastWorkspaceNumberSelector(policies, resolvedEmail, userDisplayName), [resolvedEmail, userDisplayName]); const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); return lastWorkspaceNumber; } diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 95cefb0eaa87..0d5dfd263435 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -1,5 +1,5 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; -import type {ActionHandledType} from '@components/Modal/Global/HoldMenuModalWrapper'; +import {getApprovalDropdownOptions} from '@components/ExpenseHeaderApprovalButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; import {useOpenReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; @@ -13,7 +13,7 @@ import { getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, getNextApproverAccountID, hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, - hasOnlyHeldExpenses, + hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, hasViolations as hasViolationsReportUtils, isExported as isExportedUtils, isReportOwner, @@ -30,7 +30,7 @@ import { } from '@libs/TransactionUtils'; import {cancelPayment, markReportPaymentReceived} from '@userActions/IOU/PayMoneyRequest'; -import {approveMoneyRequest, reopenReport, retractReport, submitReport, unapproveExpenseReport} from '@userActions/IOU/ReportWorkflow'; +import {approveMoneyRequest, canIOUBePaid as canIOUBePaidAction, reopenReport, retractReport, submitReport, unapproveExpenseReport} from '@userActions/IOU/ReportWorkflow'; import {markPendingRTERTransactionsAsCash} from '@userActions/Transaction'; import CONST from '@src/CONST'; @@ -42,6 +42,8 @@ import {delegateEmailSelector} from '@selectors/Account'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React from 'react'; +import type {ActionHandledType} from './useHoldMenuSubmit'; + import useConfirmModal from './useConfirmModal'; import useConfirmPendingRTERAndProceed from './useConfirmPendingRTERAndProceed'; import {useCurrencyListActions} from './useCurrencyList'; @@ -73,6 +75,12 @@ type UseLifecycleActionsResult = { handleSubmitReport: (skipAnimation?: boolean) => void; shouldBlockSubmit: boolean; isBlockSubmitDueToPreventSelfApproval: boolean; + /** Approve submenu options (partial/full) for selection-mode dropdowns when the report has held expenses; undefined otherwise */ + approveSubMenuItems: ReturnType | undefined; + /** Header text shown above the approve submenu */ + approveSubMenuHeaderText: string; + /** Whether the approve action should render as a submenu (i.e. the report has held expenses) */ + shouldShowApproveSubMenu: boolean; }; /** @@ -93,13 +101,13 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, { - selector: delegateEmailSelector, - }); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const delegateAccountID = useDelegateAccountID(); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { - selector: isTrackIntentUserSelector, - }); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + + const {convertToDisplayString} = useCurrencyListActions(); const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); @@ -127,11 +135,11 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const {currentSearchResults} = useSearchResultsContext(); const {selectedTransactionIDs} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear', 'MoneyBag']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear', 'MoneyBag', 'ArrowRight', 'DocumentCheck']); - const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); + const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport, rules); const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID); const isBlockSubmitDueToPreventSelfApproval = !!(isSubmitterSameAsNextApprover && policy?.preventSelfApproval); @@ -169,32 +177,28 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const confirmPendingRTERAndProceed = useConfirmPendingRTERAndProceed(hasAnyPendingRTERViolation, handleMarkPendingRTERTransactionsAsCash); - const confirmApproval = (skipAnimation = false) => { + const onApprove = (isFullApproval: boolean, skipAnimation = false) => { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); return; } - if (isAnyTransactionOnHold) { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.APPROVE, skipAnimation ? undefined : () => startApprovedAnimation()); - return; - } if (!skipAnimation && !isSubmitPolicy(policy)) { startApprovedAnimation(); } approveMoneyRequest({ getCurrencyDecimals, expenseReport: moneyRequestReport, - expenseReportPolicy: policy, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', hasViolations, isASAPSubmitBetaEnabled, betas, + full: isFullApproval, + expenseReportPolicy: policy, userBillingGracePeriodEnds, - amountOwed, ownerBillingGracePeriodEnd, ownerLogin: submitterLogin, - full: true, + amountOwed, onApproved: () => { if (skipAnimation) { return; @@ -204,6 +208,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, delegateEmail, delegateAccountID, isTrackIntentUser, + rules, }); if (skipAnimation) { clearSelectedTransactions(true); @@ -211,10 +216,53 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } }; + const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserPersonalDetails.login ?? '', accountID); + const onlyShowPayElsewhere = + !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserPersonalDetails.login ?? '', accountID, undefined, true); + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; + const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(transactions); + const shouldShowApprovalSecondaryActions = isAnyTransactionOnHold && !isDelegateAccessRestricted; + const secondaryApprovalActions = shouldShowApprovalSecondaryActions + ? getApprovalDropdownOptions({ + moneyRequestReport, + hasOnlyHeldExpenses, + onPartialApprove: () => onApprove(false), + onFullApprove: () => onApprove(true), + translate, + shouldShowPayButton, + illustrations: expensifyIcons, + transactions, + convertToDisplayString, + }) + : undefined; + const approvalOptionsHeaderText = hasOnlyHeldExpenses ? translate('iou.confirmApprovalAllHoldAmount') : translate('iou.confirmApprovalWithHeldAmount'); + + // Approve submenu options for selection-mode dropdowns (bulk-select approve, selection toolbar). These skip the + // approval button animation and clear the current selection after approving, mirroring the previous confirmApproval flow. + const approveSubMenuItems = shouldShowApprovalSecondaryActions + ? getApprovalDropdownOptions({ + moneyRequestReport, + hasOnlyHeldExpenses, + onPartialApprove: () => onApprove(false, true), + onFullApprove: () => onApprove(true, true), + translate, + shouldShowPayButton, + illustrations: expensifyIcons, + transactions, + convertToDisplayString, + }) + : undefined; + + // Approve the full report. When there are held expenses the partial/full choice is surfaced up front via the + // approve submenu instead, so this path always approves everything (used for the non-held case and as a safe fallback). + const confirmApproval = (skipAnimation = false) => { + onApprove(true, skipAnimation); + }; const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, report: moneyRequestReport, isTrackIntentUser, + rules, }); const handleSubmitReport = (skipAnimation = false) => { @@ -227,7 +275,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, return; } - if (hasOnlyHeldExpenses(transactions)) { + if (hasOnlyHeldExpenses) { showHeldExpensesBlockModal(showConfirmModal, translate, shouldShowMarkAsDoneCopy); return; } @@ -267,6 +315,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, delegateAccountID, submitterLogin, isTrackIntentUser, + rules, }); refreshSearchAfterReportAction({ currentSearchQueryJSON, @@ -295,9 +344,15 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, [CONST.REPORT.SECONDARY_ACTIONS.APPROVE]: { value: CONST.REPORT.SECONDARY_ACTIONS.APPROVE, text: translate('iou.approve'), + rightIcon: shouldShowApprovalSecondaryActions ? expensifyIcons.ArrowRight : undefined, + backButtonText: shouldShowApprovalSecondaryActions ? translate('iou.approve') : undefined, icon: expensifyIcons.ThumbsUp, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.APPROVE, - onSelected: confirmApproval, + subMenuItems: secondaryApprovalActions, + shouldUpdateSelectedIndex: true, + subMenuHeaderText: shouldShowApprovalSecondaryActions ? approvalOptionsHeaderText : undefined, + // Only reached when there is no submenu; otherwise PopoverMenu opens the submenu instead. + onSelected: () => confirmApproval(), }, [CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT]: { value: CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT, @@ -326,7 +381,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, CONST.IOU.REPORT_ACTION_TYPE.PAY, () => { startAnimation(); - markReportPaymentReceived(chatReport, moneyRequestReport, accountID, email ?? '', chatReportActions, isTrackIntentUser, getCurrencyDecimals); + markReportPaymentReceived(chatReport, moneyRequestReport, accountID, email ?? '', chatReportActions, isTrackIntentUser, getCurrencyDecimals, rules); }, CONST.IOU.PAYMENT_TYPE.ELSEWHERE, ); @@ -334,7 +389,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } startAnimation(); - markReportPaymentReceived(chatReport, moneyRequestReport, accountID, email ?? '', chatReportActions, isTrackIntentUser, getCurrencyDecimals); + markReportPaymentReceived(chatReport, moneyRequestReport, accountID, email ?? '', chatReportActions, isTrackIntentUser, getCurrencyDecimals, rules); }, }, [CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE]: { @@ -369,7 +424,18 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, delegateEmail, isTrackIntentUser, getCurrencyDecimals); + unapproveExpenseReport( + moneyRequestReport, + policy, + accountID, + email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + delegateEmail, + isTrackIntentUser, + getCurrencyDecimals, + rules, + ); }, }, [CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT]: { @@ -390,7 +456,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, return; } - cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, isTrackIntentUser); + cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, isTrackIntentUser, rules); }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { @@ -424,7 +490,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, delegateEmail, isTrackIntentUser); + retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, delegateEmail, isTrackIntentUser, rules); }, }, [CONST.REPORT.SECONDARY_ACTIONS.REOPEN]: { @@ -458,7 +524,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - reopenReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, chatReport, isTrackIntentUser); + reopenReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, chatReport, isTrackIntentUser, rules); }, }, }; @@ -469,6 +535,9 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval, + approveSubMenuItems, + approveSubMenuHeaderText: approvalOptionsHeaderText, + shouldShowApproveSubMenu: shouldShowApprovalSecondaryActions, }; } diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index bd2e9e817571..140dd63b9ef5 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -20,10 +20,7 @@ type UseLoadReportActionsArguments = { /** The transaction thread report ID associated with the current transaction, if any */ transactionThreadReportID: string | undefined; - /** If the report has newer actions to load */ hasNewerActions: boolean; - - /** If the report has older actions to load */ hasOlderActions: boolean; /** Newest action ID from the last pagination response, used as cursor to avoid Pusher-delivered actions skipping gaps */ diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index e5818849be5c..8fe843ab8d4c 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -1,3 +1,4 @@ +import {fontScale, lineHeightScale} from '@styles/typography'; import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; @@ -35,12 +36,12 @@ function useMarkdownStyle(hasMessageOnlyEmojis: boolean, excludeStyles: Array | undefined; + + /** The edited expense, needed to pre-seed the rule */ + transaction: Transaction | undefined; + + /** The workspace that would own the rule */ + policy: Policy | undefined; +}; + +/** + * Resolves the "Create a rule" callout: someone who can write workspace rules just edited a field a merchant rule can + * govern, and hasn't dismissed the offer for that expense. + * + * @param reportID - the report showing the expense: its transaction thread, or a report holding only that expense + */ +function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined): MerchantRuleSuggestionResult { + const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); + const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); + + // The callout belongs on a screen showing the expense itself: its transaction thread, or a report holding only + // that expense. A report listing several shows no expense detail. Asking the report what it holds beats trusting + // whichever report was in scope during the edit. + const reportTransactions = useReportTransactions(reportID); + const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; + const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportID || isOneExpenseReportForSuggestion); + const isForThisExpenseView = isMerchantRuleSuggestionLive(storedSuggestion) && isHostingReport; + // Offer it to exactly who the rule page lets in: write access to Rules on a Control workspace. Admins today, and + // editors, who can already create the same rule from workspace settings. The rule page is Control-only, so + // without that check a Collect workspace would be offered a callout that lands on Not Found. + const canCreateMerchantRule = canWriteRules && isControlPolicy(policy) && arePolicyRulesEnabled(policy, policyCategories); + const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; + + // Filtered from the canonical list, not the record's own keys, so the order is fixed and only known fields reach + // the draft. + const editedFields = suggestion ? suggestion.editedFields?.[suggestion.transactionID] : undefined; + const fields = Object.values(CONST.MERCHANT_RULE_SUGGESTION_FIELDS).filter((field) => !!editedFields?.[field]); + + const editedTagLevels = suggestion?.editedTagLevels?.[suggestion.transactionID]; + // Built here rather than only on press, so an offer that would apply nothing never appears. Clearing a field is + // still an edit worth recording, but the rule it would make sets that field to empty, which changes nothing. + const draft = suggestion && transaction ? getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels) : undefined; + const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH && key !== INPUT_IDS.RULE_TYPE); + + // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an + // offer with nothing recorded, which is how an expense reads once its fields are cleared. + if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0 || !hasUpdatesToApply) { + return {suggestion: undefined, fields: [], editedTagLevels: undefined, transaction: undefined, policy: undefined}; + } + + return {suggestion, fields, editedTagLevels, transaction, policy}; +} + +export default useMerchantRuleSuggestion; diff --git a/src/hooks/useMoneyRequestParticipantsPolicyTags.ts b/src/hooks/useMoneyRequestParticipantsPolicyTags.ts index 4e238003816a..6b5be83e423c 100644 --- a/src/hooks/useMoneyRequestParticipantsPolicyTags.ts +++ b/src/hooks/useMoneyRequestParticipantsPolicyTags.ts @@ -3,6 +3,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {getMoneyRequestParticipantOptions} from '@libs/actions/IOU/MoneyRequest'; import type {OptionData} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ParticipantsPolicyTags, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Participant} from '@src/types/onyx/IOU'; @@ -15,6 +16,8 @@ import type {Participant} from '@src/types/onyx/IOU'; import type {Locale as DateFnsLocale} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; +import {useCurrencyListActions} from './useCurrencyList'; +import useOnyx from './useOnyx'; import useParticipantsPolicyTags from './useParticipantsPolicyTags'; type UseMoneyRequestParticipantsPolicyTagsParams = { @@ -47,18 +50,22 @@ function useMoneyRequestParticipantsPolicyTags({ translate, dateFnsLocale, }: UseMoneyRequestParticipantsPolicyTagsParams): UseMoneyRequestParticipantsPolicyTagsResult { - const participants = getMoneyRequestParticipantOptions( + const {convertToDisplayString} = useCurrencyListActions(); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const participants = getMoneyRequestParticipantOptions({ currentUserAccountID, report, policy, personalDetails, conciergeReportID, - isArchived, + privateIsArchived: isArchived, + rules, reportAttributesDerived, reportDraft, translate, + convertToDisplayString, dateFnsLocale, - ); + }); const participantsPolicyTags = useParticipantsPolicyTags(participants); return {participants, participantsPolicyTags}; diff --git a/src/hooks/useNavigateToTransactionThread.ts b/src/hooks/useNavigateToTransactionThread.ts index f3f59199b9be..2aab11345956 100644 --- a/src/hooks/useNavigateToTransactionThread.ts +++ b/src/hooks/useNavigateToTransactionThread.ts @@ -12,6 +12,8 @@ import type {Report, ReportAction, Transaction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; + import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; @@ -52,10 +54,12 @@ function useNavigateToTransactionThread() { const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, backTo}: NavigateToTransactionThreadParams) => { const iouAction = getIOUActionForTransactionID(reportActions, transactionID); - const resolvedBackTo = backTo ?? Navigation.getActiveRoute(); + const routeAtPress = Navigation.getActiveRoute(); + const resolvedBackTo = backTo ?? routeAtPress; let reportIDToNavigate = iouAction?.childReportID; const routeParams: {reportID: string | undefined; reportActionID?: string; backTo?: string} = { @@ -67,6 +71,8 @@ function useNavigateToTransactionThread() { const transactionThreadReport = createTransactionThreadReport({ introSelected, conciergeChat, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, currentUserLogin: currentUserDetails.email ?? '', currentUserAccountID: currentUserDetails.accountID, betas, @@ -86,6 +92,10 @@ function useNavigateToTransactionThread() { // Single transaction report opens in RHP. We seed every sibling transaction ID so the RHP can // display prev/next arrows for navigation between expenses. setActiveTransactionIDs(siblingTransactionIDs).then(() => { + // A second press made before this resolves would stack its expense on top of the first. + if (Navigation.getActiveRoute() !== routeAtPress) { + return; + } if (reportIDToNavigate) { markReportRHPWidth(reportIDToNavigate, 'wide'); } diff --git a/src/hooks/useOnboardingWorkspaceCreationState.ts b/src/hooks/useOnboardingWorkspaceCreationState.ts index 1573fc7363b5..b199a2ad5467 100644 --- a/src/hooks/useOnboardingWorkspaceCreationState.ts +++ b/src/hooks/useOnboardingWorkspaceCreationState.ts @@ -6,6 +6,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import useActivePolicy from './useActivePolicy'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from './useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import useLocalize from './useLocalize'; import useOnboardingMessages from './useOnboardingMessages'; @@ -33,6 +34,7 @@ function useOnboardingWorkspaceCreationState() { const {translate, formatPhoneNumber} = useLocalize(); const {isRestrictedPolicyCreation} = usePreferredPolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const {onboardingMessages} = useOnboardingMessages(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -52,6 +54,7 @@ function useOnboardingWorkspaceCreationState() { formatPhoneNumber, isRestrictedPolicyCreation, hasActiveAdminPolicies, + hasOwnedPaidPolicy, onboardingMessages, lastWorkspaceNumber, shouldUseNarrowLayout, diff --git a/src/hooks/useOptimisticNextStep.ts b/src/hooks/useOptimisticNextStep.ts index 7bf4d83049aa..8022126b27d2 100644 --- a/src/hooks/useOptimisticNextStep.ts +++ b/src/hooks/useOptimisticNextStep.ts @@ -30,6 +30,7 @@ function useOptimisticNextStep(reportID: string | undefined) { const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); @@ -57,6 +58,7 @@ function useOptimisticNextStep(reportID: string | undefined) { transactionViolations: allTransactionViolations, currentUserEmail: email ?? '', currentUserAccountID: accountID, + rules, }); if (isDEWPolicy && (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN || moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) { diff --git a/src/hooks/useOptimisticPersonalDetails.ts b/src/hooks/useOptimisticPersonalDetails.ts new file mode 100644 index 000000000000..967cc04d23b5 --- /dev/null +++ b/src/hooks/useOptimisticPersonalDetails.ts @@ -0,0 +1,18 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import {optimisticPersonalDetailsSelector} from '@selectors/PersonalDetails'; + +import useOnyx from './useOnyx'; + +/** + * Subscribes to the personal details that were created optimistically only. + * Use it instead of `usePersonalDetails` when the consumer only cares about optimistic accounts: + * the optimistic set is small, so the component re-renders only when an optimistic personal detail changes + * instead of on every change of the whole personal details list. + */ +function useOptimisticPersonalDetails() { + const [optimisticPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: optimisticPersonalDetailsSelector}); + return optimisticPersonalDetails; +} + +export default useOptimisticPersonalDetails; diff --git a/src/hooks/useOtherFeedsForFeedSelector.tsx b/src/hooks/useOtherFeedsForFeedSelector.tsx index e8406ccb8122..77296a08b224 100644 --- a/src/hooks/useOtherFeedsForFeedSelector.tsx +++ b/src/hooks/useOtherFeedsForFeedSelector.tsx @@ -29,9 +29,7 @@ type CardFeedListItem = ListItem & { value: CompanyCardFeedWithDomainID; /** Card feed value */ feed: CompanyCardFeedWithNumber; - /** Feed fund value */ fundID?: number; - /** Feed country value */ country?: string; }; diff --git a/src/hooks/useOutstandingReports.ts b/src/hooks/useOutstandingReports.ts index 67e9011aee53..6910c702fe8e 100644 --- a/src/hooks/useOutstandingReports.ts +++ b/src/hooks/useOutstandingReports.ts @@ -20,6 +20,7 @@ export default function useOutstandingReports(selectedReportID: string | undefin const [selectedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`); const shouldUseAllPolicies = !selectedPolicyID || selectedPolicyID === personalPolicyID || isSelfDM(selectedReport); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Early return if no reports are available to prevent useless loop if (!outstandingReportsByPolicyID || isEmptyObject(outstandingReportsByPolicyID)) { @@ -34,7 +35,7 @@ export default function useOutstandingReports(selectedReportID: string | undefin continue; } - const reports = getOutstandingReportsForUser(policyID, ownerAccountID, reportNameValuePairs, outstandingReportsByPolicyID[policyID] ?? {}, isEditing); + const reports = getOutstandingReportsForUser(policyID, ownerAccountID, rules, reportNameValuePairs, outstandingReportsByPolicyID[policyID] ?? {}, isEditing); result.push(...reports); } return result; @@ -44,5 +45,12 @@ export default function useOutstandingReports(selectedReportID: string | undefin return []; } - return getOutstandingReportsForUser(selectedPolicyID, ownerAccountID, reportNameValuePairs, outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, isEditing); + return getOutstandingReportsForUser( + selectedPolicyID, + ownerAccountID, + rules, + reportNameValuePairs, + outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, + isEditing, + ); } diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index fada87794eb8..32fdf00a1bac 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -260,32 +260,44 @@ function useParticipantSubmission({ setMoneyRequestParticipants(initialTransactionID, val); } - if (!isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) { - // If not moving the transaction from track expense, select the default rate automatically. - // Otherwise, keep the original p2p rate and let the user manually change it to the one they want from the workspace. - if (drafts.length > 0) { - for (const transaction of drafts) { - const rateID = DistanceRequestUtils.getCustomUnitRateID({ - reportID: firstParticipantReportID, - isPolicyExpenseChat, - policy, - lastSelectedDistanceRates: distanceRates, - expenseDate: transaction.created, - }); - setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy, false, personalPolicy?.outputCurrency); + const isMovingToPolicyExpenseChat = isMovingTransactionFromTrackExpense && isPolicyExpenseChat; + const destinationRates = isMovingToPolicyExpenseChat ? DistanceRequestUtils.getMileageRates(policy) : undefined; + const shouldKeepTrackExpenseRate = (transaction: OnyxEntry) => { + if (!isDistanceRequest(transaction)) { + return true; + } + const currentRateID = transaction?.comment?.customUnit?.customUnitRateID; + return currentRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID || (!!currentRateID && !!destinationRates?.[currentRateID]); + }; + + if (drafts.length > 0) { + for (const transaction of drafts) { + if (isMovingToPolicyExpenseChat && shouldKeepTrackExpenseRate(transaction)) { + continue; } - } else { - // Fallback to using initialTransactionID directly const rateID = DistanceRequestUtils.getCustomUnitRateID({ reportID: firstParticipantReportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates: distanceRates, + expenseDate: transaction.created, }); - // personalPolicyOutputCurrency is intentionally omitted: setCustomUnitRateID only resolves a (P2P) rate when a transaction is passed, - // and no transaction is passed here, so the currency is never read. - setCustomUnitRateID(initialTransactionID, rateID, undefined, policy, false, undefined); + if (isMovingToPolicyExpenseChat && rateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) { + continue; + } + setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy, false, personalPolicy?.outputCurrency); } + } else if (!isMovingToPolicyExpenseChat) { + // Fallback to using initialTransactionID directly + const rateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: firstParticipantReportID, + isPolicyExpenseChat, + policy, + lastSelectedDistanceRates: distanceRates, + }); + // personalPolicyOutputCurrency is intentionally omitted: setCustomUnitRateID only resolves a (P2P) rate when a transaction is passed, + // and no transaction is passed here, so the currency is never read. + setCustomUnitRateID(initialTransactionID, rateID, undefined, policy, false, undefined); } // When multiple valid participants are selected, the reportID is generated at the end of the confirmation step. @@ -372,10 +384,10 @@ function useParticipantSubmission({ if ((isCategorizing || isShareAction) && numberOfParticipants.current === 0) { const email = userDetails.email ?? ''; - const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, email); + const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, email, userDetails.displayName); const {expenseChatReportID, policyID, policyName} = createDraftWorkspace({ introSelected: intro, - workspaceName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), + workspaceName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate, userDetails.displayName), currentUserAccountID: userDetails.accountID, currentUserEmail: email, currency: userDetails.localCurrencyCode ?? CONST.CURRENCY.USD, diff --git a/src/hooks/usePayAndDowngrade.ts b/src/hooks/usePayAndDowngrade.ts index 12900c876f78..b2e121df6f11 100644 --- a/src/hooks/usePayAndDowngrade.ts +++ b/src/hooks/usePayAndDowngrade.ts @@ -1,5 +1,4 @@ import {close} from '@libs/actions/Modal'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -8,9 +7,11 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; import useOnyx from './useOnyx'; +import useScreenBoundDynamicRoute from './useScreenBoundDynamicRoute'; function usePayAndDowngrade(continueAction: () => void) { const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const [shouldBillWhenDowngrading] = useOnyx(ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING); const isDeletingPaidWorkspaceRef = useRef(false); @@ -35,11 +36,11 @@ function usePayAndDowngrade(continueAction: () => void) { if (!shouldBillWhenDowngrading) { close(continueActionRef.current); } else { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.path)); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.path)); } isDeletingPaidWorkspaceRef.current = false; - }, [isLoadingBill, shouldBillWhenDowngrading]); + }, [isLoadingBill, shouldBillWhenDowngrading, buildDynamicRoute]); return {setIsDeletingPaidWorkspace, isLoadingBill}; } diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index abf1ad7bcfc6..6c27c4d93fc1 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,4 +1,4 @@ -import {BetaConfigurationContext, BetasContext} from '@components/OnyxListItemProvider'; +import {BetaConfigurationContext, BetasContext, BetaOverridesContext} from '@components/OnyxListItemProvider'; import Permissions from '@libs/Permissions'; @@ -13,9 +13,10 @@ let permissionKey: PermissionKey; export default function usePermissions(): UsePermissions { const betas = useContext(BetasContext); const betaConfiguration = useContext(BetaConfigurationContext); + const betaOverrides = useContext(BetaOverridesContext); return useMemo(() => { const permissions: UsePermissions = { - isBetaEnabled: (beta: Beta) => Permissions.isBetaEnabled(beta, betas, betaConfiguration), + isBetaEnabled: (beta: Beta) => Permissions.isBetaEnabled(beta, betas, betaConfiguration, betaOverrides), }; for (permissionKey in Permissions) { @@ -26,5 +27,5 @@ export default function usePermissions(): UsePermissions { } return permissions; - }, [betas, betaConfiguration]); + }, [betas, betaConfiguration, betaOverrides]); } diff --git a/src/hooks/usePersonalDetailOptions.ts b/src/hooks/usePersonalDetailOptions.ts index 232feb4ad940..aefd8f4c04bb 100644 --- a/src/hooks/usePersonalDetailOptions.ts +++ b/src/hooks/usePersonalDetailOptions.ts @@ -1,11 +1,14 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import memoize, {equivalentArgsComparator} from '@libs/memoize'; import {createOptionList, filterPersonalDetailsByLogins} from '@libs/PersonalDetailOptionsListUtils'; import type {OptionData, PrivateIsArchivedMap} from '@libs/PersonalDetailOptionsListUtils/types'; import {isOneOnOneChat, isSelfDM} from '@libs/ReportUtils'; +import {registerSessionCleanupCallback} from '@libs/SessionCleanup'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAttributesDerivedValue, ReportNameValuePairs} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, ReportAttributesDerivedValue, ReportNameValuePairs} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -102,6 +105,60 @@ const createReportAttributesSelector = return map; }; +/** + * Building an option per personal details entry is the expensive step of this hook and depends only on Onyx values. + * Arguments are compared one level deep because the maps are rebuilt each render from those same unchanged values. + * A single entry is held, so a consumer narrowing the list with `includeLoginsOnly` evicts the whole-list one. + */ +const memoizedCreateOptionList = memoize(createOptionList, { + maxSize: 1, + equality: equivalentArgsComparator, + monitoringName: 'usePersonalDetailOptions.createOptionList', +}); + +/** Releases the cached options. */ +function clearPersonalDetailOptionsCache() { + memoizedCreateOptionList.cache.clear(); +} + +// The cached options describe the signed-in account's contacts. +registerSessionCleanupCallback(clearPersonalDetailOptionsCache); + +type BuildOptionsDataParams = { + accountID: number; + allPersonalDetails: OnyxEntry; + includeLoginsOnly: Set | undefined; + reports: OnyxCollection; + reportAttributes: FilteredReportAttributes | undefined; + reportNameValuePairs: PrivateIsArchivedMap | undefined; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + translate: LocaleContextProps['translate']; + shouldStoreReportErrors: boolean; + shouldShowBrickRoadIndicator: boolean; +}; + +/** Turns the Onyx values into the options list. */ +function buildOptionsData({ + accountID, + allPersonalDetails, + includeLoginsOnly, + reports, + reportAttributes, + reportNameValuePairs, + formatPhoneNumber, + translate, + shouldStoreReportErrors, + shouldShowBrickRoadIndicator, +}: BuildOptionsDataParams) { + const personalDetails = includeLoginsOnly ? filterPersonalDetailsByLogins(allPersonalDetails, includeLoginsOnly) : allPersonalDetails; + const accountIDToReportIDMap = generateAccountIDToReportIDMap(reports, accountID); + + return memoizedCreateOptionList(accountID, personalDetails, accountIDToReportIDMap, reports, reportAttributes, reportNameValuePairs ?? {}, formatPhoneNumber, translate, { + shouldStoreReportErrors, + shouldShowBrickRoadIndicator, + }); +} + /** * Hook that provides options list for personal details. * @@ -132,18 +189,25 @@ function usePersonalDetailOptions(config: UseFilteredOptionsConfig = {}): UseFil const [reportAttributes, reportAttributesMetadata] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportAttributesSelector}); const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {selector: privateIsArchivedSelector}); const allPersonalDetails = usePersonalDetails(); - const personalDetails = includeLoginsOnly ? filterPersonalDetailsByLogins(allPersonalDetails, includeLoginsOnly) : allPersonalDetails; const isLoading = !enabled || isLoadingOnyxValue(reportsMetadata, reportAttributesMetadata, reportNameValuePairsMetadata); - const accountIDToReportIDMap = generateAccountIDToReportIDMap(reports, accountID); - - const optionsData = !isLoading - ? createOptionList(accountID, personalDetails, accountIDToReportIDMap, reports, reportAttributes, reportNameValuePairs ?? {}, formatPhoneNumber, translate, { + // The whole derivation chain is skipped while loading (or disabled), so a consumer that only holds the Onyx + // subscriptions doesn't rebuild these maps on every collection update. + const optionsData = isLoading + ? undefined + : buildOptionsData({ + accountID, + allPersonalDetails, + includeLoginsOnly, + reports, + reportAttributes, + reportNameValuePairs, + formatPhoneNumber, + translate, shouldStoreReportErrors, shouldShowBrickRoadIndicator, - }) - : undefined; + }); return { options: optionsData?.options, @@ -153,3 +217,4 @@ function usePersonalDetailOptions(config: UseFilteredOptionsConfig = {}): UseFil } export default usePersonalDetailOptions; +export {clearPersonalDetailOptionsCache}; diff --git a/src/hooks/usePersonalDetailSearchSelector/base.ts b/src/hooks/usePersonalDetailSearchSelector/base.ts index 656469e7a086..436eb9c41d90 100644 --- a/src/hooks/usePersonalDetailSearchSelector/base.ts +++ b/src/hooks/usePersonalDetailSearchSelector/base.ts @@ -4,8 +4,10 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalDetailOptions from '@hooks/usePersonalDetailOptions'; +import memoize, {equivalentArgsComparator} from '@libs/memoize'; import {filterOption, getValidOptions} from '@libs/PersonalDetailOptionsListUtils'; import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import {registerSessionCleanupCallback} from '@libs/SessionCleanup'; import {expensifyLoginsSelector} from '@libs/UserUtils'; import CONST from '@src/CONST'; @@ -39,13 +41,8 @@ type UseSearchSelectorConfig = { /** When set, only the logins in this set are turned into options */ includeLoginsOnly?: Set; - /** Whether to include recent reports */ includeRecentReports?: boolean; - - /** Whether to include current user */ includeCurrentUser?: boolean; - - /** Whether to include domain emails */ includeDomainEmail?: boolean; /** Enable phone contacts integration */ @@ -60,10 +57,7 @@ type UseSearchSelectorConfig = { /** Initial selected options */ initialSelected?: Set; - /** Initial extra options */ initialExtraOptions?: OptionData[]; - - /** Whether to initialize the hook */ shouldInitialize?: boolean; /** Additional contact options to merge (used by platform-specific implementations) */ @@ -72,7 +66,6 @@ type UseSearchSelectorConfig = { /** Whether to filter with recent attendees */ recentAttendees?: string[]; - /** Whether to allow name-only options */ shouldAllowNameOnlyOptions?: boolean; /** Whether to keep selected options in availableOptions instead of filtering them out */ @@ -81,7 +74,6 @@ type UseSearchSelectorConfig = { /** Whether to update selected options when in single select mode and a new option is selected */ shouldUpdateSelectedOptionsOnSingleSelect?: boolean; - /** Initial Search Phrase */ initialSearchPhrase?: string; }; @@ -89,13 +81,11 @@ type ContactState = { /** Current permission status */ permissionStatus: PermissionStatus; - /** Whether to show import UI */ showImportUI: boolean; /** Function to trigger contact import */ importContacts: () => void; - /** Function to set permission state */ setContactPermissionState: (status: PermissionStatus) => void; }; @@ -112,13 +102,11 @@ type UseSearchSelectorReturn = { /** Current search term */ searchTerm: string; - /** Debounced search term */ debouncedSearchTerm: string; /** Function to update search term */ setSearchTerm: (value: string) => void; - /** Currently selected options */ selectedOptions: OptionData[]; /** Available (unselected) options */ @@ -133,7 +121,6 @@ type UseSearchSelectorReturn = { /** Function to reset selection state of an option */ resetSelection: () => void; - /** Whether options are initialized */ areOptionsInitialized: boolean; /** Contact-related state and functions (when enablePhoneContacts is true) */ @@ -150,6 +137,43 @@ const defaultListOptions = { selectedOptions: [], }; +/** + * How many option lists the caches below hold. Consumers mount one selector at a time, except the Search filters + * popover, which keeps three people filters alive next to each other (from, to and attendee, on expense searches). + * An eviction in the first cache makes the second one miss, because its result is an identity-compared argument of + * `getValidOptions`. + */ +const MAX_CACHED_OPTION_LISTS = 3; + +/** Filtering the whole option list is a pure derivation of its inputs, so remounting consumers reuse the result. */ +const memoizedGetValidOptions = memoize(getValidOptions, { + maxSize: MAX_CACHED_OPTION_LISTS, + equality: equivalentArgsComparator, + monitoringName: 'usePersonalDetailSearchSelector.getValidOptions', +}); + +/** Marks the options matching the selected accountIDs, so the copy of the list is reused while the inputs are unchanged. */ +const buildSelectedOptions = (options: OptionData[], selectedAccountIDs: Set) => + options.map((option) => ({ + ...option, + isSelected: selectedAccountIDs.has(option.accountID.toString()), + })); + +const memoizedBuildSelectedOptions = memoize(buildSelectedOptions, { + maxSize: MAX_CACHED_OPTION_LISTS, + equality: equivalentArgsComparator, + monitoringName: 'usePersonalDetailSearchSelector.buildSelectedOptions', +}); + +/** Releases the cached lists. */ +function clearPersonalDetailSearchSelectorCaches() { + memoizedGetValidOptions.cache.clear(); + memoizedBuildSelectedOptions.cache.clear(); +} + +// Both caches hold option lists built for the signed-in account. +registerSessionCleanupCallback(clearPersonalDetailSearchSelectorCaches); + /** * Base hook that provides search functionality with selection logic for option lists. * This contains the core logic without platform-specific dependencies. @@ -197,11 +221,18 @@ function usePersonalDetailSearchSelectorBase({ return (defaultOptions ?? []).concat(allowedContactOptions); })(); const areOptionsInitialized = !isPersonalDetailsOptionsLoading; - const transformedOptions: OptionData[] = - optionsWithContacts?.map((option) => ({ - ...option, - isSelected: selectedAccountIDs.has(option.accountID.toString()), - })) ?? []; + + // With nothing selected the options already carry the right state, so the list is passed through instead of a copy + // of every option being built and then held in the cache. + const transformedOptions: OptionData[] = (() => { + if (!optionsWithContacts) { + return []; + } + if (selectedAccountIDs.size === 0) { + return optionsWithContacts; + } + return memoizedBuildSelectedOptions(optionsWithContacts, selectedAccountIDs); + })(); const selectedOptions = (() => { const options: OptionData[] = []; @@ -218,15 +249,19 @@ function usePersonalDetailSearchSelectorBase({ return options; })(); + // Trim before matching, otherwise a leading/trailing space makes Str.isValidEmail fail in getUserToInviteOption + // and the "invite user" option disappears for logins that don't have an account yet. + const trimmedSearchTerm = debouncedSearchTerm.trim(); + const optionsList = !areOptionsInitialized ? defaultListOptions - : getValidOptions(transformedOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, { + : memoizedGetValidOptions(transformedOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, { excludeLogins, excludeFromSuggestionsOnly, includeSelectedOptions: shouldKeepSelectedInAvailableOptions, includeRecentReports, recentAttendees, - searchString: debouncedSearchTerm, + searchString: trimmedSearchTerm, maxElements, recentMaxElements: maxRecentReportsToShow, includeUserToInvite, @@ -335,4 +370,5 @@ function usePersonalDetailSearchSelectorBase({ } export default usePersonalDetailSearchSelectorBase; +export {clearPersonalDetailSearchSelectorCaches}; export type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn}; diff --git a/src/hooks/usePointerMovement.ts b/src/hooks/usePointerMovement.ts new file mode 100644 index 000000000000..ef0e5f8bef14 --- /dev/null +++ b/src/hooks/usePointerMovement.ts @@ -0,0 +1,45 @@ +import {useRef} from 'react'; + +type PointerPosition = { + x: number; + y: number; +}; + +type PointerMovement = { + trackMovement: (event: {clientX: number; clientY: number}) => void; + + /** Forgets the tracked position and hands back the one the pointer was last seen moving at. */ + stopTracking: () => PointerPosition | null; +}; + +/** + * Tracks where a pointer moves, ignoring travel shorter than `restRadius`, so a hand shaking on the spot counts as + * still. The first move after mount and after `stopTracking` has nothing to measure against, so it always counts. + * + * `onMove` has to be a stable reference: it keys the memoization of whatever handler the caller builds from + * `trackMovement`. + */ +function usePointerMovement(restRadius: number, onMove: () => void): PointerMovement { + const positionRef = useRef(null); + + return { + trackMovement: (event: {clientX: number; clientY: number}) => { + const position = positionRef.current; + + if (position && Math.hypot(event.clientX - position.x, event.clientY - position.y) < restRadius) { + return; + } + + positionRef.current = {x: event.clientX, y: event.clientY}; + onMove(); + }, + stopTracking: () => { + const position = positionRef.current; + positionRef.current = null; + + return position; + }, + }; +} + +export default usePointerMovement; diff --git a/src/hooks/usePolicyCategoryPickerCategories.ts b/src/hooks/usePolicyCategoryPickerCategories.ts new file mode 100644 index 000000000000..b319f6bc1fef --- /dev/null +++ b/src/hooks/usePolicyCategoryPickerCategories.ts @@ -0,0 +1,62 @@ +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyCategory} from '@src/types/onyx'; + +import {useFocusEffect} from '@react-navigation/native'; + +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePolicy from './usePolicy'; + +type UsePolicyCategoryPickerCategoriesParams = { + policyID: string; + + /** + * Whether a category can be offered, beyond being enabled and not pending delete. Keep the current selection + * eligible, or it drops off the list and can't be deselected. + */ + isEligible?: (category: PolicyCategory) => boolean; +}; + +/** The shared half of every rule category picker: fetch, loading state, and the categories it can offer. Items stay + * per-page, since single and multi select need different shapes. */ +function usePolicyCategoryPickerCategories({policyID, isEligible}: UsePolicyCategoryPickerCategoriesParams) { + const policy = usePolicy(policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const areCategoriesEnabled = !!policy?.areCategoriesEnabled; + + const fetchPolicyCategories = () => { + if (!areCategoriesEnabled || policyCategories !== undefined) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + }); + + // Offline there is nothing to wait for, so show the list rather than a spinner that never resolves. + const isLoading = areCategoriesEnabled && policyCategories === undefined && !isOffline; + + const categories = Object.values(policyCategories ?? {}).filter((category) => { + if (!category.enabled) { + return false; + } + + // Match the rules table: keep pending-delete categories visible while offline. + if (!isOffline && category.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + + return isEligible ? isEligible(category) : true; + }); + + return {categories, policyCategories, areCategoriesEnabled, isLoading, isOffline}; +} + +export default usePolicyCategoryPickerCategories; diff --git a/src/hooks/usePolicyForTransaction.ts b/src/hooks/usePolicyForTransaction.ts index 6942903f1e1f..335ae1790ab1 100644 --- a/src/hooks/usePolicyForTransaction.ts +++ b/src/hooks/usePolicyForTransaction.ts @@ -31,7 +31,6 @@ type UsePolicyForTransactionParams = { }; type UsePolicyForTransactionResult = { - /** The policy to use for the transaction */ policy: OnyxEntry; }; diff --git a/src/hooks/usePolicyIndicatorChecks.ts b/src/hooks/usePolicyIndicatorChecks.ts index acae90d2bd55..60354921ecee 100644 --- a/src/hooks/usePolicyIndicatorChecks.ts +++ b/src/hooks/usePolicyIndicatorChecks.ts @@ -1,6 +1,6 @@ import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {hasDomainErrors} from '@libs/DomainUtils'; +import {hasDomainErrors, hasPendingDomainAdminRequestsToReview} from '@libs/DomainUtils'; import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import { getUberConnectionErrorDirectlyFromPolicy, @@ -16,18 +16,20 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type IndicatorStatus from '@src/types/utils/IndicatorStatus'; +import {accountIDSelector} from '@selectors/Session'; + import useOnyx from './useOnyx'; import usePoliciesWithCardFeedErrors from './usePoliciesWithCardFeedErrors'; type PolicyIndicatorChecksResult = { - /** The policy error indicator status. */ policyErrorStatus: IndicatorStatus | undefined; - - /** The policy info indicator status. */ policyInfoStatus: IndicatorStatus | undefined; /** The domain error indicator status. */ - domainStatus: IndicatorStatus | undefined; + domainErrorStatus: IndicatorStatus | undefined; + + /** The domain info indicator status. */ + domainInfoStatus: IndicatorStatus | undefined; /** The policy ID associated with the active policy error or info indicator. */ indicatorPolicyID: string | undefined; @@ -36,6 +38,13 @@ type PolicyIndicatorChecksResult = { function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); const [allDomainErrors] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_ERRORS); + const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); + const [allDomainPendingActions] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS); + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + + const hasPendingDomainAdminRequests = Object.entries(allDomains ?? {}).some(([key, domain]) => + hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, allDomainPendingActions?.[key.replace(ONYXKEYS.COLLECTION.DOMAIN, ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS)]), + ); const {cleanPolicies, policiesWithCardFeedErrors, isPolicyAdmin: isAdminOfPolicyWithCardFeedErrors} = usePoliciesWithCardFeedErrors(); @@ -71,23 +80,32 @@ function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { [CONST.INDICATOR_STATUS.HAS_MERGE_HR_SETUP_NEEDED, cleanPolicies.find((policy) => isPolicyAdmin(policy) && isMergeHRCompleteSetupNeeded(policy))], ]; const domainChecks: Array<[IndicatorStatus, boolean]> = [ - [CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS, Object.values(allDomainErrors ?? {}).some((domainErrors) => hasDomainErrors(domainErrors))], + [ + CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS, + Object.entries(allDomainErrors ?? {}).some(([key, domainErrors]) => + hasDomainErrors(domainErrors, allDomains?.[key.replace(ONYXKEYS.COLLECTION.DOMAIN_ERRORS, ONYXKEYS.COLLECTION.DOMAIN)]), + ), + ], ]; + const domainInfoChecks: Array<[IndicatorStatus, boolean]> = [[CONST.INDICATOR_STATUS.HAS_PENDING_DOMAIN_ADMIN_REQUESTS, hasPendingDomainAdminRequests]]; const activePolicyErrorCheck = policyErrorChecks.find(([, value]) => value); const activePolicyInfoCheck = policyInfoChecks.find(([, value]) => value); const activeDomainCheck = domainChecks.find(([, value]) => value); + const activeDomainInfoCheck = domainInfoChecks.find(([, value]) => value); const [policyErrorStatus] = activePolicyErrorCheck ?? []; const [policyInfoStatus] = activePolicyInfoCheck ?? []; - const [domainStatus] = activeDomainCheck ?? []; + const [domainErrorStatus] = activeDomainCheck ?? []; + const [domainInfoStatus] = activeDomainInfoCheck ?? []; const indicatorPolicyID = activePolicyErrorCheck?.[1]?.id ?? activePolicyInfoCheck?.[1]?.id; return { policyErrorStatus, policyInfoStatus, - domainStatus, + domainErrorStatus, + domainInfoStatus, indicatorPolicyID, }; } diff --git a/src/hooks/usePreMountDestination/index.ts b/src/hooks/usePreMountDestination/index.ts index 0a261eac3217..07377b2bb77d 100644 --- a/src/hooks/usePreMountDestination/index.ts +++ b/src/hooks/usePreMountDestination/index.ts @@ -82,7 +82,7 @@ function usePreMountDestination(route: Route | undefined, options?: UsePreMountD // set it is stale (e.g. preserved across a route change) and would mis-drive getSubmitHandler on the next submit. This // should not happen under the single-pre-inserter invariant, so surface it loudly rather than leaving it silent. if (Navigation.getIsFullscreenPreInsertedUnderRHP()) { - Log.warn('[usePreMountDestination] reveal() reached the non-owned path while a pre-inserted fullscreen flag is still set'); + Log.alert('[usePreMountDestination] reveal() reached the non-owned path while a pre-inserted fullscreen flag is still set'); } if (!route) { diff --git a/src/hooks/usePreferredPolicy.ts b/src/hooks/usePreferredPolicy.ts index c6039c77dc72..f748b97b1324 100644 --- a/src/hooks/usePreferredPolicy.ts +++ b/src/hooks/usePreferredPolicy.ts @@ -4,7 +4,6 @@ type UsePreferredPolicyResult = { /** Whether the user is restricted to use only the preferred policy */ isRestrictedToPreferredPolicy: boolean; - /** The ID of the preferred policy */ preferredPolicyID: string | undefined; /** Whether the user is restricted from creating policies */ diff --git a/src/hooks/usePrimaryContactMethod.ts b/src/hooks/usePrimaryContactMethod.ts index e74503410a6b..47135eb7b7be 100644 --- a/src/hooks/usePrimaryContactMethod.ts +++ b/src/hooks/usePrimaryContactMethod.ts @@ -7,7 +7,9 @@ import useOnyx from './useOnyx'; function usePrimaryContactMethod(): string { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - return account?.primaryLogin ?? sessionEmail ?? ''; + // 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 || ''; } export default usePrimaryContactMethod; diff --git a/src/hooks/useReceiptScanDrop.tsx b/src/hooks/useReceiptScanDrop.tsx index 91f13db7bf9b..567da1466670 100644 --- a/src/hooks/useReceiptScanDrop.tsx +++ b/src/hooks/useReceiptScanDrop.tsx @@ -14,7 +14,6 @@ import {buildOptimisticTransactionAndCreateDraft} from '@userActions/Transaction import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Transaction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; @@ -26,6 +25,10 @@ import useIsAnonymousUser from './useIsAnonymousUser'; import useOnyx from './useOnyx'; import useSelfDMReport from './useSelfDMReport'; +function areBlobBackedFiles(files: FileObject[]): files is Array { + return files.every((file) => file instanceof Blob); +} + /** * Encapsulates the receipt scan drag-and-drop logic used by SearchPage and HomePage. * Returns the drop handler and sibling-safe auxiliary UI needed for receipt scanning. @@ -51,6 +54,10 @@ function useReceiptScanDrop() { const newReportID = useMemo(() => generateReportID(), []); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { + if (!areBlobBackedFiles(files)) { + return; + } + const initialTransaction = initMoneyRequest({ isFromGlobalCreate: true, isFromFloatingActionButton: true, @@ -64,15 +71,19 @@ function useReceiptScanDrop() { draftTransactionIDs, }); + if (!initialTransaction) { + return; + } + const newReceiptFiles: ReceiptFile[] = []; for (const [index, file] of files.entries()) { - const source = URL.createObjectURL(file as Blob); + const source = URL.createObjectURL(file); const transaction = index === 0 - ? (initialTransaction as Partial) + ? initialTransaction : buildOptimisticTransactionAndCreateDraft({ - initialTransaction: initialTransaction as Partial, + initialTransaction: {...initialTransaction, category: initialTransaction.category ?? undefined}, reportID: newReportID, }); const transactionID = transaction.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; diff --git a/src/hooks/useReleaseOptionListCaches.ts b/src/hooks/useReleaseOptionListCaches.ts new file mode 100644 index 000000000000..7aee9e61372a --- /dev/null +++ b/src/hooks/useReleaseOptionListCaches.ts @@ -0,0 +1,35 @@ +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import subscribeToRootNavigation from '@libs/Navigation/helpers/subscribeToRootNavigation'; + +import {useEffect} from 'react'; + +import {clearPersonalDetailOptionsCache} from './usePersonalDetailOptions'; +import {clearPersonalDetailSearchSelectorCaches} from './usePersonalDetailSearchSelector/base'; + +/** + * Releases the option lists cached for the people selectors once another tab takes over from Search. The lists are + * derived from Onyx and built again on demand, so holding them beyond that only pins memory - tens of megabytes on an + * account with a large contact list. + */ +function useReleaseOptionListCaches() { + useEffect(() => { + // Search stays mounted while another tab is on top of it, so leaving it shows up as a navigation state change + // rather than as this hook being cleaned up. A right hand pane opening over Search keeps it the topmost tab. + let wasSearchTopmostRoute = isSearchTopmostFullScreenRoute(); + + return subscribeToRootNavigation(() => { + const isSearchTopmostRoute = isSearchTopmostFullScreenRoute(); + const hasLeftSearch = wasSearchTopmostRoute && !isSearchTopmostRoute; + wasSearchTopmostRoute = isSearchTopmostRoute; + + if (!hasLeftSearch) { + return; + } + + clearPersonalDetailOptionsCache(); + clearPersonalDetailSearchSelectorCaches(); + }); + }, []); +} + +export default useReleaseOptionListCaches; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index c907cb1865c7..b899c31b1312 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -50,7 +50,6 @@ type UseReportActionsScrollParams = { /** The transaction thread report associated with the current report, if any */ transactionThreadReport: OnyxEntry; - /** The report's parentReportAction */ parentReportAction: OnyxEntry; /** Sorted actions that should be visible to the user */ @@ -111,7 +110,6 @@ type UseReportActionsScrollResult = { /** Scrolls to the newest action and marks the report as read */ scrollToBottomAndMarkReportAsRead: () => void; - /** Scrolls to the action badge target */ scrollToActionBadgeTarget: () => void; /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ @@ -239,6 +237,8 @@ function useReportActionsScroll({ reportID, introSelected, betas, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, isOffline, reportScrollManager, setIsFloatingMessageCounterVisible, diff --git a/src/hooks/useReportPrimaryAction.ts b/src/hooks/useReportPrimaryAction.ts index 54de653727e7..948e3e6c86b9 100644 --- a/src/hooks/useReportPrimaryAction.ts +++ b/src/hooks/useReportPrimaryAction.ts @@ -34,6 +34,7 @@ function useReportPrimaryAction(reportID: string | undefined): ValueOf { setIsDismissGuardActive(false); diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index 7bcc887792e1..961a50ba8900 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -53,10 +53,6 @@ type UseResetIOUTypeParams = { /** Whether to skip keyboard dismiss for per diem tab */ skipKeyboardDismissForPerDiem?: boolean; - - /** Whether the new manual expense flow beta is enabled. When true, the fresh transaction is seeded with - * participants from the current report so the embedded confirmation's auto-assign useEffect short-circuits. */ - isNewManualExpenseFlowEnabled?: boolean; }; /** @@ -74,7 +70,6 @@ function useResetIOUType({ policy, isTrackDistanceExpense = false, skipKeyboardDismissForPerDiem = false, - isNewManualExpenseFlowEnabled = false, }: UseResetIOUTypeParams): (newIOUType: IOURequestType) => void { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [hasOnlyPersonalPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesSelector}); @@ -91,14 +86,13 @@ function useResetIOUType({ isLoadingSelectedTab, }); - // For the new manual flow, derive participants from the current report (or the global-create fallback) so the - // freshly-rebuilt transaction already includes them. This prevents the embedded confirmation's auto-assign - // useEffect from re-firing on every cleanup and dragging back unrelated draft state (receipt, billable, etc.). + // Derive participants from the current report (or the global-create fallback) so the freshly-rebuilt transaction + // already includes them. This prevents the embedded confirmation's auto-assign useEffect from re-firing on every + // cleanup and dragging back unrelated draft state (receipt, billable, etc.). const {participants: resolvedDefaultParticipants} = useDefaultParticipants({ sourceReport: report, transaction, iouType, - isNewManualExpenseFlowEnabled, }); const defaultParticipants = resolvedDefaultParticipants.length > 0 ? resolvedDefaultParticipants : undefined; diff --git a/src/hooks/useResponsiveLayout/index.ts b/src/hooks/useResponsiveLayout/index.ts index ba8e6a046244..0486cd295b94 100644 --- a/src/hooks/useResponsiveLayout/index.ts +++ b/src/hooks/useResponsiveLayout/index.ts @@ -2,6 +2,8 @@ import ModalContext from '@components/Modal/ModalContext'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; + import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -31,12 +33,14 @@ import type ResponsiveLayoutResult from './types'; export default function useResponsiveLayout(): ResponsiveLayoutResult { const {windowWidth, windowHeight} = useWindowDimensions(); + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + // When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight. const screenHeight = Dimensions.get('screen').height; const isExtraSmallScreenHeight = screenHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint; - const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint; - const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint; - const onboardingIsMediumOrLargerScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint; + const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint || isInLandscapeMode; + const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint && !isInLandscapeMode; + const onboardingIsMediumOrLargerScreenWidth = !isInLandscapeMode && windowWidth > variables.mobileResponsiveWidthBreakpoint; const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint; const isExtraLargeScreenWidth = windowWidth > variables.sidePanelResponsiveWidthBreakpoint; const isExtraSmallScreenWidth = windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint; @@ -79,6 +83,6 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { isLargeScreenWidth, isExtraLargeScreenWidth, isSmallScreen, - isInLandscapeMode: false, + isInLandscapeMode, }; } diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts new file mode 100644 index 000000000000..aa8d7454c841 --- /dev/null +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -0,0 +1,52 @@ +import {retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleSuggestion} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useEffect, useRef} from 'react'; + +import useOnyx from './useOnyx'; + +const selectLiveSeenInReportID = (suggestion: OnyxEntry) => (isMerchantRuleSuggestionLive(suggestion) ? suggestion?.seenInReportID : undefined); + +/** + * Ends the "Create a rule" offer once the user has seen it and left the report showing it. + * + * Owned by the report, not the callout: the callout unmounts whenever the layout crosses the narrow breakpoint or the + * composer expands, and retiring on those would silence an offer the user is still looking at. + * + * @param reportID - the report this list belongs to, matched against the one the callout recorded itself in + */ +function useRetireMerchantRuleSuggestionOnLeave(reportID: string | undefined) { + // Selected down to the one field this needs. The report actions list hosting this hook re-renders often, and + // subscribing to the whole record would wake it on every write the feature makes, in every report. + // + // Tracks the live value rather than latching, so an offer dismissed on the way out is not retired as well. + const [liveSeenInReportID] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {selector: selectLiveSeenInReportID}); + const hasBeenSeenRef = useRef(false); + + // Compared against this report rather than taken as a bare flag. Several report screens stay mounted at once, in a + // split pane or behind an RHP, and every one of them runs this hook. Without the match, any of them unmounting + // would retire an offer the user is still looking at somewhere else. + // + // The callout reports which report it rendered in, because the report cannot tell: an expense report holding a + // single expense renders the detail view under its own reportID, not the transaction thread's. + useEffect(() => { + hasBeenSeenRef.current = !!reportID && liveSeenInReportID === reportID; + }, [liveSeenInReportID, reportID]); + + useEffect( + () => () => { + if (!hasBeenSeenRef.current) { + return; + } + retireMerchantRuleSuggestion(); + }, + [], + ); +} + +export default useRetireMerchantRuleSuggestionOnLeave; diff --git a/src/hooks/useSafeAreaPaddings.ts b/src/hooks/useSafeAreaPaddings.ts index 151da9ae061a..3b64e03cd86b 100644 --- a/src/hooks/useSafeAreaPaddings.ts +++ b/src/hooks/useSafeAreaPaddings.ts @@ -35,7 +35,7 @@ import useStyleUtils from './useStyleUtils'; function useSafeAreaPaddings(isUsingEdgeToEdgeBottomSafeAreaPadding = false) { const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); - const {paddingTop, paddingBottom} = useMemo(() => StyleUtils.getPlatformSafeAreaPadding(insets), [StyleUtils, insets]); + const {paddingTop, paddingBottom, paddingLeft, paddingRight} = useMemo(() => StyleUtils.getPlatformSafeAreaPadding(insets), [StyleUtils, insets]); const screenWrapperStatusContext = useContext(ScreenWrapperStatusContext); const isSafeAreaTopPaddingApplied = screenWrapperStatusContext?.isSafeAreaTopPaddingApplied ?? false; @@ -66,9 +66,13 @@ function useSafeAreaPaddings(isUsingEdgeToEdgeBottomSafeAreaPadding = false) { return { paddingTop: isSafeAreaTopPaddingApplied ? 0 : paddingTop, paddingBottom: adaptedPaddingBottom, + paddingLeft, + paddingRight, unmodifiedPaddings: { top: paddingTop, bottom: paddingBottom, + left: paddingLeft, + right: paddingRight, }, insets: adaptedInsets, safeAreaPaddingBottomStyle, diff --git a/src/hooks/useScreenBoundDynamicRoute.ts b/src/hooks/useScreenBoundDynamicRoute.ts new file mode 100644 index 000000000000..c2e8bd0f989e --- /dev/null +++ b/src/hooks/useScreenBoundDynamicRoute.ts @@ -0,0 +1,36 @@ +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; + +import type {Route} from '@src/ROUTES'; + +import {NavigationRouteContext, useFocusEffect} from '@react-navigation/native'; +import {useContext, useState} from 'react'; + +/** + * Builds dynamic routes with `basePath` bound to the mounted route of the component using `useFocusEffect`, + * so a link created during render remains tied to that screen's route even after another screen is stacked over it. + * + * A screen that mounts with another one already stacked over it never focuses, so the path the screen was matched + * from seeds the base until focus can supply it. Read off the context rather than `useRoute`, which throws when the + * component renders outside a screen. + */ +function useScreenBoundDynamicRoute(): (dynamicRouteSuffixWithParams: string) => Route { + const route = useContext(NavigationRouteContext); + const [focusedBasePath, setFocusedBasePath] = useState(); + useFocusEffect(() => { + // On a cold start the focus effect can run before the navigation container is ready, when getActiveRoute + // still returns an empty string. Latching it would shadow the route path seed until the next blur and focus. + const activeRoute = Navigation.getActiveRoute(); + if (!activeRoute) { + return; + } + setFocusedBasePath(activeRoute); + }); + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const basePath = focusedBasePath || route?.path; + + return (dynamicRouteSuffixWithParams: string) => createDynamicRoute(dynamicRouteSuffixWithParams, basePath); +} + +export default useScreenBoundDynamicRoute; diff --git a/src/hooks/useScrollToEndOnNewMessageReceived.ts b/src/hooks/useScrollToEndOnNewMessageReceived.ts index 6ce82241fe73..3cfd6711b8db 100644 --- a/src/hooks/useScrollToEndOnNewMessageReceived.ts +++ b/src/hooks/useScrollToEndOnNewMessageReceived.ts @@ -7,19 +7,15 @@ import {useEffect, useLayoutEffect, useRef} from 'react'; import usePrevious from './usePrevious'; type UseScrollToEndOnPaginationMergeParams = { - /** The ref to the scroll offset. */ scrollOffsetRef: React.RefObject; /** The ID of the last visible report action. */ lastActionID?: string; /** The length of the visible report actions. */ visibleActionsLength: number; - /** The length of the report actions. */ reportActionsLength?: number; /** Whether the newest report action is the last visible report action. */ hasNewestReportAction: boolean; - /** The function to set the floating message counter visible. */ setIsFloatingMessageCounterVisible: (isVisible: boolean) => void; - /** The function to scroll to the end. */ scrollToEnd: () => void; /** * Inbox uses `previousLength !== currentLength` to detect pagination merges. diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index bf8c95b86a7e..1189c691aaf1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -5,7 +5,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; -import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import {getAccountingIntegrationDisplayName, getExportLabelForConnection} from '@libs/AccountingUtils'; import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards'; @@ -24,6 +24,7 @@ import { getPayMoneyOnSearchInvoiceParams, getPayOption, getPolicyFromSearchSnapshot, + getReportActionsFromSearchSnapshot, getReportFromSearchSnapshot, getReportType, getChatReportWithFallback, @@ -31,6 +32,7 @@ import { getSearchPayOnyxData, getTotalFormattedAmount, isCurrencySupportWalletBulkPay, + queueBulkPayReports, queueExportSearchItemsToCSV, queueExportSearchWithTemplate, resolveSearchPayPaymentMethod, @@ -47,10 +49,11 @@ import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; -import {getConnectedIntegration, isSubmitPolicy} from '@libs/PolicyUtils'; +import {getConnectedIntegration, isAdminOfCardEnabledPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; import {getReportAccountingExportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { canEditMultipleTransactions, + canMergeReports, getAllPolicyExpenseChatReportActions, getIntegrationIcon, getPolicyExpenseChat, @@ -221,7 +224,7 @@ function addSelectedGroupsFilter(queryJSON: SearchQueryJSON, selectedTransaction return queryJSON; } - const filterEntries: Array<{key: SearchFilterKey; value: string | number}> = []; + const filterEntries: Array<{key: QueryFilterKey; value: string | number}> = []; for (const key of groupKeys) { const group = searchData[key as keyof SearchResultDataType]; if (!group) { @@ -256,7 +259,7 @@ function getAllMatchingExportQueryAndExclusions( searchData: SearchResultDataType | undefined, ): {queryJSON: SearchQueryJSON; excludedTransactionIDList: string[]} | undefined { const excludedTransactionIDList: string[] = []; - const excludedGroupEntries: Array<{key: SearchFilterKey; value: string | number}> = []; + const excludedGroupEntries: Array<{key: QueryFilterKey; value: string | number}> = []; for (const key of Object.keys(excludedTransactions)) { if (!isGroupEntry(key)) { @@ -522,6 +525,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isExpenseReportType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const expensifyIcons = useMemoizedLazyExpensifyIcons([ @@ -552,6 +556,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', 'Pencil', 'Workflows', @@ -573,6 +578,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const selectedReportIDs = Object.values(selectedReports) .map((report) => report.reportID) .filter((reportID) => reportID !== undefined); + + const payableSelectedReports = useMemo(() => selectedReports.filter((report) => report.canPay), [selectedReports]); + const payableSelectedReportIDs = useMemo(() => payableSelectedReports.map((report) => report.reportID).filter((reportID) => reportID !== undefined), [payableSelectedReports]); + const payScopedReports = useMemo(() => (payableSelectedReports.length > 0 ? payableSelectedReports : selectedReports), [payableSelectedReports, selectedReports]); + const payScopedReportIDs = useMemo(() => payScopedReports.map((report) => report.reportID).filter((reportID) => reportID !== undefined), [payScopedReports]); + const isCurrencySupportedBulkWallet = isCurrencySupportWalletBulkPay(selectedReports, selectedTransactions); const doSelectedItemsBelongToSubmitPolicy = useMemo(() => { @@ -588,14 +599,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [selectedReports, selectedTransactions, policies]); const selectedPolicyIDs = useMemo( - () => [ - ...new Set( - Object.values(selectedTransactions) - .map((transaction) => transaction.policyID) - .filter(Boolean), - ), - ], - [selectedTransactions], + () => [...new Set([...Object.values(selectedTransactions).map((transaction) => transaction.policyID), ...selectedReports.map((report) => report.policyID)].filter(Boolean))], + [selectedTransactions, selectedReports], + ); + + // The Pay menu resolves its payment methods from the first policy in this list, so it must not be led by a + // workspace whose report is not being paid. Everything else (export, templates) still uses the full selection. + const payScopedPolicyIDs = useMemo( + () => [...new Set([...payScopedReports.map((report) => report.policyID), ...Object.values(selectedTransactions).map((transaction) => transaction.policyID)].filter(Boolean))], + [selectedTransactions, payScopedReports], ); // A bulk selection can span several workspaces, so the Canadian Multiple Tax Export template is only offered when every selected item belongs to a workspace that outputs in CAD. @@ -631,13 +643,17 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); }, [areAllMatchingItemsSelected, allReports, queryJSON, selectedReports, selectedTransactions, policies]); - const selectedBulkCurrency = selectedReports.at(0)?.currency ?? Object.values(selectedTransactions).at(0)?.currency; - const totalFormattedAmount = getTotalFormattedAmount(convertToDisplayString, selectedReports, selectedTransactions, selectedBulkCurrency); + // Reconciliation - All Expenses reports on card spend, so it is offered whenever the current user is a workspace admin or card + // admin of a workspace with a card product enabled, regardless of which rows are selected. + const hasCardEnabledAdminPolicy = useMemo(() => Object.values(policies ?? {}).some((policy) => isAdminOfCardEnabledPolicy(policy)), [policies]); + + const selectedBulkCurrency = payScopedReports.at(0)?.currency ?? Object.values(selectedTransactions).at(0)?.currency; + const totalFormattedAmount = getTotalFormattedAmount(convertToDisplayString, payScopedReports, selectedTransactions, selectedBulkCurrency); const onlyShowPayElsewhere = useMemo(() => { const selectedCurrencies = - selectedReports.length > 0 - ? selectedReports.map((report) => report.currency).filter(Boolean) + payScopedReports.length > 0 + ? payScopedReports.map((report) => report.currency).filter(Boolean) : Object.values(selectedTransactions) .map((transaction) => transaction.currency) .filter(Boolean); @@ -645,9 +661,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return true; } - const firstPolicyID = selectedPolicyIDs.at(0); + const firstPolicyID = payScopedPolicyIDs.at(0); const selectedPolicy = firstPolicyID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${firstPolicyID}`] : undefined; - return (selectedTransactionReportIDs ?? selectedReportIDs).some((reportID) => { + return (selectedTransactionReportIDs ?? payScopedReportIDs).some((reportID) => { const report = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReportID = report?.chatReportID; const chatReport = chatReportID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] : undefined; @@ -682,33 +698,33 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); }); }, [ - selectedPolicyIDs, + payScopedPolicyIDs, currentSearchResults?.data, selectedTransactionReportIDs, - selectedReportIDs, + payScopedReportIDs, bankAccountList, - selectedReports, + payScopedReports, selectedTransactions, - currentUserPersonalDetails?.login, + currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, ]); - const selectedBulkPayReportID = selectedTransactionReportIDs.at(0) ?? selectedReportIDs.at(0); + const selectedBulkPayReportID = payScopedReportIDs.at(0) ?? selectedTransactionReportIDs.at(0); const selectedBulkPayReport = selectedBulkPayReportID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedBulkPayReportID}`] : undefined; const selectedBulkPayChatReportID = selectedBulkPayReport?.chatReportID; const {bulkPayButtonOptions, businessBankAccountOptions} = useBulkPayOptions({ - selectedPolicyID: selectedPolicyIDs.at(0), + selectedPolicyID: payScopedPolicyIDs.at(0), selectedReportID: selectedBulkPayReportID, selectedReport: selectedBulkPayReport, selectedChatReport: selectedBulkPayChatReportID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedBulkPayChatReportID}`] : undefined, isCurrencySupportedWallet: isCurrencySupportedBulkWallet, currency: selectedBulkCurrency, formattedAmount: totalFormattedAmount, - onlyShowPayElsewhere, + onlyShowPayElsewhere: onlyShowPayElsewhere || areAllMatchingItemsSelected, }); const {hash} = queryJSON ?? {}; - const shouldCalculateTotalsOnRefresh = useSearchShouldCalculateTotals(currentSearchKey, hash, true); + const shouldCalculateTotalsOnRefresh = useSearchShouldCalculateTotals(currentSearchKey, true); const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); // Use currentSearchResults, not the lastNonEmpty fallback: the export scope must reflect the query on screen now, @@ -1104,6 +1120,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { getCurrencyDecimals, expenseReport, expenseReportPolicy: reportPolicy, + rules, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', hasViolations, @@ -1159,6 +1176,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { personalDetails, delegateAccountID, getCurrencyDecimals, + rules, ]); const {expenseCount, uniqueReportCount} = useMemo(() => { @@ -1212,15 +1230,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } const validTransactions = Object.fromEntries(Object.entries(allTransactions ?? {}).filter((entry): entry is [string, Transaction] => entry[1] !== undefined)); + const searchData = searchResults?.data; if (isExpenseReportType) { for (const reportID of selectedReportIDs) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = getReportFromSearchSnapshot(reportID, searchData, allReports); + const reportActions = getReportActionsFromSearchSnapshot(reportID, searchData, allReportActions); + const parentReportActions = getReportActionsFromSearchSnapshot(report?.parentReportID, searchData, allReportActions); deleteAppReport({ report, - reportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`], - parentReportAction: report?.parentReportActionID - ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`]?.[report?.parentReportActionID] - : undefined, + reportActions, + parentReportAction: report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined, selfDMReport, currentUserEmailParam: email ?? '', currentUserAccountIDParam: accountID, @@ -1264,13 +1283,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // Whole-report deletions keep their existing path. for (const reportID of wholeReportIDs) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = getReportFromSearchSnapshot(reportID, searchData, allReports); + const reportActions = getReportActionsFromSearchSnapshot(reportID, searchData, allReportActions); + const parentReportActions = getReportActionsFromSearchSnapshot(report?.parentReportID, searchData, allReportActions); deleteAppReport({ report, - reportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`], - parentReportAction: report?.parentReportActionID - ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`]?.[report?.parentReportActionID] - : undefined, + reportActions, + parentReportAction: report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined, selfDMReport, currentUserEmailParam: email ?? '', currentUserAccountIDParam: accountID, @@ -1278,6 +1297,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { allTransactionViolations: transactionsViolations, bankAccountList, delegateAccountID, + hash, }); } } @@ -1296,6 +1316,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { bankAccountList, clearSelectedTransactions, allReports, + searchResults?.data, selfDMReport, email, isExpenseReportType, @@ -1324,7 +1345,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const selectedOptions = selectedReports.length ? selectedReports : Object.values(selectedTransactions); + const selectedOptions = selectedReports.length ? payableSelectedReports : Object.values(selectedTransactions); const expenseReportBankAccountID = additionalData?.bankAccountID; const restrictedPolicyID = getRestrictedPolicyID(selectedOptions, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, amountOwed, policies, accountID); @@ -1334,6 +1355,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } + if (areAllMatchingItemsSelected) { + const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON); + queueBulkPayReports(serializedQuery); + playSound(SOUNDS.SUCCESS); + clearSelectedTransactions(); + return; + } + const activeRoute = Navigation.getActiveRoute(); const searchData = searchResults?.data; const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); @@ -1348,7 +1377,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const itemReport = getReportFromSearchSnapshot(itemReportID, searchData, allReports); const isExpenseReport = isExpenseReportUtil(itemReportID); const isIOUReport = isIOUReportUtil(itemReportID); - const reportType = getReportType(itemReportID); + const reportType = getReportType(itemReport); const lastPolicyPaymentMethod = paymentMethod ?? getLastPolicyPaymentMethod(itemPolicyID, personalPolicyID, lastPaymentMethods, reportType, isIOUReport); if (!lastPolicyPaymentMethod) { @@ -1408,16 +1437,17 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { getLoginByAccountID(itemReport?.ownerAccountID, personalDetails), doesPersonalDetailExistSelector(itemReport?.ownerAccountID)(personalDetails), getCurrencyDecimals, + rules, reportTransactions, ); if (!invite?.policyExpenseChatReportID) { - moveIOUReportToPolicy(itemReport, adminPolicy, reportPreviewAction, getCurrencyDecimals, false, reportTransactions); + moveIOUReportToPolicy(itemReport, adminPolicy, reportPreviewAction, getCurrencyDecimals, rules, false, reportTransactions); } } } const itemsToPay = selectedReports.length > 0 - ? selectedReports.map((report) => ({ + ? payableSelectedReports.map((report) => ({ reportID: report.reportID, amount: report.total, policyID: report.policyID, @@ -1530,6 +1560,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { chatReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${payChatReportID}`], delegateAccountID, isTrackIntentUser, + rules, }); paidReportCount += 1; continue; @@ -1561,6 +1592,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isTrackIntentUser, conciergeChat, isFallbackChatReport, + rules, }); paidReportCount += 1; } @@ -1576,9 +1608,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [ hash, + areAllMatchingItemsSelected, + queryJSON, isOffline, isDelegateAccessRestricted, - selectedReports, + selectedReports.length, + payableSelectedReports, selectedTransactions, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, @@ -1610,6 +1645,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isTrackIntentUser, conciergeChat, getCurrencyDecimals, + rules, ], ); @@ -1646,7 +1682,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (!iouReport?.chatReportID) { return false; } - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`]; + const chatReport = getReportFromSearchSnapshot(iouReport.chatReportID, currentSearchResults?.data, allReports); return isDM(chatReport); }) ); @@ -1733,6 +1769,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isTrackIntentUser, report: fullReport, policy: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], + rules, }); }); } @@ -1745,9 +1782,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isTrackIntentUser, report: fullReport, policy: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${transaction.policyID}`], + rules, }); }); - }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions]); + }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions, rules]); const noReportsShouldMarkAsDone = useMemo(() => { if (selectedReports.length > 0) { @@ -1758,6 +1796,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isTrackIntentUser, report: fullReport, policy: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], + rules, }); }); } @@ -1770,9 +1809,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isTrackIntentUser, report: fullReport, policy: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${transaction.policyID}`], + rules, }); }); - }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions]); + }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions, rules]); const headerButtonsOptions = useMemo(() => { if ((selectedTransactionsKeys.length === 0 && !(isExpenseType && areAllMatchingItemsSelected)) || !hash) { @@ -1783,6 +1823,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const options: Array> = []; const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld); + const isAnyPayableTransactionOnHold = + payableSelectedReports.length > 0 + ? Object.values(selectedTransactions).some((transaction) => transaction.isHeld && !!transaction.reportID && payableSelectedReportIDs.includes(transaction.reportID)) + : isAnyTransactionOnHold; const getExportOptions = () => { const areFullReportsSelected = selectedTransactionReportIDs.length === selectedReportIDs.length && selectedTransactionReportIDs.every((id) => selectedReportIDs.includes(id)); @@ -1806,6 +1850,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { includeReportLevelExport, !isGroupedSearch, doAllSelectedItemsBelongToCADPolicies, + hasCardEnabledAdminPolicy, ); const shouldHideTemplateExports = isExpenseType && areAllMatchingItemsSelected && Object.keys(excludedTransactions).length > 0; const availableCustomTemplates = shouldHideTemplateExports @@ -1874,14 +1919,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // Shared confirmation flow used by BOTH "Export to " and "Mark as exported" so the // two actions behave identically. When applicable, the partial-export modal is shown first and, // only after it resolves, the existing "export again" modal — the two are never combined. - // `shouldCheckCompanyMismatch` gates the different-companies guard: it only applies to the real - // integration export (which pushes data into a single external company), not to manual marking. + // `isRealIntegrationExport` distinguishes the real integration export (which pushes data into a + // single external company) from manual marking, and gates the two confirmations that only make + // sense for a real export: the different-companies guard and the "export again" warning. const buildIntegrationHandleExportAction = ( integrationReportIDs: string[], integration: NonNullable>, integrationGroupSize: number, - shouldCheckCompanyMismatch: boolean, + isRealIntegrationExport: boolean, connectionNameFriendly: string, ) => (exportAction: () => void) => { @@ -1902,7 +1948,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // This guard is skipped for "Mark as exported": the MarkAsExported backend command logs a // per-report exported action independently and never pushes into an external company, so marking // reports across different companies in one call is fine. - if (shouldCheckCompanyMismatch) { + if (isRealIntegrationExport) { const companyIDs = new Set(); for (const reportID of integrationReportIDs) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; @@ -1949,7 +1995,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // Ask about the already-exported reports (if any) and then run the export. const confirmExportAgainThenRun = () => { - if (!areAnyReportsExported) { + // The "export again" warning is skipped for "Mark as exported" for the same reason as the + // different-companies guard above: MarkAsExported only logs a per-report exported action and + // never pushes data into the external company. Warning that reports "will be exported again" + // to e.g. QuickBooks Online is wrong there and blocks users from re-marking their reports. + if (!areAnyReportsExported || !isRealIntegrationExport) { runExport(); return; } @@ -2146,8 +2196,24 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return isExportTheOnlyAction && subMenuItems.length > 0 ? subMenuItems : builtOptions; }; + const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); + const hasLoadedPayableReport = payableSelectedReports.length > 0 || selectedReports.length === 0; + const shouldShowPayOption = areAllMatchingItemsSelected + ? hasLoadedPayableReport && !!bulkPayButtonOptions?.length + : !isAnyPayableTransactionOnHold && shouldEnableBulkPayOption && !!bulkPayButtonOptions?.length; + const payButtonOption: DropdownOption & Pick = { + icon: expensifyIcons.MoneyBag, + text: translate('search.bulkActions.pay'), + backButtonText: translate('search.bulkActions.pay'), + rightIcon: expensifyIcons.ArrowRight, + value: CONST.SEARCH.BULK_ACTION_TYPES.PAY, + shouldCloseModalOnSelect: true, + subMenuItems: bulkPayButtonOptions, + onSelected: () => onBulkPaySelected(undefined), + }; + if (areAllMatchingItemsSelected) { - return openExportOptionsDirectlyIfSoleAction([exportButtonOption]); + return openExportOptionsDirectlyIfSoleAction(shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]); } if (allSelectedAreDeleted) { @@ -2190,7 +2256,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { .map((transaction) => transaction.transaction) .filter((transaction): transaction is Transaction => !!transaction); const canEditMultiple = - canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, policies, isExpenseReportSearch, searchResults?.data) && isBetaEnabled(CONST.BETAS.BULK_EDIT); + canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, policies, rules, isExpenseReportSearch, searchResults?.data) && + isBetaEnabled(CONST.BETAS.BULK_EDIT); if (canEditMultiple) { options.push({ @@ -2362,6 +2429,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { [policyForSubmit], getLoginByAccountID(snapshotReport.ownerAccountID, personalDetails), getCurrencyDecimals, + rules, currentSearchKey, managerEmail, managerAccountID, @@ -2383,7 +2451,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { for (const item of itemList) { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; if (policy) { - submitMoneyRequestOnSearch(hash, [item as Report], [policy], getLoginByAccountID(item.ownerAccountID, personalDetails), getCurrencyDecimals); + submitMoneyRequestOnSearch(hash, [item as Report], [policy], getLoginByAccountID(item.ownerAccountID, personalDetails), getCurrencyDecimals, rules); } else { Log.info('[BulkSubmit] Skipping report: policy not found in Onyx', false, {reportID: item?.reportID, policyID: item?.policyID}); } @@ -2402,28 +2470,30 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, }); } - const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); - - // Keep Pay visible while offline: selecting it is handled by onBulkPaySelected, which shows the offline modal - // rather than attempting a payment. Gating on !isOffline here would hide Pay entirely offline, which is wrong. - const shouldShowPayOption = !isAnyTransactionOnHold && shouldEnableBulkPayOption && !!bulkPayButtonOptions?.length; - if (shouldShowPayOption) { - const shouldShowPaySubmenu = !!bulkPayButtonOptions?.length; - - const payButtonOption = { - icon: expensifyIcons.MoneyBag, - text: translate('search.bulkActions.pay'), - backButtonText: shouldShowPaySubmenu ? translate('search.bulkActions.pay') : undefined, - rightIcon: shouldShowPaySubmenu ? expensifyIcons.ArrowRight : undefined, - value: CONST.SEARCH.BULK_ACTION_TYPES.PAY, - shouldCloseModalOnSelect: true, - subMenuItems: shouldShowPaySubmenu ? bulkPayButtonOptions : undefined, - onSelected: () => onBulkPaySelected(undefined), - }; options.push(payButtonOption); } + const selectedMergeReports = areAllMatchingItemsSelected + ? [] + : selectedReports + .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) + .filter((report) => !!report?.reportID && report.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + if ( + isBetaEnabled(CONST.BETAS.REPORT_MERGE) && + selectedMergeReports.length === selectedReports.length && + queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && + canMergeReports(selectedMergeReports, currentUserPersonalDetails.accountID, rules) + ) { + options.push({ + icon: expensifyIcons.ArrowCollapse, + text: translate('search.mergeReports.title'), + value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS, + shouldCloseModalOnSelect: true, + onSelected: () => Navigation.navigate(ROUTES.MERGE_REPORTS_SEARCH_RHP.getRoute()), + }); + } + options.push(exportButtonOption); if (isExpenseReportSearch && selectedReportIDs.length > 0) { @@ -2590,6 +2660,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { transactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); } clearSelectedTransactions(); @@ -2600,7 +2671,7 @@ 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, accountID)) { + if (isMergeActionForSelectedTransactions(searchedTransactions, reports, transactionPolicies, rules, accountID)) { const transactionID = searchedTransactions.at(0)?.transactionID; if (transactionID) { options.push({ @@ -2660,7 +2731,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const firstTransactionKey = selectedTransactionsKeys.at(0); const firstTransactionMeta = firstTransactionKey ? selectedTransactions[firstTransactionKey] : undefined; - const canShowDeleteAction = shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, accountID, selectedReports, queryJSON?.type); + const canShowDeleteAction = shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, accountID, rules, selectedReports, queryJSON?.type); const isSplittable = !!firstTransactionMeta?.canSplit; const isAlreadySplit = !!firstTransactionMeta?.hasBeenSplit; @@ -2770,6 +2841,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isExpenseType, isOffline, selectedReports, + payableSelectedReports.length, + payableSelectedReportIDs, lastPaymentMethods, selectedReportIDs, personalPolicyID, @@ -2833,6 +2906,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { restrictedActionPolicyID, doSelectedItemsBelongToSubmitPolicy, doAllSelectedItemsBelongToCADPolicies, + hasCardEnabledAdminPolicy, openSearchReportSubmitToPopover, firstTransactionReport, styles.textWrap, @@ -2840,10 +2914,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { allReportsShouldMarkAsDone, noReportsShouldMarkAsDone, queryJSON?.groupBy, + currentUserPersonalDetails.accountID, delegateAccountID, currentSearchQueryJSON, currentSearchResults?.search?.isLoading, shouldCalculateTotalsOnRefresh, + rules, ]); // When the export options are surfaced directly there is no "Export" row above them, so on its own the list gives diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 8da786ed3740..41379d632161 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -1,4 +1,4 @@ -import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; +import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; @@ -33,9 +33,12 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { const {clearSelectedTransactions} = useSearchSelectionActions(); const {shouldUseLiveData, currentSearchResults} = useSearchResultsContext(); const {currentSearchKey} = useSearchQueryContext(); + const {areAllMatchingItemsSelected} = useSearchSelectionContext(); const hash = queryJSON?.hash; - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true); + // Without this, a plain page-level fetch during active select-all clears totals on arrival + // (shouldClearTotals in getOnyxLoadingData), wiping a total the Search-internal effect already fetched. + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); // Derived primitives so effects do not depend on the whole snapshot object (new reference every // Onyx merge) while exhaustive-deps still sees every transition that matters for firing search(). diff --git a/src/hooks/useSearchSections.ts b/src/hooks/useSearchSections.ts index 8ff6ac3f08b6..19733b0ac474 100644 --- a/src/hooks/useSearchSections.ts +++ b/src/hooks/useSearchSections.ts @@ -34,6 +34,7 @@ function useSearchSections(): UseSearchSectionsResult { const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportAttributesDerivedValue = useReportAttributes(); const {type, sortBy, sortOrder, groupBy} = lastSearchQuery?.queryJSON ?? {}; @@ -54,6 +55,7 @@ function useSearchSections(): UseSearchSectionsResult { translate, formatPhoneNumber, bankAccountList, + rules, groupBy, queryJSON: lastSearchQuery?.queryJSON, currentSearch: searchKey, diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index bb05387b6220..06628273a69d 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -1,12 +1,13 @@ -import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {usePersonalDetails, useCardList, useWorkspaceCardList} from '@components/OnyxListItemProvider'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; import useDebouncedState from '@hooks/useDebouncedState'; import useFilteredOptions from '@hooks/useFilteredOptions'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useSortedActions from '@hooks/useSortedActions'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import type {GetOptionsConfig, Option, OptionList, Options, SearchOption} from '@libs/OptionsListUtils'; import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; @@ -55,13 +56,11 @@ type UseSearchSelectorConfig = { /** Whether to include recent reports (for getMemberInviteOptions) */ includeRecentReports?: boolean; - /** Whether to include current user */ includeCurrentUser?: boolean; /** Enable phone contacts integration */ enablePhoneContacts?: boolean; - /** Whether to include self DM */ includeSelfDM?: boolean; /** Additional configuration for getValidOptions function */ @@ -76,7 +75,6 @@ type UseSearchSelectorConfig = { /** Initial selected options */ initialSelected?: OptionData[]; - /** Whether to initialize the hook */ shouldInitialize?: boolean; /** Additional contact options to merge (used by platform-specific implementations) */ @@ -85,7 +83,6 @@ type UseSearchSelectorConfig = { /** Whether to filter with recent attendees */ recentAttendees?: Array>; - /** Whether to allow name-only options */ shouldAllowNameOnlyOptions?: boolean; /** Whether to keep selected options in availableOptions instead of filtering them out */ @@ -102,13 +99,11 @@ type ContactState = { /** Contact options from device */ contactOptions: Array>; - /** Whether to show import UI */ showImportUI: boolean; /** Function to trigger contact import */ importContacts: () => void; - /** Function to set permission state */ setContactPermissionState: (status: PermissionStatus) => void; }; @@ -116,7 +111,6 @@ type UseSearchSelectorReturn = { /** Current search term */ searchTerm: string; - /** Debounced search term */ debouncedSearchTerm: string; /** Function to update search term */ @@ -137,13 +131,11 @@ type UseSearchSelectorReturn = { /** Selected options that are not present in availableOptions.personalDetails (e.g. non-existing users invited by email). Only populated when shouldSeparateNonExistingSelectedOptions is true */ selectedNonExistingOptions?: OptionData[]; - /** Function to set selected options */ setSelectedOptions: (options: OptionData[]) => void; /** Function to toggle selection state of an option */ toggleSelection: (option: OptionData) => void; - /** Whether options are initialized */ areOptionsInitialized: boolean; /** Contact-related state and functions (when enablePhoneContacts is true) */ @@ -194,7 +186,8 @@ function useSearchSelectorBase({ shouldKeepSelectedInAvailableOptions = false, shouldSeparateNonExistingSelectedOptions = false, }: UseSearchSelectorConfig): UseSearchSelectorReturn { - const {translate, dateFnsLocale} = useLocalize(); + const {translate, dateFnsLocale, localeCompare, formatPhoneNumber} = useLocalize(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const [betas] = useOnyx(ONYXKEYS.BETAS); const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -205,14 +198,20 @@ function useSearchSelectorBase({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const sortedActions = useSortedActions(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; + const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; + const lastActions = sortedReportActionsData?.lastActions; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; + const cardList = useCardList(); + const workspaceCardList = useWorkspaceCardList(); const personalDetails = usePersonalDetails(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Searching bypasses the recent-reports pre-filter so a typed query can still match reports outside the top 500 most recently active ones. const isSearchingOptions = !!debouncedSearchTerm.trim(); @@ -276,8 +275,10 @@ function useSearchSelectorBase({ }; })(); - const computedSearchTerm = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); + // Trim before deriving the phone/email search value, otherwise a leading/trailing space makes Str.isValidEmail fail + // and the "invite user" option disappears for logins that don't have an account yet. const trimmedSearchInput = debouncedSearchTerm.trim(); + const computedSearchTerm = getSearchValueForPhoneOrEmail(trimmedSearchInput, countryCode); const {options: baseOptions, hasMore} = (() => { if (!areOptionsInitialized) { @@ -290,6 +291,8 @@ function useSearchSelectorBase({ options: optionsWithContacts, draftComments, dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, betas: betas ?? [], isUsedInChatFinder: true, includeReadOnly: true, @@ -304,9 +307,16 @@ function useSearchSelectorBase({ currentUserEmail, personalDetails, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, conciergeReportID, isTrackIntentUser, translate, + rules, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: return getValidOptions( @@ -319,6 +329,8 @@ function useSearchSelectorBase({ conciergeReportID, { dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, betas: betas ?? [], searchString: computedSearchTerm, searchInputValue: trimmedSearchInput, @@ -338,10 +350,17 @@ function useSearchSelectorBase({ reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, isTrackIntentUser, ...appliedGetValidOptionsConfig, }, translate, + rules, ); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_DESTINATION: return getValidOptions( @@ -354,6 +373,8 @@ function useSearchSelectorBase({ conciergeReportID, { dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, betas, selectedOptions, includeMultipleParticipantReports: true, @@ -375,10 +396,17 @@ function useSearchSelectorBase({ reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, isTrackIntentUser, ...appliedGetValidOptionsConfig, }, translate, + rules, ); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: return getValidOptions( @@ -391,6 +419,8 @@ function useSearchSelectorBase({ conciergeReportID, { dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, betas: betas ?? [], includeP2P: true, includeSelectedOptions: false, @@ -410,10 +440,17 @@ function useSearchSelectorBase({ reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, isTrackIntentUser, ...appliedGetValidOptionsConfig, }, translate, + rules, ); default: return getEmptyOptions(); @@ -423,18 +460,23 @@ function useSearchSelectorBase({ // Two independent pagination cursors are checked here on purpose: // - hasMore/maxResults track how many relevance-sorted options are rendered. // - hasMoreReports/loadMoreReports track the raw Onyx report pool size by useFilteredOptions - const onListEndReached = useDebounce(() => { - if (!areOptionsInitialized) { - return; - } + const onListEndReached = useDebounce( + () => { + if (!areOptionsInitialized) { + return; + } - if (hasMore) { - setMaxResults((previous) => previous + maxResultsPerPage); - } - if (hasMoreReports) { - loadMoreReports(); - } - }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + if (hasMore) { + setMaxResults((previous) => previous + maxResultsPerPage); + } + if (hasMoreReports) { + loadMoreReports(); + } + }, + CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME, + // maxWait keeps pages arriving while the scrolling continues. + {maxWait: CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME}, + ); const isOptionSelected = (option: OptionData) => selectedOptions.some((selected) => doOptionsMatch(selected, option)); diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index 7b805d24bab9..19694ba94ed1 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -1,3 +1,4 @@ +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -14,7 +15,7 @@ function getSearchRequestOffsetForMissingAllMatchingCount(offset: number, server return Math.min(offset, serverOffset ?? offset); } -function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, searchHash: number | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { +function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const shouldCalculateTotals = useMemo(() => { @@ -50,10 +51,11 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, search ]; const isSuggestedSearchWithTotals = eligibleSearchKeys.includes(searchKey); - const isSavedSearch = searchHash !== undefined && savedSearches && !!savedSearches[searchHash]; + const savedSearchID = searchKeyToSavedSearchID(searchKey); + const isSavedSearch = savedSearchID !== undefined && savedSearches && !!savedSearches[savedSearchID]; return isSuggestedSearchWithTotals || isSavedSearch; - }, [enabled, savedSearches, searchKey, searchHash, areAllMatchingItemsSelected]); + }, [enabled, savedSearches, searchKey, areAllMatchingItemsSelected]); return shouldCalculateTotals ?? false; } diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 80ded05f6a1a..d6e4d2e81403 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,6 +1,5 @@ -import {createTypeMenuSections, doesSearchItemMatchSort} from '@libs/SearchUIUtils'; +import {createTypeMenuSections} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; import type {Policy, Session} from '@src/types/onyx'; @@ -49,24 +48,14 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ accountID: session?.accountID, }); -type UseSearchTypeMenuSectionsParams = { - hash?: number; - similarSearchHash?: number; - sortBy?: string; - sortOrder?: string; - type?: string; -}; - /** - * Get a list of all search groupings, along with their search items. Also returns the - * currently focused search, based on the hash. + * Get a list of all search groupings, along with their search items. * * `isScreenFocused` gates the reports-awaiting-approval watch so an off-screen consumer stops recomputing it. It * defaults to `true` (always watch) for consumers rendered outside a navigator or where focus can't be tracked * reliably, so this hook never depends on a navigation context itself. */ -const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams, isScreenFocused = true) => { - const {hash, similarSearchHash, sortBy, sortOrder, type} = queryParams ?? {}; +const useSearchTypeMenuSections = (isScreenFocused = true) => { const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); const {defaultCardFeed, cardFeedsByPolicy, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); @@ -149,67 +138,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams ], ); - // The saved search the current query maps to (keyed by `hash`), derived from the existing `savedSearches` - // subscription. Undefined when there is no match or when the match is pending deletion (unless offline). - const activeSavedSearch = (() => { - if (hash === undefined || !savedSearches) { - return undefined; - } - const item = savedSearches[hash]; - if (!item || (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline)) { - return undefined; - } - return item; - })(); - - const activeItemIndex = (() => { - // A saved search is not part of `typeMenuSections`, so keep suggested-search focus off it. - if (activeSavedSearch) { - return -1; - } - - let index = 0; - for (const section of typeMenuSections) { - const found = section.menuItems.findIndex((item) => { - if (item.similarSearchHash !== similarSearchHash) { - return false; - } - return doesSearchItemMatchSort(item.key, item.searchQueryJSON?.sortBy, item.searchQueryJSON?.sortOrder, sortBy, sortOrder); - }); - if (found !== -1) { - return index + found; - } - index += section.menuItems.length; - } - - // Fallback: if no exact match found, select the generic search key matching the type - const typeToGenericKey: Record = { - [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, - [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, - }; - const fallbackKey = type ? typeToGenericKey[type] : undefined; - if (fallbackKey) { - let fallbackIndex = 0; - for (const section of typeMenuSections) { - const found = section.menuItems.findIndex((item) => item.key === fallbackKey); - if (found !== -1) { - return fallbackIndex + found; - } - fallbackIndex += section.menuItems.length; - } - } - - return -1; - })(); - - const activeKey = activeItemIndex < 0 ? undefined : typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex)?.key; - - return { - typeMenuSections, - activeItemIndex, - activeKey, - activeSavedSearch, - }; + return typeMenuSections; }; export default useSearchTypeMenuSections; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 57fcc37210d2..b625ee0f87ce 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -101,6 +101,7 @@ function useSelectedTransactionsActions({ const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const firstSelectedTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${selectedTransactionIDs.at(0)}`]; const splitEffectivePolicy = useSplitEffectivePolicy(report, undefined, firstSelectedTransaction); const personalPolicy = usePersonalPolicy(); @@ -286,7 +287,7 @@ function useSelectedTransactionsActions({ if (selectedTransactionIDs.length) { const options = []; - const canEditMultiple = canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, allPolicies) && isBetaEnabled(CONST.BETAS.BULK_EDIT); + const canEditMultiple = canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, allPolicies, rules) && isBetaEnabled(CONST.BETAS.BULK_EDIT); if (canEditMultiple) { options.push({ @@ -317,7 +318,7 @@ function useSelectedTransactionsActions({ } const iouReportAction = getIOUActionForTransactionID(reportActions, selectedTransaction.transactionID); const holdReportAction = getReportAction(iouReportAction?.childReportID, `${selectedTransaction?.comment?.hold ?? ''}`); - const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(report, iouReportAction, holdReportAction, selectedTransaction, policy, currentUserAccountID); + const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(report, iouReportAction, holdReportAction, selectedTransaction, policy, currentUserAccountID, rules); canHoldTransactions = canHoldTransactions && canHoldRequest; canUnholdTransactions = canUnholdTransactions && canUnholdRequest; @@ -359,7 +360,18 @@ function useSelectedTransactionsActions({ continue; } const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; - unholdRequest(transactionID, action.childReportID, policy, isOffline, login ?? '', currentUserAccountID, transactionViolations, isTrackIntentUser, delegateAccountID); + unholdRequest( + transactionID, + action.childReportID, + policy, + isOffline, + login ?? '', + currentUserAccountID, + transactionViolations, + isTrackIntentUser, + delegateAccountID, + rules, + ); } clearSelectedTransactions(true); }, @@ -465,11 +477,12 @@ function useSelectedTransactionsActions({ outstandingReportsByPolicyID, transaction, reportNameValuePairs: allReportNameValuePairs, + rules, }); return canMoveExpense; }); - const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy]); + const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy], rules); if (canMergeTransaction) { const transactionID = selectedTransactionsList.at(0)?.transactionID; if (transactionID) { @@ -518,7 +531,7 @@ function useSelectedTransactionsActions({ selectedTransactionsList.length === 1 && report && !(isExpenseSplit && hasMultipleSplits) && - isSplitAction(report, [firstTransaction], originalTransaction, login ?? '', currentUserAccountID, policy, parentReport); + isSplitAction(report, [firstTransaction], originalTransaction, login ?? '', currentUserAccountID, rules, policy, parentReport); if (canSplitTransaction) { options.push({ @@ -572,7 +585,7 @@ function useSelectedTransactionsActions({ return canRemoveTransaction && isIOUActionOwner && !isActionDeleted; }); - const canRemoveReportTransaction = canDeleteTransaction(report, isReportArchived); + const canRemoveReportTransaction = canDeleteTransaction(report, rules, isReportArchived); const shouldShowEditSplitOnDeleteAction = shouldOpenSplitExpenseEditFlowOnDelete(selectedTransactionIDs); diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 0c42b21a9f1a..e3110dc85789 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -89,7 +89,7 @@ function useSelectionModePayment({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); @@ -106,6 +106,7 @@ function useSelectionModePayment({ const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {accountID, login: currentUserLogin, localCurrencyCode} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -207,6 +208,7 @@ function useSelectionModePayment({ chatReportActions: getChatReportActions(payAsBusiness), delegateAccountID, isTrackIntentUser, + rules, }); } else { payMoneyRequest({ @@ -231,6 +233,7 @@ function useSelectionModePayment({ delegateAccountID, isTrackIntentUser, conciergeChat, + rules, }); refreshSearchAfterReportAction({ currentSearchQueryJSON, @@ -315,6 +318,7 @@ function useSelectionModePayment({ isASAPSubmitBetaEnabled, confirmApproval, iouReport: moneyRequestReport, + rules, betas, userBillingGracePeriodEnds, amountOwed, diff --git a/src/hooks/useSelectionModeReportActions.ts b/src/hooks/useSelectionModeReportActions.ts index c43dec8ee775..65a0f23f4c0a 100644 --- a/src/hooks/useSelectionModeReportActions.ts +++ b/src/hooks/useSelectionModeReportActions.ts @@ -74,6 +74,7 @@ function useSelectionModeReportActions({ `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, ); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); @@ -91,6 +92,9 @@ function useSelectionModeReportActions({ handleSubmitReport: lifecycleHandleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval, + approveSubMenuItems, + approveSubMenuHeaderText, + shouldShowApproveSubMenu, } = useLifecycleActions({ reportID: report?.reportID, startApprovedAnimation: () => {}, @@ -145,6 +149,7 @@ function useSelectionModeReportActions({ isChatReportArchived, invoiceReceiverPolicy, ownerLogin: submitterLogin, + rules, isOffline, }); @@ -170,6 +175,7 @@ function useSelectionModeReportActions({ outstandingReportsByPolicyID, isChatReportArchived, isOffline, + rules, }); })(); @@ -230,11 +236,11 @@ function useSelectionModeReportActions({ // Build report-level action menu const selectionModeReportLevelActions = (() => { - const actions: Array & Pick> = []; + const actions: Array & Pick> = []; let idx = 0; if (hasSubmitAction && !effectiveShouldBlockSubmit) { actions[idx++] = { - text: shouldShowMarkAsDone({policy, report, isTrackIntentUser}) ? translate('common.markAsDone') : translate('common.submit'), + text: shouldShowMarkAsDone({policy, report, isTrackIntentUser, rules}) ? translate('common.markAsDone') : translate('common.submit'), icon: expensifyIcons.Send, value: CONST.REPORT.PRIMARY_ACTIONS.SUBMIT, onSelected: handleSubmitReport, @@ -245,7 +251,12 @@ function useSelectionModeReportActions({ text: translate('iou.approve'), icon: expensifyIcons.ThumbsUp, value: CONST.REPORT.PRIMARY_ACTIONS.APPROVE, - onSelected: confirmApproval, + rightIcon: shouldShowApproveSubMenu ? expensifyIcons.ArrowRight : undefined, + backButtonText: shouldShowApproveSubMenu ? translate('iou.approve') : undefined, + subMenuItems: shouldShowApproveSubMenu ? approveSubMenuItems : undefined, + subMenuHeaderText: shouldShowApproveSubMenu ? approveSubMenuHeaderText : undefined, + // Only reached when there is no submenu; otherwise PopoverMenu opens the submenu instead. + onSelected: () => confirmApproval(), }; } if (hasPayAction && !(isOffline && !canAllowSettlement)) { diff --git a/src/hooks/useShareSavedSearch.ts b/src/hooks/useShareSavedSearch.ts index 733c5a1d1714..d954a3e32392 100644 --- a/src/hooks/useShareSavedSearch.ts +++ b/src/hooks/useShareSavedSearch.ts @@ -14,7 +14,7 @@ const MENU_CLOSE_DELAY_MS = 800; function useShareSavedSearch() { const {environmentURL} = useEnvironment(); - const [copiedHash, setCopiedHash] = useState(null); + const [copiedID, setCopiedID] = useState(null); const timeoutRef = useRef | null>(null); useEffect(() => { @@ -23,21 +23,21 @@ function useShareSavedSearch() { }; }, []); - const handleShare = (itemHash: number, itemQuery: string) => { + const handleShare = (itemID: string, itemQuery: string) => { const url = `${environmentURL}/${ROUTES.SEARCH_ROOT.getRoute({query: itemQuery})}`; Clipboard.setString(url); - setCopiedHash(itemHash); + setCopiedID(itemID); if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { - setCopiedHash((prev) => (prev === itemHash ? null : prev)); + setCopiedID((prev) => (prev === itemID ? null : prev)); timeoutRef.current = null; }, SHARE_FEEDBACK_DURATION_MS); }; - return {copiedHash, handleShare}; + return {copiedID, handleShare}; } export {MENU_CLOSE_DELAY_MS}; diff --git a/src/hooks/useShowNotFoundPageInIOUStep.ts b/src/hooks/useShowNotFoundPageInIOUStep.ts index 61b7b87e15db..98d81e567fcc 100644 --- a/src/hooks/useShowNotFoundPageInIOUStep.ts +++ b/src/hooks/useShowNotFoundPageInIOUStep.ts @@ -30,6 +30,7 @@ const useShowNotFoundPageInIOUStep = (action: IOUAction, iouType: IOUType, repor const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transaction?.reportID)}`); const [iouReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(transaction?.reportID)}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportActionsReportID = useMemo(() => { let actionsReportID; @@ -56,7 +57,7 @@ const useShowNotFoundPageInIOUStep = (action: IOUAction, iouType: IOUType, repor } else if (isSplitExpense) { shouldShowNotFoundPage = !canEditSplitExpense; } else { - shouldShowNotFoundPage = !isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, transaction, false, iouReport, policy, iouReportActions); + shouldShowNotFoundPage = !isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, transaction, rules, false, iouReport, policy, iouReportActions); } } diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 5150cb55f154..2806094c5b11 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -42,6 +42,8 @@ type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; setActiveTab: (tab: ValueOf) => void; setStickyReportID: (reportID: string) => void; + /** The report IDs listed under the given Inbox tab, read on demand by bulk tab actions (e.g. "Mark all as read"). */ + getReportIDsForTab: (tab: ValueOf) => string[]; }; type ReportsToDisplayInLHN = Record< @@ -69,6 +71,7 @@ const SidebarOrderedReportsActionsContext = createContext {}, setActiveTab: () => {}, setStickyReportID: () => {}, + getReportIDsForTab: () => [], }); // This file does not compile with React Compiler (render-time ref cache below keeps referential @@ -343,6 +346,17 @@ function SidebarOrderedReportsContextProvider({ // The count shown in each tab's badge, derived from the full "All" set (not the currently filtered view). const inboxTabCounts = useMemo(() => SidebarUtils.getInboxTabCounts(orderedReportIDs, reportsToDisplayInLHN), [orderedReportIDs, reportsToDisplayInLHN]); + // Held in a ref so getReportIDsForTab stays referentially stable (keeping the actions context stable) and only + // filters when a bulk tab action actually asks for a tab's reports, rather than on every LHN update. + const inboxTabSourcesRef = useRef({orderedReportIDs, reportsToDisplayInLHN}); + useEffect(() => { + inboxTabSourcesRef.current = {orderedReportIDs, reportsToDisplayInLHN}; + }, [orderedReportIDs, reportsToDisplayInLHN]); + const getReportIDsForTab = useCallback( + (tab: ValueOf) => SidebarUtils.filterReportsForInboxTab(inboxTabSourcesRef.current.orderedReportIDs, inboxTabSourcesRef.current.reportsToDisplayInLHN, tab), + [], + ); + // Get the actual reports based on the filtered IDs const getOrderedReports = useCallback( (reportIDs: string[]): OnyxTypes.Report[] => { @@ -437,7 +451,10 @@ function SidebarOrderedReportsContextProvider({ reportsToDisplayInLHN, ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab, setStickyReportID}), [clearLHNCache, setActiveTab, setStickyReportID]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo( + () => ({clearLHNCache, setActiveTab, setStickyReportID, getReportIDsForTab}), + [clearLHNCache, setActiveTab, setStickyReportID, getReportIDsForTab], + ); return ( diff --git a/src/hooks/useSortedActions.ts b/src/hooks/useSortedActions.ts deleted file mode 100644 index f0bd11753f36..000000000000 --- a/src/hooks/useSortedActions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import ONYXKEYS from '@src/ONYXKEYS'; - -import useOnyx from './useOnyx'; - -/** - * Returns `sortedActions` from the RAM_ONLY_SORTED_REPORT_ACTIONS derived value. - */ -function useSortedActions() { - const [sortedReportActions] = useOnyx(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); - return sortedReportActions?.sortedActions; -} - -export default useSortedActions; diff --git a/src/hooks/useSortedReportActionsData.ts b/src/hooks/useSortedReportActionsData.ts new file mode 100644 index 000000000000..fd3949534310 --- /dev/null +++ b/src/hooks/useSortedReportActionsData.ts @@ -0,0 +1,10 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import useOnyx from './useOnyx'; + +function useSortedReportActionsData() { + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); + return sortedReportActionsData; +} + +export default useSortedReportActionsData; diff --git a/src/hooks/useSubPage/types.ts b/src/hooks/useSubPage/types.ts index bd233757dfa8..3b41ec32c341 100644 --- a/src/hooks/useSubPage/types.ts +++ b/src/hooks/useSubPage/types.ts @@ -18,7 +18,6 @@ type SubPageProps = { /** moves user to previous sub page */ prevPage?: () => void; - /** resets to a specific page by name */ resetToPage?: (pageName?: string) => void; }; @@ -26,7 +25,6 @@ type PageConfig /** unique page name used as URL parameter */ pageName: TPageName; - /** component to render for this page */ component: ComponentType; }; @@ -46,7 +44,6 @@ type UseSubPageProps Route; /** diff --git a/src/hooks/useTextInputAccessibility/type.ts b/src/hooks/useTextInputAccessibility/type.ts index ac59fed229e2..cfd35ccfca07 100644 --- a/src/hooks/useTextInputAccessibility/type.ts +++ b/src/hooks/useTextInputAccessibility/type.ts @@ -1,7 +1,6 @@ import type {ReactNode} from 'react'; type TextInputAccessibilityResult = { - /** The accessibilityValue prop for the TextInput */ accessibilityValue: {text: string} | undefined; /** The accessibilityLabelledBy prop for the TextInput, defined on Android only */ diff --git a/src/hooks/useTextWithMiddleEllipsis.ts b/src/hooks/useTextWithMiddleEllipsis.ts index 4ce22c9e413d..9b3a90219704 100644 --- a/src/hooks/useTextWithMiddleEllipsis.ts +++ b/src/hooks/useTextWithMiddleEllipsis.ts @@ -11,7 +11,6 @@ type TruncateProps = { /** The text string that may need truncation */ text: string; - /** Reference to the Text component that will display the text */ ref: RefObject; }; diff --git a/src/hooks/useTodoCounts.ts b/src/hooks/useTodoCounts.ts index ef418d0931cf..d4643ead62c3 100644 --- a/src/hooks/useTodoCounts.ts +++ b/src/hooks/useTodoCounts.ts @@ -42,6 +42,7 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [session] = useOnyx(ONYXKEYS.SESSION); const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Holds the most recent result so a frozen (inactive) consumer can keep returning it without recomputing. const [frozen, setFrozen] = useState<{counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} | null>(null); @@ -66,6 +67,7 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To currentUserAccountID: userAccountID, login, areTransactionsLoaded: transactionsMetadata.status === 'loaded', + rules, }); const counts: TodoCounts = { diff --git a/src/hooks/useTodoSearchResults.ts b/src/hooks/useTodoSearchResults.ts index 59e7650e072f..580f7776d98d 100644 --- a/src/hooks/useTodoSearchResults.ts +++ b/src/hooks/useTodoSearchResults.ts @@ -140,6 +140,7 @@ function useTodoSearchResults(searchKey: SearchKey | undefined): {data: TodoSear const [session] = useOnyx(ONYXKEYS.SESSION); const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); if (!searchKey) { return undefined; @@ -160,6 +161,7 @@ function useTodoSearchResults(searchKey: SearchKey | undefined): {data: TodoSear currentUserAccountID: userAccountID, login, areTransactionsLoaded: transactionsMetadata.status === 'loaded', + rules, }); const metadata = computeMetadata(reports, transactionsByReportID); diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts index 51107acec8f7..2713d8b9242b 100644 --- a/src/hooks/useTransactionInlineEdit.ts +++ b/src/hooks/useTransactionInlineEdit.ts @@ -35,8 +35,10 @@ import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import {useCurrencyListActions} from './useCurrencyList'; import useDelegateAccountID from './useDelegateAccountID'; import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; +import {useLiveDuplicateTransactionsAndViolations} from './useDuplicateTransactionsAndViolations'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import usePersonalPolicy from './usePersonalPolicy'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; import usePolicyForTransaction from './usePolicyForTransaction'; @@ -94,6 +96,11 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const effectiveParentReport = isUnreported ? selfDMReport : parentReport; const effectiveParentReportID = effectiveParentReport?.reportID; + const [liveParentReport] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + const [liveSelfDMReport] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(selfDMReportID)}`); + const parentReportForAction = isUnreported ? (liveSelfDMReport ?? selfDMReport) : (effectiveParentReport ?? liveParentReport); + const linkedReportActionID = linkedReportAction?.reportActionID; // Use original Onyx here because the useOnyx wrapper can read the partial Search snapshot report actions, which may miss @@ -123,6 +130,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use }); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); + const [liveTransactionThreadReport] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reportPolicyID)}`); @@ -132,6 +140,8 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const [policyRecentlyUsedTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${getNonEmptyStringOnyxID(policyID)}`); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); // Use original Onyx here because the useOnyx wrapper can read partial Search snapshot policy data instead of the full policy object. const [completePolicy] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`); @@ -139,6 +149,16 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(originalTransactionID)}`); const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const {isBetaEnabled} = usePermissions(); + + // Scoped transaction/violation collections (the edited transaction plus any duplicates) are read here and + // passed into the pure edit actions, which need them to resolve duplicate-transaction violations. This mirrors + // the non-inline edit flow (see DynamicIOURequestStepDate) and avoids subscribing to the full collections. + const {duplicateTransactions, duplicateTransactionViolations} = useLiveDuplicateTransactionsAndViolations([transactionID]); const {hasSelectedTransactions} = useSearchSelectionContext(); @@ -170,6 +190,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use originalTransaction, disabled: hasSelectedTransactions, shouldSelectPolicyForUnreported: shouldSelectPolicy, + rules, }); const wasEditingOnMouseDownRef = useRef(false); @@ -179,9 +200,9 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use hash, transactionID, transaction, - parentReport: effectiveParentReport, + parentReport: parentReportForAction, parentReportAction, - transactionThreadReport, + transactionThreadReport: transactionThreadReport ?? liveTransactionThreadReport, policy: completePolicy ?? policy, policyForTrackExpense: isTrackExpense ? policyForMovingExpenses : undefined, policyCategories, @@ -192,12 +213,21 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use isOffline, isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed ?? false, hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow ?? false, + conciergeChat, distanceOriginalPolicy, personalDetailsList, delegateAccountID, isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + transactions: duplicateTransactions, + transactionViolations: duplicateTransactionViolations, + betas, + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + introSelected, + currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + currentUserEmail: session?.email ?? '', + rules, }; }; diff --git a/src/hooks/useTravelBillingSetupFlow.ts b/src/hooks/useTravelBillingSetupFlow.ts new file mode 100644 index 000000000000..3097cce9465e --- /dev/null +++ b/src/hooks/useTravelBillingSetupFlow.ts @@ -0,0 +1,50 @@ +import {getCardSettings, getEligibleBankAccountsForCard} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {hasInProgressUSDVBBA} from '@libs/ReimbursementAccountUtils'; +import {getTravelBillingCardSettingsKey, getTravelSettlementAccount, hasTravelBillingSettlementAccount} from '@libs/TravelBillingUtils'; + +import {configureTravelBillingForPolicy} from '@userActions/TravelBilling'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import useOnyx from './useOnyx'; +import useWorkspaceAccountID from './useWorkspaceAccountID'; + +/** + * Returns a handler that starts setting up a brand-new Travel Billing feed on the policy's own workspace fund: + * add a bank account if needed, then the settlement account. Uses the workspace fund so a joined shared feed is never touched. + */ +function useTravelBillingSetupFlow(policyID: string | undefined) { + const workspaceAccountID = useWorkspaceAccountID(policyID); + const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + + const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); + const eligibleBankAccounts = getEligibleBankAccountsForCard(bankAccountList); + const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); + const hasSettlementAccount = hasTravelBillingSettlementAccount(travelSettings); + const settlementAccount = getTravelSettlementAccount(travelSettings, bankAccountList); + + return () => { + if (!policyID) { + return; + } + if (!eligibleBankAccounts.length || isSetupUnfinished) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, backTo: ROUTES.WORKSPACE_TRAVEL.getRoute(policyID)})); + return; + } + if (!hasSettlementAccount) { + Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID)); + return; + } + if (settlementAccount?.bankAccountID) { + configureTravelBillingForPolicy(policyID, workspaceAccountID, settlementAccount.bankAccountID); + } + Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID)); + }; +} + +export default useTravelBillingSetupFlow; diff --git a/src/hooks/useUndeleteTransactions.ts b/src/hooks/useUndeleteTransactions.ts index e5af0ef61a7a..94a8ad44c383 100644 --- a/src/hooks/useUndeleteTransactions.ts +++ b/src/hooks/useUndeleteTransactions.ts @@ -27,6 +27,7 @@ function useUndeleteTransactions() { const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); return (transactions: Transaction[]) => { const transactionIDs = transactions.map((transaction) => transaction.transactionID); @@ -41,6 +42,7 @@ function useUndeleteTransactions() { transactions, allTransactionViolation: transactionViolations, reports, + rules, selfDMReportActions, isTrackIntentUser, personalPolicyOutputCurrency: policy?.outputCurrency, diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index 6bc23fc6bad1..efc377dc8634 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -3,6 +3,7 @@ import Visibility from '@libs/Visibility'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -34,6 +35,10 @@ type UseUnreadMarkerParams = { /** Whether report actions have loaded at least once; once true, the pagination anchor is ignored in favor of the scan */ hasOnceLoadedReportActions: boolean; + + /** Concierge hidden-history boundary: actions created before this were revealed/loaded from history, + * not received live, so they are never treated as read-on-arrival */ + newMessageBoundaryTime?: string | null; }; type UseUnreadMarkerResult = { @@ -53,6 +58,7 @@ function useUnreadMarker({ oldestUnreadReportActionID, isScrolledOverThreshold, hasOnceLoadedReportActions, + newMessageBoundaryTime, }: UseUnreadMarkerParams): UseUnreadMarkerResult { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isAnonymousUser = useIsAnonymousUser(); @@ -122,6 +128,7 @@ function useUnreadMarker({ isAnonymousUser, prevUnreadMarkerReportActionID, hasWindowFocus: Visibility.hasFocus(), + newMessageBoundaryTime, }); // Pagination is anchored to the oldest unread on first open; that anchor does not change when the user // marks read or unread, or when messages are deleted. Prefer the scan when it does not match that stale id. @@ -132,11 +139,16 @@ function useUnreadMarker({ setPrevUnreadMarkerReportActionID(unreadMarkerReportActionID); } - // When the user reads a new message as it is received, push unreadMarkerTime down to the - // latest action's timestamp so new incoming actions display over those new messages instead of - // sticking to the initial lastReadTime. - const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; - if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime) { + // When the user reads a new message as it arrives, advance the watermark so that only actions + // arriving after it count as unread. Only push when the newest visible `created` has advanced: + // a bulk history reveal (Concierge "Show history") also scans to a null marker, and pushing then + // would move the watermark past the unread message and permanently hide the New divider. The + // synthetic greeting is not a valid push target either, because its `created` tracks + // report.lastReadTime and would drag the watermark to "now". + const newestVisibleReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; + const prevNewestVisibleReportActionCreated = usePrevious(newestVisibleReportActionCreated); + const mostRecentReportActionCreated = sortedVisibleReportActions.find((action) => action.reportActionID !== CONST.CONCIERGE_GREETING_ACTION_ID)?.created ?? ''; + if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime && newestVisibleReportActionCreated > prevNewestVisibleReportActionCreated) { setUnreadMarkerTime(mostRecentReportActionCreated); } diff --git a/src/hooks/useViewportOffsetTop/index.ts b/src/hooks/useViewportOffsetTop/index.ts index 00c768977e53..ed71610b0439 100644 --- a/src/hooks/useViewportOffsetTop/index.ts +++ b/src/hooks/useViewportOffsetTop/index.ts @@ -2,16 +2,30 @@ import addViewportResizeListener from '@libs/VisualViewport'; import {useSyncExternalStore} from 'react'; -/** - * A hook that returns the offset of the top edge of the visual viewport - */ +function getVisualViewportOffsetTop() { + return window.visualViewport?.offsetTop ?? 0; +} + +let offsetTop = getVisualViewportOffsetTop(); + function subscribe(callback: () => void) { - const unsubscribe = addViewportResizeListener(callback); - window.visualViewport?.addEventListener('scroll', callback); + offsetTop = getVisualViewportOffsetTop(); + + const handleViewportChange = () => { + offsetTop = getVisualViewportOffsetTop(); + callback(); + }; + + const unsubscribe = addViewportResizeListener(handleViewportChange); + window.visualViewport?.addEventListener('scroll', handleViewportChange); return () => { - window.visualViewport?.removeEventListener('scroll', callback); + window.visualViewport?.removeEventListener('scroll', handleViewportChange); unsubscribe(); }; } -export default () => useSyncExternalStore(subscribe, () => window.visualViewport?.offsetTop ?? 0); +function getSnapshot() { + return offsetTop; +} + +export default () => useSyncExternalStore(subscribe, getSnapshot); diff --git a/src/hooks/useWorkspacesTabIndicatorStatus.ts b/src/hooks/useWorkspacesTabIndicatorStatus.ts index f8cf1c25681f..9ee143bc9de7 100644 --- a/src/hooks/useWorkspacesTabIndicatorStatus.ts +++ b/src/hooks/useWorkspacesTabIndicatorStatus.ts @@ -17,11 +17,11 @@ type WorkspacesTabIndicatorStatusResult = { function useWorkspacesTabIndicatorStatus(): WorkspacesTabIndicatorStatusResult { const theme = useTheme(); - const {policyErrorStatus, policyInfoStatus, domainStatus, indicatorPolicyID} = usePolicyIndicatorChecks(); + const {policyErrorStatus, policyInfoStatus, domainErrorStatus, domainInfoStatus, indicatorPolicyID} = usePolicyIndicatorChecks(); - // Workspaces tab indicator priority: policy errors (red) > domain errors (red) > policy info statuses (green). - const errorStatus = policyErrorStatus ?? domainStatus; - const status = errorStatus ?? policyInfoStatus; + // Workspaces tab indicator priority: policy errors (red) > domain errors (red) > policy info statuses (green) > domain info statuses (green). + const errorStatus = policyErrorStatus ?? domainErrorStatus; + const status = errorStatus ?? policyInfoStatus ?? domainInfoStatus; const indicatorColor = errorStatus ? theme.danger : theme.success; return {indicatorColor, status, indicatorPolicyID}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 5d1e69a9c00a..8401d1596967 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: 'Freigabe aufheben', yes: 'Ja', no: 'Nein', + approve: 'Genehmigen', + deny: 'Ablehnen', dontChange: 'Nicht ändern', ok: 'OK', notNow: 'Nicht jetzt', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: 'Suche', reports: 'Berichte', spend: 'Ausgaben', + insights: 'Einblicke', find: 'Finden', searchWithThreeDots: 'Suchen...', next: 'Weiter', @@ -465,6 +468,9 @@ const translations: TranslationDeepObject = { none: 'Keine', unstableInternetConnection: 'Instabile Internetverbindung. Bitte überprüfe dein Netzwerk und versuche es erneut.', enableGlobalReimbursements: 'Globale Rückerstattungen aktivieren', + corpayPayModalTitle: 'Bericht bezahlen', + corpayPayModalPrompt: + 'Diese einreichende Person hat ein Bankkonto in einer anderen Währung als USD. Aktiviere globale Rückerstattungen, um den Bericht zu bezahlen, oder bitte sie, ein USD-Bankkonto hinzuzufügen.', purchaseAmount: 'Kaufbetrag', originalAmount: 'Ursprünglicher Betrag', frequency: 'Häufigkeit', @@ -951,13 +957,21 @@ const translations: TranslationDeepObject = { tooltip: 'Starte hier!', }, homePage: { - forYou: 'Für dich', + toDos: 'Aufgaben', timeSensitiveSection: { title: 'Zeitkritisch', - addShippingAddress: {title: 'Wir benötigen deine Versandadresse', subtitle: 'Geben Sie eine Adresse an, um Ihre Expensify Karte zu erhalten.', cta: 'Adresse hinzufügen'}, + 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'}, activateCard: {title: 'Aktivieren Sie Ihre Expensify Karte', subtitle: 'Validieren Sie Ihre Karte und beginnen Sie mit dem Ausgeben.', cta: 'Aktivieren'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `${walletName}-Kartenergänzung erfordert Ihre Zustimmung`, + subtitle: 'Expensify Karte', + cta: 'Überprüfen', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Digitale Brieftasche', + }, reviewCardFraud: { title: 'Möglichen Betrug mit Ihrer Expensify Karte überprüfen', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Überprüfe mögliche betrügerische ${amount} bei ${merchant}`, @@ -980,7 +994,7 @@ const translations: TranslationDeepObject = { subtitle: 'Wallet', }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, - addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse zur Entfernungserfassung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, + addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse zur Entfernungserfassung hinzu', subtitle: 'Konto', cta: 'Hinzufügen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', @@ -988,11 +1002,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'Geldbörse', }, - addVirtualCardPersonalDetails: { - title: 'Fügen Sie Ihre Daten hinzu, um Ihre Expensify Karte anzuzeigen und zu verwenden.', - subtitle: 'Expensify Karte', - cta: 'Details hinzufügen', - }, + addVirtualCardPersonalDetails: {title: 'Fügen Sie Ihre Daten hinzu, um Ihre Expensify Karte anzuzeigen und zu verwenden.', subtitle: 'Expensify Karte', cta: 'Hinzufügen'}, enterSignerInfo: {title: 'Unterzeichnerdaten erforderlich', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Bankkonto ${bankAccountLastFour}`}, }, discoverSection: { @@ -1079,6 +1089,8 @@ const translations: TranslationDeepObject = { inviteAccountant: 'Lade deine:n Steuerberater:in ein', customizeSpendCategories: 'Ausgabenkategorien anpassen', customizeSpendCategoriesSubText: 'Ausgaben organisieren und klassifizieren', + customizeExpenseCategories: 'Passen Sie Ihre Ausgabenkategorien an', + customizeExpenseCategoriesSubText: 'Fügen Sie die Kategorien Ihres Unternehmens hinzu, um Ihre Ausgaben zu kodieren', createExpense: 'Ausgabe erstellen', createExpenseSubText: 'Scannen Sie eine Ausgabe, ziehen Sie sie per Drag & Drop herein oder geben Sie sie manuell über die + Schaltfläche ein', linkPersonalCard: 'Persönliche Karte verknüpfen', @@ -1120,7 +1132,6 @@ const translations: TranslationDeepObject = { title: 'Ihre Ausgaben', awaitingApproval: 'Ausstehende Genehmigung', repaidLast30Days: 'Letzte 30 Tage zurückgezahlt', - recentTransactions: ({lastFour}: {lastFour: string}) => `Aktuelle Transaktionen • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `${count} weitere anzeigen`, recentlyAddedSection: { @@ -1276,6 +1287,7 @@ const translations: TranslationDeepObject = { phrase1: 'Beleg hinzufügen', phrase2: 'oder ziehe eine hierher und lege sie ab', }, + pageCount: ({pageCount}: {pageCount: number}) => `Seite 1 von ${pageCount}`, }, quickAction: { scanReceipt: 'Beleg scannen', @@ -1665,11 +1677,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Alles erledigt!', noDuplicatesDescription: 'Es gibt hier keine doppelten Transaktionen zur Überprüfung.', confirmApprove: 'Genehmigungsbetrag bestätigen', - confirmApprovalAmount: 'Nur regelkonforme Ausgaben genehmigen oder den gesamten Bericht genehmigen.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Diese Ausgabe ist zurückgestellt. Möchtest du sie trotzdem genehmigen?', - other: 'Diese Ausgaben sind zurückgestellt. Möchtest du sie trotzdem genehmigen?', - }), + confirmApprovalWithHeldAmount: 'Der Bericht enthält zurückgestellte Ausgaben. Nur konforme Ausgaben genehmigen oder den gesamten Bericht genehmigen?', + confirmApprovalAllHoldAmount: 'Alle Ausgaben sind zurückgestellt. Trotzdem genehmigen?', confirmPay: 'Zahlungsbetrag bestätigen', confirmPayAmount: 'Bezahle, was nicht zurückgestellt ist, oder bezahle den gesamten Bericht.', confirmPayAllHoldAmount: () => ({ @@ -2301,6 +2310,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Hervorgehobene Spannen-Namen', sentryHighlightedSpanOpsPlaceholder: 'ui.interaktion.klick, navigation, ui.laden', showBranchNameInTitle: 'Branch-Namen im Browser-Titel anzeigen', + betaOverrides: 'Beta-Überschreibungen', + betaOverridesDescription: + 'Überschreibungen gelten nur für dieses Gerät und wirken sich nur auf Frontend-Prüfungen aus. Eine Beta behält eine Überschreibung nur so lange, wie sie von den Betas Ihres Kontos abweicht; schalten Sie sie zurück, wird die Überschreibung entfernt. „Alle Überschreibungen zurücksetzen“ stellt die Werte Ihres Kontos wieder her. Einige Betas werden zusätzlich vom Backend gesteuert und können daher weiterhin auf Anfrageebene fehlschlagen.', + resetAllOverrides: 'Alle Überschreibungen zurücksetzen', + overridden: 'Überschrieben', qaAuth: 'QA-Authentifizierung (Cloudflare)', qaAuthRunProbe: 'Test ausführen', qaAuthSession: 'QA-Authentifizierungssitzung', @@ -2614,6 +2628,10 @@ const translations: TranslationDeepObject = { connectionLink ? `Die Verbindung Ihrer ${cardName}-Karte ist unterbrochen. Melden Sie sich bei Ihrer Bank an, um die Karte zu reparieren.` : `Die Verbindung Ihrer ${cardName}-Karte ist unterbrochen. Melden Sie sich bei Ihrer Bank an, um die Karte zu reparieren.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Ihre ${cardName}-Verbindung ist seit 30 Tagen unterbrochen. Melden Sie sich bei Ihrer Bank an, um sie zu reparieren, oder entfernen Sie die Karte, wenn sie nicht mehr verwendet wird. Bereits eingereichte Ausgaben gehen beim Entfernen nicht verloren.` + : `Ihre ${cardName}-Verbindung ist seit 30 Tagen unterbrochen. Melden Sie sich bei Ihrer Bank an, um sie zu beheben, oder entfernen Sie die Karte, wenn sie nicht mehr verwendet wird. Sie verlieren keine eingereichten Ausgaben, wenn Sie sie entfernen.`, fixCard: 'Karte reparieren', addAdditionalCards: 'Weitere Karten hinzufügen', upgradeDescription: 'Müssen Sie weitere Karten hinzufügen? Erstellen Sie einen Workspace, um weitere persönliche Karten hinzuzufügen oder Firmenkarten dem gesamten Team zuzuweisen.', @@ -2714,6 +2732,24 @@ const translations: TranslationDeepObject = { unlock: 'Entsperren', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Karte zu ${walletName} hinzufügen`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'digitale Brieftasche', + confirmHeading: 'Bestätigen Sie Ihre Anfrage', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Möchten Sie Ihre Expensify Karte (endet auf ${lastFourDigits}) zu Ihrem ${walletName} hinzufügen?`, + deny: 'Ablehnen', + confirm: 'Bestätigen', + verifyTitle: 'Lassen Sie uns bestätigen, dass Sie es sind', + enterSecurityCode: (contactMethod: string) => + `Bitte geben Sie den Sicherheitscode ein, der an ${contactMethod} gesendet wurde, um diese Anfrage zu bestätigen. Er sollte innerhalb von ein bis zwei Minuten eintreffen.`, + successHeading: 'Erfolg!', + successDescription: ({walletName}: {walletName: string}) => `Ihre Karte ist jetzt zur Verwendung in Ihrem ${walletName} aktiv.`, + deniedHeading: 'Anfrage abgelehnt', + deniedDescription: ({walletName}: {walletName: string}) => `Ihre Karte wurde nicht zu Ihrem ${walletName} hinzugefügt.`, + }, cardPage: { expensifyCard: 'Expensify Karte', expensifyTravelCard: 'Expensify Travel Card', @@ -3165,6 +3201,7 @@ ${amount} für ${merchant} – ${date}`, unread: 'Ungelesen', markAllAsRead: 'Alle als gelesen markieren', markAllAsReadConfirmationPrompt: 'Möchtest du wirklich alle Chats als gelesen markieren?', + markAllTodosAsReadConfirmationPrompt: 'Möchtest du wirklich alle offenen Chats als gelesen markieren?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3345,6 +3382,7 @@ ${amount} für ${merchant} – ${date}`, `Wir konnten ${workEmail} nicht hinzufügen. Bitte versuche es später in den Einstellungen erneut oder chatte mit Concierge, um Unterstützung zu erhalten.`, workAccountClosedSubtitle: 'Das Arbeitskonto, das mit dieser E-Mail-Adresse verknüpft ist, wurde geschlossen. Bitte wenden Sie sich an Ihre Unternehmensadministratorin bzw. Ihren Unternehmensadministrator, um es zu reaktivieren, oder registrieren Sie sich mit einer anderen E-Mail-Adresse.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} ist ein domänengesteuerter Login für ein bestehendes Expensify-Konto.`, }, tasks: { testDriveAdminTask: { @@ -4181,8 +4219,7 @@ ${amount} für ${merchant} – ${date}`, legalFirstName: 'Rechtlicher Vorname', legalLastName: 'Rechtlicher Nachname', enterTheDateOfBirthOfTheOwner: 'Wie lautet das Geburtsdatum des Eigentümers?', - enterTheLast4: 'Wie lauten die letzten 4 Ziffern der Sozialversicherungsnummer des Inhabers?', - last4SSN: 'Letzte 4 Ziffern der SSN', + enterTheSSN: 'Wie lautet die Sozialversicherungsnummer des Inhabers?', dontWorry: 'Keine Sorge, wir führen keine persönlichen Bonitätsprüfungen durch!', enterTheOwnersAddress: 'Wie lautet die Adresse des Eigentümers?', letsDoubleCheck: 'Lass uns noch einmal überprüfen, ob alles richtig aussieht.', @@ -4209,13 +4246,12 @@ ${amount} für ${merchant} – ${date}`, whatsYourDOB: 'Wie ist dein Geburtsdatum?', whatsTheOwnersAddress: 'Wie lautet die Adresse des Eigentümers?', whatsYourAddress: 'Wie lautet deine Adresse?', - whatAreTheLast: 'Wie lauten die letzten 4 Ziffern der Sozialversicherungsnummer des Inhabers?', - whatsYourLast: 'Wie lauten die letzten 4 Ziffern Ihrer Sozialversicherungsnummer?', + whatsTheOwnersSSN: 'Wie lautet die Sozialversicherungsnummer des Inhabers?', + whatsYourSSN: 'Wie lautet Ihre Sozialversicherungsnummer?', whatsYourNationality: 'Was ist dein Staatsangehörigkeitsland?', whatsTheOwnersNationality: 'In welchem Land ist der/die Inhaber:in Staatsbürger:in?', countryOfCitizenship: 'Staatsangehörigkeit', dontWorry: 'Keine Sorge, wir führen keine persönlichen Bonitätsprüfungen durch!', - last4: 'Letzte 4 Ziffern der SSN', whyDoWeAsk: 'Warum fragen wir danach?', letsDoubleCheck: 'Lass uns noch einmal überprüfen, ob alles richtig aussieht.', legalName: 'Rechtlicher Name', @@ -4240,6 +4276,8 @@ ${amount} für ${merchant} – ${date}`, proofOfBeneficialOwner: 'Nachweis des wirtschaftlich Berechtigten', proofOfBeneficialOwnerDescription: 'Bitte legen Sie eine unterzeichnete Bescheinigung und ein Organigramm vor, ausgestellt von einem vereidigten Buchhalter, Notar oder Rechtsanwalt, die den Besitz von 25 % oder mehr des Unternehmens bestätigt. Das Dokument muss innerhalb der letzten drei Monate datiert sein und die Lizenznummer der unterzeichnenden Person enthalten.', + proofOfBeneficialOwnerDescriptionCA: + 'Bitte geben Sie Ihren Schedule 50 (Aktionärsinformationen) aus Ihrer aktuellsten T2-Körperschaftsteuererklärung an, in dem jede Person aufgeführt ist, die 25 % oder mehr des Unternehmens besitzt.', copyOfID: 'Kopie des Ausweises des wirtschaftlich Berechtigten', copyOfIDDescription: 'Beispiele: Reisepass, Führerschein usw.', proofOfAddress: 'Adressnachweis für wirtschaftlich Berechtigte', @@ -4624,6 +4662,7 @@ ${amount} für ${merchant} – ${date}`, customFieldHint: 'Füge benutzerdefinierte Codierung hinzu, die für alle Ausgaben dieses Mitglieds gilt.', reports: 'Berichte', reportFields: 'Berichtsfelder', + invoiceFields: 'Rechnungsfelder', reportTitle: 'Berichtstitel', reportField: 'Berichts­feld', taxes: 'Steuern', @@ -4633,6 +4672,7 @@ ${amount} für ${merchant} – ${date}`, travel: 'Reisen', members: 'Mitglieder', accounting: 'Buchhaltung', + mcp: 'MCP', receiptPartners: 'Belegpartner', rules: 'Regeln', displayedAs: 'Angezeigt als', @@ -4757,6 +4797,7 @@ ${amount} für ${merchant} – ${date}`, travelInvoicingVendor: 'Reiseanbieter', travelInvoicingPayableAccount: 'Reiseverbindlichkeitskonto', hr: 'Personalwesen', + recruiting: 'Personalbeschaffung', rooms: 'Räume', findDomain: 'Domain finden', cardAdminAlternateText: 'Arbeitsbereichskarten verwalten.', @@ -4770,6 +4811,25 @@ ${amount} für ${merchant} – ${date}`, title: 'Du hast einen Workspace für deinen Kunden erstellt!', description: 'Großartige Neuigkeiten 🎉. Kontaktiere uns, wenn sie Hilfe bei der Einrichtung benötigen.', }, + mcp: { + connectors: 'Konnektoren', + connectorsSubtitle: 'Verbinde einen KI-Assistenten mit deinem Expensify-Konto.', + connect: 'Verbinden', + helpPrompt: 'Brauchst du Hilfe beim Verbinden?', + helpLink: 'Lies unsere Anleitung.', + claude: { + title: 'Claude', + subtitle: 'von Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'von Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'von OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -4990,7 +5050,6 @@ ${amount} für ${merchant} – ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Verwenden Sie dieses Konto beim Exportieren von Rechnungen nach ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Legen Sie fest, wie Unternehmenskartenkäufe nach ${integrationName} exportiert werden.`, vendor: 'Lieferant', - defaultVendorDescription: 'Legen Sie einen Standardanbieter fest, der beim Export auf alle Kreditkartentransaktionen angewendet wird.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Legen Sie fest, wie Auslagen in ${integrationName} exportiert werden.`, exportCheckDescription: 'Wir erstellen für jeden Expensify-Bericht einen Einzelpostenscheck und senden ihn von dem unten stehenden Bankkonto.', exportJournalEntryDescription: 'Wir erstellen für jeden Expensify-Bericht einen detaillierten Buchungssatz und buchen ihn auf das untenstehende Konto.', @@ -5159,8 +5218,6 @@ ${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', - defaultSupplier: 'Standardlieferant', - defaultSupplierDescription: 'Legen Sie einen Standardsupplier fest, der beim Export auf alle Kreditkartentransaktionen angewendet wird.', noSuppliersFound: 'Keine Lieferanten gefunden', noSuppliersFoundDescription: 'Bitte fügen Sie den Lieferanten in Xero hinzu und synchronisieren Sie die Verbindung erneut.', accountingMethods: { @@ -6389,6 +6446,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU 'Wir konnten einige Mitglieder Ihres Arbeitsbereichs nicht für die Konsolidierte Reiseabrechnung einrichten. Bitte versuchen Sie es später erneut oder wenden Sie sich für Unterstützung an Concierge.', sendInvoiceNowCta: 'Rechnung jetzt senden', }, + setUpNewFeed: 'Neuen Reise-Feed einrichten', + feedSelectorIntro: + 'Ihre Organisation hat bereits einen konsolidierten Travel-Billing-Feed eingerichtet. Wählen Sie ihn aus, um die Reisekosten dieses Arbeitsbereichs auf derselben monatlichen Rechnung zu halten.', }, disableModal: { title: 'Konsolidierte Reiseabrechnung deaktivieren?', @@ -6547,6 +6607,10 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU title: 'Buchhaltung', subtitle: 'Synchronisieren Sie Ihren Kontenplan und mehr.', }, + mcp: { + title: 'MCP', + subtitle: 'Verbinde einen KI-Assistenten mit deinem Expensify-Konto.', + }, receiptPartners: { title: 'Belegpartner', subtitle: 'Belege automatisch importieren.', @@ -6610,6 +6674,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU subtitle: 'Berichtsfelder gelten für alle Ausgaben und können hilfreich sein, wenn du nach zusätzlichen Informationen fragen möchtest.', disableReportFields: 'Berichtsfelder deaktivieren', disableReportFieldsConfirmation: 'Bist du sicher? Text- und Datumsfelder werden gelöscht und Listen werden deaktiviert.', + cannotDisableImportedReportFields: 'Berichtsfelder, die aus Ihrer Buchhaltungsverbindung importiert wurden, können nicht deaktiviert werden.', importedFromAccountingSoftware: 'Die folgenden Berichtsfelder werden importiert aus Ihrem', textType: 'Text', dateType: 'Datum', @@ -6646,6 +6711,29 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU reportFieldInitialValueRequiredError: 'Bitte wähle einen Anfangswert für ein Berichtsfeld aus', genericFailureMessage: 'Beim Aktualisieren des Berichtfelds ist ein Fehler aufgetreten. Bitte versuche es erneut.', }, + invoiceFields: { + subtitle: 'Rechnungsfelder können hilfreich sein, wenn du zusätzliche Informationen einfügen möchtest.', + importedFromAccountingSoftware: 'Die folgenden Rechnungsfelder werden importiert aus Ihrem', + disableInvoiceFields: 'Rechnungsfelder deaktivieren', + disableInvoiceFieldsConfirmation: 'Sind Sie sicher? Rechnungsfelder werden auf Rechnungen deaktiviert.', + delete: 'Rechnungsfeld löschen', + deleteConfirmation: 'Sind Sie sicher, dass Sie dieses Rechnungsfeld löschen möchten?', + findInvoiceField: 'Rechnungsfeld suchen', + nameInputSubtitle: 'Wähle einen Namen für das Rechnungsfeld.', + typeInputSubtitle: 'Wähle aus, welcher Rechnungsfeldtyp verwendet werden soll.', + initialValueInputSubtitle: 'Gib einen Startwert ein, der im Rechnungsfeld angezeigt werden soll.', + listValuesInputSubtitle: 'Diese Werte werden im Dropdown-Menü des Rechnungsfelds angezeigt. Aktivierte Werte können von Mitgliedern ausgewählt werden.', + listInputSubtitle: 'Diese Werte werden in der Rechnungsfeldliste angezeigt. Aktivierte Werte können von Mitgliedern ausgewählt werden.', + emptyInvoiceFieldsValues: { + title: 'Noch keine Listenwerte', + subtitle: 'Füge benutzerdefinierte Werte hinzu, die auf Rechnungen angezeigt werden.', + }, + existingInvoiceFieldNameError: 'Ein Rechnungsfeld mit diesem Namen existiert bereits', + invoiceFieldNameRequiredError: 'Bitte gib einen Namen für das Rechnungsfeld ein', + invoiceFieldTypeRequiredError: 'Bitte wähle einen Rechnungsfeldtyp aus', + invoiceFieldInitialValueRequiredError: 'Bitte wähle einen Anfangswert für ein Rechnungsfeld aus', + addField: 'Feld hinzufügen', + }, vendors: { emptyTitle: 'Noch keine Lieferanten', emptySubtitle: 'Anbieter werden hier angezeigt, nachdem Ihre Buchhaltungssynchronisierung abgeschlossen ist.', @@ -7041,6 +7129,8 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7101,7 +7191,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, switch (stage) { case 'quickbooksOnlineImportCustomers': case 'quickbooksDesktopImportCustomers': - return 'Kunden werden importiert'; + return 'Kund:innen werden importiert'; case 'quickbooksOnlineImportEmployees': case 'netSuiteSyncImportEmployees': case 'intacctImportEmployees': @@ -7112,24 +7202,24 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, return 'Konten werden importiert'; case 'quickbooksOnlineImportClasses': case 'quickbooksDesktopImportClasses': - return 'Klassen werden importiert'; + return 'Klassen importieren'; case 'quickbooksOnlineImportLocations': return 'Standorte werden importiert'; case 'quickbooksOnlineImportProcessing': return 'Importierte Daten werden verarbeitet'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': - return 'Synchronisieren von erstatteten Berichten und Rechnungszahlungen'; + return 'Synchronisierung erstatteter Berichte und Rechnungszahlungen'; case 'quickbooksOnlineSyncTaxCodes': return 'Steuercodes werden importiert'; case 'quickbooksOnlineCheckConnection': - return `${integrationName}-Verbindung wird überprüft`; + return `Überprüfe ${integrationName}-Verbindung`; case 'quickbooksOnlineImportMain': - return `Importieren von ${integrationName}-Daten`; + return `${integrationName}-Daten werden importiert`; case 'startingImportXero': return 'Xero-Daten werden importiert'; case 'startingImportQBO': - return `Importieren von ${integrationName}-Daten`; + return `${integrationName}-Daten werden importiert`; case 'startingImportQBD': case 'quickbooksDesktopImportMore': return 'QuickBooks Desktop-Daten importieren'; @@ -7138,11 +7228,11 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'quickbooksDesktopImportApproveCertificate': return 'Genehmigungszertifikat wird importiert'; case 'quickbooksDesktopImportDimensions': - return 'Importieren von Dimensionen'; + return 'Dimensionen werden importiert'; case 'quickbooksDesktopImportSavePolicy': - return 'Richtlinie zum Speichern importieren'; + return 'Richtlinie wird importiert gespeichert'; case 'quickbooksDesktopWebConnectorReminder': - return 'Daten werden noch mit QuickBooks synchronisiert … Bitte stellen Sie sicher, dass der Web Connector ausgeführt wird'; + return 'Synchronisiere weiterhin Daten mit QuickBooks ... Bitte stellen Sie sicher, dass der Web Connector ausgeführt wird'; case 'quickbooksOnlineSyncTitle': return `${integrationName}-Daten werden synchronisiert`; case 'quickbooksOnlineSyncLoadData': @@ -7152,19 +7242,19 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'quickbooksOnlineSyncApplyCategories': return 'Kategorien werden aktualisiert'; case 'quickbooksOnlineSyncApplyCustomers': - return 'Kunden/Projekte werden aktualisiert'; + return 'Kund:innen/Projekte werden aktualisiert'; case 'quickbooksOnlineSyncApplyEmployees': return 'Personenliste wird aktualisiert'; case 'quickbooksOnlineSyncApplyClassesLocations': - return 'Berichts­felder werden aktualisiert'; + return 'Berichtsfelder werden aktualisiert'; case 'jobDone': - return 'Warten auf das Laden der importierten Daten'; + return 'Warten, bis importierte Daten geladen sind'; case 'xeroSyncImportChartOfAccounts': return 'Kontenplan wird synchronisiert'; case 'xeroSyncImportCategories': return 'Kategorien werden synchronisiert'; case 'xeroSyncImportCustomers': - return 'Kunden werden synchronisiert'; + return 'Kund:innen werden synchronisiert'; case 'xeroSyncXeroReimbursedReports': return 'Expensify-Berichte als erstattet markieren'; case 'xeroSyncExpensifyReimbursedReports': @@ -7182,11 +7272,11 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'netSuiteSyncConnection': return 'Verbindung zu NetSuite wird initialisiert'; case 'netSuiteSyncCustomers': - return 'Kunden werden importiert'; + return 'Kund:innen werden importiert'; case 'netSuiteSyncInitData': - return 'Daten werden aus NetSuite abgerufen'; + return 'Daten werden von NetSuite abgerufen'; case 'netSuiteSyncImportTaxes': - return 'Steuern werden importiert'; + return 'Steuern importieren'; case 'netSuiteSyncImportItems': return 'Elemente werden importiert'; case 'netSuiteSyncData': @@ -7200,7 +7290,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'netSuiteSyncReportFields': return 'Daten als Expensify-Berichts­felder importieren'; case 'netSuiteSyncTags': - return 'Import von Daten als Expensify-Tags'; + return 'Daten als Expensify-Tags importieren'; case 'netSuiteSyncUpdateConnectionData': return 'Verbindungsinformationen werden aktualisiert'; case 'netSuiteSyncNetSuiteReimbursedReports': @@ -7208,22 +7298,22 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'netSuiteSyncExpensifyReimbursedReports': return 'NetSuite-Rechnungen als bezahlt markieren'; case 'netSuiteImportVendorsTitle': - return 'Lieferanten importieren'; + return 'Lieferanten werden importiert'; case 'netSuiteImportCustomListsTitle': - return 'Import benutzerdefinierter Listen'; + return 'Benutzerdefinierte Listen importieren'; case 'netSuiteSyncImportCustomLists': - return 'Import benutzerdefinierter Listen'; + return 'Benutzerdefinierte Listen importieren'; case 'netSuiteSyncImportSubsidiaries': - return 'Import von Tochtergesellschaften'; + return 'Importieren von Tochtergesellschaften'; case 'netSuiteSyncImportVendors': case 'quickbooksDesktopImportVendors': - return 'Lieferanten importieren'; + return 'Lieferanten werden importiert'; case 'intacctCheckConnection': - return 'Sage Intacct-Verbindung wird überprüft'; + return 'Sage-Intacct-Verbindung wird überprüft'; case 'intacctImportDimensions': - return 'Sage-Intacct-Dimensionen importieren'; + return 'Importieren von Sage Intacct-Dimensionen'; case 'intacctImportTitle': - return 'Import von Sage-Intacct-Daten'; + return 'Sage Intacct-Daten werden importiert'; case 'financialForceSyncTitle': return 'Certinia-Daten werden synchronisiert'; case 'financialForceSyncStep': @@ -7233,7 +7323,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'financialForceSyncTags': return 'Tags werden importiert'; case 'financialForceSyncVendors': - return 'Lieferanten importieren'; + return 'Lieferanten werden importiert'; case 'financialForceSyncContacts': return 'Kontakte werden importiert'; case 'financialForceSyncCompanies': @@ -7241,7 +7331,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'financialForceSyncUsers': return 'Benutzer importieren'; case 'financialForceSyncDimensions': - return 'Importieren von Dimensionen'; + return 'Dimensionen werden importiert'; case 'financialForceMarkAsReimbursed': return 'Berichte als erstattet markieren'; case 'rilletSyncTitle': @@ -7251,19 +7341,31 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, case 'rilletSyncImportData': return 'Daten werden geladen'; case 'dualEntrySyncTitle': - return 'Synchronisiere DualEntry-Daten'; + return 'Daten für DualEntry werden synchronisiert'; case 'dualEntrySyncConnection': return 'Verbindung zu DualEntry wird initialisiert'; case 'dualEntrySyncImportData': return 'Daten werden geladen'; case 'dualEntrySyncPayments': - return 'Anbieterzahlungen werden synchronisiert'; + return 'Lieferantenzahlungen werden synchronisiert'; case 'dualEntrySyncCardSettlements': return 'Kartenausgleiche werden synchronisiert'; case 'dualEntrySyncTravelSettlements': return 'Reiseabrechnungen werden synchronisiert'; + case 'campfireSyncTitle': + return 'Campfire-Daten werden synchronisiert'; + case 'campfireSyncConnection': + return 'Verbindung zu Campfire wird initialisiert'; + case 'campfireSyncImportData': + return 'Daten werden geladen'; + case 'campfireSyncPayments': + return 'Lieferantenzahlungen werden synchronisiert'; + case 'campfireSyncCardSettlements': + return 'Kartenausgleiche werden synchronisiert'; + case 'campfireSyncTravelSettlements': + return 'Reiseabrechnungen werden synchronisiert'; default: { - return `Übersetzung für Stufe fehlt: ${stage}`; + return `Übersetzung fehlt für Stufe: ${stage}`; } } }, @@ -7277,11 +7379,11 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, exportCompanyCard: 'Firmenkartenausgaben exportieren als', exportDate: 'Exportdatum', defaultVendor: 'Standardanbieter', - defaultVendorHelperText: (isSet: boolean) => - isSet - ? `Spesen, die nicht automatisch abgeglichen werden, werden standardmäßig diesem Anbieter zugeordnet.` - : `Ausgaben, die nicht automatisch abgeglichen werden, werden standardmäßig diesem Lieferanten zugeordnet. Andernfalls werden sie als „Credit Card Misc.“ exportiert.`, - defaultVendorSelectHeader: (connectionName: string) => `Wählen Sie einen Standard-${connectionName}-Lieferanten für Ausgaben, die nicht automatisch zugeordnet werden.`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName + ? `Ausgaben, die nicht automatisch abgeglichen werden, werden standardmäßig diesem Lieferanten zugeordnet.` + : `Ausgaben, die nicht automatisch abgeglichen werden, werden standardmäßig diesem Lieferanten zugeordnet. Andernfalls werden sie als „${fallbackVendorName}.“ exportiert.`, + defaultVendorSelectHeader: `Wählen Sie einen Standardanbieter für Ausgaben, die nicht automatisch zugeordnet werden.`, defaultAccount: 'Standardkonto', autoSync: 'Automatische Synchronisierung', autoSyncDescription: 'NetSuite und Expensify automatisch jeden Tag synchronisieren. Finalisierte Berichte in Echtzeit exportieren', @@ -7304,6 +7406,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, syncTravelInvoicingSettlements: 'Konsolidierte Reiseabrechnungs-Abrechnungen synchronisieren', syncTravelInvoicingSettlementsNoAccountTooltip: 'Zum Aktivieren legen Sie ein Konto für Ihre Exporte fest.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Zum Entsperren automatische Synchronisierung aktivieren.', + campfire: 'Campfire', }, export: { notReadyHeading: 'Nicht bereit zum Export', @@ -7360,14 +7463,14 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, title: 'Arbeitswege ausschließen', summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, - optionDisabledTitle: 'Pendelfahrten nicht ausschließen', + optionDisabledTitle: 'Pendlerpauschale nicht ausschließen', optionDisabledHelp: 'Keine Pendelfahrt wird von Anträgen abgezogen.', - optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', - optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', + optionFixedDistanceTitle: 'Feste Entfernung entfernen', + optionFixedDistanceHelp: 'Subtrahiert die gleiche Entfernung von jeder Forderung.', distanceLabel: 'Entfernung', summaryHomeAndOffice: 'Standort für Zuhause und Büro verwenden', - optionHomeAndOfficeTitle: 'Nach Wohnsitz und Büro berechnen', - optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuweisung der betreffenden Person, um Pendlerausschlüsse zu berechnen.', + optionHomeAndOfficeTitle: 'Übliche Pendelstrecke ausschließen', + optionHomeAndOfficeHelp: 'Zieht für jedes Mitglied den regulären Arbeitsweg auf Basis von Wohnort, Arbeitsmodell und Büro ab.', workspaceAddressRequired: { title: 'Nicht so schnell …', promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, nachdem Sie zunächst einen Bürostandort hinzugefügt haben in', @@ -7569,6 +7672,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Berichtsfelder sind nur im Control-Tarif verfügbar, ab ${formattedPrice} ${hasTeam2025Pricing ? `pro Mitglied und Monat.` : `pro aktivem Mitglied und Monat.`}`, }, + invoiceFields: { + title: 'Rechnungsfelder', + description: `Mit Rechnungsfeldern kannst du zusätzliche Details auf Rechnungsebene in Rechnungen aufnehmen.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Rechnungsfelder sind nur im Control-Tarif verfügbar, ab ${formattedPrice} ${hasTeam2025Pricing ? `pro Mitglied und Monat.` : `pro aktivem Mitglied und Monat.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Profitiere von automatischer Synchronisierung und reduziere manuelle Eingaben mit der Expensify + NetSuite-Integration. Gewinne detaillierte Finanzanalysen in Echtzeit mit nativer und benutzerdefinierter Segmentunterstützung, einschließlich Projekt- und Kundenzuordnung.`, @@ -7611,6 +7720,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Unsere DualEntry-Integration ist nur im Control-Tarif verfügbar, beginnend bei ${formattedPrice} ${hasTeam2025Pricing ? `pro Mitglied und Monat.` : `pro aktivem Mitglied und Monat.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Profitiere von automatisierter Synchronisierung und reduziere manuelle Eingaben mit der Expensify + Campfire-Integration. Richte Spesenkodierungsdimensionen und die Steuersynchronisierung auf deine Campfire-Einrichtung aus, um eine klarere finanzielle Übersicht zu erhalten.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Unsere Campfire-Integration ist nur im Control-Tarif verfügbar, beginnend bei ${formattedPrice} ${hasTeam2025Pricing ? `pro Mitglied und Monat.` : `pro aktivem Mitglied und Monat.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Erweiterte Genehmigungen', description: `Wenn du weitere Genehmigungsstufen hinzufügen möchtest – oder einfach sicherstellen willst, dass die höchsten Ausgaben noch einmal geprüft werden – bist du bei uns richtig. Erweiterte Genehmigungen helfen dir, auf jeder Ebene die passenden Kontrollen einzurichten, damit du die Ausgaben deines Teams im Griff behältst.`, @@ -7906,7 +8021,6 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und alwaysReimbursableDescription: 'Spesen werden immer an Mitarbeitende zurückgezahlt', alwaysNonReimbursable: 'Immer nicht erstattungsfähig', alwaysNonReimbursableDescription: 'Auslagen werden Mitarbeitenden nie erstattet', - billableDefault: 'Standardmäßig verrechenbar', billableDefaultDescription: 'Wähle, ob Bar- und Kreditkartenausgaben standardmäßig verrechenbar sein sollen.', billable: 'Abrechenbar', billableDescription: 'Spesen werden meist an Kund:innen weiterberechnet', @@ -7933,10 +8047,12 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und publicReceiptVisibilityHintDisabled: 'Belege können nur von Expensify-Mitgliedern eingesehen werden, die Zugriff auf den Bericht haben, der den Beleg enthält.', enableTagsToUnlockTitle: 'Tags aktivieren?', enableTagsToUnlockPrompt: 'Aktivieren Sie Tags (unter Weitere Funktionen), um dies freizuschalten.', - enableTagsAndRequirePrompt: 'Sind Sie sicher, dass Sie Tags aktivieren und für alle Ausgaben verpflichtend machen möchten?', enableCategoriesToUnlockTitle: 'Kategorien aktivieren?', enableCategoriesToUnlockPrompt: 'Aktivieren Sie Kategorien (unter Weitere Funktionen), um dies freizuschalten.', enableCategoriesAndRequirePrompt: 'Sind Sie sicher, dass Sie Kategorien aktivieren und für alle Ausgaben verpflichtend machen möchten?', + enableTagsPrompt: 'Möchten Sie Tags wirklich aktivieren? Sie können sie für alle Ausgaben erforderlich machen, sobald Sie mindestens einen Tag haben.', + noTagsToRequirePrompt: 'Sie haben keine Tags. Bitte erstellen Sie einen Tag.', + noCategoriesToRequirePrompt: 'Sie haben keine Kategorien. Bitte erstellen Sie eine Kategorie.', }, expenseReportRules: { title: 'Erweitert', @@ -8009,6 +8125,21 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und 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', + confirmErrorConditionAndDefault: 'Geben Sie einen Händler ein und wenden Sie mindestens eine Standardeinstellung an', + turnOnTaxesFirstTitle: 'Schalten Sie zuerst Steuern ein', + turnOnTaxesFirstPrompt: 'Kategorienregeln legen einen Standardsteuersatz fest. Aktivieren Sie Steuern in Ihren Workspace-Einstellungen, um sie zu verwenden.', + categoryRulesApplyGoingForwardTitle: 'Kategorienregeln gelten ab jetzt', + categoryRulesApplyGoingForwardPrompt: 'Ein Standardsatz für Steuern gilt für neue Ausgaben in dieser Kategorie. Bereits vorhandene Ausgaben werden nicht geändert.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Der ausgewählte Steuersatz ist jetzt die Voreinstellung für Ihren Workspace, daher ist diese Regel nicht mehr gültig. Wählen Sie einen anderen Steuersatz.', + addTaxRateFirstTitle: 'Fügen Sie zuerst einen Steuersatz hinzu', + addTaxRateFirstPrompt: + 'Kategorienregeln legen einen standardmäßigen Steuersatz fest. Fügen Sie einen anderen Steuersatz als den Standard Ihres Arbeitsbereichs hinzu, um sie zu verwenden.', + createRuleFromExpenseAction: 'Regel erstellen', + createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, categoryRules: { title: 'Kategorienregeln', @@ -8029,7 +8160,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und flagAmountsOverSubtitle: 'Dadurch wird der Höchstbetrag für alle Ausgaben überschrieben.', expenseLimitTypes: { expense: 'Einzelne Ausgabe', - expenseSubtitle: 'Betrag von Ausgaben nach Kategorie kennzeichnen. Diese Regel überschreibt die allgemeine Arbeitsbereichsregel für den maximalen Ausgabenbetrag.', + expenseSubtitle: + 'Beträge von Ausgaben nach Kategorie kennzeichnen. Diese Regel überschreibt die allgemeine Arbeitsbereichsregel für den maximalen Ausgabenbetrag. Mehrtägige Reservierungen werden anhand des Durchschnitts pro Nacht bewertet.', daily: 'Kategoriesumme', dailySubtitle: 'Markiere die gesamte tägliche Kategorienaussage pro Spesenbericht.', }, @@ -8308,10 +8440,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc applyExpenseDefaultsDescription: 'Felder aktualisieren, ohne dass die einreichende Person etwas tun muss', flagForReview: 'Zur Überprüfung markieren', flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', - requireFields: 'Feldanforderungen', + requireFields: 'Feldanforderungen festlegen', requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', - createAgentRule: 'Agentenregel', - createAgentRuleDescription: 'Beschreiben Sie flexible Regeln, die bei Bedarf ausgeführt werden.', + createAgentRule: 'Agentenregel beschreiben', + createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', }, expenseDefaultsTable: { tableColumnType: 'Typ', @@ -8322,6 +8454,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc update: 'Aktualisieren', merchantIs: (merchant: string) => `Händler ist „${merchant}“`, merchantTypeIs: (merchantType: string) => `Händlertyp ist „${merchantType}"`, + categoryIs: (category: string) => `Kategorie ist „${category}“`, }, merchantTypeRule: {merchantType: 'Händlerart', saveRule: 'Regel speichern', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus.'}, requireFieldsTable: { @@ -8338,6 +8471,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc requireReceipt: 'Beleg erforderlich', doNotRequireReceipt: 'Beleg nicht erforderlich', doNotRequireItemizedReceipt: 'Keine detaillierte Quittung erforderlich', + typeLabel: 'Erforderlich', }, requireFieldsEmptyState: { title: 'Fehlende Details frühzeitig erfassen', @@ -8390,6 +8524,19 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc cta: 'KI-Regel hinzufügen', }, categoriesDisabledEmptyState: {title: 'Kategorien sind nicht aktiviert', subtitle: 'Aktivieren Sie Kategorien, um Ihre Ausgaben besser zu kontrollieren.'}, + expenseDefaultType: { + title: 'Standardspesen übernehmen', + subtitle: 'Worauf soll diese Regel angewendet werden?', + merchant: 'Händler', + merchantDescription: 'Felder bei Ausgaben eines bestimmten Händlers aktualisieren', + category: 'Kategorie', + categoryDescription: 'Legen Sie einen Standardsteuersatz für eine bestimmte Kategorie fest', + }, + taxesDisabledEmptyState: { + title: 'Steuern sind nicht aktiviert', + subtitle: 'Aktivieren Sie Steuern, um anfallende Steuern zu dokumentieren und zurückzufordern.', + cta: 'Steuern aktivieren', + }, }, planTypePage: { planTypes: { @@ -8416,37 +8563,26 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, hr: { title: 'Personalwesen', - connections: 'Verbindungen', connectionsSubtitle: 'Verbinden Sie sich mit Ihrem HR-System, um Mitarbeiterdaten zu synchronisieren, Erstattungen automatisch den richtigen Personen zuzuordnen und die Ausgaben Ihres Teams ohne manuellen Aufwand korrekt zu halten.', subtitle: 'HR-Tools verbinden und Mitarbeitergenehmigungen synchron halten.', - connect: 'Verbinden', - syncNow: 'Jetzt synchronisieren', - disconnect: 'Trennen', - disconnectTitle: (providerName: string) => `${providerName} trennen`, - disconnectPrompt: (providerName: string) => `Möchten Sie ${providerName} wirklich trennen?`, alreadyConnectedTitle: 'Es kann keine Verbindung zu mehreren HR-Plattformen hergestellt werden', alreadyConnectedPrompt: 'Sie müssen Ihre aktuelle HR-Plattform trennen, bevor Sie eine andere verbinden.', - lastSync: (relativeDate: string) => `Zuletzt synchronisiert ${relativeDate}`, - syncError: (providerName: string) => `Verbindung zu ${providerName} nicht möglich`, connectionDescription: (providerName: string) => `Verbinden Sie ${providerName}, um Mitarbeitergenehmigungen mit Ihrem Workspace zu synchronisieren.`, - approvalMode: 'Genehmigungsmodus', providerApprovalMode: (providerName: string) => `${providerName}-Genehmigungsmodus`, - finalApprover: 'Endgültige:r Genehmiger:in', providerFinalApprover: (providerName: string) => `${providerName} Endgenehmigende*r`, - notSet: 'Nicht festgelegt', + syncing: 'Mitarbeitende werden synchronisiert', + syncingModalTitle: 'Ihre Verbindung wird synchronisiert', + syncingModalDescription: 'Die erste Verbindung kann einige Zeit dauern. Sie werden über alle Fehler benachrichtigt.', approvalModeDescription: (providerName: string) => `Mitglieder und Manager sind für die Synchronisation mit ${providerName} eingerichtet.`, approvalModeWarningTitle: 'Genehmigungsmodus ändern?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Möchten Sie den Genehmigungsmodus für diesen Workspace wirklich ändern? Erfahren Sie mehr über die verschiedenen ${providerName}-aktivierten Workflow-Modi auf unserer Hilfeseite.`, approvalModeWarningConfirm: 'Genehmigungsmodus ändern', - approvalModes: { - basic: {label: 'Einfache Genehmigung', description: 'Alle Benutzer reichen zur Bearbeitung und Genehmigung bei einer einzigen Person ein.'}, - manager: { - label: 'Manager-Genehmigung', - description: (providerName: string) => `Mitarbeitende reichen Berichte bei ihrer in ${providerName} hinterlegten direkten Führungskraft ein.`, - }, - custom: {label: 'Benutzerdefinierte Genehmigung', description: 'Ich richte Genehmigungs-Workflows in Expensify manuell ein.'}, + approvalModeDescriptions: { + basic: 'Alle Benutzer reichen zur Bearbeitung und Genehmigung bei einer einzigen Person ein.', + manager: (providerName: string) => `Mitarbeitende reichen Berichte bei ihrer in ${providerName} hinterlegten direkten Führungskraft ein.`, + custom: 'Ich richte Genehmigungs-Workflows in Expensify manuell ein.', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8486,28 +8622,66 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc zenefits: { title: 'TriNet', }, - syncingModalTitle: 'Ihre Verbindung wird synchronisiert', - syncingModalDescription: 'Die erste Verbindung kann einige Zeit dauern. Sie werden über alle Fehler benachrichtigt.', - syncing: 'Mitarbeitende werden synchronisiert', + setupIncomplete: (setupLink: string | undefined) => + `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Mitarbeitenden.`, mergeHR: { - completeSetup: 'Einrichtung abschließen', - setupIncomplete: (setupLink: string | undefined) => - `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Mitarbeitenden.`, groups: {title: 'Gruppen', description: 'Wählen Sie die Mitarbeitergruppen aus, die Sie mit diesem Workspace synchronisieren möchten'}, }, + }, + recruiting: { + title: 'Personalbeschaffung', + connectionsSubtitle: + 'Verbinden Sie Ihr Recruiting-System, um Kandidatendaten zu synchronisieren, Erstattungen automatisch den richtigen Personen zuzuordnen und die Ausgaben Ihres Teams ohne manuelle Arbeit korrekt zu halten.', + alreadyConnectedTitle: 'Es kann keine Verbindung zu mehreren ATS-Plattformen hergestellt werden', + alreadyConnectedPrompt: 'Sie müssen Ihr aktuelles ATS trennen, bevor Sie ein anderes verbinden.', + syncing: 'Kandidaten werden synchronisiert', + setupIncomplete: (setupLink: string | undefined) => + `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Kandidaten.`, + 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'}, + }, + merge: { + connections: 'Verbindungen', + connect: 'Verbinden', + findIntegration: 'Integration finden', + syncNow: 'Jetzt synchronisieren', + disconnect: 'Trennen', + disconnectTitle: (providerName: string) => `${providerName} trennen`, + disconnectPrompt: (providerName: string) => `Möchten Sie ${providerName} wirklich trennen?`, + lastSync: (relativeDate: string) => `Zuletzt synchronisiert ${relativeDate}`, notSync: 'Nicht synchronisiert', + syncError: (providerName: string) => `Verbindung zu ${providerName} nicht möglich`, authenticationError: (providerName: string) => `Verbindung mit ${providerName} aufgrund einer abgelaufenen Verknüpfung nicht möglich.`, reconnect: 'Erneut verbinden', reconnectLink: 'Erneut verbinden.', - findIntegration: 'Integration finden', - }, - merge: { + notSet: 'Nicht festgelegt', + completeSetup: 'Einrichtung abschließen', + approvalMode: 'Genehmigungsmodus', + finalApprover: 'Endgültige:r Genehmiger:in', + approvalModes: { + basic: 'Einfache Genehmigung', + manager: 'Manager-Genehmigung', + custom: 'Benutzerdefinierte Genehmigung', + advanced: 'Erweiterte Genehmigung', + }, syncLimitReached: {title: 'Versuchen Sie es morgen noch einmal', prompt: 'Sie haben Ihr Synchronisierungslimit für heute erreicht.'}, }, emptyDomain: { title: 'Stärken Sie Ihre Sicherheit mit Domains', subtitle: 'Verlangen Sie, dass Mitglieder in Ihrer Domain sich per Single Sign-On anmelden, schränken Sie das Erstellen von Arbeitsbereichen ein und mehr.', }, + campfire: { + campfireSetup: 'Campfire-Einrichtung', + enterCredentials: 'Geben Sie Ihren Campfire-API-Schlüssel ein', + howToFindAPIKey: + 'So finden Sie Ihren API-Schlüssel.
  1. Melden Sie sich bei Campfire an
  2. Navigieren Sie zu Einstellungen -> API-Schlüssel
  3. Erstellen Sie einen API-Schlüssel
  4. Fügen Sie den API-Schlüssel unten ein
', + subsidiary: 'Tochtergesellschaft', + subsidiarySelectDescription: 'Wählen Sie die Tochtergesellschaft in Campfire aus, von der Sie Daten importieren möchten.', + noSubsidiariesFound: 'Keine Tochtergesellschaften gefunden', + noSubsidiariesFoundDescription: 'Bitte fügen Sie eine Entität in Campfire hinzu und synchronisieren Sie die Verbindung erneut', + }, }, getAssistancePage: { title: 'Hilfe erhalten', @@ -9412,7 +9586,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Konsolidierte Reiseabrechnung', }, is: 'Ist', - has: {submittedViolation: 'Eingereichter Verstoß'}, + has: {submittedViolation: 'Eingereichter Verstoß', approvedViolation: 'Genehmigter Verstoß'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Senden', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Genehmigen', @@ -9486,6 +9660,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc exportedTo: 'Exportiert nach', exportAll: { selectAllMatchingItems: 'Alle passenden Einträge auswählen', + allMatchingItemsSelected: 'Alle passenden Elemente ausgewählt', selectAllOnThisPage: 'Alle auf dieser Seite auswählen', }, errors: { @@ -9511,6 +9686,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc topMerchants: 'Top-Händler', violationsBySubmitter: 'Verstöße durch Einreichende', }, + mergeReports: { + title: 'Berichte zusammenführen', + description: 'Wählen Sie den Bericht aus, der beibehalten werden soll. Alle Ausgaben werden in ihn verschoben und die anderen Berichte werden gelöscht.', + }, }, genericErrorPage: { title: 'Ups, da ist etwas schiefgelaufen!', @@ -9594,6 +9773,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `exportiert nach ${translatedLabel}`; @@ -9630,6 +9810,16 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc integrationSyncFailedRecurrence: ({count}: {count: number}) => `(${count} Mal wiederholt.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `Die Verbindung für ${feedName} ist unterbrochen. Um Kartenimporte wiederherzustellen, melden Sie sich bei Ihrer Bank an.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `Die ${feedName}-Verbindung besteht seit 30 Tagen nicht mehr. Melden Sie sich bei Ihrer Bank an, um sie zu beheben, oder entfernen Sie die Verbindung, wenn sie nicht mehr verwendet wird. Bereits eingereichte Ausgaben gehen beim Entfernen nicht verloren.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `Die Plaid-Verbindung zu Ihrem Geschäftskonto ist unterbrochen. Bitte verbinden Sie Ihr Bankkonto ${maskedAccountNumber} erneut, damit Sie Ihre Expensify Karten weiterhin verwenden können.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10221,6 +10411,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Der Satz gilt erst ab dem ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Satz gilt nur bis ${endDate}`, cannotMergeDuplicates: 'Sie können Ausgaben nur in Entwürfen oder offenen Berichten zusammenführen. Ziehen Sie den Bericht zurück und versuchen Sie es erneut.', + overCategoryLimitPerNight: (formattedLimit: string) => `Nächtlicher Satz über dem Kategorienlimit von ${formattedLimit}/Person`, shortName: { allTagLevelsRequired: 'Alle Tags erforderlich', autoReportedRejectedExpense: 'Ausgabe abgelehnt', @@ -10836,6 +11027,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, reportLevelExport: 'Alle Daten – Berichtsebene', expenseLevelExport: 'Alle Daten – Ausgabenebene', multipleTaxExport: 'Kanadischer Export mit mehreren Steuern', + reconciliationAllExpenses: 'Abstimmung - Alle Ausgaben', exportInProgress: 'Export wird ausgeführt', conciergeWillSend: 'Concierge wird dir die Datei in Kürze senden.', currentView: 'Aktuelle Ansicht', @@ -10867,6 +11059,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, notVerified: 'Nicht verifiziert', retry: 'Wiederholen', requestSent: 'Anfrage gesendet', + requestAccessError: 'Wir konnten deine Anfrage nicht senden. Bitte versuche es erneut.', verifyDomain: { title: 'Domain bestätigen', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10927,13 +11120,12 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, setMetadataGenericError: 'SAML-Metadaten konnten nicht festgelegt werden', }, accessRestricted: { - title: 'Zugriff eingeschränkt', - subtitle: (domainName: string) => - `Bitte bestätigen Sie sich als autorisierte/r Firmenadministrator/in für ${domainName}, wenn Sie die Kontrolle über Folgendes benötigen:`, - companyCardManagement: 'Firmenkartenverwaltung', - accountCreationAndDeletion: 'Kontoerstellung und -löschung', - workspaceCreation: 'Bereichserstellung', - samlSSO: 'SAML-SSO', + headerTitle: 'Zugriff eingeschränkt', + title: 'Verifizierung erforderlich', + description: (domainName: string) => + `Bitte bestätige dich als autorisierte/r Firmenadministrator/in für ${domainName} oder fordere Zugriff von bestehenden Administratoren an.`, + requestAdminAccess: 'Admin-Zugriff anfordern', + verifyYourself: 'Verifiziere dich', }, addDomain: { title: 'Domain hinzufügen', @@ -10947,7 +11139,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, title: 'Domain bereits eingerichtet. Zugriff anfragen?', description: 'Jemand hat diese Domain bereits in Expensify eingerichtet. Möchtest du Administratorzugriff anfragen?', requestAccess: 'Admin-Zugriff anfragen', - requestAccessError: 'Wir konnten deine Anfrage nicht senden. Bitte versuche es erneut.', }, domainAdded: { title: 'Domain hinzugefügt', @@ -10972,6 +11163,9 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, consolidatedDomainBillingError: 'Die konsolidierte Domain-Abrechnung konnte nicht geändert werden. Bitte versuchen Sie es später noch einmal.', addAdmin: 'Admin hinzufügen', addAdminError: 'Dieses Mitglied kann nicht als Admin hinzugefügt werden. Bitte versuche es erneut.', + requests: 'Anfragen', + approveRequestError: 'Diese Anfrage kann nicht genehmigt werden. Bitte versuche es erneut.', + declineRequestError: 'Diese Anfrage kann nicht abgelehnt werden. Bitte versuche es erneut.', revokeAdminAccess: 'Adminzugriff widerrufen', cantRevokeAdminAccess: 'Adminzugriff kann beim technischen Kontakt nicht widerrufen werden', error: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 9a6fd6873af7..ee582c483be2 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -22,7 +22,7 @@ import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; import type en from './en'; -import type {PolicyConnectionSyncStage} from './TranslationTypes'; +import type {AllConnectionName, PolicyConnectionSyncStage} from './TranslationTypes'; import type {TranslationDeepObject} from './types'; type StateValue = { stateISO: string; @@ -53,6 +53,8 @@ const translations: TranslationDeepObject = { unshare: 'Αναίρεση κοινής χρήσης', yes: 'Ναι', no: 'Όχι', + approve: 'Έγκριση', + deny: 'Άρνηση', dontChange: 'Να μην αλλάξει', ok: 'OK', notNow: 'Όχι τώρα', @@ -74,6 +76,7 @@ const translations: TranslationDeepObject = { search: 'Αναζήτηση', reports: 'Αναφορές', spend: 'Δαπάνη', + insights: 'Στατιστικά', find: 'Εύρεση', searchWithThreeDots: 'Αναζήτηση...', next: 'Επόμενο', @@ -465,6 +468,9 @@ const translations: TranslationDeepObject = { none: 'Κανένα', unstableInternetConnection: 'Η σύνδεση στο διαδίκτυο είναι ασταθής. Ελέγξτε το δίκτυό σας και προσπαθήστε ξανά.', enableGlobalReimbursements: 'Ενεργοποίηση διεθνών αποζημιώσεων', + corpayPayModalTitle: 'Πληρωμή αναφοράς', + corpayPayModalPrompt: + 'Αυτός ο υποβάλλων έχει τραπεζικό λογαριασμό που δεν είναι σε USD. Ενεργοποιήστε τις διεθνείς αποζημιώσεις για να πληρώσετε την αναφορά, ή ζητήστε του να προσθέσει τραπεζικό λογαριασμό σε USD.', purchaseAmount: 'Ποσό αγοράς', originalAmount: 'Αρχικό ποσό', frequency: 'Συχνότητα', @@ -970,7 +976,7 @@ const translations: TranslationDeepObject = { tooltip: 'Ξεκινήστε εδώ!', }, homePage: { - forYou: 'Για εσάς', + toDos: 'Εκκρεμότητες', timeSensitiveSection: { title: 'Χρονικά ευαίσθητο', ctaFix: 'Διόρθωση', @@ -988,15 +994,11 @@ const translations: TranslationDeepObject = { defaultSubtitle: 'Χώρος εργασίας', subtitle: ({policyName}: {policyName: string}) => policyName, }, - addShippingAddress: { - title: 'Χρειαζόμαστε τη διεύθυνση αποστολής σας', - subtitle: 'Δώστε μια διεύθυνση για να λάβετε την Κάρτα Expensify.', - cta: 'Προσθήκη διεύθυνσης', - }, + addShippingAddress: {title: 'Χρειαζόμαστε τη διεύθυνση αποστολής σας', subtitle: 'Δώστε μια διεύθυνση για να λάβετε την Κάρτα Expensify.', cta: 'Προσθήκη'}, addVirtualCardPersonalDetails: { title: 'Προσθέστε τα στοιχεία σας για να δείτε και να αρχίσετε να χρησιμοποιείτε την Κάρτα Expensify.', subtitle: 'Κάρτα Expensify', - cta: 'Προσθέστε λεπτομέρειες', + cta: 'Προσθήκη', }, addPaymentCard: { title: 'Προσθέστε μια κάρτα πληρωμής για να συνεχίσετε να χρησιμοποιείτε το Expensify', @@ -1011,6 +1013,14 @@ const translations: TranslationDeepObject = { subtitle: 'Επικυρώστε την κάρτα σας και ξεκινήστε να ξοδεύετε.', cta: 'Ενεργοποίηση', }, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `Η προσθήκη της κάρτας ${walletName} χρειάζεται την έγκρισή σας`, + subtitle: 'Κάρτα Expensify', + cta: 'Έλεγχος', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Ψηφιακό πορτοφόλι', + }, reviewCardFraud: { title: 'Ελέγξτε πιθανή απάτη στην Κάρτα Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Ελέγξτε ${amount} για πιθανή απάτη στο ${merchant}`, @@ -1022,7 +1032,7 @@ const translations: TranslationDeepObject = { subtitle: 'Λογαριασμός', cta: 'Επικυρώστε', }, - addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για παρακολούθηση αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθήκη διεύθυνσης'}, + addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για παρακολούθηση αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθήκη'}, fixFailedBilling: { title: 'Δεν μπορέσαμε να χρεώσουμε την αποθηκευμένη κάρτα σας', subtitle: 'Συνδρομή', @@ -1058,7 +1068,6 @@ const translations: TranslationDeepObject = { title: 'Οι δαπάνες σας', awaitingApproval: 'Εκκρεμεί έγκριση', repaidLast30Days: 'Εξοφλήθηκε τις τελευταίες 30 ημέρες', - recentTransactions: ({lastFour}: {lastFour: string}) => `Πρόσφατες συναλλαγές • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `Δείτε ακόμα ${count}`, discoverSection: { @@ -1155,6 +1164,8 @@ const translations: TranslationDeepObject = { configureApprovalsSubText: 'Ορισμός εγκρίσεων αναφορών', customizeSpendCategories: 'Προσαρμόστε τις κατηγορίες δαπανών', customizeSpendCategoriesSubText: 'Οργανώστε και ταξινομήστε τα έξοδα', + customizeExpenseCategories: 'Προσαρμόστε τις κατηγορίες εξόδων σας', + customizeExpenseCategoriesSubText: 'Προσθέστε τις κατηγορίες της εταιρείας σας για να κωδικοποιήσετε τα έξοδά σας', createExpense: 'Δημιουργία δαπάνης', createExpenseSubText: 'Σαρώστε, σύρετε και αποθέστε ή καταχωρίστε χειροκίνητα μία δαπάνη χρησιμοποιώντας το κουμπί +', linkPersonalCard: 'Συνδέστε προσωπική κάρτα', @@ -1325,6 +1336,7 @@ const translations: TranslationDeepObject = { phrase1: 'Προσθήκη απόδειξης', phrase2: 'ή σύρετε και αποθέστε ένα εδώ', }, + pageCount: ({pageCount}: {pageCount: number}) => `Σελίδα 1 από ${pageCount}`, }, quickAction: { scanReceipt: 'Σαρώστε απόδειξη', @@ -1723,11 +1735,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Όλα έτοιμα!', noDuplicatesDescription: 'Δεν υπάρχουν διπλές συναλλαγές για έλεγχο εδώ.', confirmApprove: 'Επιβεβαιώστε το ποσό έγκρισης', - confirmApprovalAmount: 'Εγκρίνετε μόνο τις σύμφωνες δαπάνες ή εγκρίνετε ολόκληρη την αναφορά.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Αυτή η δαπάνη είναι σε αναμονή. Θέλετε παρ’ όλα αυτά να την εγκρίνετε;', - other: 'Αυτές οι δαπάνες έχουν τεθεί σε αναμονή. Θέλετε να εγκρίνετε ούτως ή άλλως;', - }), + confirmApprovalWithHeldAmount: 'Η αναφορά περιέχει δαπάνες σε αναμονή. Θέλετε να εγκρίνετε μόνο τις σύμφωνες δαπάνες ή ολόκληρη την αναφορά;', + confirmApprovalAllHoldAmount: 'Όλες οι δαπάνες είναι σε αναμονή. Θέλετε να εγκρίνετε ούτως ή άλλως;', confirmPay: 'Επιβεβαιώστε το ποσό πληρωμής', confirmPayAmount: 'Πληρώστε ό,τι δεν είναι σε αναμονή ή πληρώστε ολόκληρη την αναφορά.', confirmPayAllHoldAmount: () => ({ @@ -2343,6 +2352,11 @@ const translations: TranslationDeepObject = { usingImportedState: 'Χρησιμοποιείτε εισαγόμενη κατάσταση. Πατήστε εδώ για να την καθαρίσετε.', debugMode: 'Λειτουργία αποσφαλμάτωσης', showBranchNameInTitle: 'Εμφάνιση ονόματος κλάδου στον τίτλο του προγράμματος περιήγησης', + betaOverrides: 'Παρακάμψεις beta', + betaOverridesDescription: + 'Οι παρακάμψεις ισχύουν μόνο για αυτή τη συσκευή και επηρεάζουν μόνο τους ελέγχους του frontend. Μια beta διατηρεί μια παράκαμψη μόνο όσο διαφέρει από τις beta του λογαριασμού σας, οπότε αν την αλλάξετε ξανά η παράκαμψη αφαιρείται. Η «Επαναφορά όλων των παρακάμψεων» επαναφέρει τις τιμές του λογαριασμού σας. Ορισμένες beta ελέγχονται και από το backend, οπότε μπορεί να αποτύχουν σε επίπεδο αιτήματος.', + resetAllOverrides: 'Επαναφορά όλων των παρακάμψεων', + overridden: 'Παρακάμφθηκε', invalidFile: 'Μη έγκυρο αρχείο', invalidFileDescription: 'Το αρχείο που προσπαθείτε να εισαγάγετε δεν είναι έγκυρο. Παρακαλούμε δοκιμάστε ξανά.', invalidateWithDelay: 'Ακύρωση με καθυστέρηση', @@ -2672,6 +2686,10 @@ const translations: TranslationDeepObject = { connectionLink ? `Η σύνδεση της κάρτας ${cardName} έχει διακοπεί. Συνδεθείτε στην τράπεζά σας για να διορθώσετε την κάρτα.` : `Η σύνδεση της κάρτας ${cardName} έχει διακοπεί. Συνδεθείτε στην τράπεζά σας για να διορθώσετε την κάρτα.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Η σύνδεση της ${cardName} έχει διακοπεί για 30 ημέρες. Συνδεθείτε στην τράπεζά σας για να τη διορθώσετε ή αφαιρέστε την κάρτα αν δεν τη χρησιμοποιείτε πλέον. Δεν θα χάσετε καμία υποβληθείσα δαπάνη αν την αφαιρέσετε.` + : `Η σύνδεση της ${cardName} σας έχει διακοπεί για 30 ημέρες. Συνδεθείτε στην τράπεζά σας για να τη διορθώσετε ή αφαιρέστε την κάρτα αν δεν τη χρησιμοποιείτε πλέον. Δεν θα χάσετε καμία υποβληθείσα δαπάνη αν την αφαιρέσετε.`, addAdditionalCards: 'Προσθέστε επιπλέον κάρτες', upgradeDescription: 'Χρειάζεστε να προσθέσετε περισσότερες κάρτες; Δημιουργήστε έναν χώρο εργασίας για να προσθέσετε επιπλέον προσωπικές κάρτες ή να αναθέσετε εταιρικές κάρτες σε όλη την ομάδα.', @@ -2773,6 +2791,24 @@ const translations: TranslationDeepObject = { unlock: 'Ξεκλείδωμα', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Προσθήκη κάρτας στο ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'ψηφιακό πορτοφόλι', + confirmHeading: 'Επιβεβαιώστε το αίτημά σας', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Θέλετε να προσθέσετε την Κάρτα Expensify (που λήγει σε ${lastFourDigits}) στο ${walletName} σας;`, + deny: 'Απόρριψη', + confirm: 'Επιβεβαίωση', + verifyTitle: 'Ας επιβεβαιώσουμε ότι είστε εσείς', + enterSecurityCode: (contactMethod: string) => + `Παρακαλούμε εισαγάγετε τον κωδικό ασφαλείας που στάλθηκε στο ${contactMethod} για να επιβεβαιώσετε αυτό το αίτημα. Θα πρέπει να φτάσει μέσα σε ένα ή δύο λεπτά.`, + successHeading: 'Επιτυχία!', + successDescription: ({walletName}: {walletName: string}) => `Η κάρτα σας είναι πλέον ενεργή για χρήση στο ${walletName} σας.`, + deniedHeading: 'Το αίτημα απορρίφθηκε', + deniedDescription: ({walletName}: {walletName: string}) => `Η κάρτα σας δεν έχει προστεθεί στο(ν/η) ${walletName} σας.`, + }, cardPage: { expensifyCard: 'Κάρτα Expensify', expensifyTravelCard: 'Κάρτα Expensify Travel', @@ -3219,6 +3255,7 @@ ${amount} για ${merchant} - ${date}`, unread: 'Μη αναγνωσμένα', markAllAsRead: 'Επισήμανση όλων ως αναγνωσμένων', markAllAsReadConfirmationPrompt: 'Είστε βέβαιοι ότι θέλετε να επισημάνετε όλες τις συνομιλίες ως αναγνωσμένες;', + markAllTodosAsReadConfirmationPrompt: 'Είστε βέβαιοι ότι θέλετε να επισημάνετε ως αναγνωσμένες όλες τις συνομιλίες σε εκκρεμότητα;', }, reportDetailsPage: { goToRoom: 'Μετάβαση στο δωμάτιο', @@ -3399,6 +3436,7 @@ ${amount} για ${merchant} - ${date}`, subtitle: (workEmail: string | undefined) => `Δεν ήταν δυνατή η προσθήκη του ${workEmail}. Δοκιμάστε ξανά αργότερα στις ρυθμίσεις ή συνομιλήστε με το Concierge για καθοδήγηση.`, workAccountClosedSubtitle: 'Ο επαγγελματικός λογαριασμός που σχετίζεται με αυτό το email είναι κλειστός. Παρακαλούμε επικοινωνήστε με τον διαχειριστή της εταιρείας σας για να τον επανενεργοποιήσει ή εγγραφείτε με διαφορετικό email.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} είναι ένα στοιχείο σύνδεσης ελεγχόμενο από τομέα για έναν υπάρχοντα λογαριασμό Expensify.`, }, tasks: { testDriveAdminTask: { @@ -4239,8 +4277,7 @@ ${amount} για ${merchant} - ${date}`, legalFirstName: 'Επίσημο μικρό όνομα', legalLastName: 'Επώνυμο (όπως αναγράφεται στα επίσημα έγγραφα)', enterTheDateOfBirthOfTheOwner: 'Ποια είναι η ημερομηνία γέννησης του κατόχου;', - enterTheLast4: 'Ποια είναι τα τελευταία 4 ψηφία του αριθμού κοινωνικής ασφάλισης του κατόχου;', - last4SSN: 'Τελευταία 4 ψηφία του SSN', + enterTheSSN: 'Ποιος είναι ο αριθμός κοινωνικής ασφάλισης του ιδιοκτήτη;', dontWorry: 'Μην ανησυχείτε, δεν κάνουμε κανέναν προσωπικό έλεγχο πιστοληπτικής ικανότητας!', enterTheOwnersAddress: 'Ποια είναι η διεύθυνση του ιδιοκτήτη;', letsDoubleCheck: 'Ας ελέγξουμε διπλά ότι όλα φαίνονται σωστά.', @@ -4267,13 +4304,12 @@ ${amount} για ${merchant} - ${date}`, whatsYourDOB: 'Ποια είναι η ημερομηνία γέννησής σας;', whatsTheOwnersAddress: 'Ποια είναι η διεύθυνση του ιδιοκτήτη;', whatsYourAddress: 'Ποια είναι η διεύθυνσή σας;', - whatAreTheLast: 'Ποια είναι τα τελευταία 4 ψηφία του αριθμού κοινωνικής ασφάλισης του κατόχου;', - whatsYourLast: 'Ποια είναι τα τελευταία 4 ψηφία του αριθμού κοινωνικής σας ασφάλισης;', + whatsTheOwnersSSN: 'Ποιος είναι ο αριθμός κοινωνικής ασφάλισης του ιδιοκτήτη;', + whatsYourSSN: 'Ποιος είναι ο αριθμός κοινωνικής ασφάλισής σας;', whatsYourNationality: 'Ποια είναι η χώρα ιθαγένειάς σας;', whatsTheOwnersNationality: 'Ποια είναι η χώρα ιθαγένειας του ιδιοκτήτη;', countryOfCitizenship: 'Χώρα ιθαγένειας', dontWorry: 'Μην ανησυχείτε, δεν κάνουμε κανέναν προσωπικό έλεγχο πιστοληπτικής ικανότητας!', - last4: 'Τελευταία 4 ψηφία του SSN', whyDoWeAsk: 'Γιατί ζητάμε αυτό;', letsDoubleCheck: 'Ας ελέγξουμε διπλά ότι όλα φαίνονται σωστά.', legalName: 'Νομικό όνομα', @@ -4298,6 +4334,8 @@ ${amount} για ${merchant} - ${date}`, proofOfBeneficialOwner: 'Απόδειξη πραγματικού δικαιούχου', proofOfBeneficialOwnerDescription: 'Παρακαλούμε προσκομίστε υπογεγραμμένη βεβαίωση και οργανόγραμμα από ορκωτό λογιστή, συμβολαιογράφο ή δικηγόρο που να επιβεβαιώνουν την κατοχή του 25% ή περισσότερο της επιχείρησης. Πρέπει να φέρουν ημερομηνία εντός των τελευταίων τριών μηνών και να περιλαμβάνουν τον αριθμό άδειας του υπογράφοντος.', + proofOfBeneficialOwnerDescriptionCA: + 'Παρακαλούμε προσκομίστε το Έντυπο Schedule 50 (Shareholder Information) από την πιο πρόσφατη εταιρική δήλωση φόρου εισοδήματος T2, στο οποίο εμφανίζεται κάθε άτομο που κατέχει ποσοστό 25% ή περισσότερο της επιχείρησης.', copyOfID: 'Αντίγραφο ταυτότητας πραγματικού δικαιούχου', copyOfIDDescription: 'Παραδείγματα: διαβατήριο, άδεια οδήγησης κ.λπ.', proofOfAddress: 'Απόδειξη διεύθυνσης για τον πραγματικό δικαιούχο', @@ -4695,6 +4733,7 @@ ${amount} για ${merchant} - ${date}`, customFieldHint: 'Προσθέστε προσαρμοσμένη κωδικοποίηση που θα εφαρμόζεται σε όλες τις δαπάνες από αυτό το μέλος.', reports: 'Αναφορές', reportFields: 'Πεδία αναφοράς', + invoiceFields: 'Πεδία τιμολογίου', reportTitle: 'Τίτλος αναφοράς', reportField: 'Πεδίο αναφοράς', taxes: 'Φόροι', @@ -4706,6 +4745,8 @@ ${amount} για ${merchant} - ${date}`, rooms: 'Δωμάτια', accounting: 'Λογιστική', hr: 'HR', + recruiting: 'Προσλήψεις', + mcp: 'MCP', receiptPartners: 'Συνεργάτες αποδείξεων', rules: 'Κανόνες', displayedAs: 'Εμφανίζεται ως', @@ -4849,6 +4890,25 @@ ${amount} για ${merchant} - ${date}`, title: 'Δημιουργήσατε έναν χώρο εργασίας για τον πελάτη σας!', description: 'Καλά νέα 🎉. Επικοινωνήστε μαζί μας αν χρειαστούν βοήθεια με τη ρύθμιση.', }, + mcp: { + connectors: 'Συνδέσεις', + connectorsSubtitle: 'Συνδέστε έναν βοηθό AI με τον λογαριασμό σας στο Expensify.', + connect: 'Σύνδεση', + helpPrompt: 'Χρειάζεστε βοήθεια με τη σύνδεση;', + helpLink: 'Διαβάστε τον οδηγό μας.', + claude: { + title: 'Claude', + subtitle: 'από την Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'από την Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'από την OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -5066,7 +5126,6 @@ ${amount} για ${merchant} - ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Χρησιμοποιήστε αυτόν τον λογαριασμό κατά την εξαγωγή τιμολογίων στο ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Ορίστε πώς οι αγορές εταιρικών καρτών θα εξαχθούν στο ${integrationName}.`, vendor: 'Προμηθευτής', - defaultVendorDescription: 'Ορίστε έναν προεπιλεγμένο προμηθευτή που θα εφαρμόζεται σε όλες τις συναλλαγές πιστωτικής κάρτας κατά την εξαγωγή.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Ορίστε πώς θα εξαχθούν οι εκτός τσέπης δαπάνες στο ${integrationName}.`, exportCheckDescription: 'Θα δημιουργήσουμε μια αναλυτική επιταγή για κάθε αναφορά Expensify και θα τη στείλουμε από τον παρακάτω τραπεζικό λογαριασμό.', exportJournalEntryDescription: 'Θα δημιουργούμε μια αναλυτική λογιστική εγγραφή για κάθε αναφορά Expensify και θα την καταχωρούμε στον παρακάτω λογαριασμό.', @@ -5249,8 +5308,6 @@ ${amount} για ${merchant} - ${date}`, [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Οι εκτός τσέπης δαπάνες θα εξαχθούν όταν εξοφληθούν', }, }, - defaultSupplier: 'Προεπιλεγμένος προμηθευτής', - defaultSupplierDescription: 'Ορίστε έναν προεπιλεγμένο προμηθευτή που θα εφαρμόζεται σε όλες τις συναλλαγές πιστωτικής κάρτας κατά την εξαγωγή.', noSuppliersFound: 'Δεν βρέθηκαν προμηθευτές', noSuppliersFoundDescription: 'Παρακαλούμε προσθέστε τον προμηθευτή στο Xero και συγχρονίστε ξανά τη σύνδεση.', }, @@ -6512,6 +6569,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ currentTravelSpendInvoicePending: (amount: string) => `Ένα τιμολόγιο για ${amount} έχει αποσταλεί και αναμένει πληρωμή.`, sendInvoiceNowCta: 'Στείλτε το τιμολόγιο τώρα', }, + setUpNewFeed: 'Ρυθμίστε μια νέα ροή ταξιδιών', + feedSelectorIntro: + 'Ο οργανισμός σας έχει ήδη ρυθμίσει ροή ενοποιημένης τιμολόγησης ταξιδιών. Επιλέξτε την για να διατηρήσετε τις ταξιδιωτικές δαπάνες αυτού του χώρου εργασίας στον ίδιο μηνιαίο λογαριασμό.', }, disableModal: { title: 'Απενεργοποίηση της ενοποιημένης χρέωσης ταξιδιών;', @@ -6672,6 +6732,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ title: 'Λογιστική', subtitle: 'Συγχρονίστε το λογιστικό σας σχέδιο και άλλα.', }, + mcp: { + title: 'MCP', + subtitle: 'Συνδέστε έναν βοηθό AI με τον λογαριασμό σας στο Expensify.', + }, receiptPartners: { title: 'Συνεργάτες αποδείξεων', subtitle: 'Αυτόματη εισαγωγή αποδείξεων.', @@ -6740,6 +6804,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ subtitle: 'Τα πεδία αναφοράς εφαρμόζονται σε όλες τις δαπάνες και μπορεί να είναι χρήσιμα όταν θέλετε να ζητήσετε επιπλέον πληροφορίες.', disableReportFields: 'Απενεργοποίηση πεδίων αναφοράς', disableReportFieldsConfirmation: 'Είστε βέβαιοι; Τα πεδία κειμένου και ημερομηνίας θα διαγραφούν και οι λίστες θα απενεργοποιηθούν.', + cannotDisableImportedReportFields: 'Τα πεδία αναφοράς που έχουν εισαχθεί από τη λογιστική σας σύνδεση δεν μπορούν να απενεργοποιηθούν.', importedFromAccountingSoftware: 'Τα παρακάτω πεδία αναφοράς εισάγονται από την/τον', textType: 'Κείμενο', dateType: 'Ημερομηνία', @@ -6779,6 +6844,29 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ reportFieldInitialValueRequiredError: 'Παρακαλούμε επιλέξτε μια αρχική τιμή για το πεδίο αναφοράς', genericFailureMessage: 'Παρουσιάστηκε σφάλμα κατά την ενημέρωση του πεδίου αναφοράς. Παρακαλούμε δοκιμάστε ξανά.', }, + invoiceFields: { + subtitle: 'Τα πεδία τιμολογίου μπορεί να είναι χρήσιμα όταν θέλετε να συμπεριλάβετε επιπλέον πληροφορίες.', + importedFromAccountingSoftware: 'Τα παρακάτω πεδία τιμολογίου εισάγονται από την/τον', + disableInvoiceFields: 'Απενεργοποίηση πεδίων τιμολογίου', + disableInvoiceFieldsConfirmation: 'Είστε βέβαιοι; Τα πεδία τιμολογίου θα απενεργοποιηθούν στα τιμολόγια.', + delete: 'Διαγραφή πεδίου τιμολογίου', + deleteConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το πεδίο τιμολογίου;', + findInvoiceField: 'Εύρεση πεδίου τιμολογίου', + nameInputSubtitle: 'Επιλέξτε ένα όνομα για το πεδίο τιμολογίου.', + typeInputSubtitle: 'Επιλέξτε ποιον τύπο πεδίου τιμολογίου θέλετε να χρησιμοποιήσετε.', + initialValueInputSubtitle: 'Εισαγάγετε μια αρχική τιμή για εμφάνιση στο πεδίο τιμολογίου.', + listValuesInputSubtitle: 'Αυτές οι τιμές θα εμφανίζονται στη λίστα επιλογών του πεδίου τιμολογίου. Οι ενεργοποιημένες τιμές μπορούν να επιλεγούν από τα μέλη.', + listInputSubtitle: 'Αυτές οι τιμές θα εμφανίζονται στη λίστα πεδίων τιμολογίου. Οι ενεργοποιημένες τιμές μπορούν να επιλεγούν από τα μέλη.', + emptyInvoiceFieldsValues: { + title: 'Δεν υπάρχουν ακόμη τιμές λίστας', + subtitle: 'Προσθέστε προσαρμοσμένες τιμές για να εμφανίζονται στα τιμολόγια.', + }, + existingInvoiceFieldNameError: 'Υπάρχει ήδη πεδίο τιμολογίου με αυτό το όνομα', + invoiceFieldNameRequiredError: 'Παρακαλούμε εισαγάγετε ένα όνομα πεδίου τιμολογίου', + invoiceFieldTypeRequiredError: 'Παρακαλούμε επιλέξτε έναν τύπο πεδίου τιμολογίου', + invoiceFieldInitialValueRequiredError: 'Παρακαλούμε επιλέξτε μια αρχική τιμή για το πεδίο τιμολογίου', + addField: 'Προσθήκη πεδίου', + }, tags: { tagName: 'Όνομα ετικέτας', requiresTag: 'Τα μέλη πρέπει να επισημαίνουν όλες τις δαπάνες', @@ -7164,7 +7252,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ talkYourAccountManager: 'Συνομιλήστε με τον υπεύθυνο λογαριασμού σας.', talkToConcierge: 'Συνομιλήστε με το Concierge.', needAnotherAccounting: 'Χρειάζεστε άλλο λογιστικό λογισμικό;', - connectionName: (connectionName) => { + connectionName: (connectionName: AllConnectionName) => { switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: return 'QuickBooks Online'; @@ -7178,6 +7266,8 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7250,43 +7340,43 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ return 'Εισαγωγή λογαριασμών'; case 'quickbooksOnlineImportClasses': case 'quickbooksDesktopImportClasses': - return 'Εισαγωγή κατηγοριών'; + return 'Εισαγωγή κλάσεων'; case 'quickbooksOnlineImportLocations': - return 'Εισαγωγή τοποθεσιών'; + return 'Γίνεται εισαγωγή τοποθεσιών'; case 'quickbooksOnlineImportProcessing': return 'Επεξεργασία εισαγόμενων δεδομένων'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': return 'Συγχρονισμός αποζημιωμένων αναφορών και πληρωμών λογαριασμών'; case 'quickbooksOnlineSyncTaxCodes': - return 'Εισαγωγή κωδικών φόρου'; + return 'Εισαγωγή φορολογικών κωδικών'; case 'quickbooksOnlineCheckConnection': return `Έλεγχος σύνδεσης ${integrationName}`; case 'quickbooksOnlineImportMain': - return `Γίνεται εισαγωγή δεδομένων ${integrationName}`; + return `Γίνεται εισαγωγή δεδομένων από ${integrationName}`; case 'startingImportXero': - return 'Γίνεται εισαγωγή δεδομένων Xero'; + return 'Εισαγωγή δεδομένων Xero'; case 'startingImportQBO': - return `Γίνεται εισαγωγή δεδομένων ${integrationName}`; + return `Γίνεται εισαγωγή δεδομένων από ${integrationName}`; case 'startingImportQBD': case 'quickbooksDesktopImportMore': return 'Εισαγωγή δεδομένων QuickBooks Desktop'; case 'quickbooksDesktopImportTitle': return 'Γίνεται εισαγωγή τίτλου'; case 'quickbooksDesktopImportApproveCertificate': - return 'Γίνεται εισαγωγή πιστοποιητικού έγκρισης'; + return 'Γίνεται εισαγωγή του πιστοποιητικού έγκρισης'; case 'quickbooksDesktopImportDimensions': return 'Γίνεται εισαγωγή διαστάσεων'; case 'quickbooksDesktopImportSavePolicy': return 'Γίνεται εισαγωγή πολιτικής αποθήκευσης'; case 'quickbooksDesktopWebConnectorReminder': - return 'Γίνεται ακόμη συγχρονισμός δεδομένων με το QuickBooks... Βεβαιωθείτε ότι το Web Connector εκτελείται'; + return 'Συνεχίζεται ο συγχρονισμός δεδομένων με το QuickBooks... Βεβαιωθείτε ότι εκτελείται το Web Connector'; case 'quickbooksOnlineSyncTitle': return `Γίνεται συγχρονισμός δεδομένων ${integrationName}`; case 'quickbooksOnlineSyncLoadData': case 'xeroSyncStep': case 'intacctImportData': - return 'Γίνεται φόρτωση δεδομένων'; + return 'Φόρτωση δεδομένων'; case 'quickbooksOnlineSyncApplyCategories': return 'Ενημέρωση κατηγοριών'; case 'quickbooksOnlineSyncApplyCustomers': @@ -7298,7 +7388,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ case 'jobDone': return 'Αναμονή για τη φόρτωση των εισαγόμενων δεδομένων'; case 'xeroSyncImportChartOfAccounts': - return 'Συγχρονισμός λογιστικού σχεδίου'; + return 'Γίνεται συγχρονισμός του λογιστικού σχεδίου'; case 'xeroSyncImportCategories': return 'Γίνεται συγχρονισμός κατηγοριών'; case 'xeroSyncImportCustomers': @@ -7312,13 +7402,13 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ case 'xeroSyncImportBankAccounts': return 'Συγχρονισμός τραπεζικών λογαριασμών'; case 'xeroSyncImportTaxRates': - return 'Συγχρονισμός φορολογικών συντελεστών'; + return 'Γίνεται συγχρονισμός φορολογικών συντελεστών'; case 'xeroCheckConnection': - return 'Γίνεται έλεγχος σύνδεσης με το Xero'; + return 'Έλεγχος σύνδεσης με το Xero'; case 'xeroSyncTitle': - return 'Γίνεται συγχρονισμός δεδομένων Xero'; + return 'Συγχρονισμός δεδομένων Xero'; case 'netSuiteSyncConnection': - return 'Αρχικοποίηση σύνδεσης με το NetSuite'; + return 'Γίνεται προετοιμασία σύνδεσης με το NetSuite'; case 'netSuiteSyncCustomers': return 'Εισαγωγή πελατών'; case 'netSuiteSyncInitData': @@ -7326,11 +7416,11 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ case 'netSuiteSyncImportTaxes': return 'Εισαγωγή φόρων'; case 'netSuiteSyncImportItems': - return 'Εισαγωγή στοιχείων'; + return 'Γίνεται εισαγωγή στοιχείων'; case 'netSuiteSyncData': return 'Εισαγωγή δεδομένων στο Expensify'; case 'netSuiteSyncAccounts': - return 'Συγχρονισμός λογαριασμών'; + return 'Γίνεται συγχρονισμός λογαριασμών'; case 'netSuiteSyncCurrencies': return 'Συγχρονισμός νομισμάτων'; case 'netSuiteSyncCategories': @@ -7344,7 +7434,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ case 'netSuiteSyncNetSuiteReimbursedReports': return 'Σήμανση αναφορών Expensify ως αποζημιωμένων'; case 'netSuiteSyncExpensifyReimbursedReports': - return 'Σήμανση λογαριασμών και τιμολογίων NetSuite ως εξοφλημένων'; + return 'Σήμανση των λογαριασμών και των τιμολογίων NetSuite ως εξοφλημένων'; case 'netSuiteImportVendorsTitle': return 'Εισαγωγή προμηθευτών'; case 'netSuiteImportCustomListsTitle': @@ -7357,23 +7447,23 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ case 'quickbooksDesktopImportVendors': return 'Εισαγωγή προμηθευτών'; case 'intacctCheckConnection': - return 'Έλεγχος σύνδεσης Sage Intacct'; + return 'Έλεγχος σύνδεσης με Sage Intacct'; case 'intacctImportDimensions': return 'Εισαγωγή διαστάσεων Sage Intacct'; case 'intacctImportTitle': return 'Εισαγωγή δεδομένων Sage Intacct'; case 'financialForceSyncTitle': - return 'Γίνεται συγχρονισμός δεδομένων Certinia'; + return 'Συγχρονισμός δεδομένων Certinia'; case 'financialForceSyncStep': return 'Γίνεται συγχρονισμός της σύνδεσης Certinia'; case 'financialForceSyncCategories': - return 'Εισαγωγή κατηγοριών'; + return 'Γίνεται εισαγωγή κατηγοριών'; case 'financialForceSyncTags': return 'Γίνεται εισαγωγή ετικετών'; case 'financialForceSyncVendors': return 'Εισαγωγή προμηθευτών'; case 'financialForceSyncContacts': - return 'Εισαγωγή επαφών'; + return 'Γίνεται εισαγωγή επαφών'; case 'financialForceSyncCompanies': return 'Εισαγωγή εταιρειών'; case 'financialForceSyncUsers': @@ -7387,19 +7477,31 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ case 'rilletSyncConnection': return 'Γίνεται προετοιμασία σύνδεσης με το Rillet'; case 'rilletSyncImportData': - return 'Γίνεται φόρτωση δεδομένων'; + return 'Φόρτωση δεδομένων'; case 'dualEntrySyncTitle': - return 'Συγχρονισμός δεδομένων DualEntry'; + return 'Γίνεται συγχρονισμός δεδομένων DualEntry'; case 'dualEntrySyncConnection': return 'Γίνεται προετοιμασία σύνδεσης με το DualEntry'; case 'dualEntrySyncImportData': - return 'Γίνεται φόρτωση δεδομένων'; + return 'Φόρτωση δεδομένων'; case 'dualEntrySyncPayments': - return 'Γίνεται συγχρονισμός πληρωμών προμηθευτών'; + return 'Συγχρονισμός πληρωμών προμηθευτών'; case 'dualEntrySyncCardSettlements': - return 'Γίνεται συγχρονισμός των διακανονισμών κάρτας'; + return 'Συγχρονισμός διακανονισμών κάρτας'; case 'dualEntrySyncTravelSettlements': - return 'Γίνεται συγχρονισμός των διακανονισμών ταξιδιού'; + return 'Συγχρονισμός εκκαθαρίσεων ταξιδιών'; + case 'campfireSyncTitle': + return 'Γίνεται συγχρονισμός δεδομένων Campfire'; + case 'campfireSyncConnection': + return 'Γίνεται αρχικοποίηση σύνδεσης με το Campfire'; + case 'campfireSyncImportData': + return 'Φόρτωση δεδομένων'; + case 'campfireSyncPayments': + return 'Συγχρονισμός πληρωμών προμηθευτών'; + case 'campfireSyncCardSettlements': + return 'Συγχρονισμός διακανονισμών κάρτας'; + case 'campfireSyncTravelSettlements': + return 'Συγχρονισμός εκκαθαρίσεων ταξιδιών'; default: { return `Λείπει η μετάφραση για το στάδιο: ${stage}`; } @@ -7435,39 +7537,25 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ travelInvoicingSettlementAccountReconciliation: (lastFourPAN: string) => `Βεβαιωθείτε ότι αυτός ο λογαριασμός ταιριάζει με τον λογαριασμό διακανονισμού για τη συγκεντρωτική χρέωση ταξιδιών (καταλήγει σε ${lastFourPAN}) ώστε η συνεχής συμφωνία να λειτουργεί σωστά.`, }, - defaultVendorHelperText: (isSet) => - isSet + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName ? `Οι δαπάνες που δεν αντιστοιχίζονται αυτόματα θα αντιστοιχίζονται από προεπιλογή σε αυτόν τον προμηθευτή.` - : `Τα έξοδα που δεν αντιστοιχίζονται αυτόματα θα αντιστοιχίζονται από προεπιλογή σε αυτόν τον προμηθευτή. Διαφορετικά, θα εξαχθούν ως Credit Card Misc.`, - defaultVendorSelectHeader: (connectionName) => `Επιλέξτε έναν προεπιλεγμένο προμηθευτή ${connectionName} για δαπάνες που δεν αντιστοιχίζονται αυτόματα.`, + : `Οι δαπάνες που δεν αντιστοιχίζονται αυτόματα θα αντιστοιχίζονται σε αυτόν τον προμηθευτή από προεπιλογή. Διαφορετικά, θα εξαχθούν ως ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Επιλέξτε έναν προεπιλεγμένο προμηθευτή για δαπάνες που δεν αντιστοιχίζονται αυτόματα.`, syncTravelInvoicingSettlementsNoAccountTooltip: 'Για να το ενεργοποιήσετε, ορίστε έναν λογαριασμό για τις εξαγωγές σας.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Για να το ξεκλειδώσετε, ενεργοποιήστε τον αυτόματο συγχρονισμό.', + campfire: 'Campfire', }, hr: { title: 'HR', - connections: 'Συνδέσεις', connectionsSubtitle: 'Συνδεθείτε με το σύστημα HR σας για να συγχρονίζετε τα στοιχεία των εργαζομένων, να αντιστοιχίζετε αυτόματα τις αποζημιώσεις στα σωστά άτομα και να διατηρείτε τα έξοδα της ομάδας σας ακριβή χωρίς χειροκίνητη εργασία.', subtitle: 'Συνδέστε εργαλεία HR και διατηρήστε τις εγκρίσεις εργαζομένων συγχρονισμένες.', - connect: 'Σύνδεση', - syncNow: 'Συγχρονισμός τώρα', - disconnect: 'Αποσύνδεση', - disconnectTitle: (providerName: string) => `Αποσύνδεση ${providerName}`, - disconnectPrompt: (providerName: string) => `Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε το ${providerName};`, alreadyConnectedTitle: 'Δεν είναι δυνατή η σύνδεση σε πολλές πλατφόρμες HR', alreadyConnectedPrompt: 'Πρέπει να αποσυνδέσετε την τρέχουσα πλατφόρμα HR προτού συνδέσετε μια άλλη.', - lastSync: (relativeDate: string) => `Τελευταίος συγχρονισμός ${relativeDate}`, - notSync: 'Δεν έχει γίνει συγχρονισμός', - syncError: (providerName: string) => `Δεν είναι δυνατή η σύνδεση με το ${providerName}`, - authenticationError: (providerName: string) => `Δεν είναι δυνατή η σύνδεση με το ${providerName} λόγω ληγμένης σύνδεσης.`, - reconnect: 'Επανασύνδεση', - reconnectLink: 'Επανασυνδεθείτε.', connectionDescription: (providerName: string) => `Συνδέστε το ${providerName} για να διατηρείτε τις εγκρίσεις εργαζομένων συγχρονισμένες με τον χώρο εργασίας σας.`, - approvalMode: 'Λειτουργία έγκρισης', providerApprovalMode: (providerName: string) => `λειτουργία έγκρισης ${providerName}`, - finalApprover: 'Τελικός εγκρίνων', providerFinalApprover: (providerName: string) => `τελικός εγκριτής ${providerName}`, - notSet: 'Δεν έχει οριστεί', syncing: 'Γίνεται συγχρονισμός υπαλλήλων', syncingModalTitle: 'Η σύνδεσή σας συγχρονίζεται', syncingModalDescription: 'Η πρώτη σύνδεση μπορεί να πάρει λίγο χρόνο. Θα ενημερωθείτε για τυχόν σφάλματα.', @@ -7476,19 +7564,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Είστε βέβαιοι ότι θέλετε να αλλάξετε τη λειτουργία έγκρισης για αυτόν τον χώρο εργασίας; Μάθετε περισσότερα σχετικά με τις διαφορετικές λειτουργίες ροής εργασιών με ενεργοποιημένο το ${providerName} στον ιστότοπο βοήθειας μας.`, approvalModeWarningConfirm: 'Αλλαγή λειτουργίας έγκρισης', - approvalModes: { - basic: { - label: 'Βασική έγκριση', - description: 'Όλοι οι χρήστες υποβάλλουν σε ένα μόνο άτομο για επεξεργασία και έγκριση.', - }, - manager: { - label: 'Έγκριση προϊσταμένου', - description: (providerName: string) => `Οι υπάλληλοι υποβάλλουν αναφορές στον άμεσο προϊστάμενό τους που έχει ρυθμιστεί στο ${providerName}.`, - }, - custom: { - label: 'Προσαρμοσμένη έγκριση', - description: 'Θα ρυθμίσω χειροκίνητα ροές έγκρισης στο Expensify.', - }, + approvalModeDescriptions: { + basic: 'Όλοι οι χρήστες υποβάλλουν σε ένα μόνο άτομο για επεξεργασία και έγκριση.', + manager: (providerName: string) => `Οι υπάλληλοι υποβάλλουν αναφορές στον άμεσο προϊστάμενό τους που έχει ρυθμιστεί στο ${providerName}.`, + custom: 'Θα ρυθμίσω χειροκίνητα ροές έγκρισης στο Expensify.', }, syncStageName: (stage) => { switch (stage) { @@ -7528,18 +7607,53 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ zenefits: { title: 'TriNet', }, + setupIncomplete: (setupLink: string | undefined) => + `Συνδέθηκε. ${setupLink ? `Ολοκλήρωση ρύθμισης` : 'Ολοκληρώστε τη ρύθμιση'} για εισαγωγή υπαλλήλων.`, mergeHR: { - completeSetup: 'Ολοκληρώστε τη ρύθμιση', - setupIncomplete: (setupLink: string | undefined) => - `Συνδέθηκε. ${setupLink ? `Ολοκλήρωση ρύθμισης` : 'Ολοκληρώστε τη ρύθμιση'} για εισαγωγή υπαλλήλων.`, groups: { title: 'Ομάδες', description: 'Επιλέξτε τις ομάδες υπαλλήλων που θέλετε να συγχρονίσετε με αυτόν τον χώρο εργασίας', }, }, - findIntegration: 'Βρείτε ενοποίηση', + }, + recruiting: { + title: 'Προσλήψεις', + connectionsSubtitle: + 'Συνδεθείτε με το σύστημα προσλήψεών σας για να συγχρονίζετε τα δεδομένα υποψηφίων, να αντιστοιχίζετε αυτόματα τις αποζημιώσεις στα σωστά άτομα και να διατηρείτε τις δαπάνες της ομάδας σας ακριβείς χωρίς χειροκίνητη εργασία.', + alreadyConnectedTitle: 'Αδυναμία σύνδεσης σε πολλές πλατφόρμες ATS', + alreadyConnectedPrompt: 'Πρέπει να αποσυνδέσετε το τρέχον ATS σας πριν συνδέσετε κάποιο άλλο.', + syncing: 'Γίνεται συγχρονισμός υποψηφίων', + setupIncomplete: (setupLink: string | undefined) => + `Συνδέθηκε. ${setupLink ? `Ολοκληρώστε τη ρύθμιση` : 'Ολοκληρώστε τη ρύθμιση'} για εισαγωγή υποψηφίων.`, + dontSeeYourATS: `Δεν βλέπετε εδώ το ATS σας; Ρωτήστε το Concierge και μπορούμε να το προσθέσουμε.`, + importSettings: 'Ρυθμίσεις εισαγωγής', + defaultApprover: 'Προεπιλεγμένος εγκρίνων', + approverFields: {recruiter: 'Στρατολογητής', recruitingCoordinator: 'Συντονιστής προσλήψεων'}, }, merge: { + connections: 'Συνδέσεις', + connect: 'Σύνδεση', + findIntegration: 'Βρείτε ενοποίηση', + syncNow: 'Συγχρονισμός τώρα', + disconnect: 'Αποσύνδεση', + disconnectTitle: (providerName: string) => `Αποσύνδεση ${providerName}`, + disconnectPrompt: (providerName: string) => `Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε το ${providerName};`, + lastSync: (relativeDate: string) => `Τελευταίος συγχρονισμός ${relativeDate}`, + notSync: 'Δεν έχει γίνει συγχρονισμός', + syncError: (providerName: string) => `Δεν είναι δυνατή η σύνδεση με το ${providerName}`, + authenticationError: (providerName: string) => `Δεν είναι δυνατή η σύνδεση με το ${providerName} λόγω ληγμένης σύνδεσης.`, + reconnect: 'Επανασύνδεση', + reconnectLink: 'Επανασυνδεθείτε.', + notSet: 'Δεν έχει οριστεί', + completeSetup: 'Ολοκληρώστε τη ρύθμιση', + approvalMode: 'Λειτουργία έγκρισης', + finalApprover: 'Τελικός εγκρίνων', + approvalModes: { + basic: 'Βασική έγκριση', + manager: 'Έγκριση προϊσταμένου', + custom: 'Προσαρμοσμένη έγκριση', + advanced: 'Προηγμένη έγκριση', + }, syncLimitReached: { title: 'Δοκιμάστε ξανά αύριο', prompt: 'Έχετε φτάσει το όριο συγχρονισμών σας για σήμερα.', @@ -7601,12 +7715,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ summaryDisabled: 'Χωρίς εξαίρεση μετακίνησης', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, summaryHomeAndOffice: 'Χρησιμοποιήστε τις τοποθεσίες σπιτιού και γραφείου', - optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', + optionDisabledTitle: 'Να μην εξαιρείται η μετακίνηση προς/από την εργασία', optionDisabledHelp: 'Καμία μετακίνηση από και προς την εργασία δεν αφαιρείται από τις αιτήσεις.', - optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', - optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', - optionHomeAndOfficeTitle: 'Υπολογισμός ανά σπίτι και γραφείο', - optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας, τη ρύθμιση εργασίας και την ανάθεση γραφείου του μέλους για να υπολογίσετε τις εξαιρέσεις μετακίνησης.', + optionFixedDistanceTitle: 'Κατάργηση σταθερής απόστασης', + optionFixedDistanceHelp: 'Αφαιρεί την ίδια απόσταση από κάθε αίτημα.', + optionHomeAndOfficeTitle: 'Εξαίρεση συνήθους μετακίνησης', + optionHomeAndOfficeHelp: 'Αφαιρεί το συνηθισμένο δρομολόγιο μετακίνησης κάθε μέλους, με βάση το σπίτι, τη μορφή εργασίας και το γραφείο του.', distanceLabel: 'Απόσταση', workspaceAddressRequired: { title: 'Όχι τόσο γρήγορα...', @@ -7815,6 +7929,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Τα πεδία αναφοράς είναι διαθέσιμα μόνο στο πλάνο Control, ξεκινώντας από ${formattedPrice} ${hasTeam2025Pricing ? `ανά μέλος ανά μήνα.` : `ανά ενεργό μέλος ανά μήνα.`}`, }, + invoiceFields: { + title: 'Πεδία τιμολογίου', + description: `Τα πεδία τιμολογίου σάς επιτρέπουν να συμπεριλάβετε επιπλέον λεπτομέρειες σε επίπεδο τιμολογίου στα τιμολόγια.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Τα πεδία τιμολογίου είναι διαθέσιμα μόνο στο πλάνο Control, ξεκινώντας από ${formattedPrice} ${hasTeam2025Pricing ? `ανά μέλος ανά μήνα.` : `ανά ενεργό μέλος ανά μήνα.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Απολαύστε αυτόματο συγχρονισμό και μειώστε τις χειροκίνητες καταχωρίσεις με την ενοποίηση Expensify + NetSuite. Αποκτήστε σε βάθος, σε πραγματικό χρόνο οικονομικές πληροφορίες με υποστήριξη ενσωματωμένων και προσαρμοσμένων τμημάτων, συμπεριλαμβανομένης της αντιστοίχισης έργων και πελατών.`, @@ -7857,6 +7977,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Η ενοποίησή μας με το DualEntry είναι διαθέσιμη μόνο στο πρόγραμμα Control, ξεκινώντας από ${formattedPrice} ${hasTeam2025Pricing ? `ανά μέλος ανά μήνα.` : `ανά ενεργό μέλος ανά μήνα.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Απολαύστε τον αυτόματο συγχρονισμό και μειώστε τις χειροκίνητες εγγραφές με την ενσωμάτωση Expensify + Campfire. Ευθυγραμμίστε τις διαστάσεις κωδικοποίησης εξόδων και τον συγχρονισμό φόρων με τη ρύθμιση Campfire για πιο ξεκάθαρη οικονομική εικόνα.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Η ενοποίησή μας με το Campfire είναι διαθέσιμη μόνο στο πρόγραμμα Control, ξεκινώντας από ${formattedPrice} ${hasTeam2025Pricing ? `ανά μέλος ανά μήνα.` : `ανά ενεργό μέλος ανά μήνα.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Προηγμένες εγκρίσεις', description: `Αν θέλετε να προσθέσετε περισσότερα επίπεδα έγκρισης στη διαδικασία – ή απλώς να βεβαιωθείτε ότι οι μεγαλύτερες δαπάνες ελέγχονται από ένα ακόμη άτομο – σας καλύπτουμε. Οι προηγμένες εγκρίσεις σάς βοηθούν να θέσετε τους κατάλληλους ελέγχους σε κάθε επίπεδο, ώστε να κρατάτε τις δαπάνες της ομάδας σας υπό έλεγχο.`, @@ -8219,7 +8345,6 @@ ${reportName}`, alwaysReimbursableDescription: 'Τα έξοδα αποζημιώνονται πάντα στους υπαλλήλους', alwaysNonReimbursable: 'Πάντα μη αποζημιώσιμες', alwaysNonReimbursableDescription: 'Τα έξοδα δεν αποζημιώνονται ποτέ στους υπαλλήλους', - billableDefault: 'Προεπιλογή χρεώσιμων', billableDefaultDescription: 'Επιλέξτε αν οι δαπάνες με μετρητά και με πιστωτική κάρτα θα είναι χρεώσιμες από προεπιλογή.', billable: 'Χρεώσιμη', billableDescription: 'Οι δαπάνες συνήθως τιμολογούνται ξανά στους πελάτες', @@ -8246,10 +8371,12 @@ ${reportName}`, publicReceiptVisibilityHintDisabled: 'Οι αποδείξεις είναι ορατές μόνο από μέλη του Expensify που έχουν πρόσβαση στην αναφορά που περιέχει την απόδειξη.', enableTagsToUnlockTitle: 'Ενεργοποίηση ετικετών;', enableTagsToUnlockPrompt: 'Ενεργοποιήστε τις ετικέτες (στην ενότητα «Περισσότερες δυνατότητες») για να τις ξεκλειδώσετε.', - enableTagsAndRequirePrompt: 'Είστε βέβαιοι ότι θέλετε να ενεργοποιήσετε τις ετικέτες και να τις απαιτείτε για όλες τις δαπάνες;', enableCategoriesToUnlockTitle: 'Ενεργοποίηση κατηγοριών;', enableCategoriesToUnlockPrompt: 'Ενεργοποιήστε τις κατηγορίες (στην ενότητα «Περισσότερες λειτουργίες») για να τις ξεκλειδώσετε.', enableCategoriesAndRequirePrompt: 'Είστε βέβαιοι ότι θέλετε να ενεργοποιήσετε τις κατηγορίες και να τις απαιτείτε για όλες τις δαπάνες;', + enableTagsPrompt: 'Είστε βέβαιοι ότι θέλετε να ενεργοποιήσετε τις ετικέτες; Μπορείτε να τις κάνετε υποχρεωτικές για όλες τις δαπάνες μόλις έχετε τουλάχιστον μία ετικέτα.', + noTagsToRequirePrompt: 'Δεν έχετε ετικέτες. Δημιουργήστε μία ετικέτα.', + noCategoriesToRequirePrompt: 'Δεν έχετε κατηγορίες. Δημιουργήστε μια κατηγορία.', }, expenseReportRules: { title: 'Για προχωρημένους', @@ -8324,6 +8451,23 @@ ${reportName}`, importColumnUpdatedDescription: 'Ενημερωμένη περιγραφή', vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', + confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', + confirmErrorCategoryTax: 'Επιλέξτε έναν φορολογικό συντελεστή', + confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', + confirmErrorConditionAndDefault: 'Προσθέστε έναν έμπορο και ορίστε τουλάχιστον μία προεπιλογή', + turnOnTaxesFirstTitle: 'Ενεργοποιήστε πρώτα τους φόρους', + turnOnTaxesFirstPrompt: + 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Ενεργοποιήστε τους φόρους στις ρυθμίσεις του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', + categoryRulesApplyGoingForwardTitle: 'Οι κανόνες κατηγορίας ισχύουν από εδώ και στο εξής', + categoryRulesApplyGoingForwardPrompt: + 'Ένας προεπιλεγμένος φορολογικός συντελεστής εφαρμόζεται σε νέες δαπάνες σε αυτή την κατηγορία. Οι δαπάνες που υπάρχουν ήδη δεν θα αλλάξουν.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Ο επιλεγμένος φορολογικός συντελεστής είναι πλέον η προεπιλογή για τον χώρο εργασίας σας, επομένως αυτός ο κανόνας δεν είναι πλέον έγκυρος. Επιλέξτε έναν διαφορετικό φορολογικό συντελεστή.', + addTaxRateFirstTitle: 'Προσθέστε πρώτα έναν φορολογικό συντελεστή', + addTaxRateFirstPrompt: + 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Προσθέστε έναν φορολογικό συντελεστή διαφορετικό από την προεπιλογή του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', + createRuleFromExpenseAction: 'Δημιουργία κανόνα', + createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, newRule: { title: 'Νέος κανόνας', @@ -8332,12 +8476,12 @@ ${reportName}`, restrictCardSpendDescription: 'Μπλοκάρετε ή περιορίστε τις δαπάνες στο σημείο πώλησης', flagForReview: 'Σημείωση για έλεγχο', flagForReviewDescription: 'Ειδοποίηση εγκριτών όταν τα έξοδα υπερβαίνουν τα όρια κατηγορίας', - requireFields: 'Απαιτήσεις πεδίου', + requireFields: 'Ορίστε απαιτήσεις πεδίου', requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', - createAgentRule: 'Κανόνας αντιπροσώπου', - createAgentRuleDescription: 'Περιγράψτε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', + createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', }, expenseDefaultsTable: { tableColumnType: 'Τύπος', @@ -8348,6 +8492,7 @@ ${reportName}`, update: 'Ενημέρωση', merchantIs: (merchant: string) => `Ο έμπορος είναι «${merchant}»`, merchantTypeIs: (merchantType: string) => `Ο τύπος εμπόρου είναι «${merchantType}»`, + categoryIs: (category: string) => `Η κατηγορία είναι «${category}»`, }, merchantTypeRule: { merchantType: 'Τύπος εμπόρου', @@ -8368,6 +8513,7 @@ ${reportName}`, requireReceipt: 'Απαιτείται απόδειξη', doNotRequireReceipt: 'Να μην απαιτείται απόδειξη', doNotRequireItemizedReceipt: 'Να μην απαιτείται αναλυτική απόδειξη', + typeLabel: 'Απαιτείται', }, requireFieldsEmptyState: { title: 'Εντοπίστε έγκαιρα τα ελλιπή στοιχεία', @@ -8433,7 +8579,8 @@ ${reportName}`, flagAmountsOverSubtitle: 'Αυτό παρακάμπτει το μέγιστο ποσό για όλες τις δαπάνες.', expenseLimitTypes: { expense: 'Ατομική δαπάνη', - expenseSubtitle: 'Σημαδέψτε τα ποσά εξόδων ανά κατηγορία. Αυτός ο κανόνας παρακάμπτει τον γενικό κανόνα χώρου εργασίας για το μέγιστο ποσό εξόδου.', + expenseSubtitle: + 'Επισήμανση ποσών εξόδων ανά κατηγορία. Αυτός ο κανόνας παρακάμπτει τον γενικό κανόνα χώρου εργασίας για το μέγιστο ποσό εξόδου. Οι κρατήσεις πολλών ημερών αξιολογούνται με βάση τον μέσο όρο ανά διανυκτέρευση.', daily: 'Σύνολο κατηγορίας', dailySubtitle: 'Σήμανση συνολικής ημερήσιας δαπάνης ανά κατηγορία για κάθε αναφορά εξόδων.', }, @@ -8648,6 +8795,19 @@ ${reportName}`, subtitle: 'Δημιουργήστε έναν κανόνα για την αυτοματοποίηση των πολιτικών του χώρου εργασίας σας.', cta: 'Προσθήκη κανόνα AI', }, + expenseDefaultType: { + title: 'Εφαρμογή προεπιλογών εξόδων', + subtitle: 'Σε τι πρέπει να αντιστοιχεί αυτός ο κανόνας;', + merchant: 'Έμπορος', + merchantDescription: 'Ενημέρωση πεδίων σε δαπάνες από συγκεκριμένο έμπορο', + category: 'Κατηγορία', + categoryDescription: 'Ορίστε προεπιλεγμένο φορολογικό συντελεστή για μια συγκεκριμένη κατηγορία', + }, + taxesDisabledEmptyState: { + title: 'Οι φόροι δεν είναι ενεργοποιημένοι', + subtitle: 'Ενεργοποιήστε τους φόρους για να τεκμηριώσετε και να διεκδικήσετε τους επιλέξιμους φόρους.', + cta: 'Ενεργοποίηση φόρων', + }, }, planTypePage: { planTypes: { @@ -8678,6 +8838,16 @@ ${reportName}`, findVendor: 'Εύρεση προμηθευτή', managedInAccountingSoftware: 'Οι προμηθευτές διαχειρίζονται στο', }, + campfire: { + campfireSetup: 'Ρύθμιση campfire', + enterCredentials: 'Εισαγάγετε το κλειδί API του Campfire', + howToFindAPIKey: + 'Εύρεση του κλειδιού API.
  1. Συνδεθείτε στο Campfire
  2. Μεταβείτε στις Ρυθμίσεις -> API Keys
  3. Δημιουργήστε κλειδί API
  4. Επικολλήστε το κλειδί API παρακάτω
', + subsidiary: 'Θυγατρική', + subsidiarySelectDescription: 'Επιλέξτε τη θυγατρική στο Campfire από την οποία θέλετε να εισαγάγετε δεδομένα.', + noSubsidiariesFound: 'Δεν βρέθηκαν θυγατρικές', + noSubsidiariesFoundDescription: 'Παρακαλούμε προσθέστε μια οντότητα στο Campfire και συγχρονίστε ξανά τη σύνδεση', + }, }, getAssistancePage: { title: 'Λάβετε βοήθεια', @@ -9627,7 +9797,7 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Πληρωμή', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Εξαγωγή', }, - has: {submittedViolation: 'Υποβληθείσα παράβαση'}, + has: {submittedViolation: 'Υποβληθείσα παράβαση', approvedViolation: 'Εγκεκριμένη παράβαση'}, filterType: { label: 'Τύπος φίλτρου', has: {positive: 'έχει', negative: 'δεν έχει'}, @@ -9701,11 +9871,16 @@ ${reportName}`, exportedTo: 'Εξήχθη σε', exportAll: { selectAllMatchingItems: 'Επιλέξτε όλα τα στοιχεία που ταιριάζουν', + allMatchingItemsSelected: 'Έχουν επιλεγεί όλα τα στοιχεία που ταιριάζουν', selectAllOnThisPage: 'Επιλέξτε όλα σε αυτή τη σελίδα', }, errors: { pleaseSelectDatesForBothFromAndTo: 'Παρακαλούμε επιλέξτε ημερομηνίες και για το Από και για το Έως', }, + mergeReports: { + title: 'Συγχώνευση αναφορών', + description: 'Επιλέξτε την αναφορά που θέλετε να κρατήσετε. Όλες οι δαπάνες θα μετακινηθούν σε αυτήν και οι άλλες αναφορές θα διαγραφούν.', + }, goTo: ({destination}: {destination: string}) => `Μετάβαση στο ${destination}`, mySavedSearch: 'Οι δαπάνες μου', expensifyCardStatementPDF: { @@ -9799,6 +9974,7 @@ ${reportName}`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `εξήχθη σε ${translatedLabel}`; @@ -9839,6 +10015,16 @@ ${reportName}`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Επαναλήφθηκε ${count} φορές.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `Η σύνδεση ${feedName} δεν λειτουργεί. Για να επαναφέρετε τις εισαγωγές καρτών, συνδεθείτε στην τράπεζά σας.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `Η σύνδεση ${feedName} έχει διακοπεί εδώ και 30 ημέρες. Συνδεθείτε στην τράπεζά σας για να τη διορθώσετε ή καταργήστε τη σύνδεση αν δεν χρησιμοποιείται πλέον. Δεν θα χάσετε καμία υποβληθείσα δαπάνη αν την καταργήσετε.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `η σύνδεση Plaid με τον επαγγελματικό σας τραπεζικό λογαριασμό έχει διακοπεί. Παρακαλούμε συνδέστε ξανά τον τραπεζικό σας λογαριασμό ${maskedAccountNumber} ώστε να μπορείτε να συνεχίσετε να χρησιμοποιείτε τις Κάρτες Expensify.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10440,6 +10626,7 @@ ${reportName}`, companyCardRequired: 'Απαιτούνται αγορές με εταιρική κάρτα', noRoute: 'Παρακαλούμε επιλέξτε μια έγκυρη διεύθυνση', cannotMergeDuplicates: 'Μπορείτε να συγχωνεύετε έξοδα μόνο σε πρόχειρες ή εκκρεμείς αναφορές. Ανακαλέστε την και δοκιμάστε ξανά.', + overCategoryLimitPerNight: (formattedLimit: string) => `Νυχτερινή τιμή πάνω από το όριο κατηγορίας ${formattedLimit}/άτομο`, shortName: { allTagLevelsRequired: 'Απαιτούνται όλες οι ετικέτες', autoReportedRejectedExpense: 'Η δαπάνη απορρίφθηκε', @@ -11032,6 +11219,7 @@ ${reportName}`, reportLevelExport: 'Όλα τα δεδομένα - σε επίπεδο αναφοράς', expenseLevelExport: 'Όλα τα δεδομένα - επίπεδο δαπάνης', multipleTaxExport: 'Καναδική εξαγωγή πολλαπλών φόρων', + reconciliationAllExpenses: 'Συμφωνία - Όλα τα έξοδα', exportInProgress: 'Εξαγωγή σε εξέλιξη', conciergeWillSend: 'Ο Concierge θα σας στείλει το αρχείο σύντομα.', downloadStatementPDF: 'Λήψη κατάστασης', @@ -11063,6 +11251,7 @@ ${reportName}`, notVerified: 'Μη επαληθευμένο', retry: 'Προσπαθήστε ξανά', requestSent: 'Το αίτημα εστάλη', + requestAccessError: 'Δεν μπορέσαμε να στείλουμε το αίτημά σου. Παρακαλώ δοκίμασε ξανά.', verifyDomain: { title: 'Επαλήθευση τομέα', beforeProceeding: ({domainName}: {domainName: string}) => `Πριν συνεχίσετε, επιβεβαιώστε ότι σας ανήκει το ${domainName} ενημερώνοντας τις ρυθμίσεις DNS του.`, @@ -11122,13 +11311,12 @@ ${reportName}`, setMetadataGenericError: 'Δεν ήταν δυνατός ο ορισμός των μεταδεδομένων SAML', }, accessRestricted: { - title: 'Περιορισμένη πρόσβαση', - subtitle: (domainName: string) => - `Παρακαλούμε επιβεβαιώστε ότι είστε εξουσιοδοτημένος διαχειριστής της εταιρείας για το ${domainName} εάν χρειάζεστε έλεγχο στα εξής:`, - companyCardManagement: 'Διαχείριση εταιρικής κάρτας', - accountCreationAndDeletion: 'Δημιουργία και διαγραφή λογαριασμού', - workspaceCreation: 'Δημιουργία χώρου εργασίας', - samlSSO: 'SAML SSO', + headerTitle: 'Περιορισμένη πρόσβαση', + title: 'Απαιτείται επαλήθευση', + description: (domainName: string) => + `Παρακαλούμε επιβεβαιώστε ότι είστε εξουσιοδοτημένος διαχειριστής εταιρείας για το ${domainName} ή ζητήστε πρόσβαση από τους υπάρχοντες διαχειριστές.`, + requestAdminAccess: 'Αίτημα πρόσβασης διαχειριστή', + verifyYourself: 'Επιβεβαιώστε την ταυτότητά σας', }, addDomain: { title: 'Προσθήκη τομέα', @@ -11142,7 +11330,6 @@ ${reportName}`, title: 'Ο τομέας έχει ήδη ρυθμιστεί. Αίτημα πρόσβασης;', description: 'Κάποιος έχει ήδη ρυθμίσει αυτόν τον τομέα στο Expensify. Θέλετε να ζητήσετε πρόσβαση διαχειριστή;', requestAccess: 'Αίτημα πρόσβασης διαχειριστή', - requestAccessError: 'Δεν μπορέσαμε να στείλουμε το αίτημά σου. Παρακαλώ δοκίμασε ξανά.', }, domainAdded: { title: 'Το domain προστέθηκε', @@ -11167,6 +11354,9 @@ ${reportName}`, consolidatedDomainBillingError: 'Η ενοποιημένη χρέωση τομέα δεν ήταν δυνατό να αλλάξει. Δοκιμάστε ξανά αργότερα.', addAdmin: 'Προσθήκη διαχειριστή', addAdminError: 'Δεν είναι δυνατή η προσθήκη αυτού του μέλους ως διαχειριστή. Παρακαλούμε δοκιμάστε ξανά.', + requests: 'Αιτήματα', + approveRequestError: 'Δεν είναι δυνατή η έγκριση αυτού του αιτήματος. Παρακαλούμε δοκιμάστε ξανά.', + declineRequestError: 'Δεν είναι δυνατή η άρνηση αυτού του αιτήματος. Παρακαλούμε δοκιμάστε ξανά.', revokeAdminAccess: 'Ανακαλέστε πρόσβαση διαχειριστή', cantRevokeAdminAccess: 'Δεν είναι δυνατή η ανάκληση της πρόσβασης διαχειριστή από το τεχνικό σημείο επαφής', error: { diff --git a/src/languages/en.ts b/src/languages/en.ts index eeac487c54ea..ae8676e96fc7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -63,6 +63,8 @@ const translations = { unshare: 'Unshare', yes: 'Yes', no: 'No', + approve: 'Approve', + deny: 'Deny', dontChange: 'Don’t change', // @context Universal confirmation button. Keep the UI-standard term “OK” unless the locale strongly prefers an alternative. ok: 'OK', @@ -85,6 +87,7 @@ const translations = { search: 'Search', reports: 'Reports', spend: 'Spend', + insights: 'Insights', find: 'Find', searchWithThreeDots: 'Search...', next: 'Next', @@ -511,6 +514,8 @@ const translations = { none: 'None', unstableInternetConnection: 'Unstable internet connection. Please check your network and try again.', enableGlobalReimbursements: 'Enable Global Reimbursements', + corpayPayModalTitle: 'Pay report', + corpayPayModalPrompt: 'This submitter has a non-USD bank account. Enable global reimbursements to pay the report, or ask them to add a USD bank account.', purchaseAmount: 'Purchase amount', originalAmount: 'Original amount', frequency: 'Frequency', @@ -1001,7 +1006,7 @@ const translations = { tooltip: 'Get started here!', }, homePage: { - forYou: 'For you', + toDos: 'To-dos', conciergePrompt: { goodMorning: ({name}: {name?: string}) => (name ? `Good morning, ${name}.` : 'Good morning.'), goodAfternoon: ({name}: {name?: string}) => (name ? `Good afternoon, ${name}.` : 'Good afternoon.'), @@ -1029,12 +1034,12 @@ const translations = { addShippingAddress: { title: 'We need your shipping address', subtitle: 'Provide an address to receive your Expensify Card.', - cta: 'Add address', + cta: 'Add', }, addVirtualCardPersonalDetails: { title: 'Add your details to view and start using your Expensify Card.', subtitle: 'Expensify Card', - cta: 'Add details', + cta: 'Add', }, addPaymentCard: { title: 'Add a payment card to keep using Expensify', @@ -1050,6 +1055,14 @@ const translations = { subtitle: 'Validate your card and start spending.', cta: 'Activate', }, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `${walletName} card addition needs your approval`, + subtitle: 'Expensify Card', + cta: 'Review', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Digital wallet', + }, reviewCardFraud: { title: 'Review potential fraud on your Expensify Card', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Review ${amount} in potential fraud at ${merchant}`, @@ -1064,7 +1077,7 @@ const translations = { addHomeAddress: { title: 'Add your home address for distance tracking', subtitle: 'Account', - cta: 'Add address', + cta: 'Add', }, fixFailedBilling: { title: "We couldn't bill your card on file", @@ -1101,7 +1114,6 @@ const translations = { title: 'Your spend', awaitingApproval: 'Awaiting approval', repaidLast30Days: 'Repaid last 30 days', - recentTransactions: ({lastFour}: {lastFour: string}) => `Recent transactions • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `See ${count} more`, discoverSection: { @@ -1195,6 +1207,8 @@ const translations = { setupRules: 'Set up spend rules', customizeSpendCategories: 'Customize spend categories', customizeSpendCategoriesSubText: 'Organize and classify expenses', + customizeExpenseCategories: 'Customize your expense categories', + customizeExpenseCategoriesSubText: 'Add your company’s categories to code your expenses', createExpense: 'Create an expense', createExpenseSubText: 'Scan, drag and drop, or manually enter an expense using the + button', linkPersonalCard: 'Link personal card', @@ -1356,6 +1370,7 @@ const translations = { addAdditionalReceipt: 'Add additional receipt', scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.", crop: 'Crop', + pageCount: ({pageCount}: {pageCount: number}) => `Page 1 of ${pageCount}`, addAReceipt: { phrase1: 'Add a receipt', phrase2: 'or drag and drop one here', @@ -1749,11 +1764,8 @@ const translations = { noDuplicatesTitle: 'All set!', noDuplicatesDescription: 'There are no duplicate transactions for review here.', confirmApprove: 'Confirm approval amount', - confirmApprovalAmount: 'Approve only compliant expenses, or approve the entire report.', - confirmApprovalAllHoldAmount: () => ({ - one: 'This expense is on hold. Do you want to approve anyway?', - other: 'These expenses are on hold. Do you want to approve anyway?', - }), + confirmApprovalWithHeldAmount: 'Report contains held expenses. Approve only compliant expenses, or approve the entire report?', + confirmApprovalAllHoldAmount: 'All expenses are on hold. Approve anyway?', confirmPay: 'Confirm payment amount', confirmPayAmount: "Pay what's not on hold, or pay the entire report.", confirmPayAllHoldAmount: () => ({ @@ -2398,6 +2410,11 @@ const translations = { usingImportedState: 'You are using imported state. Press here to clear it.', debugMode: 'Debug mode', showBranchNameInTitle: 'Show branch name in browser title', + betaOverrides: 'Beta overrides', + betaOverridesDescription: + 'Overrides are local to this device and only affect frontend checks. A beta keeps an override only while it differs from your account betas, so toggling it back removes the override. “Reset all overrides” restores the values from your account. Some betas are gated on the backend too, so those can still fail at the request level.', + resetAllOverrides: 'Reset all overrides', + overridden: 'Overridden', invalidFile: 'Invalid file', invalidFileDescription: 'The file you are trying to import is not valid. Please try again.', invalidateWithDelay: 'Invalidate with delay', @@ -2715,6 +2732,10 @@ const translations = { connectionLink ? `Your ${cardName} card connection is broken. Log into your bank to fix the card.` : `Your ${cardName} card connection is broken. Log into your bank to fix the card.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Your ${cardName} connection has been broken for 30 days. Log into your bank to fix it or remove the card if it's no longer in use. You won't lose any submitted expenses if you remove it.` + : `Your ${cardName} connection has been broken for 30 days. Log into your bank to fix it or remove the card if it's no longer in use. You won't lose any submitted expenses if you remove it.`, addAdditionalCards: 'Add additional cards', upgradeDescription: 'Need to add more cards? Create a workspace to add additional personal cards or assign company cards to the entire team.', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2813,6 +2834,23 @@ const translations = { }, chaseAccountNumberDifferent: 'Why is my account number different?', }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Add card to ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'digital wallet', + confirmHeading: 'Confirm your request', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Do you want to add your Expensify Card (ending in ${lastFourDigits}) to your ${walletName}?`, + deny: 'Deny', + confirm: 'Confirm', + verifyTitle: "Let's verify it's you", + enterSecurityCode: (contactMethod: string) => `Please enter the security code sent to ${contactMethod} to confirm this request. It should arrive within a minute or two.`, + successHeading: 'Success!', + successDescription: ({walletName}: {walletName: string}) => `Your card is now active to use in your ${walletName}.`, + deniedHeading: 'Request denied', + deniedDescription: ({walletName}: {walletName: string}) => `Your card has not been added to your ${walletName}.`, + }, cardPage: { expensifyCard: 'Expensify Card', expensifyTravelCard: 'Expensify Travel Card', @@ -3260,6 +3298,7 @@ const translations = { unread: 'Unread', markAllAsRead: 'Mark all as read', markAllAsReadConfirmationPrompt: 'Are you sure you want to mark all chats as read?', + markAllTodosAsReadConfirmationPrompt: 'Are you sure you want to mark all to-do chats as read?', }, reportDetailsPage: { goToRoom: 'Go to room', @@ -3437,6 +3476,7 @@ const translations = { title: 'Couldn’t add work email', subtitle: (workEmail: string | undefined) => `We couldn’t add ${workEmail}. Please try again later in Settings or chat with Concierge for guidance.`, workAccountClosedSubtitle: 'The work account associated with this email is closed. Please contact your company admin to reactivate it, or sign up with a different email.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} is a domain controlled login for an existing Expensify account.`, }, tasks: { testDriveAdminTask: { @@ -4284,8 +4324,7 @@ const translations = { legalFirstName: 'Legal first name', legalLastName: 'Legal last name', enterTheDateOfBirthOfTheOwner: "What's the owner's date of birth?", - enterTheLast4: 'What are the last 4 digits of the owner’s Social Security Number?', - last4SSN: 'Last 4 of SSN', + enterTheSSN: "What's the owner's Social Security Number?", dontWorry: "Don't worry, we don't do any personal credit checks!", enterTheOwnersAddress: "What's the owner's address?", letsDoubleCheck: 'Let’s double check that everything looks right.', @@ -4312,13 +4351,12 @@ const translations = { whatsYourDOB: "What's your date of birth?", whatsTheOwnersAddress: "What's the owner's address?", whatsYourAddress: "What's your address?", - whatAreTheLast: "What are the last 4 digits of the owner's Social Security Number?", - whatsYourLast: 'What are the last 4 digits of your Social Security Number?', + whatsTheOwnersSSN: "What's the owner's Social Security Number?", + whatsYourSSN: 'What is your Social Security Number?', whatsYourNationality: 'What is your country of citizenship?', whatsTheOwnersNationality: "What's the owner's country of citizenship?", countryOfCitizenship: 'Country of citizenship', dontWorry: "Don't worry, we don't do any personal credit checks!", - last4: 'Last 4 of SSN', whyDoWeAsk: 'Why do we ask for this?', letsDoubleCheck: 'Let’s double check that everything looks right.', legalName: 'Legal name', @@ -4340,6 +4378,8 @@ const translations = { proofOfBeneficialOwner: 'Proof of beneficial owner', proofOfBeneficialOwnerDescription: "Please provide a signed attestation and org chart from a public accountant, notary, or lawyer verifying ownership of 25% or more of the business. It must be dated within the last three months and include the signer's license number.", + proofOfBeneficialOwnerDescriptionCA: + 'Please provide your Schedule 50 (Shareholder Information) from your most recent T2 corporate income tax return, showing every individual who owns 25% or more of the business.', copyOfID: 'Copy of ID for beneficial owner', copyOfIDDescription: "Examples: Passport, driver's license, etc.", proofOfAddress: 'Address proof for beneficial owner', @@ -4738,6 +4778,7 @@ const translations = { customFieldHint: 'Add custom coding that applies to all spend from this member.', reports: 'Reports', reportFields: 'Report fields', + invoiceFields: 'Invoice fields', reportTitle: 'Report title', reportField: 'Report field', taxes: 'Taxes', @@ -4749,6 +4790,8 @@ const translations = { rooms: 'Rooms', accounting: 'Accounting', hr: 'HR', + recruiting: 'Recruiting', + mcp: 'MCP', receiptPartners: 'Receipt partners', rules: 'Rules', displayedAs: 'Displayed as', @@ -4892,6 +4935,25 @@ const translations = { title: "You've created a workspace for your client!", description: 'Great news 🎉. Reach out to us if they need any help with the setup.', }, + mcp: { + connectors: 'Connectors', + connectorsSubtitle: 'Connect an AI assistant to your Expensify account.', + connect: 'Connect', + helpPrompt: 'Need help connecting?', + helpLink: 'Read our guide.', + claude: { + title: 'Claude', + subtitle: 'by Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'by Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'by OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `Connected to ${organizationName}` : 'Automate travel and meal delivery expenses across your organization.'), @@ -5111,7 +5173,6 @@ const translations = { exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Use this account when exporting invoices to ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Set how company card purchases export to ${integrationName}.`, vendor: 'Vendor', - defaultVendorDescription: 'Set a default vendor that will apply to all credit card transactions upon export.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Set how out-of-pocket expenses export to ${integrationName}.`, exportCheckDescription: "We'll create an itemized check for each Expensify report and send it from the bank account below.", exportJournalEntryDescription: "We'll create an itemized journal entry for each Expensify report and post it to the account below.", @@ -5273,8 +5334,6 @@ const translations = { }, noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Please add the account in Xero and sync the connection again', - defaultSupplier: 'Default supplier', - defaultSupplierDescription: 'Set a default supplier that will apply to all credit card transactions upon export.', noSuppliersFound: 'No suppliers found', noSuppliersFoundDescription: 'Please add the supplier in Xero and sync the connection again.', accountingMethods: { @@ -6079,6 +6138,16 @@ const translations = { label: 'Travel Invoicing payable account', }, }, + campfire: { + campfireSetup: 'Campfire setup', + enterCredentials: 'Enter your Campfire API key', + howToFindAPIKey: + 'Finding your API key.
  1. Log in to Campfire
  2. Navigate to Settings -> API Keys
  3. Create API key
  4. Paste the API key below
', + subsidiary: 'Subsidiary', + subsidiarySelectDescription: "Choose the subsidiary in Campfire that you'd like to import data from.", + noSubsidiariesFound: 'No subsidiaries found', + noSubsidiariesFoundDescription: 'Please add an entity in Campfire and sync the connection again', + }, type: { free: 'Free', control: 'Control', @@ -6461,6 +6530,9 @@ const translations = { title: 'Consolidated Travel Billing', subtitle: 'Centralize all travel spend in a monthly bill instead of paying at time of purchase.', learnHow: 'Learn how.', + setUpNewFeed: 'Set up a new travel feed', + feedSelectorIntro: + 'Your organization already has a Consolidated Travel Billing feed set up. Select it to keep this workspace’s travel spend on the same monthly bill.', subsections: { currentTravelSpendLabel: 'Current travel spend', currentTravelSpendPaymentQueued: (amount: string) => `Payment of ${amount} is queued and will be processed soon.`, @@ -6642,6 +6714,10 @@ const translations = { title: 'Accounting', subtitle: 'Sync your chart of accounts and more.', }, + mcp: { + title: 'MCP', + subtitle: 'Connect an AI assistant to your Expensify account.', + }, receiptPartners: { title: 'Receipt partners', subtitle: 'Automatically import receipts.', @@ -6704,6 +6780,7 @@ const translations = { subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information.", disableReportFields: 'Disable report fields', disableReportFieldsConfirmation: 'Are you sure? Text and date fields will be deleted, and lists will be disabled.', + cannotDisableImportedReportFields: 'Report fields imported from your accounting connection cannot be disabled.', importedFromAccountingSoftware: 'The report fields below are imported from your', textType: 'Text', dateType: 'Date', @@ -6743,6 +6820,29 @@ const translations = { reportFieldInitialValueRequiredError: 'Please choose a report field initial value', genericFailureMessage: 'An error occurred while updating the report field. Please try again.', }, + invoiceFields: { + subtitle: "Invoice fields can be helpful when you'd like to include extra information.", + importedFromAccountingSoftware: 'The invoice fields below are imported from your', + disableInvoiceFields: 'Disable invoice fields', + disableInvoiceFieldsConfirmation: 'Are you sure? Invoice fields will be disabled on invoices.', + delete: 'Delete invoice field', + deleteConfirmation: 'Are you sure you want to delete this invoice field?', + findInvoiceField: 'Find invoice field', + nameInputSubtitle: 'Choose a name for the invoice field.', + typeInputSubtitle: 'Choose what type of invoice field to use.', + initialValueInputSubtitle: 'Enter a starting value to show in the invoice field.', + listValuesInputSubtitle: 'These values will appear in your invoice field dropdown. Enabled values can be selected by members.', + listInputSubtitle: 'These values will appear in your invoice field list. Enabled values can be selected by members.', + emptyInvoiceFieldsValues: { + title: 'No list values yet', + subtitle: 'Add custom values to appear on invoices.', + }, + existingInvoiceFieldNameError: 'An invoice field with this name already exists', + invoiceFieldNameRequiredError: 'Please enter an invoice field name', + invoiceFieldTypeRequiredError: 'Please choose an invoice field type', + invoiceFieldInitialValueRequiredError: 'Please choose an invoice field initial value', + addField: 'Add field', + }, vendors: { emptyTitle: 'No vendors yet', emptySubtitle: 'Vendors will appear here after your accounting sync completes.', @@ -7122,6 +7222,7 @@ const translations = { intacct: 'Sage Intacct', rillet: 'Rillet', dualEntry: 'DualEntry', + campfire: 'Campfire', sap: 'SAP', oracle: 'Oracle', microsoftDynamics: 'Microsoft Dynamics', @@ -7143,6 +7244,8 @@ const translations = { return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7365,6 +7468,18 @@ const translations = { return 'Syncing card settlements'; case 'dualEntrySyncTravelSettlements': return 'Syncing travel settlements'; + case 'campfireSyncTitle': + return 'Syncing Campfire data'; + case 'campfireSyncConnection': + return 'Initializing connection to Campfire'; + case 'campfireSyncImportData': + return 'Loading data'; + case 'campfireSyncPayments': + return 'Syncing vendor payments'; + case 'campfireSyncCardSettlements': + return 'Syncing card settlements'; + case 'campfireSyncTravelSettlements': + return 'Syncing travel settlements'; default: { return `Translation missing for stage: ${stage}`; } @@ -7380,11 +7495,14 @@ const translations = { exportCompanyCard: 'Export company card expenses as', exportDate: 'Export date', defaultVendor: 'Default vendor', - defaultVendorHelperText: (isSet: boolean) => - isSet + // fallbackVendorName is the vendor the integration auto-creates when nothing matches. Only integrations + // that actually create one pass it. The rest get the first sentence alone, because telling a Xero admin + // their expenses will export as "Credit Card Misc." describes a fallback Xero doesn't have. + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName ? `Expenses that don't auto-match will default to this vendor.` - : `Expenses that don't auto-match will default to this vendor. Otherwise, they'll export as Credit Card Misc.`, - defaultVendorSelectHeader: (connectionName: string) => `Choose a default ${connectionName} vendor for expenses that don't match automatically.`, + : `Expenses that don't auto-match will default to this vendor. Otherwise, they'll export as ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Choose a default vendor for expenses that don't match automatically.`, defaultAccount: 'Default account', autoSync: 'Auto-sync', autoSyncDescription: 'Sync NetSuite and Expensify automatically, every day. Export finalized report in realtime', @@ -7410,30 +7528,14 @@ const translations = { }, hr: { title: 'HR', - connections: 'Connections', connectionsSubtitle: "Connect to your HR system to sync employee data, auto-match reimbursements to the right people, and keep your team's expenses accurate without the manual work.", subtitle: 'Connect HR tools and keep employee approvals in sync.', - connect: 'Connect', - findIntegration: 'Find integration', - syncNow: 'Sync now', - disconnect: 'Disconnect', - disconnectTitle: (providerName: string) => `Disconnect ${providerName}`, - disconnectPrompt: (providerName: string) => `Are you sure you want to disconnect ${providerName}?`, alreadyConnectedTitle: 'Cannot connect to multiple HR platforms', alreadyConnectedPrompt: 'You must disconnect your current HR platform before connecting another.', - lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, - notSync: 'Not synced', - syncError: (providerName: string) => `Can't connect to ${providerName}`, - authenticationError: (providerName: string) => `Can't connect to ${providerName} due to an expired connection.`, - reconnect: 'Reconnect', - reconnectLink: 'Reconnect.', connectionDescription: (providerName: string) => `Connect ${providerName} to keep employee approvals in sync with your workspace.`, - approvalMode: 'Approval mode', providerApprovalMode: (providerName: string) => `${providerName} approval mode`, - finalApprover: 'Final approver', providerFinalApprover: (providerName: string) => `${providerName} final approver`, - notSet: 'Not set', syncing: 'Syncing employees', syncingModalTitle: 'Your connection is syncing', syncingModalDescription: "The first connection can take some time. You'll be notified of any errors.", @@ -7442,13 +7544,10 @@ const translations = { approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Are you sure you would like to change the approval mode for this workspace? Learn more about the different ${providerName}-enabled workflow modes in our help site.`, approvalModeWarningConfirm: 'Change approval mode', - approvalModes: { - basic: {label: 'Basic approval', description: 'All users submit to a single person for processing and approval.'}, - manager: { - label: 'Manager approval', - description: (providerName: string) => `Employees submit reports to their direct manager configured in ${providerName}.`, - }, - custom: {label: 'Custom approval', description: "I'll manually setup approval workflows in Expensify."}, + approvalModeDescriptions: { + basic: 'All users submit to a single person for processing and approval.', + manager: (providerName: string) => `Employees submit reports to their direct manager configured in ${providerName}.`, + custom: "I'll manually setup approval workflows in Expensify.", }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -7488,17 +7587,56 @@ const translations = { zenefits: { title: 'TriNet', }, + setupIncomplete: (setupLink: string | undefined) => + `Connected. ${setupLink ? `Complete setup` : 'Complete setup'} to import employees.`, mergeHR: { - completeSetup: 'Complete setup', - setupIncomplete: (setupLink: string | undefined) => - `Connected. ${setupLink ? `Complete setup` : 'Complete setup'} to import employees.`, groups: { title: 'Groups', description: 'Choose the groups of employees you would like to sync with this workspace', }, }, }, + recruiting: { + title: 'Recruiting', + connectionsSubtitle: + "Connect to your recruiting system to sync candidate data, auto-match reimbursements to the right people, and keep your team's expenses accurate without the manual work.", + alreadyConnectedTitle: 'Cannot connect to multiple ATS platforms', + alreadyConnectedPrompt: 'You must disconnect your current ATS before connecting another.', + syncing: 'Syncing candidates', + setupIncomplete: (setupLink: string | undefined) => + `Connected. ${setupLink ? `Complete setup` : 'Complete setup'} to import candidates.`, + dontSeeYourATS: `Don't see your ATS here? Ask Concierge and we can add it.`, + importSettings: 'Import settings', + defaultApprover: 'Default approver', + approverFields: { + recruiter: 'Recruiter', + recruitingCoordinator: 'Recruiting coordinator', + }, + }, merge: { + connections: 'Connections', + connect: 'Connect', + findIntegration: 'Find integration', + syncNow: 'Sync now', + disconnect: 'Disconnect', + disconnectTitle: (providerName: string) => `Disconnect ${providerName}`, + disconnectPrompt: (providerName: string) => `Are you sure you want to disconnect ${providerName}?`, + lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, + notSync: 'Not synced', + syncError: (providerName: string) => `Can't connect to ${providerName}`, + authenticationError: (providerName: string) => `Can't connect to ${providerName} due to an expired connection.`, + reconnect: 'Reconnect', + reconnectLink: 'Reconnect.', + notSet: 'Not set', + completeSetup: 'Complete setup', + approvalMode: 'Approval mode', + finalApprover: 'Final approver', + approvalModes: { + basic: 'Basic approval', + manager: 'Manager approval', + advanced: 'Advanced approval', + custom: 'Custom approval', + }, syncLimitReached: { title: 'Try again tomorrow', prompt: "You've reached your sync limit for the day.", @@ -7558,12 +7696,12 @@ const translations = { summaryDisabled: 'No commute exclusion', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclude ${distance} ${unit} per claim`, summaryHomeAndOffice: 'Use home and office locations', - optionDisabledTitle: 'Do not exclude commutes', + optionDisabledTitle: 'Do not exclude commute', optionDisabledHelp: 'No commute is removed from claims.', - optionFixedDistanceTitle: 'Exclude a fixed distance per claim', - optionFixedDistanceHelp: 'Remove the same commute distance from each claim. Best for members who submit one claim per workday.', - optionHomeAndOfficeTitle: 'Calculate by home and office', - optionHomeAndOfficeHelp: 'Use member’s home address, work arrangement, and office assignment to calculate commute exclusions.', + optionFixedDistanceTitle: 'Remove fixed distance', + optionFixedDistanceHelp: 'Subtracts the same distance from every claim.', + optionHomeAndOfficeTitle: 'Exclude usual commute', + optionHomeAndOfficeHelp: "Subtracts each member's regular commute based on their home, work arrangement, and office.", distanceLabel: 'Distance', workspaceAddressRequired: { title: 'Not so fast...', @@ -7758,6 +7896,12 @@ const translations = { onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Report fields are only available on the Control plan, starting at ${formattedPrice} ${hasTeam2025Pricing ? `per member per month.` : `per active member per month.`}`, }, + invoiceFields: { + title: 'Invoice fields', + description: `Invoice fields let you include extra invoice-level details on invoices.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Invoice fields are only available on the Control plan, starting at ${formattedPrice} ${hasTeam2025Pricing ? `per member per month.` : `per active member per month.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Enjoy automated syncing and reduce manual entries with the Expensify + NetSuite integration. Gain in-depth, realtime financial insights with native and custom segment support, including project and customer mapping.`, @@ -7800,6 +7944,12 @@ const translations = { onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Our DualEntry integration is only available on the Control plan, starting at ${formattedPrice} ${hasTeam2025Pricing ? `per member per month.` : `per active member per month.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Enjoy automated syncing and reduce manual entries with the Expensify + Campfire integration. Align expense coding dimensions and tax sync with your Campfire setup for clearer financial visibility.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Our Campfire integration is only available on the Control plan, starting at ${formattedPrice} ${hasTeam2025Pricing ? `per member per month.` : `per active member per month.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Advanced Approvals', description: `If you want to add more layers of approval to the mix – or just make sure the largest expenses get another set of eyes – we’ve got you covered. Advanced approvals help you put the right checks in place at every level so you keep your team’s spend under control.`, @@ -8149,7 +8299,6 @@ const translations = { alwaysReimbursableDescription: 'Expenses are always paid back to employees', alwaysNonReimbursable: 'Always non-reimbursable', alwaysNonReimbursableDescription: 'Expenses are never paid back to employees', - billableDefault: 'Billable default', billableDefaultDescription: 'Choose whether cash and credit card expenses should be billable by default.', billable: 'Billable', billableDescription: 'Expenses are most often re-billed to clients', @@ -8176,10 +8325,12 @@ const translations = { requireCompanyCardDisabledTooltip: 'Enable Company cards (under More features) to unlock.', enableTagsToUnlockTitle: 'Enable tags?', enableTagsToUnlockPrompt: 'Enable Tags (under More features) to unlock.', - enableTagsAndRequirePrompt: 'Are you sure you want to enable tags and require them for all expenses?', + enableTagsPrompt: 'Are you sure you want to enable tags? You can require them for all expenses once you have at least one tag.', + noTagsToRequirePrompt: "You don't have any tags. Please create a tag.", enableCategoriesToUnlockTitle: 'Enable categories?', enableCategoriesToUnlockPrompt: 'Enable Categories (under More features) to unlock.', enableCategoriesAndRequirePrompt: 'Are you sure you want to enable categories and require them for all expenses?', + noCategoriesToRequirePrompt: "You don't have any categories. Please create a category.", }, expenseReportRules: { title: 'Advanced', @@ -8211,6 +8362,8 @@ const translations = { subtitle: 'Set merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', findRule: 'Find merchant rule', + createRuleFromExpenseAction: 'Create a rule', + createRuleFromExpensePrompt: 'to apply your changes to all expenses that match your criteria.', addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', importRulesTitle: 'Import merchant rules', @@ -8251,6 +8404,25 @@ const translations = { expenseDefaultsSubtitle: 'Update fields without submitter doing anything', ifAnyExpenseMatches: 'If any expense matches:', thenApplyFollowingDefaults: 'Then apply the following defaults:', + confirmErrorCategory: 'Please select a category', + confirmErrorCategoryTax: 'Please select a tax rate', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: 'The selected tax rate is now your workspace default, so this rule is no longer valid. Choose a different tax rate.', + confirmErrorCondition: 'Please enter a merchant', + confirmErrorConditionAndDefault: 'Enter a merchant, and apply at least one default', + turnOnTaxesFirstTitle: 'Turn on taxes first', + turnOnTaxesFirstPrompt: 'Category rules set a default tax rate. Turn on taxes in your workspace settings to use them.', + addTaxRateFirstTitle: 'Add a tax rate first', + addTaxRateFirstPrompt: 'Category rules set a default tax rate. Add a tax rate other than your workspace default to use them.', + categoryRulesApplyGoingForwardTitle: 'Category rules apply going forward', + categoryRulesApplyGoingForwardPrompt: "A default tax rate applies to new expenses in this category. Expenses that already exist won't change.", + }, + expenseDefaultType: { + title: 'Apply expense defaults', + subtitle: 'What should this rule match on?', + merchant: 'Merchant', + merchantDescription: 'Update fields on expenses from a specific merchant', + category: 'Category', + categoryDescription: 'Set a default tax rate for a specific category', }, newRule: { title: 'New rule', @@ -8259,12 +8431,12 @@ const translations = { restrictCardSpendDescription: 'Block or limit spend at the point of sale', flagForReview: 'Flag for review', flagForReviewDescription: 'Notify approvers when expenses exceed category limits', - requireFields: 'Field requirements', + requireFields: 'Set field requirements', requireFieldsDescription: 'Require specific expense fields or waive requiring them.', applyExpenseDefaults: 'Apply expense defaults', applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', - createAgentRule: 'Agent rule', - createAgentRuleDescription: 'Describe flexible rules that run when you need.', + createAgentRule: 'Describe agent rule', + createAgentRuleDescription: 'Create flexible rules that run when you need.', }, expenseDefaultsTable: { tableColumnType: 'Type', @@ -8273,6 +8445,7 @@ const translations = { findRule: 'Find rule', rename: 'Rename', update: 'Update', + categoryIs: (category: string) => `Category is "${category}"`, merchantIs: (merchant: string) => `Merchant is "${merchant}"`, merchantTypeIs: (merchantType: string) => `Merchant type is "${merchantType}"`, }, @@ -8283,6 +8456,7 @@ const translations = { }, requireFieldsTable: { tableColumnType: 'Type', + typeLabel: 'Require', tableColumnCondition: 'Condition', tableColumnRule: 'Rule', findRule: 'Find rule', @@ -8305,6 +8479,11 @@ const translations = { title: 'Categories are not enabled', subtitle: 'Enable categories to gain more control of your spend.', }, + taxesDisabledEmptyState: { + title: 'Taxes are not enabled', + subtitle: 'Enable taxes to document and reclaim eligible taxes.', + cta: 'Enable taxes', + }, requireFieldsRule: { title: 'Field requirements', subtitle: 'Require specific expense fields or waive requiring them.', @@ -8369,7 +8548,8 @@ const translations = { flagAmountsOverSubtitle: 'This overrides the max amount for all expenses.', expenseLimitTypes: { expense: 'Individual expense', - expenseSubtitle: 'Flag expense amounts by category. This rule overrides the general workspace rule for max expense amount.', + expenseSubtitle: + 'Flag expense amounts by category. This rule overrides the general workspace rule for max expense amount. Multi-day reservations are evaluated using the per-night average.', daily: 'Category total', dailySubtitle: 'Flag total daily category spend per expense report.', }, @@ -9556,6 +9736,7 @@ const translations = { is: 'Is', has: { submittedViolation: 'Submitted violation', + approvedViolation: 'Approved violation', }, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Submit', @@ -9636,11 +9817,16 @@ const translations = { exportedTo: 'Exported to', exportAll: { selectAllMatchingItems: 'Select all matching items', + allMatchingItemsSelected: 'All matching items selected', selectAllOnThisPage: 'Select all on this page', }, errors: { pleaseSelectDatesForBothFromAndTo: 'Please select dates for both From and To', }, + mergeReports: { + title: 'Merge reports', + description: 'Select the report to keep. All expenses will be moved into it and the other reports will be deleted.', + }, }, genericErrorPage: { title: 'Uh-oh, something went wrong!', @@ -9727,6 +9913,7 @@ const translations = { const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `exported to ${translatedLabel}`; @@ -9767,6 +9954,16 @@ const translations = { integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Repeated ${count} times.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `The ${feedName} connection is broken. To restore card imports, log into your bank.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `The ${feedName} connection has been broken for 30 days. Log into your bank to fix it or remove the connection if it's no longer in use. You won't lose any submitted expenses if you remove it.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `the Plaid connection to your business bank account is broken. Please reconnect your bank account ${maskedAccountNumber} so you can continue to use your Expensify Cards.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10213,6 +10410,7 @@ const translations = { overAutoApprovalLimit: (formattedLimit: string) => `Expense exceeds auto-approval limit of ${formattedLimit}`, overCategoryLimit: (formattedLimit: string) => `Amount over ${formattedLimit}/person category limit`, overLimit: (formattedLimit: string) => `Amount over ${formattedLimit}/person limit`, + overCategoryLimitPerNight: (formattedLimit: string) => `Nightly rate over ${formattedLimit}/person category limit`, overTripLimit: (formattedLimit: string) => `Amount over ${formattedLimit}/trip limit`, overLimitAttendee: (formattedLimit: string) => `Amount over ${formattedLimit}/person limit`, perDayLimit: (formattedLimit: string) => `Amount over daily ${formattedLimit}/person category limit`, @@ -10998,6 +11196,7 @@ const translations = { reportLevelExport: 'All Data - report level', expenseLevelExport: 'All Data - expense level', multipleTaxExport: 'Canadian Multiple Tax Export', + reconciliationAllExpenses: 'Reconciliation - All Expenses', exportInProgress: 'Export in progress', conciergeWillSend: 'Concierge will send you the file shortly.', }, @@ -11028,6 +11227,7 @@ const translations = { notVerified: 'Not verified', retry: 'Retry', requestSent: 'Request sent', + requestAccessError: "We couldn't send your request. Please try again.", verifyDomain: { title: 'Verify domain', beforeProceeding: ({domainName}: {domainName: string}) => `Before proceeding, verify that you own ${domainName} by updating its DNS settings.`, @@ -11087,12 +11287,12 @@ const translations = { setMetadataGenericError: "Couldn't set SAML MetaData", }, accessRestricted: { - title: 'Access restricted', - subtitle: (domainName: string) => `Please verify yourself as an authorized company administrator for ${domainName} if you need control over:`, - companyCardManagement: 'Company card management', - accountCreationAndDeletion: 'Account creation and deletion', - workspaceCreation: 'Workspace creation', - samlSSO: 'SAML SSO', + headerTitle: 'Access restricted', + title: 'Verification required', + description: (domainName: string) => + `Please verify yourself as an authorized company administrator for ${domainName} or request access from existing admins.`, + requestAdminAccess: 'Request admin access', + verifyYourself: 'Verify yourself', }, addDomain: { title: 'Add domain', @@ -11106,7 +11306,6 @@ const translations = { title: 'Domain already set up. Request access?', description: 'Someone already set this domain up in Expensify. Want to request admin access?', requestAccess: 'Ask for admin access', - requestAccessError: "We couldn't send your request. Please try again.", }, domainAdded: { title: 'Domain added', @@ -11131,6 +11330,9 @@ const translations = { consolidatedDomainBillingError: "Consolidated domain billing couldn't be changed. Please try again later.", addAdmin: 'Add admin', addAdminError: 'Unable to add this member as an admin. Please try again.', + requests: 'Requests', + approveRequestError: 'Unable to approve this request. Please try again.', + declineRequestError: 'Unable to deny this request. Please try again.', revokeAdminAccess: 'Revoke admin access', cantRevokeAdminAccess: "Can't revoke admin access from the technical contact", error: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 362276b8dc24..e8f4094813d5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -17,7 +17,7 @@ import type {ValueOf} from 'type-fest'; import {CONST as COMMON_CONST, Str} from 'expensify-common'; import type en from './en'; -import type {PolicyConnectionSyncStage} from './TranslationTypes'; +import type {AllConnectionName, PolicyConnectionSyncStage} from './TranslationTypes'; import type {TranslationDeepObject} from './types'; const translations: TranslationDeepObject = { common: { @@ -42,6 +42,8 @@ const translations: TranslationDeepObject = { unshare: 'Dejar de compartir', yes: 'Sí', no: 'No', + approve: 'Aprobar', + deny: 'Rechazar', dontChange: 'No cambiar', ok: 'OK', notNow: 'Ahora no', @@ -63,6 +65,7 @@ const translations: TranslationDeepObject = { search: 'Buscar', reports: 'Informes', spend: 'Gastos', + insights: 'Información', find: 'Encontrar', searchWithThreeDots: 'Buscar...', select: 'Seleccionar', @@ -453,6 +456,9 @@ const translations: TranslationDeepObject = { none: 'Ninguno', unstableInternetConnection: 'Conexión a internet inestable. Por favor, revisa tu red e inténtalo de nuevo.', enableGlobalReimbursements: 'Habilitar Reembolsos Globales', + corpayPayModalTitle: 'Pagar informe', + corpayPayModalPrompt: + 'Esta persona que envió el informe tiene una cuenta bancaria que no es en USD. Habilita los reembolsos globales para pagar el informe, o pídele que añada una cuenta bancaria en USD.', purchaseAmount: 'Importe de compra', originalAmount: 'Importe original', frequency: 'Frecuencia', @@ -932,7 +938,7 @@ const translations: TranslationDeepObject = { tooltip: '¡Comienza aquí!', }, homePage: { - forYou: 'Para ti', + toDos: 'Tareas pendientes', timeSensitiveSection: { title: 'Requiere atención inmediata', ctaFix: 'Corrige', @@ -950,12 +956,8 @@ const translations: TranslationDeepObject = { defaultSubtitle: 'Espacio de trabajo', subtitle: ({policyName}: {policyName: string}) => policyName, }, - addShippingAddress: { - title: 'Necesitamos tu dirección de envío', - subtitle: 'Proporciona una dirección para recibir tu Tarjeta Expensify.', - cta: 'Añade dirección', - }, - addVirtualCardPersonalDetails: {title: 'Añade tus datos para ver y empezar a usar tu Tarjeta Expensify.', subtitle: 'Tarjeta Expensify', cta: 'Añade detalles'}, + addShippingAddress: {title: 'Necesitamos tu dirección de envío', subtitle: 'Proporciona una dirección para recibir tu Tarjeta Expensify.', cta: 'Añade'}, + addVirtualCardPersonalDetails: {title: 'Añade tus datos para ver y empezar a usar tu Tarjeta Expensify.', subtitle: 'Tarjeta Expensify', cta: 'Añade'}, addPaymentCard: { title: 'Añade una tarjeta de pago para seguir usando Expensify', subtitle: 'Cuenta > Suscripción', @@ -967,6 +969,14 @@ const translations: TranslationDeepObject = { subtitle: 'Valida tu tarjeta y empieza a gastar.', cta: 'Activa', }, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `La adición de la tarjeta ${walletName} necesita tu aprobación`, + subtitle: 'Tarjeta Expensify', + cta: 'Revisar', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Billetera digital', + }, reviewCardFraud: { title: 'Revisa un posible fraude en tu tarjeta Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Revisa ${amount} en posible fraude en ${merchant}`, @@ -978,7 +988,7 @@ const translations: TranslationDeepObject = { subtitle: 'Cuenta', cta: 'Validar', }, - addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancia', subtitle: 'Cuenta', cta: 'Añadir dirección'}, + addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancia', subtitle: 'Cuenta', cta: 'Añade'}, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', @@ -1011,7 +1021,6 @@ const translations: TranslationDeepObject = { title: 'Tu gasto', awaitingApproval: 'Pendiente de aprobación', repaidLast30Days: 'Reembolsado últimos 30 días', - recentTransactions: ({lastFour}: {lastFour: string}) => `Transacciones recientes • ${lastFour}`, }, discoverSection: { title: 'Descubrir', @@ -1087,6 +1096,8 @@ const translations: TranslationDeepObject = { setupRules: 'Configurar reglas de gasto', customizeSpendCategories: 'Personaliza las categorías de gasto', customizeSpendCategoriesSubText: 'Organiza y clasifica gastos', + customizeExpenseCategories: 'Personaliza tus categorías de gastos', + customizeExpenseCategoriesSubText: 'Añade las categorías de tu empresa para codificar tus gastos', createExpense: 'Crear un gasto', createExpenseSubText: 'Escanea, arrastra y suelta o introduce manualmente un gasto usando el botón +', linkPersonalCard: 'Vincular tarjeta personal', @@ -1271,6 +1282,7 @@ const translations: TranslationDeepObject = { addAdditionalReceipt: 'Añadir recibo adicional', scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el importe.', crop: 'Recortar', + pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, addAReceipt: { phrase1: 'Añade un recibo', phrase2: 'o arrastra y suelta uno aquí', @@ -1660,11 +1672,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: '¡Todo listo!', noDuplicatesDescription: 'No hay transacciones duplicadas para revisar aquí.', confirmApprove: 'Confirmar importe a aprobar', - confirmApprovalAmount: 'Aprueba sólo los gastos conformes, o aprueba todo el informe.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Este gasto está retenido. ¿Quieres aprobarlo de todos modos?', - other: 'Estos gastos están retenidos. ¿Quieres aprobarlos de todos modos?', - }), + confirmApprovalWithHeldAmount: 'El informe contiene gastos retenidos. ¿Quieres aprobar sólo los gastos que cumplen con los requisitos o aprobar todo el informe?', + confirmApprovalAllHoldAmount: 'Todos los gastos están retenidos. ¿Aprobar de todos modos?', confirmPay: 'Confirmar importe de pago', confirmPayAmount: 'Paga lo que no está retenido, o paga el informe completo.', confirmPayAllHoldAmount: () => ({ @@ -2224,6 +2233,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Nombres de spans resaltados', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click, navigation, ui.load', showBranchNameInTitle: 'Mostrar nombre de rama en el título del navegador', + betaOverrides: 'Anulaciones de betas', + betaOverridesDescription: + 'Las anulaciones solo se aplican a este dispositivo y solo afectan a las comprobaciones del frontend. Una beta conserva una anulación solo mientras difiere de las betas de tu cuenta, por lo que al volver a cambiarla se elimina la anulación. «Restablecer todas las anulaciones» recupera los valores de tu cuenta. Algunas betas también están controladas por el backend, así que pueden fallar a nivel de solicitud.', + resetAllOverrides: 'Restablecer todas las anulaciones', + overridden: 'Anulada', qaAuth: 'Autenticación QA (Cloudflare)', qaAuthRunProbe: 'Ejecutar prueba', qaAuthSession: 'Sesión de autenticación QA', @@ -2537,6 +2551,10 @@ const translations: TranslationDeepObject = { connectionLink ? `La conexión de tu tarjeta ${cardName} se ha interrumpido. Inicia sesión en tu banco para arreglarla.` : `La conexión de tu tarjeta ${cardName} se ha interrumpido. Inicia sesión en tu banco para arreglarla.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `La conexión de tu ${cardName} se ha interrumpido desde hace 30 días. Inicia sesión en tu banco para solucionarlo o elimina la tarjeta si ya no la usas. No perderás ningún gasto enviado si la eliminas.` + : `La conexión de tu ${cardName} se ha interrumpido durante 30 días. Inicia sesión en tu banco para solucionarlo o elimina la tarjeta si ya no la utilizas. No perderás ningún gasto enviado si la eliminas.`, addAdditionalCards: 'Añadir más tarjetas', upgradeDescription: '¿Necesitas añadir más tarjetas? Crea un espacio de trabajo para añadir tarjetas personales o asignar tarjetas de empresa a todo el equipo.', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2636,6 +2654,24 @@ const translations: TranslationDeepObject = { unlock: 'Desbloquear', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Añadir tarjeta a ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'billetera digital', + confirmHeading: 'Confirma tu solicitud', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `¿Quieres añadir tu Tarjeta Expensify (terminada en ${lastFourDigits}) a tu ${walletName}?`, + deny: 'Denegar', + confirm: 'Confirmar', + verifyTitle: 'Verifiquemos que eres tú', + enterSecurityCode: (contactMethod: string) => + `Por favor, introduce el código de seguridad enviado a ${contactMethod} para confirmar esta solicitud. Debería llegar en uno o dos minutos.`, + successHeading: '¡Listo!', + successDescription: ({walletName}: {walletName: string}) => `Tu tarjeta ya está activa para usarla en tu ${walletName}.`, + deniedHeading: 'Solicitud denegada', + deniedDescription: ({walletName}: {walletName: string}) => `Tu tarjeta no se ha añadido a tu ${walletName}.`, + }, cardPage: { expensifyCard: 'Tarjeta Expensify', expensifyTravelCard: 'Tarjeta Expensify de Viaje', @@ -3073,6 +3109,7 @@ ${amount} para ${merchant} - ${date}`, unread: 'No leído', markAllAsRead: 'Marcar todo como leído', markAllAsReadConfirmationPrompt: '¿Seguro que quieres marcar todos los chats como leídos?', + markAllTodosAsReadConfirmationPrompt: '¿Seguro que quieres marcar como leídos todos los chats pendientes?', }, reportDetailsPage: { goToRoom: 'Ir a la sala', @@ -3252,6 +3289,7 @@ ${amount} para ${merchant} - ${date}`, subtitle: (workEmail) => `No pudimos añadir ${workEmail}. Por favor, inténtalo de nuevo más tarde en Configuración o chatea con Concierge para obtener ayuda.`, workAccountClosedSubtitle: 'La cuenta de trabajo asociada a este correo electrónico está cerrada. Ponte en contacto con el administrador de tu empresa para reactivarla o regístrate con un correo electrónico diferente.', + domainControlledSubtitle: (workEmail) => `${workEmail} es un inicio de sesión controlado por dominio de una cuenta de Expensify existente.`, }, tasks: { testDriveAdminTask: { @@ -4107,8 +4145,7 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', enterTheDateOfBirthOfTheOwner: '¿Cuál es la fecha de nacimiento del dueño?', - enterTheLast4: '¿Cuáles son los últimos 4 dígitos del número de la seguridad social del dueño?', - last4SSN: 'Últimos 4 dígitos del número de la seguridad social', + enterTheSSN: '¿Cuál es el número de la Seguridad Social del propietario?', dontWorry: 'No te preocupes, ¡no realizamos verificaciones de crédito personales!', enterTheOwnersAddress: '¿Cuál es la dirección del dueño?', letsDoubleCheck: 'Vamos a verificar que todo esté correcto.', @@ -4135,13 +4172,12 @@ ${amount} para ${merchant} - ${date}`, whatsYourDOB: '¿Cuál es su fecha de nacimiento?', whatsTheOwnersAddress: '¿Cuál es la dirección del propietario?', whatsYourAddress: '¿Cuál es su dirección?', - whatAreTheLast: '¿Cuáles son los últimos 4 dígitos del número de seguro social del propietario?', - whatsYourLast: '¿Cuáles son los últimos 4 dígitos de su número de seguro social?', + whatsTheOwnersSSN: '¿Cuál es el número de la Seguridad Social del propietario?', + whatsYourSSN: '¿Cuál es tu número de la Seguridad Social?', whatsYourNationality: '¿Cuál es tu país de ciudadanía?', whatsTheOwnersNationality: '¿Cuál es el país de ciudadanía del propietario?', countryOfCitizenship: 'País de ciudadanía', dontWorry: 'No se preocupe, ¡no realizamos ninguna verificación de crédito personal!', - last4: 'Últimos 4 del SSN', whyDoWeAsk: '¿Por qué solicitamos esto?', letsDoubleCheck: 'Verifiquemos que todo esté correcto.', legalName: 'Nombre legal', @@ -4164,6 +4200,8 @@ ${amount} para ${merchant} - ${date}`, proofOfBeneficialOwner: 'Prueba del propietario beneficiario', proofOfBeneficialOwnerDescription: 'Por favor, proporciona una declaración firmada y un organigrama de un contador público, notario o abogado que verifique la propiedad del 25% o más del negocio. Debe estar fechado dentro de los últimos tres meses e incluir el número de licencia del firmante.', + proofOfBeneficialOwnerDescriptionCA: + 'Por favor, proporciona tu Anexo 50 (Información de los accionistas) de la declaración más reciente del impuesto sobre la renta de sociedades T2, donde se muestren todas las personas físicas que poseen el 25% o más del negocio.', copyOfID: 'Copia de la identificación del propietario beneficiario', copyOfIDDescription: 'Ejemplos: Pasaporte, licencia de conducir, etc.', proofOfAddress: 'Prueba de la dirección del propietario beneficiario', @@ -4553,6 +4591,7 @@ ${amount} para ${merchant} - ${date}`, customFieldHint: 'Añade una codificación personalizada que se aplique a todos los gastos de este miembro.', reports: 'Informes', reportFields: 'Campos de informe', + invoiceFields: 'Campos de factura', reportTitle: 'El título del informe.', taxes: 'Impuestos', bills: 'Pagar facturas', @@ -4563,6 +4602,8 @@ ${amount} para ${merchant} - ${date}`, rooms: 'Salas', accounting: 'Contabilidad', hr: 'HR', + recruiting: 'Reclutamiento', + mcp: 'MCP', receiptPartners: 'Socios de recibos', rules: 'Reglas', plan: 'Plan', @@ -4707,6 +4748,25 @@ ${amount} para ${merchant} - ${date}`, title: '¡Has creado un espacio de trabajo para tu cliente!', description: 'Excelentes noticias 🎉. Contáctanos si necesitan ayuda con la configuración.', }, + mcp: { + connectors: 'Conectores', + connectorsSubtitle: 'Conecta un asistente de IA a tu cuenta de Expensify.', + connect: 'Conectar', + helpPrompt: '¿Necesitas ayuda para conectar?', + helpLink: 'Lee nuestra guía.', + claude: { + title: 'Claude', + subtitle: 'de Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'de Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'de OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName) => (organizationName ? `Conectado a ${organizationName}` : 'Automatice los gastos de viajes y entrega de comidas en toda su organización.'), @@ -4927,7 +4987,6 @@ ${amount} para ${merchant} - ${date}`, account: 'Cuenta', accountDescription: 'Elige dónde contabilizar los asientos contables.', vendor: 'Proveedor', - defaultVendorDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al momento de exportarlas.', accountsPayable: 'Cuentas por Pagar', accountsPayableDescription: 'Elige dónde crear las facturas de proveedores.', bankAccount: 'Cuenta bancaria', @@ -5092,8 +5151,6 @@ ${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', - defaultSupplier: 'Proveedor predeterminado', - defaultSupplierDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al exportar.', noSuppliersFound: 'No se encontraron proveedores', noSuppliersFoundDescription: 'Por favor, añade el proveedor en Xero y sincroniza de nuevo la conexión.', accountingMethods: { @@ -6248,6 +6305,7 @@ ${amount} para ${merchant} - ${date}`, title: 'Facturación consolidada de viajes', subtitle: 'Centraliza todos los gastos de viaje en una factura mensual en lugar de pagar en el momento de la compra.', learnHow: 'Aprende cómo.', + setUpNewFeed: 'Configura un nuevo feed de viajes', subsections: { currentTravelSpendLabel: 'Gasto actual en viajes', currentTravelSpendPaymentQueued: (amount: string) => `El pago de ${amount} está en cola y se procesará pronto.`, @@ -6268,6 +6326,8 @@ ${amount} para ${merchant} - ${date}`, provisioningError: 'No hemos podido aprovisionar a algunos miembros de tu espacio de trabajo para la Facturación de Viajes Consolidada. Por favor, inténtalo de nuevo más tarde o contacta con Concierge para obtener ayuda.', }, + feedSelectorIntro: + 'Tu organización ya tiene configurado un feed de facturación de viajes consolidada. Selecciónalo para mantener los gastos de viaje de este espacio de trabajo en la misma factura mensual.', }, disableModal: { title: '¿Desactivar la facturación consolidada de viajes?', @@ -6432,6 +6492,10 @@ ${amount} para ${merchant} - ${date}`, title: 'Contabilidad', subtitle: 'Sincroniza tu plan de cuentas y otras opciones.', }, + mcp: { + title: 'MCP', + subtitle: 'Conecta un asistente de IA a tu cuenta de Expensify.', + }, receiptPartners: { title: 'Socios de recibos', subtitle: 'Importación automática de recibos.', @@ -6489,6 +6553,7 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando quieras solicitar información adicional.', disableReportFields: 'Desactivar campos de informe', disableReportFieldsConfirmation: 'Estás seguro? Se eliminarán los campos de texto y fecha y se desactivarán las listas.', + cannotDisableImportedReportFields: 'Los campos de informe importados desde tu conexión de contabilidad no se pueden deshabilitar.', importedFromAccountingSoftware: 'Campos de informes importadas desde', textType: 'Texto', dateType: 'Fecha', @@ -6525,6 +6590,29 @@ ${amount} para ${merchant} - ${date}`, reportFieldInitialValueRequiredError: 'Elige un valor inicial de campo de informe', genericFailureMessage: 'Se ha producido un error al actualizar el campo de informe. Por favor, inténtalo de nuevo.', }, + invoiceFields: { + subtitle: 'Los campos de factura pueden ayudarte cuando quieras incluir información adicional.', + importedFromAccountingSoftware: 'Campos de factura importados desde', + disableInvoiceFields: 'Desactivar campos de factura', + disableInvoiceFieldsConfirmation: '¿Estás seguro? Los campos de factura se desactivarán en las facturas.', + delete: 'Eliminar campo de factura', + deleteConfirmation: '¿Seguro que deseas eliminar este campo de factura?', + findInvoiceField: 'Buscar campo de factura', + nameInputSubtitle: 'Elige un nombre para el campo de factura.', + typeInputSubtitle: 'Elige qué tipo de campo de factura usar.', + initialValueInputSubtitle: 'Ingresa un valor inicial para mostrar en el campo de factura.', + listValuesInputSubtitle: 'Estos valores aparecerán en el menú desplegable del campo de factura. Los miembros pueden seleccionar los valores activados.', + listInputSubtitle: 'Estos valores aparecerán en la lista del campo de factura. Los miembros pueden seleccionar los valores activados.', + emptyInvoiceFieldsValues: { + title: 'Aún no hay valores de lista', + subtitle: 'Agrega valores personalizados para que aparezcan en las facturas.', + }, + existingInvoiceFieldNameError: 'Ya existe un campo de factura con este nombre', + invoiceFieldNameRequiredError: 'Ingresa un nombre para el campo de factura', + invoiceFieldTypeRequiredError: 'Elige un tipo de campo de factura', + invoiceFieldInitialValueRequiredError: 'Elige un valor inicial para el campo de factura', + addField: 'Añadir campo', + }, vendors: { emptyTitle: 'Todavía no hay proveedores', emptySubtitle: 'Los proveedores aparecerán aquí una vez que se complete la sincronización de contabilidad.', @@ -6844,7 +6932,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, talkYourAccountManager: 'Chatea con tu gestor de cuenta.', talkToConcierge: 'Chatear con Concierge.', needAnotherAccounting: '¿Necesitas otro software de contabilidad? ', - connectionName: (connectionName) => { + connectionName: (connectionName: AllConnectionName) => { switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: return 'QuickBooks Online'; @@ -6858,6 +6946,8 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -6930,14 +7020,14 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, return 'Importando cuentas'; case 'quickbooksOnlineImportClasses': case 'quickbooksDesktopImportClasses': - return 'Importación de clases'; + return 'Importar clases'; case 'quickbooksOnlineImportLocations': return 'Importando ubicaciones'; case 'quickbooksOnlineImportProcessing': return 'Procesando datos importados'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': - return 'Sincronizar informes reembolsados y pagos de facturas'; + return 'Sincronización de informes reembolsados y pagos de facturas'; case 'quickbooksOnlineSyncTaxCodes': return 'Importando códigos de impuestos'; case 'quickbooksOnlineCheckConnection': @@ -6960,7 +7050,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'quickbooksDesktopImportSavePolicy': return 'Importando política de guardado'; case 'quickbooksDesktopWebConnectorReminder': - return 'Sincronizando datos con QuickBooks... Asegúrate de que Web Connector esté en ejecución'; + return 'Todavía se están sincronizando los datos con QuickBooks... Asegúrate de que Web Connector esté en ejecución'; case 'quickbooksOnlineSyncTitle': return `Sincronizando datos de ${integrationName}`; case 'quickbooksOnlineSyncLoadData': @@ -6970,7 +7060,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'quickbooksOnlineSyncApplyCategories': return 'Actualizando categorías'; case 'quickbooksOnlineSyncApplyCustomers': - return 'Actualizar clientes/proyectos'; + return 'Actualización de clientes/proyectos'; case 'quickbooksOnlineSyncApplyEmployees': return 'Actualizando la lista de personas'; case 'quickbooksOnlineSyncApplyClassesLocations': @@ -6978,7 +7068,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'jobDone': return 'Esperando a que se carguen los datos importados'; case 'xeroSyncImportChartOfAccounts': - return 'Sincronizando el plan de cuentas'; + return 'Sincronizando plan de cuentas'; case 'xeroSyncImportCategories': return 'Sincronizando categorías'; case 'xeroSyncImportCustomers': @@ -6986,7 +7076,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'xeroSyncXeroReimbursedReports': return 'Marcar los informes de Expensify como reembolsados'; case 'xeroSyncExpensifyReimbursedReports': - return 'Marcar facturas y comprobantes de Xero como pagados'; + return 'Marcar facturas y cobros de Xero como pagados'; case 'xeroSyncImportTrackingCategories': return 'Sincronizando categorías de seguimiento'; case 'xeroSyncImportBankAccounts': @@ -6998,11 +7088,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'xeroSyncTitle': return 'Sincronizando datos de Xero'; case 'netSuiteSyncConnection': - return 'Iniciando conexión a NetSuite'; + return 'Iniciando la conexión con NetSuite'; case 'netSuiteSyncCustomers': return 'Importando clientes'; case 'netSuiteSyncInitData': - return 'Obteniendo datos desde NetSuite'; + return 'Recuperando datos de NetSuite'; case 'netSuiteSyncImportTaxes': return 'Importando impuestos'; case 'netSuiteSyncImportItems': @@ -7024,18 +7114,18 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'netSuiteSyncNetSuiteReimbursedReports': return 'Marcar los informes de Expensify como reembolsados'; case 'netSuiteSyncExpensifyReimbursedReports': - return 'Marcar facturas y recibos de NetSuite como pagados'; + return 'Marcar las facturas y los recibos de NetSuite como pagados'; case 'netSuiteImportVendorsTitle': - return 'Importando proveedores'; + return 'Importación de proveedores'; case 'netSuiteImportCustomListsTitle': - return 'Importar listas personalizadas'; + return 'Importación de listas personalizadas'; case 'netSuiteSyncImportCustomLists': - return 'Importar listas personalizadas'; + return 'Importación de listas personalizadas'; case 'netSuiteSyncImportSubsidiaries': - return 'Importando filiales'; + return 'Importación de subsidiarias'; case 'netSuiteSyncImportVendors': case 'quickbooksDesktopImportVendors': - return 'Importando proveedores'; + return 'Importación de proveedores'; case 'intacctCheckConnection': return 'Comprobando la conexión con Sage Intacct'; case 'intacctImportDimensions': @@ -7051,13 +7141,13 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'financialForceSyncTags': return 'Importando etiquetas'; case 'financialForceSyncVendors': - return 'Importando proveedores'; + return 'Importación de proveedores'; case 'financialForceSyncContacts': return 'Importando contactos'; case 'financialForceSyncCompanies': return 'Importando empresas'; case 'financialForceSyncUsers': - return 'Importación de usuarios'; + return 'Importando usuarios'; case 'financialForceSyncDimensions': return 'Importando dimensiones'; case 'financialForceMarkAsReimbursed': @@ -7065,7 +7155,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, case 'rilletSyncTitle': return 'Sincronizando datos de Rillet'; case 'rilletSyncConnection': - return 'Iniciando conexión con Rillet'; + return 'Inicializando la conexión con Rillet'; case 'rilletSyncImportData': return 'Cargando datos'; case 'dualEntrySyncTitle': @@ -7080,6 +7170,18 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, return 'Sincronizando liquidaciones de tarjetas'; case 'dualEntrySyncTravelSettlements': return 'Sincronizando liquidaciones de viaje'; + case 'campfireSyncTitle': + return 'Sincronizando datos de Campfire'; + case 'campfireSyncConnection': + return 'Iniciando conexión con Campfire'; + case 'campfireSyncImportData': + return 'Cargando datos'; + case 'campfireSyncPayments': + return 'Sincronizando pagos a proveedores'; + case 'campfireSyncCardSettlements': + return 'Sincronizando liquidaciones de tarjetas'; + case 'campfireSyncTravelSettlements': + return 'Sincronizando liquidaciones de viaje'; default: { return `Falta la traducción para la etapa: ${stage}`; } @@ -7095,11 +7197,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, exportCompanyCard: 'Exportar gastos de la tarjeta de empresa como', exportDate: 'Fecha de exportación', defaultVendor: 'Proveedor predeterminado', - defaultVendorHelperText: (isSet: boolean) => - isSet - ? `Los gastos que no se asignen automáticamente se asociarán por defecto a este proveedor.` - : `Los gastos que no se concilien automáticamente se asignarán a este proveedor de forma predeterminada. En caso contrario, se exportarán como Credit Card Misc.`, - defaultVendorSelectHeader: (connectionName: string) => `Elige un proveedor predeterminado de ${connectionName} para los gastos que no se asignen automáticamente.`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName + ? `Los gastos que no se concilien automáticamente se asignarán por defecto a este proveedor.` + : `Los gastos que no se concilien automáticamente se asignarán a este proveedor de forma predeterminada. En caso contrario, se exportarán como ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Elige un proveedor predeterminado para los gastos que no se asignen automáticamente.`, defaultAccount: 'Cuenta predeterminada', autoSync: 'Autosincronización', autoSyncDescription: 'Sincroniza NetSuite y Expensify automáticamente, todos los días. Exporta el informe finalizado en tiempo real', @@ -7122,6 +7224,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, syncTravelInvoicingSettlements: 'Sincronizar liquidaciones de facturación de viajes consolidadas', syncTravelInvoicingSettlementsNoAccountTooltip: 'Para desbloquearlo, configura una cuenta para tus exportaciones.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Para desbloquear, habilita la sincronización automática.', + campfire: 'Campfire', }, card: { issueCard: 'Emitir tarjeta', @@ -7189,37 +7292,26 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, hr: { title: 'HR', - connections: 'Conexiones', connectionsSubtitle: 'Conéctate a tu sistema de RR. HH. para sincronizar los datos de empleados, emparejar automáticamente los reembolsos con las personas correctas y mantener los gastos de tu equipo precisos sin trabajo manual.', subtitle: 'Conecta herramientas de HR y mantén sincronizadas las aprobaciones de empleados.', - connect: 'Conectar', - syncNow: 'Sincronizar ahora', - disconnect: 'Desconectar', - disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, - disconnectPrompt: (providerName: string) => `¿Seguro que quieres desconectar ${providerName}?`, alreadyConnectedTitle: 'No se puede conectar a varias plataformas de RR. HH.', alreadyConnectedPrompt: 'Debes desconectar tu plataforma de RR. HH. actual antes de conectar otra.', - lastSync: (relativeDate: string) => `Última sincronización ${relativeDate}`, - syncError: (providerName: string) => `No se puede conectar con ${providerName}`, connectionDescription: (providerName: string) => `Conecta ${providerName} para mantener sincronizadas las aprobaciones de empleados con tu espacio de trabajo.`, - approvalMode: 'Modo de aprobación', providerApprovalMode: (providerName: string) => `Modo de aprobación de ${providerName}`, - finalApprover: 'Aprobador final', providerFinalApprover: (providerName: string) => `Aprobador final de ${providerName}`, - notSet: 'No configurado', + syncing: 'Sincronizando empleados', + syncingModalTitle: 'Tu conexión se está sincronizando', + syncingModalDescription: 'La primera conexión puede tardar un poco. Se te notificará de cualquier error.', approvalModeDescription: (providerName: string) => `Los miembros y gerentes están configurados para sincronizarse con ${providerName}.`, approvalModeWarningTitle: '¿Cambiar modo de aprobación?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `¿Seguro que quieres cambiar el modo de aprobación de este espacio de trabajo? Obtén más información sobre los diferentes modos de flujo de trabajo con ${providerName} en nuestro sitio de ayuda.`, approvalModeWarningConfirm: 'Cambiar modo de aprobación', - approvalModes: { - basic: {label: 'Aprobación básica', description: 'Todos los usuarios envían a una sola persona para su procesamiento y aprobación.'}, - manager: { - label: 'Aprobación del gerente', - description: (providerName: string) => `Los empleados envían informes a su gerente directo configurado en ${providerName}.`, - }, - custom: {label: 'Aprobación personalizada', description: 'Configuraré manualmente los flujos de aprobación en Expensify.'}, + approvalModeDescriptions: { + basic: 'Todos los usuarios envían a una sola persona para su procesamiento y aprobación.', + manager: (providerName: string) => `Los empleados envían informes a su gerente directo configurado en ${providerName}.`, + custom: 'Configuraré manualmente los flujos de aprobación en Expensify.', }, syncStageName: (stage) => { switch (stage) { @@ -7259,22 +7351,50 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, zenefits: { title: 'TriNet', }, - syncingModalTitle: 'Tu conexión se está sincronizando', - syncingModalDescription: 'La primera conexión puede tardar un poco. Se te notificará de cualquier error.', - syncing: 'Sincronizando empleados', + setupIncomplete: (setupLink: string | undefined) => + `Conectado. ${setupLink ? `Completa la configuración` : 'Completar configuración'} para importar empleados.`, mergeHR: { - completeSetup: 'Completar configuración', - setupIncomplete: (setupLink: string | undefined) => - `Conectado. ${setupLink ? `Completa la configuración` : 'Completar configuración'} para importar empleados.`, groups: {title: 'Grupos', description: 'Elige los grupos de empleados que te gustaría sincronizar con este espacio de trabajo'}, }, + }, + recruiting: { + title: 'Reclutamiento', + connectionsSubtitle: + 'Conéctate a tu sistema de selección para sincronizar los datos de los candidatos, vincular automáticamente los reembolsos con las personas correctas y mantener los gastos de tu equipo precisos sin trabajo manual.', + alreadyConnectedTitle: 'No se puede conectar a varias plataformas ATS', + alreadyConnectedPrompt: 'Debes desconectar tu ATS actual antes de conectar otro.', + syncing: 'Sincronizando candidatos', + setupIncomplete: (setupLink: string | undefined) => + `Conectado. ${setupLink ? `Completar configuración` : 'Completar configuración'} para importar candidatos.`, + 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'}, + }, + merge: { + connections: 'Conexiones', + connect: 'Conectar', + findIntegration: 'Buscar integración', + syncNow: 'Sincronizar ahora', + disconnect: 'Desconectar', + disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, + disconnectPrompt: (providerName: string) => `¿Seguro que quieres desconectar ${providerName}?`, + lastSync: (relativeDate: string) => `Última sincronización ${relativeDate}`, notSync: 'No sincronizado', + syncError: (providerName: string) => `No se puede conectar con ${providerName}`, authenticationError: (providerName: string) => `No se puede conectar a ${providerName} porque la conexión ha caducado.`, reconnect: 'Volver a conectar', reconnectLink: 'Volver a conectar.', - findIntegration: 'Buscar integración', - }, - merge: { + notSet: 'No configurado', + completeSetup: 'Completar configuración', + approvalMode: 'Modo de aprobación', + finalApprover: 'Aprobador final', + approvalModes: { + basic: 'Aprobación básica', + manager: 'Aprobación del gerente', + custom: 'Aprobación personalizada', + advanced: 'Aprobación avanzada', + }, syncLimitReached: {title: 'Inténtalo de nuevo mañana', prompt: 'Has alcanzado tu límite de sincronización de hoy.'}, }, export: { @@ -7332,12 +7452,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, summaryHomeAndOffice: 'Usa las ubicaciones de casa y oficina', - optionDisabledTitle: 'No excluir los desplazamientos al trabajo', + optionDisabledTitle: 'No excluir desplazamientos al trabajo', optionDisabledHelp: 'No se ha eliminado ningún trayecto al trabajo de las reclamaciones.', - optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', - optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', - optionHomeAndOfficeTitle: 'Calcular por casa y oficina', - optionHomeAndOfficeHelp: 'Utiliza la dirección de casa del miembro, su modalidad de trabajo y su asignación de oficina para calcular las exclusiones de desplazamiento.', + optionFixedDistanceTitle: 'Eliminar distancia fija', + optionFixedDistanceHelp: 'Resta la misma distancia de cada solicitud.', + optionHomeAndOfficeTitle: 'Excluir desplazamiento habitual', + optionHomeAndOfficeHelp: 'Resta el trayecto habitual de cada miembro según su hogar, modalidad de trabajo y oficina.', distanceLabel: 'Distancia', workspaceAddressRequired: { title: 'No tan rápido...', @@ -7555,6 +7675,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}) => `Los campos de informe sólo están disponibles en el plan Controlar, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por miembro al mes.` : `por miembro activo al mes.`}`, }, + invoiceFields: { + title: 'Campos de factura', + description: `Los campos de factura te permiten incluir detalles adicionales a nivel de factura.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}) => + `Los campos de factura sólo están disponibles en el plan Controlar, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por miembro al mes.` : `por miembro activo al mes.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración Expensify + NetSuite. Obtén información financiera en profundidad y en tiempo real con la compatibilidad nativa y personalizada con segmentos, incluida la asignación de proyectos y clientes.`, @@ -7597,6 +7723,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}) => `Nuestra integración con DualEntry solo está disponible en el plan Controlar, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por miembro al mes.` : `por miembro activo al mes.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Disfruta de la sincronización automatizada y reduce las entradas manuales con la integración Expensify + Campfire. Alinea dimensiones de codificación de gastos y la sincronización de impuestos con tu configuración de Campfire para una visibilidad financiera más clara.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}) => + `Nuestra integración con Campfire solo está disponible en el plan Controlar, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por miembro al mes.` : `por miembro activo al mes.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Aprobaciones anticipadas', description: `Si quieres añadir más niveles de aprobación, o simplemente asegurarte de que los gastos más importantes reciben otro vistazo, no hay problema. Las aprobaciones avanzadas ayudan a realizar las comprobaciones adecuadas a cada nivel para mantener los gastos de tu equipo bajo control.`, @@ -7887,7 +8019,6 @@ ${reportName}`, alwaysReimbursableDescription: 'Los gastos siempre se reembolsados a los empleados', alwaysNonReimbursable: 'Siempre no reembolsable', alwaysNonReimbursableDescription: 'Los gastos nunca son reembolsados a los empleados', - billableDefault: 'Valor predeterminado facturable', billableDefaultDescription: 'Elige si los gastos en efectivo y con tarjeta de crédito deben ser facturables por defecto.', billable: 'Facturable', billableDescription: 'Los gastos se vuelven a facturar a los clientes en la mayoría de los casos', @@ -7914,10 +8045,12 @@ ${reportName}`, publicReceiptVisibilityHintDisabled: 'Solo los miembros de Expensify con acceso al informe que contiene el recibo pueden ver los recibos.', enableTagsToUnlockTitle: '¿Habilitar etiquetas?', enableTagsToUnlockPrompt: 'Habilita las etiquetas (en Más funciones) para desbloquear.', - enableTagsAndRequirePrompt: '¿Seguro que quieres habilitar las etiquetas y hacerlas obligatorias para todos los gastos?', enableCategoriesToUnlockTitle: '¿Habilitar categorías?', enableCategoriesToUnlockPrompt: 'Activa Categorías (en Más funciones) para desbloquear.', enableCategoriesAndRequirePrompt: '¿Seguro que quieres habilitar las categorías y hacerlas obligatorias para todos los gastos?', + enableTagsPrompt: '¿Seguro que quieres habilitar las etiquetas? Podrás exigirlas para todos los gastos cuando tengas al menos una etiqueta.', + noTagsToRequirePrompt: 'No tienes ninguna etiqueta. Por favor, crea una etiqueta.', + noCategoriesToRequirePrompt: 'No tienes ninguna categoría. Por favor, crea una categoría.', }, expenseReportRules: { title: 'Avanzado', @@ -7990,6 +8123,22 @@ ${reportName}`, expenseDefaultsSubtitle: 'Actualizar campos sin que quien los envía haga nada', ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', + confirmErrorCategory: 'Selecciona una categoría', + confirmErrorCategoryTax: 'Selecciona una tasa de impuesto', + confirmErrorCondition: 'Por favor, introduce un comercio', + confirmErrorConditionAndDefault: 'Introduce un comercio y aplica al menos un valor predeterminado', + turnOnTaxesFirstTitle: 'Activa primero los impuestos', + turnOnTaxesFirstPrompt: + 'Las reglas de categoría establecen una tasa de impuesto predeterminada. Activa los impuestos en la configuración de tu espacio de trabajo para usarlos.', + categoryRulesApplyGoingForwardTitle: 'Las reglas de categoría se aplican de ahora en adelante', + categoryRulesApplyGoingForwardPrompt: 'Se aplica una tasa de impuesto predeterminada a los nuevos gastos de esta categoría. Los gastos que ya existen no cambiarán.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'El tipo impositivo seleccionado es ahora el predeterminado de tu espacio de trabajo, por lo que esta regla ya no es válida. Elige un tipo impositivo diferente.', + addTaxRateFirstTitle: 'Añade primero una tasa de impuesto', + addTaxRateFirstPrompt: + 'Las reglas de categoría establecen una tasa de impuestos predeterminada. Añade una tasa de impuestos distinta de la predeterminada de tu espacio de trabajo para poder usarlas.', + createRuleFromExpenseAction: 'Crear una norma', + createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, categoryRules: { title: 'Reglas de categoría', @@ -8010,7 +8159,8 @@ ${reportName}`, flagAmountsOverSubtitle: 'Esto anula el importe máximo para todos los gastos.', expenseLimitTypes: { expense: 'Gasto individual', - expenseSubtitle: 'Señala importes de gastos por categoría. Esta regla anula la regla general del espacio de trabajo para el importe máximo de gastos.', + expenseSubtitle: + 'Marca importes de gastos por categoría. Esta regla sustituye la regla general del espacio de trabajo para el importe máximo de gasto. Las reservas de varios días se evalúan usando la media por noche.', daily: 'Total por categoría', dailySubtitle: 'Marcar el gasto total por día por categoría en cada informe de gastos.', }, @@ -8260,10 +8410,10 @@ ${reportName}`, applyExpenseDefaultsDescription: 'Actualizar campos sin que quien los envía haga nada', flagForReview: 'Marcar para revisión', flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', - requireFields: 'Requisitos del campo', + requireFields: 'Configurar requisitos de campos', requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', - createAgentRule: 'Regla de agente', - createAgentRuleDescription: 'Describe reglas flexibles que se ejecutan cuando las necesitas.', + createAgentRule: 'Describir regla de agente', + createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', }, expenseDefaultsTable: { tableColumnType: 'Tipo', @@ -8274,6 +8424,7 @@ ${reportName}`, update: 'Actualizar', merchantIs: (merchant: string) => `El comercio es «${merchant}»`, merchantTypeIs: (merchantType: string) => `El tipo de comercio es "${merchantType}"`, + categoryIs: (category: string) => `La categoría es «${category}»`, }, merchantTypeRule: {merchantType: 'Tipo de comercio', saveRule: 'Guardar regla', confirmErrorCategory: 'Por favor, selecciona una categoría.'}, requireFieldsTable: { @@ -8290,6 +8441,7 @@ ${reportName}`, requireReceipt: 'Solicitar recibo', doNotRequireReceipt: 'No solicitar recibo', doNotRequireItemizedReceipt: 'No exigir recibo desglosado', + typeLabel: 'Requerir', }, requireFieldsEmptyState: { title: 'Detecta los datos que faltan desde el principio', @@ -8342,11 +8494,34 @@ ${reportName}`, cta: 'Añadir regla de IA', }, categoriesDisabledEmptyState: {title: 'Las categorías no están habilitadas', subtitle: 'Habilita las categorías para tener más control sobre tus gastos.'}, + expenseDefaultType: { + title: 'Aplicar valores predeterminados de gastos', + subtitle: '¿Con qué debería coincidir esta regla?', + merchant: 'Comercio', + merchantDescription: 'Actualizar campos en gastos de un comerciante específico', + category: 'Categoría', + categoryDescription: 'Establece una tasa de impuesto predeterminada para una categoría específica', + }, + taxesDisabledEmptyState: { + title: 'Los impuestos no están habilitados', + subtitle: 'Habilita los impuestos para documentar y recuperar los impuestos que sean deducibles.', + cta: 'Habilitar impuestos', + }, }, emptyDomain: { title: 'Mejora tu seguridad con dominios', subtitle: 'Exige que los miembros de tu dominio inicien sesión mediante inicio de sesión único, restrinjan la creación de espacios de trabajo y más.', }, + campfire: { + campfireSetup: 'Configuración de Campfire', + enterCredentials: 'Introduce tu clave de API de Campfire', + howToFindAPIKey: + 'Encontrar tu clave de API.
  1. Inicia sesión en Campfire
  2. Ve a Configuración -> Claves de API
  3. Crea una clave de API
  4. Pega la clave de API abajo
', + subsidiary: 'Filial', + subsidiarySelectDescription: 'Elige la filial en Campfire de la que te gustaría importar datos.', + noSubsidiariesFound: 'No se han encontrado filiales', + noSubsidiariesFoundDescription: 'Por favor, añade una entidad en Campfire y sincroniza la conexión de nuevo', + }, }, getAssistancePage: { title: 'Obtener ayuda', @@ -9278,7 +9453,7 @@ ${reportName}`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Facturación consolidada de viajes', }, is: 'Es', - has: {submittedViolation: 'Infracción enviada'}, + has: {submittedViolation: 'Infracción enviada', approvedViolation: 'Infracción aprobada'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Enviar', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Aprobar', @@ -9338,11 +9513,13 @@ ${reportName}`, exportedTo: 'Exported to', exportAll: { selectAllMatchingItems: 'Seleccionar todos los elementos coincidentes', + allMatchingItemsSelected: 'Todos los elementos coincidentes seleccionados', selectAllOnThisPage: 'Seleccionar todo en esta página', }, errors: { pleaseSelectDatesForBothFromAndTo: 'Por favor, selecciona fechas para Desde y Hasta', }, + mergeReports: {title: 'Combinar informes', description: 'Selecciona el informe que quieres conservar. Todos los gastos se moverán a él y los demás informes se eliminarán.'}, }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', @@ -9426,6 +9603,7 @@ ${reportName}`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `exportado a ${translatedLabel}`; @@ -9466,6 +9644,16 @@ ${reportName}`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Repetido ${count} veces.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `La conexión ${feedName} está rota. Para restaurar las importaciones de tarjetas, inicia sesión en tu banco.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `La conexión ${feedName} se ha interrumpido durante 30 días. Inicia sesión en tu banco para solucionarlo o elimina la conexión si ya no se utiliza. No perderás ningún gasto enviado si la eliminas.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la conexión Plaid con tu cuenta bancaria de empresa está rota. Por favor, reconecta tu cuenta bancaria ${maskedAccountNumber} para poder seguir usando tus Tarjetas Expensify.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10436,6 +10624,7 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `La tasa solo es válida desde ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `La tasa solo es válida hasta ${endDate}`, cannotMergeDuplicates: 'Solo puedes combinar gastos en informes en borrador o pendientes. Retíralo e inténtalo de nuevo.', + overCategoryLimitPerNight: (formattedLimit: string) => `Tarifa nocturna superior al límite de categoría de ${formattedLimit}/persona`, shortName: { allTagLevelsRequired: 'Todas las etiquetas obligatorias', autoReportedRejectedExpense: 'Gasto rechazado', @@ -11040,6 +11229,7 @@ ${reportName}`, reportLevelExport: 'Todos los datos - a nivel de informe', expenseLevelExport: 'Todos los datos - a nivel de gasto', multipleTaxExport: 'Exportación canadiense de impuestos múltiples', + reconciliationAllExpenses: 'Conciliación - Todos los gastos', exportInProgress: 'Exportación en curso', conciergeWillSend: 'Concierge te enviará el archivo en breve.', currentView: 'Vista actual', @@ -11076,6 +11266,7 @@ ${reportName}`, notVerified: 'No verificado', retry: 'Reintentar', requestSent: 'Solicitud enviada', + requestAccessError: 'No pudimos enviar tu solicitud. Por favor, inténtalo de nuevo.', verifyDomain: { title: 'Verificar dominio', beforeProceeding: ({domainName}: {domainName: string}) => @@ -11137,12 +11328,12 @@ ${reportName}`, setMetadataGenericError: 'No se pudieron establecer los metadatos de SAML', }, accessRestricted: { - title: 'Acceso restringido', - subtitle: (domainName: string) => `Por favor, verifícate como un administrador autorizado de la empresa para ${domainName} si necesitas control sobre:`, - companyCardManagement: 'Gestión de tarjetas de la empresa', - accountCreationAndDeletion: 'Creación y eliminación de cuentas', - workspaceCreation: 'Creación de espacios de trabajo', - samlSSO: 'SAML SSO', + headerTitle: 'Acceso restringido', + title: 'Verificación requerida', + description: (domainName: string) => + `Por favor, verifícate como administrador autorizado de la empresa para ${domainName} o solicita acceso a los administradores existentes.`, + requestAdminAccess: 'Solicitar acceso de administrador', + verifyYourself: 'Verifícate', }, addDomain: { title: 'Añadir dominio', @@ -11156,7 +11347,6 @@ ${reportName}`, title: 'Dominio ya configurado. ¿Solicitar acceso?', description: 'Alguien ya configuró este dominio en Expensify. ¿Quieres solicitar acceso de administrador?', requestAccess: 'Solicitar acceso de administrador', - requestAccessError: 'No pudimos enviar tu solicitud. Por favor, inténtalo de nuevo.', }, domainAdded: { title: 'Dominio añadido', @@ -11181,6 +11371,9 @@ ${reportName}`, consolidatedDomainBillingError: 'No se pudo cambiar la facturación consolidada del dominio. Por favor, inténtalo de nuevo más tarde.', addAdmin: 'Añadir administrador', addAdminError: 'No se pudo añadir a este miembro como administrador. Por favor, inténtalo de nuevo.', + requests: 'Solicitudes', + approveRequestError: 'No se pudo aprobar esta solicitud. Por favor, inténtalo de nuevo.', + declineRequestError: 'No se pudo rechazar esta solicitud. Por favor, inténtalo de nuevo.', revokeAdminAccess: 'Revocar acceso de administrador', cantRevokeAdminAccess: 'No se puede revocar el acceso de administrador del contacto técnico', error: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8066064deb58..c57e018546c4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: 'Arrêter le partage', yes: 'Oui', no: 'Non', + approve: 'Approuver', + deny: 'Refuser', dontChange: 'Ne pas modifier', ok: 'OK', notNow: 'Pas maintenant', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: 'Rechercher', reports: 'Notes de frais', spend: 'Dépenses', + insights: 'Analyses', find: 'Rechercher', searchWithThreeDots: 'Rechercher...', next: 'Suivant', @@ -465,6 +468,9 @@ const translations: TranslationDeepObject = { none: 'Aucun', unstableInternetConnection: 'Connexion Internet instable. Veuillez vérifier votre réseau et réessayer.', enableGlobalReimbursements: 'Activer les remboursements globaux', + corpayPayModalTitle: 'Payer la note de frais', + corpayPayModalPrompt: + "Cette personne a un compte bancaire qui n'est pas en USD. Activez les remboursements globaux pour payer la note de frais, ou demandez-lui d'ajouter un compte bancaire en USD.", purchaseAmount: "Montant de l'achat", originalAmount: 'Montant initial', frequency: 'Fréquence', @@ -954,13 +960,21 @@ const translations: TranslationDeepObject = { tooltip: 'Commencez ici !', }, homePage: { - forYou: 'Pour vous', + toDos: 'Tâches', timeSensitiveSection: { title: 'Urgent', - addShippingAddress: {title: 'Nous avons besoin de votre adresse de livraison', subtitle: 'Indiquez une adresse pour recevoir votre Carte Expensify.', cta: 'Ajouter une adresse'}, + 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é'}, activateCard: {title: 'Activer votre Carte Expensify', subtitle: 'Validez votre carte et commencez à dépenser.', cta: 'Activer'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `L’ajout de la carte ${walletName} nécessite votre approbation`, + subtitle: 'Carte Expensify', + cta: 'Examiner', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Portefeuille numérique', + }, reviewCardFraud: { title: 'Examiner une éventuelle fraude sur votre Carte Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Examiner ${amount} de fraude potentielle chez ${merchant}`, @@ -983,7 +997,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portefeuille', }, validateAccount: {title: 'Validez votre compte', subtitle: 'Compte', cta: 'Valider'}, - addHomeAddress: {title: 'Ajoutez votre adresse personnelle pour le suivi des distances', subtitle: 'Compte', cta: 'Ajouter une adresse'}, + addHomeAddress: {title: 'Ajoutez votre adresse personnelle pour le suivi des distances', subtitle: 'Compte', cta: 'Ajouter'}, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Votre compte bancaire professionnel a été verrouillé', @@ -991,11 +1005,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'Portefeuille', }, - addVirtualCardPersonalDetails: { - title: 'Ajoutez vos informations pour voir et commencer à utiliser votre Carte Expensify.', - subtitle: 'Carte Expensify', - cta: 'Ajouter des détails', - }, + addVirtualCardPersonalDetails: {title: 'Ajoutez vos informations pour voir et commencer à utiliser votre Carte Expensify.', subtitle: 'Carte Expensify', cta: 'Ajouter'}, enterSignerInfo: {title: 'Informations du signataire requises', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Compte bancaire ${bankAccountLastFour}`}, }, discoverSection: { @@ -1098,6 +1108,8 @@ const translations: TranslationDeepObject = { inviteAccountant: 'Inviter votre comptable', customizeSpendCategories: 'Personnaliser les catégories de dépenses', customizeSpendCategoriesSubText: 'Organiser et classer les dépenses', + customizeExpenseCategories: 'Personnalisez vos catégories de dépenses', + customizeExpenseCategoriesSubText: 'Ajoutez les catégories de votre entreprise pour coder vos dépenses', createExpense: 'Créer une dépense', createExpenseSubText: 'Numérisez, faites glisser-déposer ou saisissez manuellement une dépense à l’aide du bouton +', linkPersonalCard: 'Lier une carte personnelle', @@ -1123,7 +1135,6 @@ const translations: TranslationDeepObject = { title: 'Vos dépenses', awaitingApproval: 'En attente d’approbation', repaidLast30Days: 'Remboursé 30 derniers jours', - recentTransactions: ({lastFour}: {lastFour: string}) => `Transactions récentes • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `Voir ${count} de plus`, recentlyAddedSection: { @@ -1280,6 +1291,7 @@ const translations: TranslationDeepObject = { phrase1: 'Ajouter un reçu', phrase2: 'ou faites-en glisser un ici', }, + pageCount: ({pageCount}: {pageCount: number}) => `Page 1 sur ${pageCount}`, }, quickAction: { scanReceipt: 'Scanner le reçu', @@ -1671,11 +1683,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Tout est en ordre !', noDuplicatesDescription: "Il n'y a aucune transaction en double à vérifier ici.", confirmApprove: 'Confirmer le montant approuvé', - confirmApprovalAmount: 'Approuvez uniquement les dépenses conformes, ou approuvez l’intégralité de la note de frais.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Cette dépense est en attente. Voulez-vous l’approuver quand même ?', - other: 'Ces dépenses sont en attente. Voulez-vous tout de même approuver ?', - }), + confirmApprovalWithHeldAmount: "Le rapport contient des dépenses en attente. Approuver uniquement les dépenses conformes, ou approuver l'ensemble du rapport ?", + confirmApprovalAllHoldAmount: 'Toutes les dépenses sont en attente. Approuver quand même ?', confirmPay: 'Confirmer le montant du paiement', confirmPayAmount: 'Payez ce qui n’est pas en attente, ou payez l’intégralité de la note de frais.', confirmPayAllHoldAmount: () => ({ @@ -2307,6 +2316,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Noms de segments surlignés', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.clic, navigation, ui.chargement', showBranchNameInTitle: 'Afficher le nom de la branche dans le titre du navigateur', + betaOverrides: 'Remplacements de bêtas', + betaOverridesDescription: + "Les remplacements ne s'appliquent qu'à cet appareil et n'affectent que les vérifications côté frontend. Une bêta ne conserve un remplacement que tant qu'il diffère des bêtas de votre compte ; la rebasculer supprime donc le remplacement. « Réinitialiser tous les remplacements » rétablit les valeurs de votre compte. Certaines bêtas sont aussi contrôlées par le backend et peuvent donc échouer au niveau de la requête.", + resetAllOverrides: 'Réinitialiser tous les remplacements', + overridden: 'Remplacée', qaAuth: 'Authentification QA (Cloudflare)', qaAuthRunProbe: 'Lancer le test', qaAuthSession: 'Session d’authentification QA', @@ -2621,6 +2635,10 @@ const translations: TranslationDeepObject = { connectionLink ? `La connexion de votre carte ${cardName} est rompue. Connectez-vous à votre banque pour corriger la carte.` : `La connexion de votre carte ${cardName} est rompue. Connectez-vous à votre banque pour corriger la carte.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Votre connexion ${cardName} est rompue depuis 30 jours. Connectez-vous à votre banque pour la corriger ou supprimez la carte si elle n’est plus utilisée. Vous ne perdrez aucune dépense soumise si vous la supprimez.` + : `La connexion de votre ${cardName} est rompue depuis 30 jours. Connectez-vous à votre banque pour la réparer ou supprimez la carte si elle n’est plus utilisée. Vous ne perdrez aucune dépense soumise si vous la supprimez.`, addAdditionalCards: "Ajouter d'autres cartes", upgradeDescription: "Vous devez ajouter plus de cartes ? Créez un espace de travail pour ajouter des cartes personnelles ou assigner des cartes d'entreprise à toute l'équipe.", onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2720,6 +2738,24 @@ const translations: TranslationDeepObject = { unlock: 'Déverrouiller', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Ajouter la carte à ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'portefeuille numérique', + confirmHeading: 'Confirmez votre demande', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Voulez-vous ajouter votre Carte Expensify (se terminant par ${lastFourDigits}) à votre ${walletName} ?`, + deny: 'Refuser', + confirm: 'Confirmer', + verifyTitle: 'Vérifions qu’il s’agit bien de vous', + enterSecurityCode: (contactMethod: string) => + `Veuillez saisir le code de sécurité envoyé à ${contactMethod} pour confirmer cette demande. Il devrait arriver d’ici une à deux minutes.`, + successHeading: 'Succès !', + successDescription: ({walletName}: {walletName: string}) => `Votre carte est maintenant active pour être utilisée dans votre ${walletName}.`, + deniedHeading: 'Demande refusée', + deniedDescription: ({walletName}: {walletName: string}) => `Votre carte n’a pas été ajoutée à votre ${walletName}.`, + }, cardPage: { expensifyCard: 'Carte Expensify', expensifyTravelCard: 'Carte Travel Expensify', @@ -3172,6 +3208,7 @@ ${amount} pour ${merchant} - ${date}`, unread: 'Non lu', markAllAsRead: 'Tout marquer comme lu', markAllAsReadConfirmationPrompt: 'Voulez-vous vraiment marquer toutes les discussions comme lues ?', + markAllTodosAsReadConfirmationPrompt: 'Voulez-vous vraiment marquer comme lues toutes les discussions à traiter ?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, @@ -3353,6 +3390,7 @@ ${amount} pour ${merchant} - ${date}`, `Nous n’avons pas pu ajouter ${workEmail}. Veuillez réessayer plus tard dans les Paramètres ou discuter avec Concierge pour obtenir de l’aide.`, workAccountClosedSubtitle: 'Le compte professionnel associé à cet e-mail est fermé. Veuillez contacter l’administrateur de votre entreprise pour le réactiver, ou inscrivez-vous avec un autre e-mail.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} est un identifiant contrôlé par domaine pour un compte Expensify existant.`, }, tasks: { testDriveAdminTask: { @@ -4190,8 +4228,7 @@ ${amount} pour ${merchant} - ${date}`, legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', enterTheDateOfBirthOfTheOwner: 'Quelle est la date de naissance du responsable ?', - enterTheLast4: 'Quels sont les 4 derniers chiffres du numéro de sécurité sociale du responsable ?', - last4SSN: '4 derniers chiffres du SSN', + enterTheSSN: 'Quel est le numéro de sécurité sociale du propriétaire ?', dontWorry: 'Ne vous inquiétez pas, nous ne faisons aucun contrôle de crédit personnel !', enterTheOwnersAddress: 'Quelle est l’adresse du responsable ?', letsDoubleCheck: 'Vérifions une dernière fois que tout est correct.', @@ -4218,13 +4255,12 @@ ${amount} pour ${merchant} - ${date}`, whatsYourDOB: 'Quelle est votre date de naissance ?', whatsTheOwnersAddress: 'Quelle est l’adresse du responsable ?', whatsYourAddress: 'Quelle est votre adresse ?', - whatAreTheLast: 'Quels sont les 4 derniers chiffres du numéro de sécurité sociale du propriétaire ?', - whatsYourLast: 'Quels sont les 4 derniers chiffres de votre numéro de Sécurité sociale ?', + whatsTheOwnersSSN: 'Quel est le numéro de sécurité sociale du propriétaire ?', + whatsYourSSN: 'Quel est votre numéro de sécurité sociale ?', whatsYourNationality: 'Quel est votre pays de citoyenneté ?', whatsTheOwnersNationality: 'Quel est le pays de citoyenneté du responsable ?', countryOfCitizenship: 'Pays de citoyenneté', dontWorry: 'Ne vous inquiétez pas, nous ne faisons aucun contrôle de crédit personnel !', - last4: '4 derniers chiffres du SSN', whyDoWeAsk: 'Pourquoi demandons-nous cela ?', letsDoubleCheck: 'Vérifions une dernière fois que tout est correct.', legalName: 'Nom légal', @@ -4248,6 +4284,8 @@ ${amount} pour ${merchant} - ${date}`, proofOfBeneficialOwner: 'Preuve du bénéficiaire effectif', proofOfBeneficialOwnerDescription: 'Veuillez fournir une attestation signée et un organigramme émis par un expert-comptable, un notaire ou un avocat, confirmant la détention de 25 % ou plus de l’entreprise. Le document doit être daté de moins de trois mois et inclure le numéro de licence du signataire.', + proofOfBeneficialOwnerDescriptionCA: + 'Veuillez fournir votre Annexe 50 (Renseignements sur les actionnaires) de votre plus récente déclaration T2 de l’impôt sur le revenu des sociétés, indiquant chaque personne détenant 25 % ou plus de l’entreprise.', copyOfID: 'Copie de la pièce d’identité du bénéficiaire effectif', copyOfIDDescription: 'Exemples : passeport, permis de conduire, etc.', proofOfAddress: 'Justificatif de domicile du bénéficiaire effectif', @@ -4632,6 +4670,7 @@ ${amount} pour ${merchant} - ${date}`, customFieldHint: 'Ajoutez un codage personnalisé qui s’applique à toutes les dépenses de ce membre.', reports: 'Notes de frais', reportFields: 'Champs de note de frais', + invoiceFields: 'Champs de facture', reportTitle: 'Titre de la note de frais', reportField: 'Champ de note de frais', taxes: 'Taxes', @@ -4641,6 +4680,7 @@ ${amount} pour ${merchant} - ${date}`, travel: 'Déplacements', members: 'Membres', accounting: 'Comptabilité', + mcp: 'MCP', receiptPartners: 'Partenaires de reçus', rules: 'Règles', displayedAs: 'Affiché comme', @@ -4764,6 +4804,7 @@ ${amount} pour ${merchant} - ${date}`, travelInvoicingVendor: 'Fournisseur de voyages', travelInvoicingPayableAccount: 'Compte fournisseur déplacements', hr: 'RH', + recruiting: 'Recrutement', rooms: 'Salons', findDomain: 'Trouver un domaine', cardAdminAlternateText: 'Gérer les cartes de l’espace de travail.', @@ -4777,6 +4818,25 @@ ${amount} pour ${merchant} - ${date}`, title: 'Vous avez créé un espace de travail pour votre client !', description: "Excellente nouvelle 🎉. Contactez-nous si votre client a besoin d'aide pour la configuration.", }, + mcp: { + connectors: 'Connecteurs', + connectorsSubtitle: 'Connectez un assistant IA à votre compte Expensify.', + connect: 'Connecter', + helpPrompt: "Besoin d'aide pour la connexion ?", + helpLink: 'Consultez notre guide.', + claude: { + title: 'Claude', + subtitle: 'par Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'par Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'par OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -4997,7 +5057,6 @@ ${amount} pour ${merchant} - ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Utiliser ce compte lors de l’exportation des factures vers ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Définissez comment les achats par carte d’entreprise sont exportés vers ${integrationName}.`, vendor: 'Fournisseur', - defaultVendorDescription: 'Définissez un fournisseur par défaut qui s’appliquera à toutes les transactions par carte de crédit lors de l’exportation.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Définissez comment les dépenses hors poche sont exportées vers ${integrationName}.`, exportCheckDescription: 'Nous créerons un chèque détaillé pour chaque note de frais Expensify et l’enverrons depuis le compte bancaire ci-dessous.', exportJournalEntryDescription: 'Nous créerons une écriture comptable détaillée pour chaque note de frais Expensify et la comptabiliserons sur le compte ci-dessous.', @@ -5166,8 +5225,6 @@ ${amount} pour ${merchant} - ${date}`, }, noAccountsFound: 'Aucun compte trouvé', noAccountsFoundDescription: 'Veuillez ajouter le compte dans Xero et synchroniser à nouveau la connexion', - defaultSupplier: 'Fournisseur par défaut', - defaultSupplierDescription: 'Définissez un fournisseur par défaut qui s’appliquera à toutes les transactions par carte de crédit lors de l’exportation.', noSuppliersFound: 'Aucun fournisseur trouvé', noSuppliersFoundDescription: 'Veuillez ajouter le fournisseur dans Xero et synchroniser à nouveau la connexion.', accountingMethods: { @@ -6405,6 +6462,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. 'Nous n’avons pas pu configurer certains membres de votre espace de travail pour la facturation de voyage consolidée. Veuillez réessayer plus tard ou contacter Concierge pour obtenir de l’aide.', sendInvoiceNowCta: 'Envoyer la facture maintenant', }, + setUpNewFeed: 'Configurer un nouveau flux de voyage', + feedSelectorIntro: + 'Votre organisation dispose déjà d’un flux de facturation de voyage consolidée configuré. Sélectionnez-le pour conserver les dépenses de voyage de cet espace de travail sur la même facture mensuelle.', }, disableModal: { title: 'Désactiver la facturation voyage consolidée ?', @@ -6564,6 +6624,10 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. title: 'Comptabilité', subtitle: 'Synchronisez votre plan comptable et plus encore.', }, + mcp: { + title: 'MCP', + subtitle: 'Connectez un assistant IA à votre compte Expensify.', + }, receiptPartners: { title: 'Partenaires de reçus', subtitle: 'Importer automatiquement les reçus.', @@ -6630,6 +6694,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. subtitle: 'Les champs de note de frais s’appliquent à toutes les dépenses et peuvent être utiles lorsque vous souhaitez demander des informations supplémentaires.', disableReportFields: 'Désactiver les champs de note de frais', disableReportFieldsConfirmation: 'Êtes-vous sûr(e) ? Les champs de texte et de date seront supprimés, et les listes seront désactivées.', + cannotDisableImportedReportFields: 'Les champs de note de frais importés depuis votre connexion comptable ne peuvent pas être désactivés.', importedFromAccountingSoftware: 'Les champs de note de frais ci-dessous sont importés depuis votre', textType: 'Texte', dateType: 'Date', @@ -6666,6 +6731,29 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. reportFieldInitialValueRequiredError: 'Veuillez choisir une valeur initiale pour le champ de note de frais', genericFailureMessage: 'Une erreur s’est produite lors de la mise à jour du champ de note de frais. Veuillez réessayer.', }, + invoiceFields: { + subtitle: 'Les champs de facture peuvent être utiles lorsque vous souhaitez inclure des informations supplémentaires.', + importedFromAccountingSoftware: 'Les champs de facture ci-dessous sont importés depuis votre', + disableInvoiceFields: 'Désactiver les champs de facture', + disableInvoiceFieldsConfirmation: 'Êtes-vous sûr ? Les champs de facture seront désactivés sur les factures.', + delete: 'Supprimer le champ de facture', + deleteConfirmation: 'Voulez-vous vraiment supprimer ce champ de facture ?', + findInvoiceField: 'Trouver un champ de facture', + nameInputSubtitle: 'Choisissez un nom pour le champ de facture.', + typeInputSubtitle: 'Choisissez le type de champ de facture à utiliser.', + initialValueInputSubtitle: 'Saisissez une valeur de départ à afficher dans le champ de facture.', + listValuesInputSubtitle: 'Ces valeurs apparaîtront dans la liste déroulante du champ de facture. Les valeurs activées peuvent être sélectionnées par les membres.', + listInputSubtitle: 'Ces valeurs apparaîtront dans la liste du champ de facture. Les valeurs activées peuvent être sélectionnées par les membres.', + emptyInvoiceFieldsValues: { + title: 'Aucune valeur de liste pour le moment', + subtitle: 'Ajoutez des valeurs personnalisées à afficher sur les factures.', + }, + existingInvoiceFieldNameError: 'Un champ de facture portant ce nom existe déjà', + invoiceFieldNameRequiredError: 'Veuillez saisir un nom de champ de facture', + invoiceFieldTypeRequiredError: 'Veuillez choisir un type de champ de facture', + invoiceFieldInitialValueRequiredError: 'Veuillez choisir une valeur initiale de champ de facture', + addField: 'Ajouter un champ', + }, vendors: { emptyTitle: 'Aucun fournisseur pour le moment', emptySubtitle: 'Les fournisseurs apparaîtront ici une fois la synchronisation comptable terminée.', @@ -7061,6 +7149,8 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7127,7 +7217,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, case 'netSuiteSyncImportEmployees': case 'intacctImportEmployees': case 'quickbooksDesktopImportEmployees': - return 'Importation des employés'; + return 'Importation d’employés'; case 'quickbooksOnlineImportAccounts': case 'quickbooksDesktopImportAccounts': return 'Importation de comptes'; @@ -7135,14 +7225,14 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, case 'quickbooksDesktopImportClasses': return 'Importation de classes'; case 'quickbooksOnlineImportLocations': - return 'Importation des emplacements'; + return 'Importation des lieux'; case 'quickbooksOnlineImportProcessing': return 'Traitement des données importées'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': return 'Synchronisation des notes de frais remboursées et des paiements de factures'; case 'quickbooksOnlineSyncTaxCodes': - return 'Importation des codes fiscaux'; + return 'Importation de codes fiscaux'; case 'quickbooksOnlineCheckConnection': return `Vérification de la connexion ${integrationName}`; case 'quickbooksOnlineImportMain': @@ -7153,7 +7243,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, return `Importation des données ${integrationName}`; case 'startingImportQBD': case 'quickbooksDesktopImportMore': - return 'Importation des données QuickBooks Desktop'; + return 'Importation de données QuickBooks Desktop'; case 'quickbooksDesktopImportTitle': return 'Importation du titre'; case 'quickbooksDesktopImportApproveCertificate': @@ -7175,7 +7265,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, case 'quickbooksOnlineSyncApplyCustomers': return 'Mise à jour des clients/projets'; case 'quickbooksOnlineSyncApplyEmployees': - return 'Mise à jour de la liste de personnes'; + return 'Mise à jour de la liste des personnes'; case 'quickbooksOnlineSyncApplyClassesLocations': return 'Mise à jour des champs de note de frais'; case 'jobDone': @@ -7189,7 +7279,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, case 'xeroSyncXeroReimbursedReports': return 'Marquer les notes de frais Expensify comme remboursées'; case 'xeroSyncExpensifyReimbursedReports': - return 'Marquer les factures et notes de frais Xero comme payées'; + return 'Marquer les factures et avoirs Xero comme payés'; case 'xeroSyncImportTrackingCategories': return 'Synchronisation des catégories de suivi'; case 'xeroSyncImportBankAccounts': @@ -7227,7 +7317,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, case 'netSuiteSyncNetSuiteReimbursedReports': return 'Marquer les notes de frais Expensify comme remboursées'; case 'netSuiteSyncExpensifyReimbursedReports': - return 'Marquer les factures et notes de débit NetSuite comme payées'; + return 'Marquer les factures et notes de frais NetSuite comme payées'; case 'netSuiteImportVendorsTitle': return 'Importation de fournisseurs'; case 'netSuiteImportCustomListsTitle': @@ -7240,15 +7330,15 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, case 'quickbooksDesktopImportVendors': return 'Importation de fournisseurs'; case 'intacctCheckConnection': - return 'Vérification de la connexion Sage Intacct'; + return 'Vérification de la connexion à Sage Intacct'; case 'intacctImportDimensions': return 'Importation des dimensions Sage Intacct'; case 'intacctImportTitle': - return 'Importation de données Sage Intacct'; + return 'Importation des données Sage Intacct'; case 'financialForceSyncTitle': return 'Synchronisation des données Certinia'; case 'financialForceSyncStep': - return 'Synchronisation de la connexion Certinia'; + return 'Synchronisation de la connexion à Certinia'; case 'financialForceSyncCategories': return 'Importation de catégories'; case 'financialForceSyncTags': @@ -7283,6 +7373,18 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, return 'Synchronisation des règlements de carte'; case 'dualEntrySyncTravelSettlements': return 'Synchronisation des règlements de voyage'; + case 'campfireSyncTitle': + return 'Synchronisation des données Campfire'; + case 'campfireSyncConnection': + return 'Initialisation de la connexion à Campfire'; + case 'campfireSyncImportData': + return 'Chargement des données'; + case 'campfireSyncPayments': + return 'Synchronisation des paiements fournisseurs'; + case 'campfireSyncCardSettlements': + return 'Synchronisation des règlements de carte'; + case 'campfireSyncTravelSettlements': + return 'Synchronisation des règlements de voyage'; default: { return `Traduction manquante pour l’étape : ${stage}`; } @@ -7298,11 +7400,11 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, exportCompanyCard: 'Exporter les dépenses de carte d’entreprise en tant que', exportDate: 'Date d’exportation', defaultVendor: 'Fournisseur par défaut', - defaultVendorHelperText: (isSet: boolean) => - isSet - ? `Les dépenses qui ne sont pas automatiquement rapprochées seront, par défaut, associées à ce fournisseur.` - : `Les dépenses qui ne sont pas associées automatiquement seront attribuées par défaut à ce fournisseur. Sinon, elles seront exportées sous Crédit carte diverse.`, - defaultVendorSelectHeader: (connectionName: string) => `Choisissez un fournisseur ${connectionName} par défaut pour les dépenses qui ne correspondent pas automatiquement.`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName + ? `Les dépenses qui ne correspondent pas automatiquement seront par défaut associées à ce fournisseur.` + : `Les dépenses qui ne se rapprochent pas automatiquement utiliseront par défaut ce fournisseur. Sinon, elles seront exportées sous ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Choisissez un fournisseur par défaut pour les dépenses qui ne correspondent pas automatiquement.`, defaultAccount: 'Compte par défaut', autoSync: 'Synchronisation automatique', autoSyncDescription: 'Synchronisez automatiquement NetSuite et Expensify, chaque jour. Exportez les notes de frais finalisées en temps réel', @@ -7325,6 +7427,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, syncTravelInvoicingSettlements: 'Synchroniser les règlements de facturation de voyages consolidés', syncTravelInvoicingSettlementsNoAccountTooltip: 'Pour le déverrouiller, définissez un compte pour vos exports.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Pour le déverrouiller, activez la synchronisation automatique.', + campfire: 'Campfire', }, export: { notReadyHeading: 'Pas prêt à être exporté', @@ -7383,12 +7486,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des demandes.', - optionFixedDistanceTitle: 'Exclure une distance fixe par demande', - optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', + optionFixedDistanceTitle: 'Supprimer la distance fixe', + optionFixedDistanceHelp: 'Soustrait la même distance de chaque déclaration.', distanceLabel: 'Distance', summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', - optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', - optionHomeAndOfficeHelp: 'Utiliser l’adresse personnelle du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet.', + optionHomeAndOfficeTitle: 'Exclure le trajet domicile-travail habituel', + optionHomeAndOfficeHelp: 'Soustrait le trajet domicile-travail habituel de chaque membre en fonction de son domicile, de son mode de travail et de son bureau.', workspaceAddressRequired: { title: 'Pas si vite...', promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau avant d’ajouter d’abord un lieu de travail dans', @@ -7590,6 +7693,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Les champs de note de frais sont uniquement disponibles avec l’offre Control, à partir de ${formattedPrice} ${hasTeam2025Pricing ? `par membre et par mois.` : `par membre actif et par mois.`}`, }, + invoiceFields: { + title: 'Champs de facture', + description: `Les champs de facture vous permettent d'inclure des détails supplémentaires au niveau de la facture.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Les champs de facture sont uniquement disponibles avec l’offre Control, à partir de ${formattedPrice} ${hasTeam2025Pricing ? `par membre et par mois.` : `par membre actif et par mois.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Profitez de la synchronisation automatisée et réduisez les saisies manuelles grâce à l’intégration Expensify + NetSuite. Obtenez des informations financières détaillées et en temps réel avec la prise en charge des segments natifs et personnalisés, y compris la correspondance des projets et des clients.`, @@ -7632,6 +7741,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Notre intégration DualEntry est disponible uniquement avec l’offre Control, à partir de ${formattedPrice} ${hasTeam2025Pricing ? `par membre et par mois.` : `par membre actif et par mois.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Profitez de la synchronisation automatisée et réduisez les saisies manuelles grâce à l’intégration Expensify + Campfire. Alignez les dimensions de codage des dépenses et la synchronisation fiscale sur votre configuration Campfire pour une meilleure visibilité financière.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Notre intégration Campfire est disponible uniquement avec l’offre Control, à partir de ${formattedPrice} ${hasTeam2025Pricing ? `par membre et par mois.` : `par membre actif et par mois.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Approbations avancées', description: `Si vous souhaitez ajouter plusieurs niveaux d’approbation au processus – ou simplement vous assurer que les plus grosses dépenses sont examinées une fois de plus – nous avons ce qu’il vous faut. Les approbations avancées vous aident à mettre en place les bons contrôles à chaque niveau afin de garder les dépenses de votre équipe sous contrôle.`, @@ -7927,7 +8042,6 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip alwaysReimbursableDescription: 'Les dépenses sont toujours remboursées aux employés', alwaysNonReimbursable: 'Toujours non remboursable', alwaysNonReimbursableDescription: 'Les dépenses ne sont jamais remboursées aux employés', - billableDefault: 'Facturable par défaut', billableDefaultDescription: 'Choisissez si les dépenses en espèces et par carte de crédit doivent être facturables par défaut.', billable: 'Facturable', billableDescription: 'Les dépenses sont le plus souvent refacturées aux clients', @@ -7954,10 +8068,12 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip publicReceiptVisibilityHintDisabled: 'Les reçus ne sont visibles que par les membres Expensify ayant accès à la note de frais contenant le reçu.', enableTagsToUnlockTitle: 'Activer les tags ?', enableTagsToUnlockPrompt: 'Activez les tags (sous Plus de fonctionnalités) pour débloquer.', - enableTagsAndRequirePrompt: 'Voulez-vous vraiment activer les tags et les rendre obligatoires pour toutes les dépenses ?', enableCategoriesToUnlockTitle: 'Activer les catégories ?', enableCategoriesToUnlockPrompt: 'Activez les catégories (dans Plus de fonctionnalités) pour déverrouiller.', enableCategoriesAndRequirePrompt: 'Voulez-vous vraiment activer les catégories et les rendre obligatoires pour toutes les dépenses ?', + enableTagsPrompt: 'Voulez-vous vraiment activer les tags ? Vous pourrez les rendre obligatoires pour toutes les dépenses une fois que vous aurez au moins un tag.', + noTagsToRequirePrompt: "Vous n'avez aucun tag. Veuillez créer un tag.", + noCategoriesToRequirePrompt: 'Vous n’avez aucune catégorie. Veuillez créer une catégorie.', }, expenseReportRules: { title: 'Avancé', @@ -8031,6 +8147,22 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip 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', + confirmErrorConditionAndDefault: 'Saisissez un commerçant et appliquez au moins un paramètre par défaut', + turnOnTaxesFirstTitle: 'Activer d’abord les taxes', + turnOnTaxesFirstPrompt: 'Les règles de catégorie définissent un taux de taxe par défaut. Activez les taxes dans les paramètres de votre espace de travail pour les utiliser.', + categoryRulesApplyGoingForwardTitle: 'Les règles de catégorie s’appliquent à partir de maintenant', + categoryRulesApplyGoingForwardPrompt: + 'Un taux de taxe par défaut s’applique aux nouvelles dépenses de cette catégorie. Les dépenses déjà existantes ne seront pas modifiées.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Le taux de taxe sélectionné est désormais le taux par défaut de votre espace de travail, cette règle n’est donc plus valide. Choisissez un autre taux de taxe.', + addTaxRateFirstTitle: 'Ajoutez d’abord un taux de taxe', + addTaxRateFirstPrompt: + 'Les règles de catégorie définissent un taux de taxe par défaut. Ajoutez un taux de taxe autre que le taux par défaut de votre espace de travail pour les utiliser.', + createRuleFromExpenseAction: 'Créer une règle', + createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, categoryRules: { title: 'Règles de catégorie', @@ -8052,7 +8184,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip expenseLimitTypes: { expense: 'Dépense individuelle', expenseSubtitle: - 'Signaler les montants des dépenses par catégorie. Cette règle remplace la règle générale de l’espace de travail concernant le montant maximal d’une dépense.', + 'Signaler les montants de dépense par catégorie. Cette règle remplace la règle générale de l’espace de travail pour le montant maximal de dépense. Les réservations de plusieurs jours sont évaluées selon la moyenne par nuit.', daily: 'Total par catégorie', dailySubtitle: 'Signaler le montant quotidien total par catégorie pour chaque note de frais.', }, @@ -8331,10 +8463,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e applyExpenseDefaultsDescription: 'Mettre à jour les champs sans que le déclarant ne fasse quoi que ce soit', flagForReview: 'Marquer pour examen', flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', - requireFields: 'Exigences des champs', + requireFields: 'Définir les exigences des champs', requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', - createAgentRule: 'Règle d’agent', - createAgentRuleDescription: 'Décrivez des règles flexibles qui s’exécutent quand vous en avez besoin.', + 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.', }, expenseDefaultsTable: { tableColumnType: 'Type', @@ -8345,6 +8477,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e update: 'Mettre à jour', merchantIs: (merchant: string) => `Le commerçant est « ${merchant} »`, merchantTypeIs: (merchantType: string) => `Le type de commerçant est « ${merchantType} »`, + categoryIs: (category: string) => `La catégorie est « ${category} »`, }, merchantTypeRule: {merchantType: 'Type de marchand', saveRule: 'Enregistrer la règle', confirmErrorCategory: 'Veuillez sélectionner une catégorie.'}, requireFieldsTable: { @@ -8361,6 +8494,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e requireReceipt: 'Reçu obligatoire', doNotRequireReceipt: 'Ne pas exiger de reçu', doNotRequireItemizedReceipt: 'Ne pas exiger de reçu détaillé', + typeLabel: 'Exiger', }, requireFieldsEmptyState: { title: 'Identifiez les informations manquantes dès le départ', @@ -8413,6 +8547,15 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e cta: 'Ajouter une règle IA', }, categoriesDisabledEmptyState: {title: 'Les catégories ne sont pas activées', subtitle: 'Activez les catégories pour mieux contrôler vos dépenses.'}, + expenseDefaultType: { + title: 'Appliquer les valeurs de dépense par défaut', + subtitle: 'Sur quoi cette règle doit-elle faire correspondre ?', + merchant: 'Commerçant', + merchantDescription: 'Mettre à jour les champs des dépenses d’un commerçant spécifique', + category: 'Catégorie', + categoryDescription: 'Définir un taux de taxe par défaut pour une catégorie spécifique', + }, + taxesDisabledEmptyState: {title: 'Les taxes ne sont pas activées', subtitle: 'Activez les taxes pour documenter et récupérer les taxes admissibles.', cta: 'Activer les taxes'}, }, planTypePage: { planTypes: { @@ -8439,43 +8582,26 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, hr: { title: 'RH', - connections: 'Connexions', connectionsSubtitle: 'Connectez votre système RH pour synchroniser les données des employé·es, faire correspondre automatiquement les remboursements aux bonnes personnes et garder les dépenses de votre équipe exactes, sans travail manuel.', subtitle: 'Connectez vos outils RH et gardez les approbations des employés synchronisées.', - connect: 'Connecter', - syncNow: 'Synchroniser maintenant', - disconnect: 'Déconnecter', - disconnectTitle: (providerName: string) => `Déconnecter ${providerName}`, - disconnectPrompt: (providerName: string) => `Êtes-vous sûr de vouloir déconnecter ${providerName} ?`, alreadyConnectedTitle: 'Impossible de se connecter à plusieurs plateformes RH', alreadyConnectedPrompt: 'Vous devez déconnecter votre plateforme RH actuelle avant d’en connecter une autre.', - lastSync: (relativeDate: string) => `Dernière synchronisation ${relativeDate}`, - syncError: (providerName: string) => `Impossible de se connecter à ${providerName}`, connectionDescription: (providerName: string) => `Connectez ${providerName} pour synchroniser les approbations des employés avec votre espace de travail.`, - approvalMode: "Mode d'approbation", providerApprovalMode: (providerName: string) => `Mode d'approbation ${providerName}`, - finalApprover: 'Approbateur final', providerFinalApprover: (providerName: string) => `Approbateur final ${providerName}`, - notSet: 'Non défini', + syncing: 'Synchronisation des employés', + syncingModalTitle: 'Votre connexion est en cours de synchronisation', + syncingModalDescription: 'La première connexion peut prendre un certain temps. Vous serez informé de toute erreur.', approvalModeDescription: (providerName: string) => `Les membres et les responsables sont configurés pour se synchroniser avec ${providerName}.`, approvalModeWarningTitle: 'Changer le mode d’approbation ?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Êtes-vous sûr·e de vouloir modifier le mode d’approbation de cet espace de travail ? En savoir plus sur les différents modes de workflow compatibles avec ${providerName} sur notre site d’aide.`, approvalModeWarningConfirm: 'Modifier le mode d’approbation', - approvalModes: { - basic: { - label: 'Approbation simple', - description: 'Tous les utilisateurs soumettent à une seule personne pour traitement et approbation.', - }, - manager: { - label: 'Approbation du responsable', - description: (providerName: string) => `Les employé·e·s soumettent leurs rapports à leur responsable direct configuré dans ${providerName}.`, - }, - custom: { - label: 'Approbation personnalisée', - description: 'Je configurerai manuellement les circuits de validation dans Expensify.', - }, + approvalModeDescriptions: { + basic: 'Tous les utilisateurs soumettent à une seule personne pour traitement et approbation.', + manager: (providerName: string) => `Les employé·e·s soumettent leurs rapports à leur responsable direct configuré dans ${providerName}.`, + custom: 'Je configurerai manuellement les circuits de validation dans Expensify.', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8515,28 +8641,66 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e zenefits: { title: 'TriNet', }, - syncingModalTitle: 'Votre connexion est en cours de synchronisation', - syncingModalDescription: 'La première connexion peut prendre un certain temps. Vous serez informé de toute erreur.', - syncing: 'Synchronisation des employés', + setupIncomplete: (setupLink: string | undefined) => + `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer les employés.`, mergeHR: { - completeSetup: 'Terminer la configuration', - setupIncomplete: (setupLink: string | undefined) => - `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer les employés.`, groups: {title: 'Groupes', description: 'Choisissez les groupes d’employés que vous souhaitez synchroniser avec cet espace de travail'}, }, + }, + recruiting: { + title: 'Recrutement', + connectionsSubtitle: + 'Connectez votre système de recrutement pour synchroniser les données des candidats, faire correspondre automatiquement les remboursements aux bonnes personnes et maintenir des dépenses d’équipe exactes sans travail manuel.', + alreadyConnectedTitle: 'Impossible de se connecter à plusieurs plateformes ATS', + alreadyConnectedPrompt: 'Vous devez déconnecter votre ATS actuel avant d’en connecter un autre.', + syncing: 'Synchronisation des candidats', + setupIncomplete: (setupLink: string | undefined) => + `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer des candidats.`, + 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'}, + }, + merge: { + connections: 'Connexions', + connect: 'Connecter', + findIntegration: 'Rechercher une intégration', + syncNow: 'Synchroniser maintenant', + disconnect: 'Déconnecter', + disconnectTitle: (providerName: string) => `Déconnecter ${providerName}`, + disconnectPrompt: (providerName: string) => `Êtes-vous sûr de vouloir déconnecter ${providerName} ?`, + lastSync: (relativeDate: string) => `Dernière synchronisation ${relativeDate}`, notSync: 'Non synchronisé', + syncError: (providerName: string) => `Impossible de se connecter à ${providerName}`, authenticationError: (providerName: string) => `Connexion à ${providerName} impossible en raison d'une connexion expirée.`, reconnect: 'Reconnect', reconnectLink: 'Reconnectez-vous.', - findIntegration: 'Rechercher une intégration', - }, - merge: { + notSet: 'Non défini', + completeSetup: 'Terminer la configuration', + approvalMode: "Mode d'approbation", + finalApprover: 'Approbateur final', + approvalModes: { + basic: 'Approbation simple', + manager: 'Approbation du responsable', + custom: 'Approbation personnalisée', + advanced: 'Approbation avancée', + }, syncLimitReached: {title: 'Réessayez demain', prompt: "Vous avez atteint votre limite de synchronisation pour aujourd'hui."}, }, emptyDomain: { title: 'Renforcez votre sécurité avec des domaines', subtitle: 'Obligez les membres de votre domaine à se connecter via l’authentification unique, restreignez la création d’espaces de travail, et plus encore.', }, + campfire: { + campfireSetup: 'Configuration de Campfire', + enterCredentials: 'Saisissez votre clé API Campfire', + howToFindAPIKey: + 'Recherche de votre clé API.
  1. Connectez-vous à Campfire
  2. Accédez à Paramètres -> Clés API
  3. Créez une clé API
  4. Collez la clé API ci-dessous
', + subsidiary: 'Filiale', + subsidiarySelectDescription: 'Choisissez la filiale dans Campfire à partir de laquelle vous souhaitez importer des données.', + noSubsidiariesFound: 'Aucune filiale trouvée', + noSubsidiariesFoundDescription: 'Veuillez ajouter une entité dans Campfire et synchroniser à nouveau la connexion', + }, }, getAssistancePage: { title: 'Obtenir de l’aide', @@ -9442,7 +9606,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Facturation de voyages consolidée', }, is: 'Est', - has: {submittedViolation: 'Infraction soumise'}, + has: {submittedViolation: 'Infraction soumise', approvedViolation: 'Violation approuvée'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Soumettre', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Approuver', @@ -9516,6 +9680,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e exportedTo: 'Exporté vers', exportAll: { selectAllMatchingItems: 'Sélectionnez tous les éléments correspondants', + allMatchingItemsSelected: 'Tous les éléments correspondants sont sélectionnés', selectAllOnThisPage: 'Tout sélectionner sur cette page', }, errors: { @@ -9541,6 +9706,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e topMerchants: 'Meilleurs commerçants', violationsBySubmitter: 'Infractions par déclarant', }, + mergeReports: { + title: 'Fusionner des notes de frais', + description: 'Sélectionnez la note de frais à conserver. Toutes les dépenses y seront déplacées et les autres notes de frais seront supprimées.', + }, }, genericErrorPage: { title: 'Oups, quelque chose s’est mal passé !', @@ -9624,6 +9793,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `exporté vers ${translatedLabel}`; @@ -9660,6 +9830,16 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Répété ${count} fois.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `La connexion ${feedName} est interrompue. Pour rétablir l’importation des cartes, connectez-vous à votre banque.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `La connexion ${feedName} est interrompue depuis 30 jours. Connectez-vous à votre banque pour la corriger ou supprimez la connexion si elle n’est plus utilisée. Vous ne perdrez aucune dépense soumise si vous la supprimez.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connexion Plaid à votre compte bancaire professionnel est rompue. Veuillez reconnecter votre compte bancaire ${maskedAccountNumber} afin de pouvoir continuer à utiliser vos Cartes Expensify.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10250,6 +10430,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Le taux n’est valable qu’à partir du ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Le taux n’est valable que jusqu’au ${endDate}`, cannotMergeDuplicates: 'Vous ne pouvez fusionner des dépenses que sur des notes de frais à l’état de brouillon ou en circulation. Retirez-la puis réessayez.', + overCategoryLimitPerNight: (formattedLimit: string) => `Tarif de nuit supérieur à la limite de catégorie de ${formattedLimit}/personne`, shortName: { allTagLevelsRequired: 'Tous les tags sont obligatoires', autoReportedRejectedExpense: 'Dépense rejetée', @@ -10866,6 +11047,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, reportLevelExport: 'Toutes les données - niveau note de frais', expenseLevelExport: 'Toutes les données - niveau dépense', multipleTaxExport: 'Export canadien à taxes multiples', + reconciliationAllExpenses: 'Rapprochement - Toutes les dépenses', exportInProgress: 'Export en cours', conciergeWillSend: 'Concierge vous enverra le fichier sous peu.', currentView: 'Vue actuelle', @@ -10897,6 +11079,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, notVerified: 'Non vérifié', retry: 'Réessayer', requestSent: 'Demande envoyée', + requestAccessError: "Nous n'avons pas pu envoyer votre demande. Veuillez réessayer.", verifyDomain: { title: 'Vérifier le domaine', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10957,13 +11140,12 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, setMetadataGenericError: 'Impossible de définir les métadonnées SAML', }, accessRestricted: { - title: 'Accès restreint', - subtitle: (domainName: string) => - `Veuillez vous vérifier en tant qu’administrateur d’entreprise autorisé pour ${domainName} si vous avez besoin de contrôle sur :`, - companyCardManagement: 'Gestion des cartes d’entreprise', - accountCreationAndDeletion: 'Création et suppression de compte', - workspaceCreation: 'Création d’espace de travail', - samlSSO: 'SSO SAML', + headerTitle: 'Accès restreint', + title: 'Vérification requise', + description: (domainName: string) => + `Veuillez vous vérifier en tant qu’administrateur d’entreprise autorisé pour ${domainName} ou demander l’accès aux administrateurs existants.`, + requestAdminAccess: 'Demander l’accès administrateur', + verifyYourself: 'Vérifiez-vous', }, addDomain: { title: 'Ajouter un domaine', @@ -10977,7 +11159,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, title: "Domaine déjà configuré. Demander l'accès ?", description: "Quelqu'un a déjà configuré ce domaine dans Expensify. Voulez-vous demander l'accès administrateur ?", requestAccess: "Demander l'accès administrateur", - requestAccessError: "Nous n'avons pas pu envoyer votre demande. Veuillez réessayer.", }, domainAdded: { title: 'Domaine ajouté', @@ -11002,6 +11183,9 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, consolidatedDomainBillingError: 'La facturation de domaine consolidée n’a pas pu être modifiée. Veuillez réessayer plus tard.', addAdmin: 'Ajouter un administrateur', addAdminError: 'Impossible d’ajouter ce membre en tant qu’administrateur. Veuillez réessayer.', + requests: 'Demandes', + approveRequestError: 'Impossible d’approuver cette demande. Veuillez réessayer.', + declineRequestError: 'Impossible de refuser cette demande. Veuillez réessayer.', revokeAdminAccess: 'Révoquer l’accès administrateur', cantRevokeAdminAccess: "Impossible de révoquer l'accès administrateur au contact technique", error: { diff --git a/src/languages/it.ts b/src/languages/it.ts index ad32b05e5507..99ae8352bfc0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: 'Rimuovi condivisione', yes: 'Sì', no: 'No', + approve: 'Approva', + deny: 'Nega', dontChange: 'Non modificare', ok: 'OK', notNow: 'Non ora', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: 'Cerca', reports: 'Report', spend: 'Spese', + insights: 'Analisi', find: 'Trova', searchWithThreeDots: 'Cerca...', next: 'Avanti', @@ -465,6 +468,8 @@ const translations: TranslationDeepObject = { none: 'Nessuno', unstableInternetConnection: 'Connessione Internet instabile. Controlla la rete e riprova.', enableGlobalReimbursements: 'Abilita rimborsi globali', + corpayPayModalTitle: 'Paga nota spese', + corpayPayModalPrompt: 'Questa persona ha un conto bancario non in USD. Abilita i rimborsi globali per pagare la nota spese, o chiedi di aggiungere un conto bancario in USD.', purchaseAmount: "Importo dell'acquisto", originalAmount: 'Importo originale', frequency: 'Frequenza', @@ -952,13 +957,21 @@ const translations: TranslationDeepObject = { tooltip: 'Inizia qui!', }, homePage: { - forYou: 'Per te', + toDos: 'Attività da fare', timeSensitiveSection: { title: 'Sensibile al tempo', - addShippingAddress: {title: 'Ci serve il tuo indirizzo di spedizione', subtitle: 'Fornisci un indirizzo per ricevere la tua Carta Expensify.', cta: 'Aggiungi indirizzo'}, + 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'}, activateCard: {title: 'Attiva la tua Carta Expensify', subtitle: 'Convalida la tua carta e inizia a spendere.', cta: 'Attiva'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `L’aggiunta della carta ${walletName} richiede la tua approvazione`, + subtitle: 'Carta Expensify', + cta: 'Rivedi', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Portafoglio digitale', + }, reviewCardFraud: { title: 'Esamina una possibile frode sulla tua Carta Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Verifica ${amount} di potenziale frode presso ${merchant}`, @@ -981,7 +994,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portafoglio', }, validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, - addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il tracciamento delle distanze', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, + addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il tracciamento delle distanze', subtitle: 'Account', cta: 'Aggiungi'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', @@ -989,11 +1002,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'Portafoglio', }, - addVirtualCardPersonalDetails: { - title: 'Aggiungi i tuoi dati per visualizzare e iniziare a usare la tua Carta Expensify.', - subtitle: 'Carta Expensify', - cta: 'Aggiungi dettagli', - }, + addVirtualCardPersonalDetails: {title: 'Aggiungi i tuoi dati per visualizzare e iniziare a usare la tua Carta Expensify.', subtitle: 'Carta Expensify', cta: 'Aggiungi'}, enterSignerInfo: {title: 'Informazioni del firmatario necessarie', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Conto bancario ${bankAccountLastFour}`}, }, discoverSection: { @@ -1096,6 +1105,8 @@ const translations: TranslationDeepObject = { inviteAccountant: 'Invita il tuo commercialista', customizeSpendCategories: 'Personalizza le categorie di spesa', customizeSpendCategoriesSubText: 'Organizza e classifica le spese', + customizeExpenseCategories: 'Personalizza le tue categorie di spesa', + customizeExpenseCategoriesSubText: 'Aggiungi le categorie della tua azienda per codificare le tue spese', createExpense: 'Crea una spesa', createExpenseSubText: 'Scansiona, trascina e rilascia oppure inserisci manualmente una spesa usando il pulsante +', linkPersonalCard: 'Collega carta personale', @@ -1121,7 +1132,6 @@ const translations: TranslationDeepObject = { title: 'Le tue spese', awaitingApproval: 'In attesa di approvazione', repaidLast30Days: 'Rimborsato ultimi 30 giorni', - recentTransactions: ({lastFour}: {lastFour: string}) => `Transazioni recenti • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `Vedi altri ${count}`, recentlyAddedSection: { @@ -1275,6 +1285,7 @@ const translations: TranslationDeepObject = { phrase1: 'Aggiungi una ricevuta', phrase2: 'o trascinalo qui', }, + pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 di ${pageCount}`, }, quickAction: { scanReceipt: 'Scansiona ricevuta', @@ -1664,11 +1675,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Tutto a posto!', noDuplicatesDescription: 'Non ci sono transazioni duplicate da verificare qui.', confirmApprove: 'Conferma l’importo approvato', - confirmApprovalAmount: 'Approva solo le spese conformi oppure approva l’intero rapporto.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Questa spesa è in sospeso. Vuoi approvarla comunque?', - other: 'Queste note spese sono in sospeso. Vuoi approvarle comunque?', - }), + confirmApprovalWithHeldAmount: "Il rapporto contiene spese in sospeso. Approvare solo le spese conformi o approvare l'intero rapporto?", + confirmApprovalAllHoldAmount: 'Tutte le spese sono in sospeso. Approvare comunque?', confirmPay: 'Conferma l’importo del pagamento', confirmPayAmount: 'Paga le voci non in sospeso oppure paga l’intero resoconto.', confirmPayAllHoldAmount: () => ({ @@ -2298,6 +2306,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Nomi degli intervalli evidenziati', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click, navigazione, ui.load', showBranchNameInTitle: 'Mostra nome del ramo nel titolo del browser', + betaOverrides: 'Sostituzioni delle beta', + betaOverridesDescription: + 'Le sostituzioni si applicano solo a questo dispositivo e influiscono solo sui controlli del frontend. Una beta mantiene una sostituzione solo finché differisce dalle beta del tuo account, quindi riportandola indietro la sostituzione viene rimossa. «Reimposta tutte le sostituzioni» ripristina i valori del tuo account. Alcune beta sono controllate anche dal backend, quindi possono comunque fallire a livello di richiesta.', + resetAllOverrides: 'Reimposta tutte le sostituzioni', + overridden: 'Sostituita', qaAuth: 'Autenticazione QA (Cloudflare)', qaAuthRunProbe: 'Esegui probe', qaAuthSession: 'Sessione di autenticazione QA', @@ -2609,6 +2622,10 @@ const translations: TranslationDeepObject = { connectionLink ? `La connessione della tua carta ${cardName} non funziona. Accedi alla tua banca per sistemare la carta.` : `La connessione della tua carta ${cardName} non funziona. Accedi alla tua banca per sistemare la carta.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `La connessione della tua ${cardName} è interrotta da 30 giorni. Accedi alla tua banca per sistemarla oppure rimuovi la carta se non la usi più. Non perderai le spese già inviate se la rimuovi.` + : `La connessione della tua ${cardName} è interrotta da 30 giorni. Accedi alla tua banca per sistemarla oppure rimuovi la carta se non la usi più. Non perderai nessuna spesa inviata se la rimuovi.`, addAdditionalCards: 'Aggiungi altre carte', upgradeDescription: 'Devi aggiungere altre carte? Crea uno spazio di lavoro per aggiungere carte personali o assegnare carte aziendali a tutto il team.', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => `Disponibile nel piano Collect, ${formattedPrice} per membro al mese.`, @@ -2707,6 +2724,24 @@ const translations: TranslationDeepObject = { unlock: 'Sblocca', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Aggiungi carta a ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'portafoglio digitale', + confirmHeading: 'Conferma la tua richiesta', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Vuoi aggiungere la tua Carta Expensify (che termina con ${lastFourDigits}) al tuo ${walletName}?`, + deny: 'Rifiuta', + confirm: 'Conferma', + verifyTitle: 'Verifichiamo che sia tu', + enterSecurityCode: (contactMethod: string) => + `Inserisci il codice di sicurezza inviato a ${contactMethod} per confermare questa richiesta. Dovrebbe arrivare entro uno o due minuti.`, + successHeading: 'Operazione riuscita!', + successDescription: ({walletName}: {walletName: string}) => `La tua carta è ora attiva per l’uso nel tuo ${walletName}.`, + deniedHeading: 'Richiesta rifiutata', + deniedDescription: ({walletName}: {walletName: string}) => `La tua carta non è stata aggiunta al tuo ${walletName}.`, + }, cardPage: { expensifyCard: 'Carta Expensify', expensifyTravelCard: 'Carta Viaggio Expensify', @@ -3156,6 +3191,7 @@ ${amount} per ${merchant} - ${date}`, unread: 'Non letti', markAllAsRead: 'Segna tutto come letto', markAllAsReadConfirmationPrompt: 'Vuoi davvero segnare tutte le chat come lette?', + markAllTodosAsReadConfirmationPrompt: 'Vuoi davvero segnare come lette tutte le chat da gestire?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3334,6 +3370,7 @@ ${amount} per ${merchant} - ${date}`, `Non è stato possibile aggiungere ${workEmail}. Riprova più tardi in Impostazioni oppure chatta con Concierge per ricevere assistenza.`, workAccountClosedSubtitle: 'L’account di lavoro associato a questa email è chiuso. Contatta l’amministratore della tua azienda per riattivarlo oppure registrati con un’altra email.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} è un accesso controllato dal dominio per un account Expensify esistente.`, }, tasks: { testDriveAdminTask: { @@ -4164,8 +4201,7 @@ ${amount} per ${merchant} - ${date}`, legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', enterTheDateOfBirthOfTheOwner: 'Qual è la data di nascita del titolare?', - enterTheLast4: 'Quali sono le ultime 4 cifre del codice fiscale del titolare?', - last4SSN: 'Ultime 4 cifre del SSN', + enterTheSSN: 'Qual è il numero di Previdenza Sociale del titolare?', dontWorry: 'Non preoccuparti, non effettuiamo alcun controllo del credito personale!', enterTheOwnersAddress: "Qual è l'indirizzo del proprietario?", letsDoubleCheck: 'Controlliamo che sia tutto a posto.', @@ -4192,13 +4228,12 @@ ${amount} per ${merchant} - ${date}`, whatsYourDOB: 'Qual è la tua data di nascita?', whatsTheOwnersAddress: "Qual è l'indirizzo del proprietario?", whatsYourAddress: 'Qual è il tuo indirizzo?', - whatAreTheLast: 'Quali sono le ultime 4 cifre del numero di previdenza sociale del titolare?', - whatsYourLast: 'Quali sono le ultime 4 cifre del tuo numero di previdenza sociale?', + whatsTheOwnersSSN: 'Qual è il numero di Previdenza Sociale del titolare?', + whatsYourSSN: 'Qual è il tuo numero di previdenza sociale?', whatsYourNationality: 'Qual è il tuo Paese di cittadinanza?', whatsTheOwnersNationality: 'Qual è il paese di cittadinanza del titolare?', countryOfCitizenship: 'Paese di cittadinanza', dontWorry: 'Non preoccuparti, non effettuiamo alcun controllo del credito personale!', - last4: 'Ultime 4 cifre del SSN', whyDoWeAsk: 'Perché lo chiediamo?', letsDoubleCheck: 'Controlliamo che sia tutto a posto.', legalName: 'Nome legale', @@ -4221,6 +4256,8 @@ ${amount} per ${merchant} - ${date}`, proofOfBeneficialOwner: 'Prova del titolare effettivo', proofOfBeneficialOwnerDescription: 'Fornisci un’attestazione firmata e un organigramma da parte di un commercialista, notaio o avvocato che verifichi la titolarità del 25% o più dell’azienda. Il documento deve essere datato negli ultimi tre mesi e includere il numero di licenza del firmatario.', + proofOfBeneficialOwnerDescriptionCA: + 'Fornisci il tuo modulo Schedule 50 (Informazioni sugli azionisti) dalla più recente dichiarazione T2 dell’imposta sul reddito societario, che mostri ogni persona fisica che possiede il 25% o più dell’attività.', copyOfID: 'Copia del documento d’identità del titolare effettivo', copyOfIDDescription: 'Esempi: passaporto, patente di guida, ecc.', proofOfAddress: 'Prova di indirizzo per il titolare effettivo', @@ -4598,6 +4635,7 @@ ${amount} per ${merchant} - ${date}`, customFieldHint: 'Aggiungi una codifica personalizzata che si applichi a tutte le spese di questo membro.', reports: 'Report', reportFields: 'Campi del report', + invoiceFields: 'Campi fattura', reportTitle: 'Titolo del report', reportField: 'Campo report', taxes: 'Tasse', @@ -4607,6 +4645,7 @@ ${amount} per ${merchant} - ${date}`, travel: 'Viaggi', members: 'Membri', accounting: 'Contabilità', + mcp: 'MCP', receiptPartners: 'Partner ricevute', rules: 'Regole', displayedAs: 'Visualizzato come', @@ -4730,6 +4769,7 @@ ${amount} per ${merchant} - ${date}`, travelInvoicingVendor: 'Fornitore di viaggi', travelInvoicingPayableAccount: 'Conto debiti per viaggi', hr: 'Risorse umane', + recruiting: 'Assunzioni', rooms: 'Stanze', findDomain: 'Trova dominio', cardAdminAlternateText: 'Gestisci le carte dello spazio di lavoro.', @@ -4743,6 +4783,25 @@ ${amount} per ${merchant} - ${date}`, title: 'Hai creato uno spazio di lavoro per il tuo cliente!', description: 'Ottime notizie 🎉. Contattaci se hanno bisogno di aiuto con la configurazione.', }, + mcp: { + connectors: 'Connettori', + connectorsSubtitle: 'Collega un assistente IA al tuo account Expensify.', + connect: 'Connetti', + helpPrompt: 'Hai bisogno di aiuto con la connessione?', + helpLink: 'Leggi la nostra guida.', + claude: { + title: 'Claude', + subtitle: 'di Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'di Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'di OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -4962,7 +5021,6 @@ ${amount} per ${merchant} - ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Usa questo conto quando esporti le fatture in ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Imposta come esportare gli acquisti con carta aziendale in ${integrationName}.`, vendor: 'Fornitore', - defaultVendorDescription: 'Imposta un fornitore predefinito che verrà applicato a tutte le transazioni con carta di credito al momento dell’esportazione.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Imposta come esportare le spese anticipate in ${integrationName}.`, exportCheckDescription: 'Creeremo un assegno dettagliato per ogni report di Expensify e lo invieremo dal conto bancario qui sotto.', exportJournalEntryDescription: 'Creeremo una registrazione contabile dettagliata per ogni report di Expensify e la registreremo sul conto qui sotto.', @@ -5127,8 +5185,6 @@ ${amount} per ${merchant} - ${date}`, }, noAccountsFound: 'Nessun account trovato', noAccountsFoundDescription: 'Aggiungi l’account in Xero e sincronizza nuovamente la connessione', - defaultSupplier: 'Fornitore predefinito', - defaultSupplierDescription: 'Imposta un fornitore predefinito che verrà applicato a tutte le transazioni con carta di credito al momento dell’esportazione.', noSuppliersFound: 'Nessun fornitore trovato', noSuppliersFoundDescription: 'Aggiungi il fornitore in Xero e sincronizza di nuovo la connessione.', accountingMethods: { @@ -6357,6 +6413,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. 'Non siamo riusciti a effettuare il provisioning di alcuni membri del tuo workspace per la Fatturazione Viaggi Consolidata. Riprova più tardi oppure contatta Concierge per assistenza.', sendInvoiceNowCta: 'Invia fattura ora', }, + setUpNewFeed: 'Configura un nuovo feed di viaggio', + feedSelectorIntro: + 'La tua organizzazione ha già configurato un feed di fatturazione viaggio consolidata. Selezionalo per mantenere le spese di viaggio di questo spazio di lavoro nello stesso conto mensile.', }, disableModal: { title: 'Disattivare la fatturazione di viaggio consolidata?', @@ -6515,6 +6574,10 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. title: 'Contabilità', subtitle: 'Sincronizza il tuo piano dei conti e altro ancora.', }, + mcp: { + title: 'MCP', + subtitle: 'Collega un assistente IA al tuo account Expensify.', + }, receiptPartners: { title: 'Partner ricevute', subtitle: 'Importa automaticamente le ricevute.', @@ -6578,6 +6641,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. subtitle: 'I campi del report si applicano a tutte le spese e possono essere utili quando desideri richiedere informazioni aggiuntive.', disableReportFields: 'Disattiva campi del report', disableReportFieldsConfirmation: 'Sei sicuro? I campi di testo e data verranno eliminati e gli elenchi verranno disattivati.', + cannotDisableImportedReportFields: 'I campi del report importati dalla tua connessione contabile non possono essere disattivati.', importedFromAccountingSoftware: 'I campi del report riportati di seguito sono importati dal tuo', textType: 'Testo', dateType: 'Data', @@ -6614,6 +6678,29 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. reportFieldInitialValueRequiredError: 'Scegli un valore iniziale per il campo del resoconto', genericFailureMessage: 'Si è verificato un errore durante l’aggiornamento del campo del report. Riprova.', }, + invoiceFields: { + subtitle: 'I campi della fattura possono essere utili quando vuoi includere informazioni aggiuntive.', + importedFromAccountingSoftware: 'I campi della fattura riportati di seguito sono importati dal tuo', + disableInvoiceFields: 'Disattiva i campi della fattura', + disableInvoiceFieldsConfirmation: 'Sei sicuro? I campi della fattura verranno disattivati nelle fatture.', + delete: 'Elimina campo della fattura', + deleteConfirmation: 'Sei sicuro di voler eliminare questo campo della fattura?', + findInvoiceField: 'Trova campo della fattura', + nameInputSubtitle: 'Scegli un nome per il campo della fattura.', + typeInputSubtitle: 'Scegli il tipo di campo della fattura da utilizzare.', + initialValueInputSubtitle: 'Inserisci un valore iniziale da mostrare nel campo della fattura.', + listValuesInputSubtitle: 'Questi valori appariranno nel menu a discesa del campo della fattura. I valori abilitati possono essere selezionati dai membri.', + listInputSubtitle: 'Questi valori appariranno nell’elenco del campo della fattura. I valori abilitati possono essere selezionati dai membri.', + emptyInvoiceFieldsValues: { + title: 'Nessun valore elenco ancora', + subtitle: 'Aggiungi valori personalizzati da mostrare sulle fatture.', + }, + existingInvoiceFieldNameError: 'Esiste già un campo della fattura con questo nome', + invoiceFieldNameRequiredError: 'Inserisci un nome per il campo della fattura', + invoiceFieldTypeRequiredError: 'Scegli un tipo di campo della fattura', + invoiceFieldInitialValueRequiredError: 'Scegli un valore iniziale per il campo della fattura', + addField: 'Aggiungi campo', + }, vendors: { emptyTitle: 'Nessun fornitore ancora', emptySubtitle: 'I fornitori verranno visualizzati qui al termine della sincronizzazione contabile.', @@ -7007,6 +7094,8 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7078,16 +7167,16 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, return 'Importazione conti'; case 'quickbooksOnlineImportClasses': case 'quickbooksDesktopImportClasses': - return 'Importazione di classi'; + return 'Importazione delle classi'; case 'quickbooksOnlineImportLocations': - return 'Importazione sedi'; + return 'Importazione delle sedi'; case 'quickbooksOnlineImportProcessing': return 'Elaborazione dei dati importati'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': return 'Sincronizzazione dei report rimborsati e dei pagamenti delle fatture'; case 'quickbooksOnlineSyncTaxCodes': - return 'Importazione dei codici IVA'; + return 'Importazione dei codici fiscali'; case 'quickbooksOnlineCheckConnection': return `Verifica della connessione ${integrationName}`; case 'quickbooksOnlineImportMain': @@ -7100,15 +7189,15 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'quickbooksDesktopImportMore': return 'Importazione dei dati di QuickBooks Desktop'; case 'quickbooksDesktopImportTitle': - return 'Titolo importazione'; + return "Titolo dell'importazione"; case 'quickbooksDesktopImportApproveCertificate': return 'Importazione del certificato di approvazione'; case 'quickbooksDesktopImportDimensions': return 'Importazione dimensioni'; case 'quickbooksDesktopImportSavePolicy': - return 'Importazione della regola di risparmio'; + return 'Importazione della politica di salvataggio'; case 'quickbooksDesktopWebConnectorReminder': - return 'Sincronizzazione dei dati con QuickBooks in corso... Assicurati che il Web Connector sia in esecuzione'; + return 'Sincronizzazione dei dati con QuickBooks ancora in corso... Assicurati che il Web Connector sia in esecuzione'; case 'quickbooksOnlineSyncTitle': return `Sincronizzazione dei dati di ${integrationName}`; case 'quickbooksOnlineSyncLoadData': @@ -7126,17 +7215,17 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'jobDone': return 'In attesa del caricamento dei dati importati'; case 'xeroSyncImportChartOfAccounts': - return 'Sincronizzazione del piano dei conti'; + return 'Sincronizzazione piano dei conti'; case 'xeroSyncImportCategories': return 'Sincronizzazione categorie'; case 'xeroSyncImportCustomers': return 'Sincronizzazione clienti'; case 'xeroSyncXeroReimbursedReports': - return 'Impostare i report Expensify come rimborsati'; + return 'Contrassegnare i report di Expensify come rimborsati'; case 'xeroSyncExpensifyReimbursedReports': return 'Contrassegnare le fatture e le note di credito Xero come pagate'; case 'xeroSyncImportTrackingCategories': - return 'Sincronizzazione delle categorie di monitoraggio'; + return 'Sincronizzazione delle categorie di tracciamento'; case 'xeroSyncImportBankAccounts': return 'Sincronizzazione dei conti bancari'; case 'xeroSyncImportTaxRates': @@ -7152,13 +7241,13 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'netSuiteSyncInitData': return 'Recupero dei dati da NetSuite'; case 'netSuiteSyncImportTaxes': - return 'Importazione imposte'; + return 'Importazione delle imposte'; case 'netSuiteSyncImportItems': return 'Importazione articoli'; case 'netSuiteSyncData': - return 'Importazione di dati in Expensify'; + return 'Importazione dei dati in Expensify'; case 'netSuiteSyncAccounts': - return 'Sincronizzazione account'; + return 'Sincronizzazione degli account'; case 'netSuiteSyncCurrencies': return 'Sincronizzazione valute'; case 'netSuiteSyncCategories': @@ -7166,11 +7255,11 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'netSuiteSyncReportFields': return 'Importazione dei dati come campi del report Expensify'; case 'netSuiteSyncTags': - return 'Importazione dei dati come tag Expensify'; + return 'Importazione dei dati come tag di Expensify'; case 'netSuiteSyncUpdateConnectionData': return 'Aggiornamento delle informazioni di connessione'; case 'netSuiteSyncNetSuiteReimbursedReports': - return 'Impostare i report Expensify come rimborsati'; + return 'Contrassegnare i report di Expensify come rimborsati'; case 'netSuiteSyncExpensifyReimbursedReports': return 'Contrassegnare le fatture e le note di addebito NetSuite come pagate'; case 'netSuiteImportVendorsTitle': @@ -7180,7 +7269,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'netSuiteSyncImportCustomLists': return 'Importazione di elenchi personalizzati'; case 'netSuiteSyncImportSubsidiaries': - return 'Importazione filiali'; + return 'Importazione controllate'; case 'netSuiteSyncImportVendors': case 'quickbooksDesktopImportVendors': return 'Importazione fornitori'; @@ -7189,11 +7278,11 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'intacctImportDimensions': return 'Importazione delle dimensioni Sage Intacct'; case 'intacctImportTitle': - return 'Importazione dei dati da Sage Intacct'; + return 'Importazione dei dati Sage Intacct'; case 'financialForceSyncTitle': return 'Sincronizzazione dei dati Certinia'; case 'financialForceSyncStep': - return 'Sincronizzazione connessione Certinia'; + return 'Sincronizzazione della connessione Certinia'; case 'financialForceSyncCategories': return 'Importazione categorie'; case 'financialForceSyncTags': @@ -7211,7 +7300,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'financialForceMarkAsReimbursed': return 'Contrassegnare i report come rimborsati'; case 'rilletSyncTitle': - return 'Sincronizzazione dati Rillet'; + return 'Sincronizzazione dei dati Rillet'; case 'rilletSyncConnection': return 'Inizializzazione della connessione a Rillet'; case 'rilletSyncImportData': @@ -7225,11 +7314,23 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, case 'dualEntrySyncPayments': return 'Sincronizzazione dei pagamenti ai fornitori'; case 'dualEntrySyncCardSettlements': - return 'Sincronizzazione delle compensazioni della carta'; + return 'Sincronizzazione delle chiusure carta'; case 'dualEntrySyncTravelSettlements': return 'Sincronizzazione dei conguagli di viaggio'; + case 'campfireSyncTitle': + return 'Sincronizzazione dei dati Campfire'; + case 'campfireSyncConnection': + return 'Inizializzazione della connessione a Campfire'; + case 'campfireSyncImportData': + return 'Caricamento dei dati'; + case 'campfireSyncPayments': + return 'Sincronizzazione dei pagamenti ai fornitori'; + case 'campfireSyncCardSettlements': + return 'Sincronizzazione delle chiusure carta'; + case 'campfireSyncTravelSettlements': + return 'Sincronizzazione dei conguagli di viaggio'; default: { - return `Traduzione mancante per la fase: ${stage}`; + return `Traduzione mancante per fase: ${stage}`; } } }, @@ -7243,11 +7344,11 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, exportCompanyCard: 'Esporta le spese con carta aziendale come', exportDate: 'Data di esportazione', defaultVendor: 'Fornitore predefinito', - defaultVendorHelperText: (isSet: boolean) => - isSet - ? `Le spese che non vengono abbinate automaticamente verranno assegnate per impostazione predefinita a questo fornitore.` - : `Le spese che non vengono abbinate automaticamente useranno questo fornitore per impostazione predefinita. In caso contrario, saranno esportate come Varie carta di credito.`, - defaultVendorSelectHeader: (connectionName: string) => `Scegli un fornitore predefinito ${connectionName} per le spese che non vengono abbinate automaticamente.`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName + ? `Le spese che non vengono abbinate automaticamente verranno associate per impostazione predefinita a questo fornitore.` + : `Le spese che non vengono abbinate automaticamente avranno questo fornitore predefinito. In caso contrario, verranno esportate come ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Scegli un fornitore predefinito per le spese che non vengono abbinate automaticamente.`, defaultAccount: 'Conto predefinito', autoSync: 'Sincronizzazione automatica', autoSyncDescription: 'Sincronizza automaticamente NetSuite ed Expensify ogni giorno. Esporta i report finalizzati in tempo reale', @@ -7270,6 +7371,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, syncTravelInvoicingSettlements: 'Sincronizza i regolamenti di fatturazione viaggio consolidata', syncTravelInvoicingSettlementsNoAccountTooltip: 'Per sbloccare, imposta un conto per le tue esportazioni.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Per sbloccare, abilita la sincronizzazione automatica.', + campfire: 'Campfire', }, export: { notReadyHeading: 'Non pronto per l’esportazione', @@ -7324,14 +7426,14 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, title: 'Escludi spostamenti casa-lavoro', summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, - optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', + optionDisabledTitle: 'Non escludere il tragitto per il lavoro', optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle richieste.', - optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', - optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', + optionFixedDistanceTitle: 'Rimuovi distanza fissa', + optionFixedDistanceHelp: 'Sottrae la stessa distanza da ogni richiesta.', distanceLabel: 'Distanza', summaryHomeAndOffice: 'Usa gli indirizzi di casa e ufficio', - optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', - optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa‑lavoro.', + optionHomeAndOfficeTitle: 'Escludi tragitto abituale', + optionHomeAndOfficeHelp: 'Sottrae il tragitto casa-lavoro abituale di ogni membro in base alla sua casa, alla modalità di lavoro e all’ufficio.', workspaceAddressRequired: { title: 'Non così in fretta...', promptStart: 'Non puoi abilitare l’impostazione di calcolo per casa e ufficio finché non aggiungi prima una sede dell’ufficio in', @@ -7529,6 +7631,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `I campi del report sono disponibili solo con il piano Control, a partire da ${formattedPrice} ${hasTeam2025Pricing ? `per utente al mese.` : `per membro attivo al mese.`}`, }, + invoiceFields: { + title: 'Campi fattura', + description: `I campi fattura ti consentono di includere dettagli aggiuntivi a livello di fattura.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `I campi fattura sono disponibili solo con il piano Control, a partire da ${formattedPrice} ${hasTeam2025Pricing ? `per utente al mese.` : `per membro attivo al mese.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Approfitta della sincronizzazione automatica e riduci le registrazioni manuali con l’integrazione Expensify + NetSuite. Ottieni approfondimenti finanziari dettagliati e in tempo reale grazie al supporto di segmenti nativi e personalizzati, inclusa la mappatura di progetti e clienti.`, @@ -7571,6 +7679,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `La nostra integrazione con DualEntry è disponibile solo con il piano Control, a partire da ${formattedPrice} ${hasTeam2025Pricing ? `per utente al mese.` : `per membro attivo al mese.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Approfitta della sincronizzazione automatizzata e riduci le registrazioni manuali con l’integrazione Expensify + Campfire. Allinea dimensioni di codifica delle spese e sincronizzazione fiscale alla tua configurazione Campfire per una maggiore visibilità finanziaria.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `La nostra integrazione con Campfire è disponibile solo con il piano Control, a partire da ${formattedPrice} ${hasTeam2025Pricing ? `per utente al mese.` : `per membro attivo al mese.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Approvazioni avanzate', description: `Se desideri aggiungere altri livelli di approvazione al processo – o semplicemente assicurarti che le spese più elevate ricevano un ulteriore controllo – ci pensiamo noi. Le approvazioni avanzate ti aiutano a impostare i controlli giusti a ogni livello, così mantieni la spesa del tuo team sotto controllo.`, @@ -7865,7 +7979,6 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo alwaysReimbursableDescription: 'Le spese sono sempre rimborsate ai dipendenti', alwaysNonReimbursable: 'Sempre non rimborsabile', alwaysNonReimbursableDescription: 'Le spese non vengono mai rimborsate ai dipendenti', - billableDefault: 'Fatturabile predefinito', billableDefaultDescription: 'Scegli se le spese in contanti e con carta di credito devono essere fatturabili per impostazione predefinita.', billable: 'Fatturabile', billableDescription: 'Le spese sono più spesso rifatturate ai clienti', @@ -7892,10 +8005,12 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo publicReceiptVisibilityHintDisabled: 'Le ricevute possono essere visualizzate solo dai membri di Expensify che hanno accesso al report che contiene la ricevuta.', enableTagsToUnlockTitle: 'Abilitare i tag?', enableTagsToUnlockPrompt: 'Attiva i Tag (in Altre funzionalità) per sbloccare.', - enableTagsAndRequirePrompt: 'Sei sicuro di voler abilitare le etichette e renderle obbligatorie per tutte le spese?', enableCategoriesToUnlockTitle: 'Abilitare le categorie?', enableCategoriesToUnlockPrompt: 'Attiva Categorie (in Altre funzionalità) per sbloccare.', enableCategoriesAndRequirePrompt: 'Sei sicuro di voler abilitare le categorie e renderle obbligatorie per tutte le spese?', + enableTagsPrompt: 'Sei sicuro di voler abilitare i tag? Potrai renderli obbligatori per tutte le spese non appena avrai almeno un tag.', + noTagsToRequirePrompt: 'Non hai ancora nessun tag. Crea un tag.', + noCategoriesToRequirePrompt: 'Non hai ancora nessuna categoria. Crea una categoria.', }, expenseReportRules: { title: 'Avanzate', @@ -7969,6 +8084,21 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo 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', + confirmErrorConditionAndDefault: 'Inserisci un esercente e applica almeno un valore predefinito', + turnOnTaxesFirstTitle: 'Attiva prima le tasse', + turnOnTaxesFirstPrompt: 'Le regole di categoria impostano un’aliquota fiscale predefinita. Attiva le imposte nelle impostazioni dello spazio di lavoro per usarle.', + categoryRulesApplyGoingForwardTitle: 'Le regole di categoria si applicano da ora in poi', + categoryRulesApplyGoingForwardPrompt: 'Un’aliquota fiscale predefinita viene applicata alle nuove spese in questa categoria. Le spese già esistenti non verranno modificate.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'L’aliquota fiscale selezionata è ora quella predefinita del tuo workspace, quindi questa regola non è più valida. Scegli un’altra aliquota fiscale.', + addTaxRateFirstTitle: "Aggiungi prima un'aliquota fiscale", + addTaxRateFirstPrompt: + 'Le regole di categoria impostano un’aliquota fiscale predefinita. Aggiungi un’aliquota diversa da quella predefinita dello spazio di lavoro per poterle usare.', + createRuleFromExpenseAction: 'Crea una regola', + createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, categoryRules: { title: 'Regole di categoria', @@ -7990,7 +8120,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo expenseLimitTypes: { expense: 'Spesa individuale', expenseSubtitle: - 'Contrassegna gli importi delle spese per categoria. Questa regola sostituisce la regola generale dello spazio di lavoro per l’importo massimo della spesa.', + 'Evidenzia gli importi delle spese per categoria. Questa regola sostituisce la regola generale dello spazio di lavoro per l’importo massimo della spesa. Le prenotazioni di più giorni vengono valutate usando la media per notte.', daily: 'Totale categoria', dailySubtitle: 'Contrassegna la spesa giornaliera totale per categoria per ogni report spese.', }, @@ -8269,10 +8399,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, applyExpenseDefaultsDescription: 'Aggiorna i campi senza che chi invia debba fare nulla', flagForReview: 'Contrassegna per revisione', flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', - requireFields: 'Requisiti del campo', + requireFields: 'Imposta i requisiti del campo', requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', - createAgentRule: 'Regola agente', - createAgentRuleDescription: 'Descrivi regole flessibili che vengono eseguite quando ne hai bisogno.', + createAgentRule: 'Descrivi regola agente', + createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', }, expenseDefaultsTable: { tableColumnType: 'Tipo', @@ -8283,6 +8413,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, update: 'Aggiorna', merchantIs: (merchant: string) => `L'esercente è "${merchant}"`, merchantTypeIs: (merchantType: string) => `Il tipo di esercente è "${merchantType}"`, + categoryIs: (category: string) => `La categoria è "${category}"`, }, merchantTypeRule: {merchantType: 'Tipo di esercente', saveRule: 'Salva regola', confirmErrorCategory: 'Seleziona una categoria.'}, requireFieldsTable: { @@ -8299,6 +8430,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, requireReceipt: 'Richiedi ricevuta', doNotRequireReceipt: 'Non richiedere ricevuta', doNotRequireItemizedReceipt: 'Non richiedere ricevuta dettagliata', + typeLabel: 'Richiedi', }, requireFieldsEmptyState: { title: 'Individua subito i dettagli mancanti', @@ -8347,6 +8479,15 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, agentRulesEmptyState: {title: 'Nessuna regola agente aggiunta', subtitle: 'Crea una regola per automatizzare le policy del tuo workspace.', cta: 'Aggiungi regola IA'}, categoriesDisabledEmptyState: {title: 'Le categorie non sono abilitate', subtitle: 'Attiva le categorie per avere un maggiore controllo sulle tue spese.'}, + expenseDefaultType: { + title: 'Applica impostazioni predefinite spesa', + subtitle: 'Su cosa dovrebbe corrispondere questa regola?', + merchant: 'Commerciante', + merchantDescription: 'Aggiorna i campi delle spese di un determinato esercente', + category: 'Categoria', + categoryDescription: "Imposta un'aliquota fiscale predefinita per una categoria specifica", + }, + taxesDisabledEmptyState: {title: 'Le imposte non sono abilitate', subtitle: 'Abilita le imposte per documentare e recuperare quelle ammissibili.', cta: 'Abilita imposte'}, }, planTypePage: { planTypes: { @@ -8373,37 +8514,26 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, hr: { title: 'Risorse umane', - connections: 'Connessioni', connectionsSubtitle: 'Collega il tuo sistema HR per sincronizzare i dati dei dipendenti, abbinare automaticamente i rimborsi alle persone giuste e mantenere le spese del tuo team accurate senza lavoro manuale.', subtitle: 'Collega gli strumenti HR e mantieni sincronizzate le approvazioni dei dipendenti.', - connect: 'Connetti', - syncNow: 'Sincronizza ora', - disconnect: 'Disconnetti', - disconnectTitle: (providerName: string) => `Disconnetti ${providerName}`, - disconnectPrompt: (providerName: string) => `Sei sicuro di voler disconnettere ${providerName}?`, alreadyConnectedTitle: 'Impossibile connettersi a più piattaforme HR', alreadyConnectedPrompt: 'Devi disconnettere la tua attuale piattaforma HR prima di collegarne un’altra.', - lastSync: (relativeDate: string) => `Ultima sincronizzazione ${relativeDate}`, - syncError: (providerName: string) => `Impossibile connettersi a ${providerName}`, connectionDescription: (providerName: string) => `Collega ${providerName} per mantenere sincronizzate le approvazioni dei dipendenti con il tuo spazio di lavoro.`, - approvalMode: 'Modalità di approvazione', providerApprovalMode: (providerName: string) => `Modalità di approvazione ${providerName}`, - finalApprover: 'Approvatore finale', providerFinalApprover: (providerName: string) => `Approvatore finale ${providerName}`, - notSet: 'Non impostato', + syncing: 'Sincronizzazione dipendenti', + syncingModalTitle: 'La tua connessione è in sincronizzazione', + syncingModalDescription: "La prima connessione può richiedere un po' di tempo. Ti verrà notificato qualsiasi errore.", approvalModeDescription: (providerName: string) => `I membri e i responsabili sono configurati per la sincronizzazione con ${providerName}.`, approvalModeWarningTitle: 'Cambiare modalità di approvazione?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Sei sicuro di voler cambiare la modalità di approvazione per questo spazio di lavoro? Scopri di più sulle diverse modalità di flusso di lavoro abilitate per ${providerName} nel nostro sito di assistenza.`, approvalModeWarningConfirm: 'Cambia modalità di approvazione', - approvalModes: { - basic: {label: 'Approvazione di base', description: 'Tutti gli utenti inviano a una singola persona per l’elaborazione e l’approvazione.'}, - manager: { - label: 'Approvazione del responsabile', - description: (providerName: string) => `I dipendenti inviano i report al proprio responsabile diretto configurato in ${providerName}.`, - }, - custom: {label: 'Approvazione personalizzata', description: 'Imposterò manualmente i flussi di approvazione in Expensify.'}, + approvalModeDescriptions: { + basic: 'Tutti gli utenti inviano a una singola persona per l’elaborazione e l’approvazione.', + manager: (providerName: string) => `I dipendenti inviano i report al proprio responsabile diretto configurato in ${providerName}.`, + custom: 'Imposterò manualmente i flussi di approvazione in Expensify.', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8443,28 +8573,66 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, zenefits: { title: 'TriNet', }, - syncingModalTitle: 'La tua connessione è in sincronizzazione', - syncingModalDescription: "La prima connessione può richiedere un po' di tempo. Ti verrà notificato qualsiasi errore.", - syncing: 'Sincronizzazione dipendenti', + setupIncomplete: (setupLink: string | undefined) => + `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i dipendenti.`, mergeHR: { - completeSetup: 'Completa configurazione', - setupIncomplete: (setupLink: string | undefined) => - `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i dipendenti.`, groups: {title: 'Gruppi', description: 'Scegli i gruppi di dipendenti che vuoi sincronizzare con questo spazio di lavoro'}, }, + }, + recruiting: { + title: 'Assunzioni', + connectionsSubtitle: + 'Connetti il tuo sistema di recruiting per sincronizzare i dati dei candidati, abbinare automaticamente i rimborsi alle persone giuste e mantenere le spese del team accurate senza lavoro manuale.', + alreadyConnectedTitle: 'Impossibile connettersi a più piattaforme ATS', + alreadyConnectedPrompt: 'Devi disconnettere il tuo ATS attuale prima di collegarne un altro.', + syncing: 'Sincronizzazione candidati', + setupIncomplete: (setupLink: string | undefined) => + `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i candidati.`, + 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'}, + }, + merge: { + connections: 'Connessioni', + connect: 'Connetti', + findIntegration: 'Trova integrazione', + syncNow: 'Sincronizza ora', + disconnect: 'Disconnetti', + disconnectTitle: (providerName: string) => `Disconnetti ${providerName}`, + disconnectPrompt: (providerName: string) => `Sei sicuro di voler disconnettere ${providerName}?`, + lastSync: (relativeDate: string) => `Ultima sincronizzazione ${relativeDate}`, notSync: 'Non sincronizzato', + syncError: (providerName: string) => `Impossibile connettersi a ${providerName}`, authenticationError: (providerName: string) => `Impossibile connettersi a ${providerName} a causa di una connessione scaduta.`, reconnect: 'Riconnetti', reconnectLink: 'Riconnetti.', - findIntegration: 'Trova integrazione', - }, - merge: { + notSet: 'Non impostato', + completeSetup: 'Completa configurazione', + approvalMode: 'Modalità di approvazione', + finalApprover: 'Approvatore finale', + approvalModes: { + basic: 'Approvazione di base', + manager: 'Approvazione del responsabile', + custom: 'Approvazione personalizzata', + advanced: 'Approvazione avanzata', + }, syncLimitReached: {title: 'Riprova domani', prompt: 'Hai raggiunto il limite di sincronizzazioni per oggi.'}, }, emptyDomain: { title: 'Migliora la tua sicurezza con i domini', subtitle: 'Richiedi ai membri del tuo dominio di accedere tramite accesso Single Sign-On, limita la creazione di space di lavoro e altro ancora.', }, + campfire: { + campfireSetup: 'Configurazione di Campfire', + enterCredentials: 'Inserisci la tua chiave API Campfire', + howToFindAPIKey: + 'Trova la tua chiave API.
  1. Accedi a Campfire
  2. Vai a Impostazioni -> Chiavi API
  3. Crea una chiave API
  4. Incolla la chiave API qui sotto
', + subsidiary: 'Consociata', + subsidiarySelectDescription: 'Scegli la consociata in Campfire da cui vuoi importare i dati.', + noSubsidiariesFound: 'Nessuna consociata trovata', + noSubsidiariesFoundDescription: "Aggiungi un'entità in Campfire e sincronizza di nuovo la connessione", + }, }, getAssistancePage: { title: 'Ottieni assistenza', @@ -9376,7 +9544,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Fatturazione viaggio consolidata', }, is: 'È', - has: {submittedViolation: 'Violazione inviata'}, + has: {submittedViolation: 'Violazione inviata', approvedViolation: 'Violazione approvata'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Invia', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Approva', @@ -9450,6 +9618,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, exportedTo: 'Esportato in', exportAll: { selectAllMatchingItems: 'Seleziona tutti gli elementi corrispondenti', + allMatchingItemsSelected: 'Tutti gli elementi corrispondenti selezionati', selectAllOnThisPage: 'Seleziona tutto in questa pagina', }, errors: { @@ -9475,6 +9644,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, topMerchants: 'Principali esercenti', violationsBySubmitter: 'Violazioni da parte dell’autore dell’invio', }, + mergeReports: {title: 'Unisci report', description: 'Seleziona il report da mantenere. Tutte le spese verranno spostate al suo interno e gli altri report verranno eliminati.'}, }, genericErrorPage: { title: 'Oops, qualcosa è andato storto!', @@ -9558,6 +9728,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `esportato in ${translatedLabel}`; @@ -9594,6 +9765,16 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Ripetuto ${count} volte.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `La connessione ${feedName} è interrotta. Per ripristinare le importazioni della carta, accedi alla tua banca.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `La connessione ${feedName} è interrotta da 30 giorni. Accedi alla tua banca per risolvere il problema oppure rimuovi la connessione se non è più utilizzata. Non perderai nessuna spesa inviata se la rimuovi.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connessione Plaid al conto bancario della tua azienda non funziona. Per favore, ricollega il conto bancario ${maskedAccountNumber} così puoi continuare a usare le tue Carte Expensify.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10185,6 +10366,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `La tariffa è valida solo a partire dal ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `La tariffa è valida solo fino al ${endDate}`, cannotMergeDuplicates: 'Puoi unire le spese solo nei report in bozza o in sospeso. Revoca il report e riprova.', + overCategoryLimitPerNight: (formattedLimit: string) => `Tariffa notturna oltre il limite di categoria di ${formattedLimit}/persona`, shortName: { allTagLevelsRequired: 'Tutti i tag obbligatori', autoReportedRejectedExpense: 'Spesa rifiutata', @@ -10798,6 +10980,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, reportLevelExport: 'Tutti i dati - livello report', expenseLevelExport: 'Tutti i dati - livello spesa', multipleTaxExport: 'Esportazione canadese con imposte multiple', + reconciliationAllExpenses: 'Riconciliazione - Tutte le spese', exportInProgress: 'Esportazione in corso', conciergeWillSend: 'Concierge ti invierà il file a breve.', currentView: 'Vista corrente', @@ -10829,6 +11012,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, notVerified: 'Non verificato', retry: 'Riprova', requestSent: 'Richiesta inviata', + requestAccessError: 'Non siamo riusciti a inviare la tua richiesta. Riprova.', verifyDomain: { title: 'Verifica dominio', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10889,12 +11073,12 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, setMetadataGenericError: 'Impossibile impostare i metadati SAML', }, accessRestricted: { - title: 'Accesso limitato', - subtitle: (domainName: string) => `Verificati come amministratore autorizzato dell’azienda per ${domainName} se hai bisogno di controllare:`, - companyCardManagement: 'Gestione carte aziendali', - accountCreationAndDeletion: 'Creazione ed eliminazione dell’account', - workspaceCreation: 'Creazione dello spazio di lavoro', - samlSSO: 'SSO SAML', + headerTitle: 'Accesso limitato', + title: 'Verifica richiesta', + description: (domainName: string) => + `Verificati come amministratore autorizzato dell’azienda per ${domainName} oppure richiedi l’accesso agli amministratori esistenti.`, + requestAdminAccess: 'Richiedi accesso amministratore', + verifyYourself: 'Verificati', }, addDomain: { title: 'Aggiungi dominio', @@ -10908,7 +11092,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, title: "Dominio già configurato. Vuoi richiedere l'accesso?", description: "Qualcuno ha già configurato questo dominio in Expensify. Vuoi richiedere l'accesso come amministratore?", requestAccess: "Richiedi l'accesso come amministratore", - requestAccessError: 'Non siamo riusciti a inviare la tua richiesta. Riprova.', }, domainAdded: { title: 'Dominio aggiunto', @@ -10933,6 +11116,9 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, consolidatedDomainBillingError: 'La fatturazione consolidata del dominio non può essere modificata. Riprova più tardi.', addAdmin: 'Aggiungi amministratore', addAdminError: 'Impossibile aggiungere questo membro come amministratore. Riprova.', + requests: 'Richieste', + approveRequestError: 'Impossibile approvare questa richiesta. Riprova.', + declineRequestError: 'Impossibile negare questa richiesta. Riprova.', revokeAdminAccess: 'Revoca accesso amministratore', cantRevokeAdminAccess: 'Impossibile revocare l’accesso di amministratore al referente tecnico', error: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 57b613b4b83e..c0450874eb61 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: '共有を解除', yes: 'はい', no: 'いいえ', + approve: '承認', + deny: '拒否', dontChange: '変更しない', ok: 'OK', notNow: '今はしない', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: '検索', reports: 'レポート', spend: '支出', + insights: 'インサイト', find: '検索', searchWithThreeDots: '検索…', next: '次へ', @@ -464,6 +467,8 @@ const translations: TranslationDeepObject = { none: 'なし', unstableInternetConnection: 'インターネット接続が不安定です。ネットワークを確認して、もう一度お試しください。', enableGlobalReimbursements: 'グローバル払い戻しを有効にする', + corpayPayModalTitle: 'レポートを支払う', + corpayPayModalPrompt: 'この提出者は非USDの銀行口座を使用しています。レポートを支払うにはグローバル払い戻しを有効にするか、USDの銀行口座を追加するよう依頼してください。', purchaseAmount: '購入金額', originalAmount: '元の金額', frequency: '頻度', @@ -942,13 +947,21 @@ const translations: TranslationDeepObject = { tooltip: 'ここから始めましょう!', }, homePage: { - forYou: 'あなた向け', + toDos: 'To-do リスト', timeSensitiveSection: { title: '時間に敏感', - addShippingAddress: {title: '配送先住所が必要です', subtitle: 'Expensify カードを受け取る住所を入力してください。', cta: '住所を追加'}, + addShippingAddress: {title: '配送先住所が必要です', subtitle: 'Expensify カードを受け取る住所を入力してください。', cta: '追加'}, addPaymentCard: {title: 'Expensify を引き続きご利用いただくには、支払いカードを追加してください', subtitle: 'アカウント > サブスクリプション', cta: '追加'}, addBankAccount: {title: '銀行口座を追加して払い戻しを受け取りましょう'}, activateCard: {title: 'Expensify カードを有効化する', subtitle: 'カードを認証して支出を始めましょう。', cta: '有効化'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `${walletName}カードの追加には承認が必要です`, + subtitle: 'Expensify カード', + cta: 'レビュー', + appleWallet: 'Apple Wallet', + googleWallet: 'Google ウォレット', + digitalWallet: 'デジタルウォレット', + }, reviewCardFraud: { title: 'Expensify カードの不正利用の可能性を確認する', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `${merchant} での不正の可能性がある ${amount} を確認`, @@ -968,7 +981,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, - addHomeAddress: {title: '距離の追跡用に自宅住所を追加してください', subtitle: 'アカウント', cta: '住所を追加'}, + addHomeAddress: {title: '距離の追跡用に自宅住所を追加してください', subtitle: 'アカウント', cta: '追加'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -976,7 +989,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'ウォレット', }, - addVirtualCardPersonalDetails: {title: 'Expensify カードを表示して利用を開始するには、ご自身の情報を追加してください。', subtitle: 'Expensify カード', cta: '詳細を追加'}, + addVirtualCardPersonalDetails: {title: 'Expensify カードを表示して利用を開始するには、ご自身の情報を追加してください。', subtitle: 'Expensify カード', cta: '追加'}, enterSignerInfo: {title: '署名者情報が必要です', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `銀行口座 ${bankAccountLastFour}`}, }, discoverSection: { @@ -1079,6 +1092,8 @@ const translations: TranslationDeepObject = { inviteAccountant: '会計士を招待', customizeSpendCategories: '支出カテゴリをカスタマイズする', customizeSpendCategoriesSubText: '経費を整理して分類する', + customizeExpenseCategories: '経費カテゴリをカスタマイズ', + customizeExpenseCategoriesSubText: '会社のカテゴリを追加して経費を分類しましょう', createExpense: '経費を作成', createExpenseSubText: 'スキャンするか、ドラッグ&ドロップするか、または+ボタンから手入力して経費を追加します', linkPersonalCard: '個人カードをリンクする', @@ -1104,7 +1119,6 @@ const translations: TranslationDeepObject = { title: 'あなたの支出', awaitingApproval: '承認待ち', repaidLast30Days: '過去30日間の返済', - recentTransactions: ({lastFour}: {lastFour: string}) => `最近の取引 • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `さらに${count}件表示`, recentlyAddedSection: { @@ -1257,6 +1271,7 @@ const translations: TranslationDeepObject = { phrase1: '領収書を追加', phrase2: 'または、ここにファイルをドラッグ&ドロップしてください', }, + pageCount: ({pageCount}: {pageCount: number}) => `${pageCount} ページ中 1 ページ`, }, quickAction: { scanReceipt: 'レシートをスキャン', @@ -1643,11 +1658,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: '準備完了!', noDuplicatesDescription: '確認が必要な重複取引はありません。', confirmApprove: '承認金額を確認', - confirmApprovalAmount: '準拠している経費のみを承認するか、レポート全体を承認します。', - confirmApprovalAllHoldAmount: () => ({ - one: 'この経費は保留中です。それでも承認しますか?', - other: 'これらの経費は保留中です。それでも承認しますか?', - }), + confirmApprovalWithHeldAmount: 'レポートには保留中の経費が含まれています。準拠している経費のみを承認しますか、それともレポート全体を承認しますか?', + confirmApprovalAllHoldAmount: 'すべての経費が保留中です。それでも承認しますか?', confirmPay: '支払金額を確認', confirmPayAmount: '保留されていない金額のみ支払うか、レポート全額を支払ってください。', confirmPayAllHoldAmount: () => ({ @@ -2275,6 +2287,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: '強調表示されたスパン名', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click、ナビゲーション、ui.load', showBranchNameInTitle: 'ブラウザのタイトルにブランチ名を表示', + betaOverrides: 'ベータのオーバーライド', + betaOverridesDescription: + 'オーバーライドはこの端末にのみ適用され、フロントエンドのチェックにのみ影響します。ベータはアカウントのベータと異なる間だけオーバーライドを保持するため、元に戻すとオーバーライドは削除されます。「すべてのオーバーライドをリセット」はアカウントの値に戻します。一部のベータはバックエンドでも制御されているため、リクエストレベルで失敗する場合があります。', + resetAllOverrides: 'すべてのオーバーライドをリセット', + overridden: 'オーバーライド済み', qaAuth: 'QA 認証(Cloudflare)', qaAuthRunProbe: 'プローブを実行', qaAuthSession: 'QA 認証セッション', @@ -2588,6 +2605,10 @@ const translations: TranslationDeepObject = { connectionLink ? `${cardName}カードとの接続が切れています。カードを修正するには、銀行にログインしてください。` : `${cardName}カードとの接続が切れています。カードを修正するには、銀行にログインしてください。`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `${cardName} との接続が30日間切れています。修正するには銀行にログインするか、もう使用していない場合はカードを削除してください。カードを削除しても、送信済みの経費は失われません。` + : `${cardName} との接続が30日間切断されています。復旧するには銀行にログインするか、もう使用していない場合はカードを削除してください。削除しても、送信済みの経費は失われません。`, addAdditionalCards: '他のカードを追加', upgradeDescription: 'さらにカードを追加しますか?ワークスペースを作成して、個人カードを追加するか、会社カードをチーム全体に割り当てることができます。', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2682,6 +2703,22 @@ const translations: TranslationDeepObject = { unlock: 'ロック解除', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `${walletName} にカードを追加`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google ウォレット', + digitalWallet: 'デジタルウォレット', + confirmHeading: 'リクエストを確認してください', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => `${walletName} に、末尾が ${lastFourDigits} の Expensify カードを追加しますか?`, + deny: '拒否', + confirm: '確認', + verifyTitle: 'ご本人様確認を行います', + enterSecurityCode: (contactMethod: string) => `このリクエストを確認するために、${contactMethod} に送信されたセキュリティコードを入力してください。1~2分以内に届きます。`, + successHeading: '成功しました!', + successDescription: ({walletName}: {walletName: string}) => `カードは${walletName}でご利用いただける状態になりました。`, + deniedHeading: 'リクエストは拒否されました', + deniedDescription: ({walletName}: {walletName: string}) => `お使いのカードは${walletName}に追加されていません。`, + }, cardPage: { expensifyCard: 'Expensify カード', expensifyTravelCard: 'Expensify トラベルカード', @@ -3120,7 +3157,14 @@ ${date} の ${merchant} への ${amount}`, prompt: (priorityModePageUrl: string) => `未読のチャットや対応が必要なチャットだけを表示して、状況を常に把握できるようにしましょう。いつでも設定で変更できます。`, }, - inboxTabs: {all: 'すべて', todo: 'To-do リスト', unread: '未読', markAllAsRead: 'すべて既読にする', markAllAsReadConfirmationPrompt: 'すべてのチャットを既読にしてもよろしいですか?'}, + inboxTabs: { + all: 'すべて', + todo: 'To-do リスト', + unread: '未読', + markAllAsRead: 'すべて既読にする', + markAllAsReadConfirmationPrompt: 'すべてのチャットを既読にしてもよろしいですか?', + markAllTodosAsReadConfirmationPrompt: 'To-do のチャットをすべて既読にしてもよろしいですか?', + }, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, generatingPDF: 'PDFを生成', @@ -3298,6 +3342,7 @@ ${date} の ${merchant} への ${amount}`, `${workEmail} を追加できませんでした。後で「設定」からもう一度お試しいただくか、ガイダンスについて Concierge にチャットでお問い合わせください。`, workAccountClosedSubtitle: 'このメールアドレスに関連付けられている業務用アカウントは停止されています。再有効化するには会社の管理者にご連絡いただくか、別のメールアドレスでサインアップしてください。', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} は既存のExpensifyアカウントのドメイン管理ログインです。`, }, tasks: { testDriveAdminTask: { @@ -4125,8 +4170,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalFirstName: '法的な名', legalLastName: '法的な姓', enterTheDateOfBirthOfTheOwner: '所有者の生年月日はいつですか?', - enterTheLast4: '所有者の社会保障番号の下4桁を入力してください。', - last4SSN: 'SSN の下4桁', + enterTheSSN: '所有者の社会保障番号は何ですか?', dontWorry: 'ご安心ください。個人信用情報の審査は一切行いません。', enterTheOwnersAddress: 'オーナーの住所は何ですか?', letsDoubleCheck: 'すべて正しく表示されているか、もう一度確認しましょう。', @@ -4153,13 +4197,12 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' whatsYourDOB: '生年月日はいつですか?', whatsTheOwnersAddress: 'オーナーの住所は何ですか?', whatsYourAddress: 'あなたの住所は何ですか?', - whatAreTheLast: '所有者の社会保障番号の下4桁は何ですか?', - whatsYourLast: 'あなたの社会保障番号の下4桁は何ですか?', + whatsTheOwnersSSN: '所有者の社会保障番号は何ですか?', + whatsYourSSN: 'あなたの社会保障番号は何ですか?', whatsYourNationality: 'あなたの市民権を持つ国はどこですか?', whatsTheOwnersNationality: '所有者の国籍はどこですか?', countryOfCitizenship: '市民権のある国', dontWorry: 'ご安心ください。個人信用情報の審査は一切行いません。', - last4: 'SSN の下4桁', whyDoWeAsk: 'なぜこの情報が必要なのですか?', letsDoubleCheck: 'すべて正しく表示されているか、もう一度確認しましょう。', legalName: '法的氏名', @@ -4181,6 +4224,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' proofOfBeneficialOwner: '受益所有者の証明', proofOfBeneficialOwnerDescription: '事業の所有権が25%以上であることを証明するため、公認会計士、公証人、または弁護士が署名した宣誓書および組織図をご提出ください。日付は過去3か月以内のものであり、署名者の免許番号が記載されている必要があります。', + proofOfBeneficialOwnerDescriptionCA: + '直近の T2 法人所得税申告書に含まれる Schedule 50(株主情報)をご提出ください。事業の持分を 25%以上所有しているすべての個人が分かるものをご用意ください。', copyOfID: '実質的支配者の本人確認書類のコピー', copyOfIDDescription: '例:パスポート、運転免許証など', proofOfAddress: '実質的支配者の住所証明', @@ -4554,6 +4599,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' customFieldHint: 'このメンバーのすべての支出に適用されるカスタムコードを追加します。', reports: 'レポート', reportFields: 'レポート項目', + invoiceFields: '請求書項目', reportTitle: 'レポートタイトル', reportField: 'レポート項目', taxes: '税金', @@ -4563,6 +4609,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' travel: '出張', members: 'メンバー', accounting: '会計', + mcp: 'MCP', receiptPartners: '領収書パートナー', rules: 'ルール', displayedAs: '表示形式', @@ -4686,6 +4733,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' travelInvoicingVendor: '出張ベンダー', travelInvoicingPayableAccount: '旅費未払金勘定', hr: '人事', + recruiting: '採用', rooms: 'ルーム', findDomain: 'ドメインを検索', cardAdminAlternateText: 'ワークスペースカードを管理します。', @@ -4699,6 +4747,25 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' title: 'クライアントのワークスペースを作成しました!', description: '素晴らしいニュースです 🎉。セットアップにサポートが必要な場合はお問い合わせください。', }, + mcp: { + connectors: 'コネクタ', + connectorsSubtitle: 'AIアシスタントをExpensifyアカウントに接続します。', + connect: '接続', + helpPrompt: '接続にお困りですか?', + helpLink: 'ガイドをご覧ください。', + claude: { + title: 'Claude', + subtitle: 'Anthropic 提供', + }, + cursor: { + title: 'Cursor', + subtitle: 'Anysphere 提供', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'OpenAI 提供', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `${organizationName} に接続しました` : '組織全体の出張費や飲食デリバリー経費を自動化しましょう。'), @@ -4915,7 +4982,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `${integrationName} に請求書をエクスポートする際は、この勘定科目を使用してください。`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `会社カードでの購入を${integrationName}へどのようにエクスポートするかを設定します。`, vendor: '取引先', - defaultVendorDescription: 'エクスポート時にすべてのクレジットカード取引に適用されるデフォルトのベンダーを設定します。', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `立替経費を ${integrationName} へどのようにエクスポートするかを設定します。`, exportCheckDescription: '以下の銀行口座から各Expensifyレポートごとに明細付きの小切手を作成し、送金します。', exportJournalEntryDescription: '各Expensifyレポートごとに明細付きの仕訳を作成し、以下の勘定科目に計上します。', @@ -5075,8 +5141,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, noAccountsFound: 'アカウントが見つかりません', noAccountsFoundDescription: 'Xero にアカウントを追加して、もう一度同期してください', - defaultSupplier: 'デフォルト仕入先', - defaultSupplierDescription: 'エクスポート時にすべてのクレジットカード取引に適用されるデフォルトの仕入先を設定します。', noSuppliersFound: '仕入先が見つかりません', noSuppliersFoundDescription: 'Xero に仕入先を追加して、もう一度同期してください。', accountingMethods: { @@ -6270,6 +6334,9 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO 'ワークスペース内の一部メンバーに対して、Consolidated Travel Billing を有効化できませんでした。時間をおいてもう一度お試しいただくか、サポートが必要な場合は Concierge までお問い合わせください。', sendInvoiceNowCta: '請求書を今すぐ送信', }, + setUpNewFeed: '新しい出張フィードを設定する', + feedSelectorIntro: + '組織にはすでに統合トラベル請求フィードが設定されています。このワークスペースの出張費を同じ月次請求書にまとめるには、そのフィードを選択してください。', }, disableModal: { title: '一括旅行請求をオフにしますか?', @@ -6423,6 +6490,10 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO title: '会計', subtitle: '勘定科目表などを同期します。', }, + mcp: { + title: 'MCP', + subtitle: 'AIアシスタントをExpensifyアカウントに接続します。', + }, receiptPartners: { title: '領収書パートナー', subtitle: '領収書を自動で取り込みます。', @@ -6486,6 +6557,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO subtitle: 'レポートフィールドはすべての支出に適用され、追加情報の入力を促したい場合に便利です。', disableReportFields: 'レポート項目を無効にする', disableReportFieldsConfirmation: '本当に実行しますか?テキストと日付フィールドは削除され、リストは無効になります。', + cannotDisableImportedReportFields: '会計ソフト連携からインポートされたレポートフィールドは無効にすることができません。', importedFromAccountingSoftware: '以下のレポート項目は、次からインポートされます', textType: 'テキスト', dateType: '日付', @@ -6522,6 +6594,29 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO reportFieldInitialValueRequiredError: 'レポート項目の初期値を選択してください', genericFailureMessage: 'レポートフィールドの更新中にエラーが発生しました。もう一度お試しください。', }, + invoiceFields: { + subtitle: '追加情報を含めたい場合、請求書フィールドが役立ちます。', + importedFromAccountingSoftware: '以下の請求書フィールドは、次からインポートされます', + disableInvoiceFields: '請求書フィールドを無効にする', + disableInvoiceFieldsConfirmation: 'よろしいですか?請求書フィールドは請求書で無効になります。', + delete: '請求書フィールドを削除', + deleteConfirmation: 'この請求書フィールドを削除してもよろしいですか?', + findInvoiceField: '請求書フィールドを検索', + nameInputSubtitle: '請求書フィールドの名前を選択してください。', + typeInputSubtitle: '使用する請求書フィールドの種類を選択してください。', + initialValueInputSubtitle: '請求書フィールドに表示する開始値を入力してください。', + listValuesInputSubtitle: 'これらの値は請求書フィールドのドロップダウンに表示されます。有効な値はメンバーが選択できます。', + listInputSubtitle: 'これらの値は請求書フィールドのリストに表示されます。有効な値はメンバーが選択できます。', + emptyInvoiceFieldsValues: { + title: 'リスト値はまだありません', + subtitle: '請求書に表示するカスタム値を追加します。', + }, + existingInvoiceFieldNameError: 'この名前の請求書フィールドは既に存在します', + invoiceFieldNameRequiredError: '請求書フィールド名を入力してください', + invoiceFieldTypeRequiredError: '請求書フィールドの種類を選択してください', + invoiceFieldInitialValueRequiredError: '請求書フィールドの初期値を選択してください', + addField: 'フィールドを追加', + }, vendors: { emptyTitle: 'まだ取引先がありません', emptySubtitle: '会計システムとの同期が完了すると、ここに取引先が表示されます。', @@ -6914,6 +7009,8 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -6975,7 +7072,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から switch (stage) { case 'quickbooksOnlineImportCustomers': case 'quickbooksDesktopImportCustomers': - return '顧客のインポート中'; + return '顧客をインポートしています'; case 'quickbooksOnlineImportEmployees': case 'netSuiteSyncImportEmployees': case 'intacctImportEmployees': @@ -6983,42 +7080,42 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から return '従業員のインポート'; case 'quickbooksOnlineImportAccounts': case 'quickbooksDesktopImportAccounts': - return '口座のインポート'; + return '口座をインポートしています'; case 'quickbooksOnlineImportClasses': case 'quickbooksDesktopImportClasses': return 'クラスのインポート'; case 'quickbooksOnlineImportLocations': - return '場所をインポートしています'; + return '所在地をインポート中'; case 'quickbooksOnlineImportProcessing': return 'インポートしたデータを処理しています'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': - return '払い戻されたレポートと支払い済み請求書の同期'; + return '払い戻し済みレポートと支払い済み請求書の同期'; case 'quickbooksOnlineSyncTaxCodes': return '税コードのインポート'; case 'quickbooksOnlineCheckConnection': return `${integrationName} 接続を確認しています`; case 'quickbooksOnlineImportMain': - return `${integrationName} のデータをインポートしています`; + return `${integrationName} データをインポート中`; case 'startingImportXero': return 'Xero データのインポート'; case 'startingImportQBO': - return `${integrationName} のデータをインポートしています`; + return `${integrationName} データをインポート中`; case 'startingImportQBD': case 'quickbooksDesktopImportMore': return 'QuickBooks Desktop データのインポート'; case 'quickbooksDesktopImportTitle': return 'タイトルをインポート中'; case 'quickbooksDesktopImportApproveCertificate': - return '承認証明書をインポート中'; + return '承認証明書をインポートしています'; case 'quickbooksDesktopImportDimensions': - return 'ディメンションをインポート中'; + return 'ディメンションをインポートしています'; case 'quickbooksDesktopImportSavePolicy': return '保存ポリシーをインポート中'; case 'quickbooksDesktopWebConnectorReminder': - return 'QuickBooks とデータを同期中です… Web Connector が実行中であることを確認してください'; + return 'QuickBooks とデータを同期中です… Web Connector が起動していることを確認してください'; case 'quickbooksOnlineSyncTitle': - return `${integrationName} データを同期しています`; + return `${integrationName} のデータを同期しています`; case 'quickbooksOnlineSyncLoadData': case 'xeroSyncStep': case 'intacctImportData': @@ -7026,13 +7123,13 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から case 'quickbooksOnlineSyncApplyCategories': return 'カテゴリを更新しています'; case 'quickbooksOnlineSyncApplyCustomers': - return '顧客/プロジェクトの更新'; + return '顧客 / プロジェクトの更新'; case 'quickbooksOnlineSyncApplyEmployees': - return 'メンバー一覧を更新しています'; + return 'メンバーリストを更新しています'; case 'quickbooksOnlineSyncApplyClassesLocations': - return 'レポート項目を更新中'; + return 'レポート項目を更新しています'; case 'jobDone': - return 'インポートしたデータの読み込みを待っています'; + return 'インポートしたデータの読み込みを待機しています'; case 'xeroSyncImportChartOfAccounts': return '勘定科目表を同期しています'; case 'xeroSyncImportCategories': @@ -7044,45 +7141,45 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から case 'xeroSyncExpensifyReimbursedReports': return 'Xero の請求書とインボイスを支払済みにする'; case 'xeroSyncImportTrackingCategories': - return 'トラッキングカテゴリを同期しています'; + return 'トラッキングカテゴリーを同期しています'; case 'xeroSyncImportBankAccounts': return '銀行口座を同期しています'; case 'xeroSyncImportTaxRates': return '税率を同期しています'; case 'xeroCheckConnection': - return 'Xero への接続を確認しています'; + return 'Xero との接続を確認しています'; case 'xeroSyncTitle': - return 'Xero データを同期しています'; + return 'Xero データを同期中'; case 'netSuiteSyncConnection': return 'NetSuite への接続を初期化しています'; case 'netSuiteSyncCustomers': - return '顧客のインポート中'; + return '顧客をインポートしています'; case 'netSuiteSyncInitData': return 'NetSuite からデータを取得しています'; case 'netSuiteSyncImportTaxes': return '税金のインポート'; case 'netSuiteSyncImportItems': - return '項目をインポート中'; + return '項目をインポートしています'; case 'netSuiteSyncData': return 'Expensify へのデータインポート'; case 'netSuiteSyncAccounts': - return 'アカウントを同期しています'; + return '口座を同期しています'; case 'netSuiteSyncCurrencies': return '通貨を同期しています'; case 'netSuiteSyncCategories': return 'カテゴリを同期しています'; case 'netSuiteSyncReportFields': - return 'Expensify レポート項目としてデータをインポート'; + return 'Expensify レポートフィールドとしてデータをインポート'; case 'netSuiteSyncTags': - return 'データを Expensify タグとしてインポートする'; + return 'Expensifyタグとしてデータをインポートする'; case 'netSuiteSyncUpdateConnectionData': return '接続情報を更新しています'; case 'netSuiteSyncNetSuiteReimbursedReports': return 'Expensify レポートを精算済みにする'; case 'netSuiteSyncExpensifyReimbursedReports': - return 'NetSuite の請求書および請求書を支払済みにマークする'; + return 'NetSuite の請求書とインボイスを支払い済みにする'; case 'netSuiteImportVendorsTitle': - return '仕入先をインポート中'; + return '取引先のインポート'; case 'netSuiteImportCustomListsTitle': return 'カスタムリストのインポート'; case 'netSuiteSyncImportCustomLists': @@ -7091,9 +7188,9 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から return '子会社のインポート'; case 'netSuiteSyncImportVendors': case 'quickbooksDesktopImportVendors': - return '仕入先をインポート中'; + return '取引先のインポート'; case 'intacctCheckConnection': - return 'Sage Intacct との接続を確認しています'; + return 'Sage Intacct 接続を確認しています'; case 'intacctImportDimensions': return 'Sage Intacct ディメンションのインポート'; case 'intacctImportTitle': @@ -7103,19 +7200,19 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から case 'financialForceSyncStep': return 'Certinia 接続を同期しています'; case 'financialForceSyncCategories': - return 'カテゴリをインポートしています'; + return 'カテゴリをインポート中'; case 'financialForceSyncTags': - return 'タグのインポート'; + return 'タグをインポート中'; case 'financialForceSyncVendors': - return '仕入先をインポート中'; + return '取引先のインポート'; case 'financialForceSyncContacts': - return '連絡先のインポート'; + return '連絡先をインポートしています'; case 'financialForceSyncCompanies': return '会社のインポート'; case 'financialForceSyncUsers': return 'ユーザーのインポート'; case 'financialForceSyncDimensions': - return 'ディメンションをインポート中'; + return 'ディメンションをインポートしています'; case 'financialForceMarkAsReimbursed': return 'レポートを精算済みにする'; case 'rilletSyncTitle': @@ -7131,13 +7228,25 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から case 'dualEntrySyncImportData': return 'データを読み込んでいます'; case 'dualEntrySyncPayments': - return '取引先への支払いを同期しています'; + return '仕入先への支払いを同期しています'; case 'dualEntrySyncCardSettlements': - return 'カード決済を同期しています'; + return 'カード精算を同期しています'; case 'dualEntrySyncTravelSettlements': return '出張精算を同期しています'; + case 'campfireSyncTitle': + return 'Campfire データを同期しています'; + case 'campfireSyncConnection': + return 'Campfire への接続を初期化しています'; + case 'campfireSyncImportData': + return 'データを読み込んでいます'; + case 'campfireSyncPayments': + return '仕入先への支払いを同期しています'; + case 'campfireSyncCardSettlements': + return 'カード精算を同期しています'; + case 'campfireSyncTravelSettlements': + return '出張精算を同期しています'; default: { - return `ステージ「${stage}」の翻訳が見つかりません`; + return `ステージの翻訳が見つかりません: ${stage}`; } } }, @@ -7151,11 +7260,11 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から exportCompanyCard: '法人カード経費のエクスポート形式', exportDate: 'エクスポート日', defaultVendor: 'デフォルトのベンダー', - defaultVendorHelperText: (isSet: boolean) => - isSet + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName ? `自動照合されない経費は、デフォルトでこのベンダーに割り当てられます。` - : `自動照合されない経費は、デフォルトでこのベンダーに割り当てられます。それ以外は「Credit Card Misc」としてエクスポートされます。`, - defaultVendorSelectHeader: (connectionName: string) => `自動的に照合されない経費に対して使用する、デフォルトの ${connectionName} 仕入先を選択します。`, + : `自動照合されない経費はデフォルトでこのベンダーに割り当てられます。それ以外は「${fallbackVendorName}.」としてエクスポートされます。`, + defaultVendorSelectHeader: `自動的に一致しない経費のためのデフォルトの取引先を選択します。`, defaultAccount: 'デフォルトのアカウント', autoSync: '自動同期', autoSyncDescription: 'NetSuite と Expensify を毎日自動で同期。確定したレポートをリアルタイムでエクスポート', @@ -7177,6 +7286,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から syncTravelInvoicingSettlements: '統合トラベル請求の精算を同期する', syncTravelInvoicingSettlementsNoAccountTooltip: 'ロックを解除するには、エクスポート用の口座を設定してください。', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'ロックを解除するには、自動同期を有効にしてください。', + campfire: 'Campfire', }, export: { notReadyHeading: 'エクスポートの準備ができていません', @@ -7233,12 +7343,12 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', optionDisabledHelp: '通勤分は精算申請から除外されていません。', - optionFixedDistanceTitle: '申請ごとに一定距離を除外します', - optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', + optionFixedDistanceTitle: '固定距離を削除', + optionFixedDistanceHelp: 'すべての申請から同じ距離を差し引きます。', distanceLabel: '距離', summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', - optionHomeAndOfficeTitle: '自宅とオフィスで按分計算します', - optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィス配属先を使用して、通勤の除外額を計算します。', + optionHomeAndOfficeTitle: '通常の通勤を除外', + optionHomeAndOfficeHelp: '各メンバーの自宅、勤務形態、オフィスに基づいて通常の通勤分を差し引きます。', workspaceAddressRequired: { title: 'ちょっとお待ちください…', promptStart: '自宅とオフィスで計算する設定は、先にオフィスの所在地を追加しないと有効にできません', @@ -7431,6 +7541,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `レポートフィールドは、${formattedPrice} ${hasTeam2025Pricing ? `メンバー1人あたり月額` : `アクティブメンバー1人あたり月額`}からのControlプランでのみ利用できます`, }, + invoiceFields: { + title: '請求書項目', + description: `請求書フィールドを使うと、請求書レベルの追加情報を請求書に含めることができます。`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `請求書フィールドは、${formattedPrice} ${hasTeam2025Pricing ? `メンバー1人あたり月額` : `アクティブメンバー1人あたり月額`}からのControlプランでのみ利用できます`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Expensify と NetSuite の連携により、自動同期を活用して手入力を減らしましょう。プロジェクトや顧客のマッピングを含むネイティブおよびカスタムセグメントのサポートで、詳細かつリアルタイムな財務インサイトを得られます。`, @@ -7473,6 +7589,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `DualEntry 連携は Control プランでのみご利用いただけます。${formattedPrice} ${hasTeam2025Pricing ? `メンバー1人あたり月額` : `アクティブメンバー1人あたり月額`} からご利用いただけます。`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Expensify と Campfire の連携で自動同期を活用し、手入力を減らしましょう。経費のコーディングディメンションと税務同期を Campfire の設定に合わせて、財務の可視性を高めます。`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Campfire 連携は Control プランでのみご利用いただけます。${formattedPrice} ${hasTeam2025Pricing ? `メンバー1人あたり月額` : `アクティブメンバー1人あたり月額`} からご利用いただけます。`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: '高度な承認', description: `承認フローにさらに多くの段階を追加したい場合や、高額な経費に必ず別の承認者の目を通したい場合も、ご安心ください。高度な承認機能により、あらゆるレベルで適切なチェック体制を整え、チームの支出をしっかり管理できます。`, @@ -7759,7 +7881,6 @@ ${reportName}`, alwaysReimbursableDescription: '経費は常に従業員に払い戻されます', alwaysNonReimbursable: '常に精算対象外', alwaysNonReimbursableDescription: '経費は従業員に精算されません', - billableDefault: '請求可能のデフォルト', billableDefaultDescription: '現金およびクレジットカード経費をデフォルトで請求可能にするかどうかを選択してください。', billable: '請求可能', billableDescription: '経費は多くの場合、クライアントに再請求されます', @@ -7786,10 +7907,12 @@ ${reportName}`, publicReceiptVisibilityHintDisabled: 'レシートは、そのレシートを含むレポートへのアクセス権を持つ Expensify のメンバーのみが閲覧できます。', enableTagsToUnlockTitle: 'タグを有効にしますか?', enableTagsToUnlockPrompt: '有効にするには、「その他の機能」でタグを有効化してください。', - enableTagsAndRequirePrompt: 'タグを有効にし、すべての経費でタグを必須にしてもよろしいですか?', enableCategoriesToUnlockTitle: 'カテゴリーを有効にしますか?', enableCategoriesToUnlockPrompt: 'ロックを解除するには、[その他の機能]で[カテゴリ]を有効にしてください。', enableCategoriesAndRequirePrompt: 'カテゴリを有効にし、すべての経費でカテゴリを必須にしてもよろしいですか?', + enableTagsPrompt: 'タグを有効にしてもよろしいですか?タグが少なくとも1つある場合、すべての経費でタグの入力を必須にできます。', + noTagsToRequirePrompt: 'タグがありません。タグを作成してください。', + noCategoriesToRequirePrompt: 'カテゴリがありません。カテゴリを作成してください。', }, expenseReportRules: { title: '詳細設定', @@ -7862,6 +7985,19 @@ ${reportName}`, thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', vendorUnavailable: 'ベンダーを利用できません', supplierUnavailable: 'サプライヤーを利用できません', + confirmErrorCategory: 'カテゴリを選択してください', + confirmErrorCategoryTax: '税率を選択してください', + confirmErrorCondition: '店舗名を入力してください', + confirmErrorConditionAndDefault: '取引先を入力し、少なくとも 1 つのデフォルトを適用してください', + turnOnTaxesFirstTitle: '先に税金を有効にしてください', + turnOnTaxesFirstPrompt: 'カテゴリルールでは、デフォルトの税率を設定できます。利用するには、ワークスペース設定で税金を有効にしてください。', + categoryRulesApplyGoingForwardTitle: 'カテゴリルールは今後に適用されます', + categoryRulesApplyGoingForwardPrompt: 'このカテゴリーの新しい経費には、デフォルトの税率が適用されます。既存の経費は変更されません。', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: '選択した税率がワークスペースのデフォルトになっているため、このルールは無効になりました。別の税率を選択してください。', + addTaxRateFirstTitle: '先に税率を追加してください', + addTaxRateFirstPrompt: 'カテゴリルールではデフォルトの税率が設定されます。ワークスペースのデフォルト以外の税率を追加してから、カテゴリルールを使用してください。', + createRuleFromExpenseAction: 'ルールを作成', + createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, categoryRules: { title: 'カテゴリルール', @@ -7881,7 +8017,8 @@ ${reportName}`, flagAmountsOverSubtitle: 'これは、すべての経費の上限金額を上書きします。', expenseLimitTypes: { expense: '個別経費', - expenseSubtitle: 'カテゴリごとに経費金額にフラグを付けます。このルールは、経費金額の上限に関するワークスペース全体の一般ルールを上書きします。', + expenseSubtitle: + 'カテゴリごとに経費金額にフラグを付けます。このルールは、経費金額の上限に関するワークスペース全体の一般ルールを上書きします。複数日にわたる予約は、1泊あたりの平均額で評価されます。', daily: 'カテゴリ合計', dailySubtitle: '経費レポートごとに、カテゴリ別の1日あたり合計支出をフラグ設定する。', }, @@ -8157,10 +8294,10 @@ ${reportName}`, applyExpenseDefaultsDescription: '申請者が何も操作しなくてもフィールドを更新する', flagForReview: '確認のためにフラグを付ける', flagForReviewDescription: '条件が満たされたときに通知します。', - requireFields: '必須項目', + requireFields: 'フィールドの必須条件を設定', requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', - createAgentRule: 'エージェントルール', - createAgentRuleDescription: '必要なときに実行できる柔軟なルールを設定します。', + createAgentRule: 'エージェントルールの説明', + createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', }, expenseDefaultsTable: { tableColumnType: '種類', @@ -8171,6 +8308,7 @@ ${reportName}`, update: '更新', merchantIs: (merchant: string) => `加盟店名は「${merchant}」です`, merchantTypeIs: (merchantType: string) => `加盟店タイプ: 「${merchantType}」`, + categoryIs: (category: string) => `カテゴリは「${category}」です`, }, merchantTypeRule: {merchantType: '加盟店種別', saveRule: 'ルールを保存', confirmErrorCategory: 'カテゴリを選択してください。'}, requireFieldsTable: { @@ -8187,6 +8325,7 @@ ${reportName}`, requireReceipt: '領収書を必須にする', doNotRequireReceipt: '領収書を必須にしない', doNotRequireItemizedReceipt: '明細付き領収書を必須にしない', + typeLabel: '必須', }, requireFieldsEmptyState: { title: '不足している詳細を事前に把握しましょう', @@ -8235,6 +8374,15 @@ ${reportName}`, }, agentRulesEmptyState: {title: 'エージェントルールが追加されていません', subtitle: 'ワークスペースのポリシーを自動化するルールを作成します。', cta: 'AIルールを追加'}, categoriesDisabledEmptyState: {title: 'カテゴリが有効になっていません', subtitle: 'カテゴリを有効にして、支出をより細かく管理しましょう。'}, + expenseDefaultType: { + title: '経費のデフォルトを適用', + subtitle: 'このルールは何に一致させますか?', + merchant: '加盟店', + merchantDescription: '特定の加盟店の経費フィールドを更新', + category: 'カテゴリ', + categoryDescription: '特定のカテゴリにデフォルトの税率を設定する', + }, + taxesDisabledEmptyState: {title: '税金は有効になっていません', subtitle: '税金を有効にして、対象となる税金を記録し還付請求できるようにします。', cta: '税金を有効にする'}, }, planTypePage: { planTypes: { @@ -8261,36 +8409,25 @@ ${reportName}`, }, hr: { title: '人事', - connections: '接続', connectionsSubtitle: '人事システムと連携して従業員データを同期し、精算を自動で正しい担当者に紐づけることで、手作業なしでチームの経費を正確に管理できます。', subtitle: '人事ツールを連携して、従業員の承認を常に同期させます。', - connect: '接続', - syncNow: '今すぐ同期', - disconnect: '切断', - disconnectTitle: (providerName: string) => `${providerName}を切断`, - disconnectPrompt: (providerName: string) => `${providerName}を切断してもよろしいですか?`, alreadyConnectedTitle: '複数の人事プラットフォームには接続できません', alreadyConnectedPrompt: '別の人事プラットフォームに接続する前に、現在の人事プラットフォームとの接続を解除する必要があります。', - lastSync: (relativeDate: string) => `最終同期: ${relativeDate}`, - syncError: (providerName: string) => `${providerName}に接続できません`, connectionDescription: (providerName: string) => `${providerName}を接続して、従業員の承認をワークスペースと同期させましょう。`, - approvalMode: '承認モード', providerApprovalMode: (providerName: string) => `${providerName} 承認モード`, - finalApprover: '最終承認者', providerFinalApprover: (providerName: string) => `${providerName} 最終承認者`, - notSet: '未設定', + syncing: '従業員を同期しています', + syncingModalTitle: '接続を同期しています', + syncingModalDescription: '最初の接続には時間がかかる場合があります。エラーが発生した場合は通知されます。', approvalModeDescription: (providerName: string) => `メンバーとマネージャーは ${providerName} と同期するように設定されています。`, approvalModeWarningTitle: '承認モードを変更しますか?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `このワークスペースの承認モードを変更してもよろしいですか?${providerName} 対応の各ワークフローモードについては、ヘルプサイトで詳しくご覧いただけます。`, approvalModeWarningConfirm: '承認モードを変更', - approvalModes: { - basic: {label: '基本承認', description: 'すべてのユーザーは、処理と承認のために 1 人の担当者に提出します。'}, - manager: { - label: 'マネージャー承認', - description: (providerName: string) => `従業員は、${providerName} で設定された直属のマネージャーにレポートを提出します。`, - }, - custom: {label: 'カスタム承認', description: 'Expensify で承認ワークフローを手動で設定します。'}, + approvalModeDescriptions: { + basic: 'すべてのユーザーは、処理と承認のために 1 人の担当者に提出します。', + manager: (providerName: string) => `従業員は、${providerName} で設定された直属のマネージャーにレポートを提出します。`, + custom: 'Expensify で承認ワークフローを手動で設定します。', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8330,28 +8467,65 @@ ${reportName}`, zenefits: { title: 'TriNet', }, - syncingModalTitle: '接続を同期しています', - syncingModalDescription: '最初の接続には時間がかかる場合があります。エラーが発生した場合は通知されます。', - syncing: '従業員を同期しています', + setupIncomplete: (setupLink: string | undefined) => + `接続されました。従業員をインポートするには ${setupLink ? `セットアップを完了` : '設定を完了'} に接続してください。`, mergeHR: { - completeSetup: '設定を完了', - setupIncomplete: (setupLink: string | undefined) => - `接続されました。従業員をインポートするには ${setupLink ? `セットアップを完了` : '設定を完了'} に接続してください。`, groups: {title: 'グループ', description: 'このワークスペースと同期したい従業員グループを選択してください'}, }, + }, + recruiting: { + title: '採用', + connectionsSubtitle: '採用システムに接続して候補者データを同期し、払い戻しを自動的に適切な人に紐づけることで、手作業なしでチームの経費精度を保てます。', + alreadyConnectedTitle: '複数の ATS プラットフォームには接続できません', + alreadyConnectedPrompt: '別の ATS を接続する前に、現在の ATS を必ず切断する必要があります。', + syncing: '候補者を同期しています', + setupIncomplete: (setupLink: string | undefined) => + `接続済み。${setupLink ? `セットアップを完了` : '設定を完了'} をインポート候補として追加します。`, + dontSeeYourATS: `ご利用のATSが見つかりませんか?Concierge にお問い合わせいただければ、追加できます。`, + importSettings: 'インポート設定', + defaultApprover: 'デフォルト承認者', + approverFields: {recruiter: '採用担当者', recruitingCoordinator: '採用コーディネーター'}, + }, + merge: { + connections: '接続', + connect: '接続', + findIntegration: '連携を検索', + syncNow: '今すぐ同期', + disconnect: '切断', + disconnectTitle: (providerName: string) => `${providerName}を切断`, + disconnectPrompt: (providerName: string) => `${providerName}を切断してもよろしいですか?`, + lastSync: (relativeDate: string) => `最終同期: ${relativeDate}`, notSync: '未同期', + syncError: (providerName: string) => `${providerName}に接続できません`, authenticationError: (providerName: string) => `有効期限が切れた接続のため、${providerName} に接続できません。`, reconnect: '再接続', reconnectLink: '再接続する', - findIntegration: '連携を検索', - }, - merge: { + notSet: '未設定', + completeSetup: '設定を完了', + approvalMode: '承認モード', + finalApprover: '最終承認者', + approvalModes: { + basic: '基本承認', + manager: 'マネージャー承認', + custom: 'カスタム承認', + advanced: '詳細承認', + }, syncLimitReached: {title: '明日もう一度お試しください', prompt: '本日の同期上限に達しました。'}, }, emptyDomain: { title: 'ドメインでセキュリティを強化しましょう', subtitle: 'ドメインのメンバーにシングルサインオンでのログインを必須にし、ワークスペースの作成を制限するなどの管理ができます。', }, + campfire: { + campfireSetup: 'Campfire セットアップ', + enterCredentials: 'Campfire の API キーを入力してください', + howToFindAPIKey: + 'APIキーの見つけ方
  1. Campfire にログインします
  2. [Settings]→[API Keys]に移動します
  3. APIキーを作成します
  4. 作成したAPIキーを下に貼り付けます
', + subsidiary: '子会社', + subsidiarySelectDescription: 'データを取り込みたい Campfire 内の子会社を選択してください。', + noSubsidiariesFound: '子会社が見つかりません', + noSubsidiariesFoundDescription: 'Campfire でエンティティを追加して、接続をもう一度同期してください', + }, }, getAssistancePage: { title: 'サポートを受ける', @@ -9247,7 +9421,7 @@ ${reportName}`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: '出張費の一括請求', }, is: 'は', - has: {submittedViolation: '違反を送信しました'}, + has: {submittedViolation: '違反を送信しました', approvedViolation: '承認済みの違反'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: '送信', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: '承認', @@ -9313,7 +9487,7 @@ ${reportName}`, description: 'おっと、アイテムがたくさんありますね!まとめて整理して、間もなくConciergeからファイルをお送りします。', }, exportedTo: 'エクスポート先', - exportAll: {selectAllMatchingItems: '一致する項目をすべて選択', selectAllOnThisPage: 'このページのすべてを選択'}, + exportAll: {selectAllMatchingItems: '一致する項目をすべて選択', allMatchingItemsSelected: '一致する項目をすべて選択済み', selectAllOnThisPage: 'このページのすべてを選択'}, errors: { pleaseSelectDatesForBothFromAndTo: '開始日と終了日の両方を選択してください', }, @@ -9337,6 +9511,7 @@ ${reportName}`, topMerchants: '上位加盟店', violationsBySubmitter: '申請者による違反', }, + mergeReports: {title: 'レポートをマージする', description: '保持するレポートを選択してください。すべての経費はそのレポートに移動され、他のレポートは削除されます。'}, }, genericErrorPage: { title: 'おっと、問題が発生しました!', @@ -9419,6 +9594,7 @@ ${reportName}`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `${translatedLabel} にエクスポートしました`; @@ -9455,6 +9631,16 @@ ${reportName}`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(${count} 回繰り返し)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `${feedName} との接続が切断されています。カードの取引明細の取込を再開するには、銀行にログインしてください。`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `${feedName} との接続が 30 日間切断されています。修正するには銀行にログインするか、もう使用していない場合は接続を削除してください。削除しても、送信済みの経費が失われることはありません。`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `Plaid によるビジネス銀行口座との接続が切断されています。Expensify カードを引き続きご利用いただくために、銀行口座 ${maskedAccountNumber} を再接続してください。`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10038,6 +10224,7 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `料金は${startDate}からのみ有効です`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `料金は${endDate}までのみ有効です`, cannotMergeDuplicates: '経費を統合できるのは、下書きまたは未清算のレポートのみです。レポートを取り下げて、もう一度お試しください。', + overCategoryLimitPerNight: (formattedLimit: string) => `1人あたりのカテゴリ上限額 ${formattedLimit} を超える宿泊単価`, shortName: { allTagLevelsRequired: 'すべてのタグが必須です', autoReportedRejectedExpense: '経費が却下されました', @@ -10644,6 +10831,7 @@ ${reportName}`, reportLevelExport: 'すべてのデータ - レポートレベル', expenseLevelExport: 'すべてのデータ - 経費レベル', multipleTaxExport: 'カナダの複数税エクスポート', + reconciliationAllExpenses: '照合 - すべての経費', exportInProgress: 'エクスポート処理中', conciergeWillSend: 'Conciergeがまもなくファイルを送信します。', currentView: '現在のビュー', @@ -10675,6 +10863,7 @@ ${reportName}`, notVerified: '未確認', retry: '再試行', requestSent: 'リクエストを送信しました', + requestAccessError: 'リクエストを送信できませんでした。もう一度お試しください。', verifyDomain: { title: 'ドメインを確認', beforeProceeding: ({domainName}: {domainName: string}) => `続行する前に、DNS 設定を更新して、${domainName} の所有者であることを確認してください。`, @@ -10734,12 +10923,12 @@ ${reportName}`, setMetadataGenericError: 'SAMLメタデータを設定できませんでした', }, accessRestricted: { - title: 'アクセスが制限されています', - subtitle: (domainName: string) => `以下の管理が必要な場合は、${domainName} の承認済み会社管理者としてご本人確認を行ってください。`, - companyCardManagement: 'コーポレートカード管理', - accountCreationAndDeletion: 'アカウントの作成と削除', - workspaceCreation: 'ワークスペースの作成', - samlSSO: 'SAML SSO', + headerTitle: 'アクセスが制限されています', + title: '確認が必要です', + description: (domainName: string) => + `${domainName} の承認済み会社管理者としてご本人確認を行うか、既存の管理者にアクセスをリクエストしてください。`, + requestAdminAccess: '管理者アクセスをリクエスト', + verifyYourself: '本人確認を行う', }, addDomain: { title: 'ドメインを追加', @@ -10753,7 +10942,6 @@ ${reportName}`, title: 'このドメインはすでに設定されています。アクセスをリクエストしますか?', description: '誰かがこのドメインをExpensifyにすでに設定しています。管理者アクセスをリクエストしますか?', requestAccess: '管理者アクセスをリクエスト', - requestAccessError: 'リクエストを送信できませんでした。もう一度お試しください。', }, domainAdded: { title: 'ドメインを追加しました', @@ -10778,6 +10966,9 @@ ${reportName}`, consolidatedDomainBillingError: '統合ドメインの請求を変更できませんでした。後でもう一度お試しください。', addAdmin: '管理者を追加', addAdminError: 'このメンバーを管理者として追加できませんでした。もう一度お試しください。', + requests: 'リクエスト', + approveRequestError: 'このリクエストを承認できませんでした。もう一度お試しください。', + declineRequestError: 'このリクエストを拒否できませんでした。もう一度お試しください。', revokeAdminAccess: '管理者アクセスを取り消す', cantRevokeAdminAccess: '技術担当者から管理者アクセス権を取り消すことはできません', error: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d8526ed91be2..510de3dc2229 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: 'Delen stoppen', yes: 'Ja', no: 'Nee', + approve: 'Goedkeuren', + deny: 'Weigeren', dontChange: 'Niet wijzigen', ok: 'OK', notNow: 'Nu niet', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: 'Zoeken', reports: 'Rapporten', spend: 'Uitgaven', + insights: 'Inzichten', find: 'Zoeken', searchWithThreeDots: 'Zoeken...', next: 'Volgende', @@ -464,6 +467,9 @@ const translations: TranslationDeepObject = { none: 'Geen', unstableInternetConnection: 'Onstabiele internetverbinding. Controleer je netwerk en probeer het opnieuw.', enableGlobalReimbursements: 'Wereldwijde terugbetalingen inschakelen', + corpayPayModalTitle: 'Rapport betalen', + corpayPayModalPrompt: + 'Deze indiener heeft een bankrekening die niet in USD is. Schakel wereldwijde terugbetalingen in om het rapport te betalen, of vraag hen een USD-bankrekening toe te voegen.', purchaseAmount: 'Aankoopsbedrag', originalAmount: 'Oorspronkelijk bedrag', frequency: 'Frequentie', @@ -950,13 +956,21 @@ const translations: TranslationDeepObject = { tooltip: 'Begin hier!', }, homePage: { - forYou: 'Voor jou', + toDos: 'To-do’s', timeSensitiveSection: { title: 'Tijdgevoelig', - addShippingAddress: {title: 'We hebben je verzendadres nodig', subtitle: 'Geef een adres op om je Expensify Kaart te ontvangen.', cta: 'Adres toevoegen'}, + 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'}, activateCard: {title: 'Activeer je Expensify Kaart', subtitle: 'Valideer je kaart en begin met uitgeven.', cta: 'Activeren'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `Toevoegen van ${walletName}-kaart vereist jouw goedkeuring`, + subtitle: 'Expensify Kaart', + cta: 'Beoordelen', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Digitale portemonnee', + }, reviewCardFraud: { title: 'Controleer mogelijk misbruik van je Expensify Kaart', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Controleer ${amount} aan mogelijke fraude bij ${merchant}`, @@ -979,7 +993,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portemonnee', }, validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, - addHomeAddress: {title: 'Voeg je thuisadres toe voor afstandsregistratie', subtitle: 'Account', cta: 'Adres toevoegen'}, + addHomeAddress: {title: 'Voeg je thuisadres toe voor afstandsregistratie', subtitle: 'Account', cta: 'Toevoegen'}, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', @@ -987,11 +1001,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'Portemonnee', }, - addVirtualCardPersonalDetails: { - title: 'Voeg je gegevens toe om je Expensify Kaart te bekijken en te gebruiken.', - subtitle: 'Expensify Kaart', - cta: 'Details toevoegen', - }, + addVirtualCardPersonalDetails: {title: 'Voeg je gegevens toe om je Expensify Kaart te bekijken en te gebruiken.', subtitle: 'Expensify Kaart', cta: 'Toevoegen'}, enterSignerInfo: {title: 'Ondertekenaargegevens vereist', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Bankrekening ${bankAccountLastFour}`}, }, discoverSection: { @@ -1094,6 +1104,8 @@ const translations: TranslationDeepObject = { inviteAccountant: 'Nodig je accountant uit', customizeSpendCategories: 'Uitgaven­categorieën aanpassen', customizeSpendCategoriesSubText: 'Orden en classificeer uitgaven', + customizeExpenseCategories: 'Pas je uitgavencategorieën aan', + customizeExpenseCategoriesSubText: 'Voeg de categorieën van je bedrijf toe om je uitgaven te coderen', createExpense: 'Maak een uitgave', createExpenseSubText: 'Scan, sleep neer en zet neer, of voer handmatig een uitgave in met de +-knop', linkPersonalCard: 'Koppel persoonlijke kaart', @@ -1119,7 +1131,6 @@ const translations: TranslationDeepObject = { title: 'Je uitgaven', awaitingApproval: 'In afwachting van goedkeuring', repaidLast30Days: 'Terugbetaald afgelopen 30 dagen', - recentTransactions: ({lastFour}: {lastFour: string}) => `Recente transacties • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `Bekijk nog ${count}`, recentlyAddedSection: { @@ -1274,6 +1285,7 @@ const translations: TranslationDeepObject = { phrase1: 'Voeg een bon toe', phrase2: 'of sleep ze hier naartoe', }, + pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 van ${pageCount}`, }, quickAction: { scanReceipt: 'Bon scannen', @@ -1660,11 +1672,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Alles in orde!', noDuplicatesDescription: 'Er zijn hier geen dubbele transacties om te beoordelen.', confirmApprove: 'Bevestig goedkeuringsbedrag', - confirmApprovalAmount: 'Keur alleen conforme uitgaven goed, of keur het hele rapport goed.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Deze uitgave is in de wacht gezet. Wil je toch goedkeuren?', - other: 'Deze declaraties staan in de wacht. Wil je ze toch goedkeuren?', - }), + confirmApprovalWithHeldAmount: 'Het rapport bevat uitgaven in de wacht. Alleen conforme uitgaven goedkeuren, of het hele rapport goedkeuren?', + confirmApprovalAllHoldAmount: 'Alle uitgaven staan in de wacht. Toch goedkeuren?', confirmPay: 'Bevestig betalingsbedrag', confirmPayAmount: 'Betaal wat niet in de wacht staat, of betaal het volledige rapport.', confirmPayAllHoldAmount: () => ({ @@ -2293,6 +2302,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Gemarkeerde span-namen', sentryHighlightedSpanOpsPlaceholder: 'ui.interactie.klik, navigatie, ui.laden', showBranchNameInTitle: 'Toon branchnaam in titel van browser', + betaOverrides: 'Bèta-overrides', + betaOverridesDescription: + "Overrides gelden alleen voor dit apparaat en zijn alleen van invloed op frontendcontroles. Een bèta houdt een override alleen zolang die afwijkt van de bèta's van je account, dus door hem terug te zetten wordt de override verwijderd. 'Alle overrides resetten' herstelt de waarden van je account. Sommige bèta's worden ook door de backend bepaald en kunnen dus alsnog mislukken op verzoekniveau.", + resetAllOverrides: 'Alle overrides resetten', + overridden: 'Overschreven', qaAuth: 'QA-authenticatie (Cloudflare)', qaAuthRunProbe: 'Probe uitvoeren', qaAuthSession: 'QA-authsessie', @@ -2608,6 +2622,10 @@ const translations: TranslationDeepObject = { connectionLink ? `Je verbinding met de kaart ${cardName} is verbroken. Log in bij je bank om de kaart te herstellen.` : `Je verbinding met de kaart ${cardName} is verbroken. Log in bij je bank om de kaart te herstellen.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Je verbinding met ${cardName} is al 30 dagen verbroken. Log in bij je bank om dit op te lossen of verwijder de kaart als deze niet meer wordt gebruikt. Je verliest geen ingediende uitgaven als je de kaart verwijdert.` + : `Je verbinding met ${cardName} is al 30 dagen verbroken. Log in bij je bank om dit op te lossen of verwijder de kaart als je die niet meer gebruikt. Je raakt geen ingediende uitgaven kwijt als je haar verwijdert.`, addAdditionalCards: 'Extra kaarten toevoegen', upgradeDescription: 'Meer kaarten toevoegen? Maak een werkruimte om extra persoonlijke kaarten toe te voegen of bedrijfskaarten aan het hele team toe te wijzen.', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2708,6 +2726,24 @@ const translations: TranslationDeepObject = { unlock: 'Ontgrendelen', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Kaart toevoegen aan ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'digitale portemonnee', + confirmHeading: 'Bevestig je verzoek', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Wil je je Expensify Kaart (eindigend op ${lastFourDigits}) toevoegen aan je ${walletName}?`, + deny: 'Weigeren', + confirm: 'Bevestigen', + verifyTitle: 'Laten we controleren of jij het bent', + enterSecurityCode: (contactMethod: string) => + `Voer de beveiligingscode in die is verzonden naar ${contactMethod} om dit verzoek te bevestigen. Die zou binnen een à twee minuten moeten aankomen.`, + successHeading: 'Gelukt!', + successDescription: ({walletName}: {walletName: string}) => `Je kaart is nu actief en klaar voor gebruik in je ${walletName}.`, + deniedHeading: 'Verzoek geweigerd', + deniedDescription: ({walletName}: {walletName: string}) => `Je kaart is niet toegevoegd aan je ${walletName}.`, + }, cardPage: { expensifyCard: 'Expensify Kaart', expensifyTravelCard: 'Expensify Travel Card', @@ -3157,6 +3193,7 @@ ${amount} voor ${merchant} - ${date}`, unread: 'Ongelezen', markAllAsRead: 'Alles als gelezen markeren', markAllAsReadConfirmationPrompt: 'Weet je zeker dat je alle chats als gelezen wilt markeren?', + markAllTodosAsReadConfirmationPrompt: 'Weet je zeker dat je alle openstaande chats als gelezen wilt markeren?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3334,6 +3371,7 @@ ${amount} voor ${merchant} - ${date}`, subtitle: (workEmail: string | undefined) => `We konden ${workEmail} niet toevoegen. Probeer het later opnieuw in Instellingen of chat met Concierge voor hulp.`, workAccountClosedSubtitle: 'De zakelijke account die aan dit e-mailadres is gekoppeld, is gesloten. Neem contact op met de beheerder van je bedrijf om het opnieuw te activeren, of meld je aan met een ander e-mailadres.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} is een door het domein beheerde login voor een bestaand Expensify-account.`, }, tasks: { testDriveAdminTask: { @@ -4163,8 +4201,7 @@ ${amount} voor ${merchant} - ${date}`, legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', enterTheDateOfBirthOfTheOwner: 'Wat is de geboortedatum van de eigenaar?', - enterTheLast4: 'Wat zijn de laatste 4 cijfers van het burgerservicenummer (BSN) van de eigenaar?', - last4SSN: 'Laatste 4 cijfers van SSN', + enterTheSSN: 'Wat is het Amerikaanse Social Security Number (SSN) van de eigenaar?', dontWorry: 'Geen zorgen, we voeren geen persoonlijke kredietcontroles uit!', enterTheOwnersAddress: 'Wat is het adres van de eigenaar?', letsDoubleCheck: 'Laten we voor de zekerheid controleren of alles er goed uitziet.', @@ -4191,13 +4228,12 @@ ${amount} voor ${merchant} - ${date}`, whatsYourDOB: 'Wat is je geboortedatum?', whatsTheOwnersAddress: 'Wat is het adres van de eigenaar?', whatsYourAddress: 'Wat is je adres?', - whatAreTheLast: 'Wat zijn de laatste 4 cijfers van het burgerservicenummer (BSN) van de eigenaar?', - whatsYourLast: 'Wat zijn de laatste 4 cijfers van uw sofinummer?', + whatsTheOwnersSSN: 'Wat is het Amerikaanse Social Security Number (SSN) van de eigenaar?', + whatsYourSSN: 'Wat is je Amerikaanse Social Security Number (SSN)?', whatsYourNationality: 'Wat is uw nationaliteit?', whatsTheOwnersNationality: 'Wat is het land van staatsburgerschap van de eigenaar?', countryOfCitizenship: 'Land van nationaliteit', dontWorry: 'Geen zorgen, we voeren geen persoonlijke kredietcontroles uit!', - last4: 'Laatste 4 cijfers van SSN', whyDoWeAsk: 'Waarom vragen we dit?', letsDoubleCheck: 'Laten we voor de zekerheid controleren of alles er goed uitziet.', legalName: 'Wettelijke naam', @@ -4220,6 +4256,8 @@ ${amount} voor ${merchant} - ${date}`, proofOfBeneficialOwner: 'Bewijs van uiteindelijk belanghebbende', proofOfBeneficialOwnerDescription: 'Geef een ondertekende verklaring en een organisatieschema van een openbare accountant, notaris of advocaat waarin wordt bevestigd wie 25% of meer van het bedrijf bezit. Deze moeten dateren van binnen de laatste drie maanden en het licentienummer van de ondertekenaar bevatten.', + proofOfBeneficialOwnerDescriptionCA: + 'Upload alsjeblieft je Schedule 50 (aandeelhoudersinformatie) van je meest recente T2-vennootschapsbelastingaangifte, waarop iedere persoon staat die 25% of meer van het bedrijf bezit.', copyOfID: 'Kopie van identiteitsbewijs van uiteindelijk belanghebbende', copyOfIDDescription: 'Voorbeelden: paspoort, rijbewijs, enz.', proofOfAddress: 'Adresbewijs voor uiteindelijke belanghebbende', @@ -4596,6 +4634,7 @@ ${amount} voor ${merchant} - ${date}`, customFieldHint: 'Voeg aangepaste codering toe die van toepassing is op alle uitgaven van dit lid.', reports: 'Rapporten', reportFields: 'Rapportvelden', + invoiceFields: 'Factuurvelden', reportTitle: 'Rapporttitel', reportField: 'Rapportveld', taxes: 'Belastingen', @@ -4605,6 +4644,7 @@ ${amount} voor ${merchant} - ${date}`, travel: 'Reizen', members: 'Leden', accounting: 'Boekhouding', + mcp: 'MCP', receiptPartners: 'Bonpartners', rules: 'Regels', displayedAs: 'Weergegeven als', @@ -4729,6 +4769,7 @@ ${amount} voor ${merchant} - ${date}`, travelInvoicingVendor: 'Reisaanbieder', travelInvoicingPayableAccount: 'Reiscrediteurenrekening', hr: 'HR', + recruiting: 'Werving', rooms: 'Kamers', findDomain: 'Domein zoeken', cardAdminAlternateText: 'Werkruimtekaarten beheren.', @@ -4742,6 +4783,25 @@ ${amount} voor ${merchant} - ${date}`, title: 'Je hebt een werkruimte voor je klant aangemaakt!', description: 'Geweldig nieuws 🎉. Neem contact met ons op als ze hulp nodig hebben bij de configuratie.', }, + mcp: { + connectors: 'Connectoren', + connectorsSubtitle: 'Verbind een AI-assistent met je Expensify-account.', + connect: 'Verbinden', + helpPrompt: 'Hulp nodig bij het verbinden?', + helpLink: 'Lees onze handleiding.', + claude: { + title: 'Claude', + subtitle: 'van Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'van Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'van OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `Verbonden met ${organizationName}` : 'Automatiseer reis- en maaltijdbezorgingskosten in uw hele organisatie.'), @@ -4958,7 +5018,6 @@ ${amount} voor ${merchant} - ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Gebruik deze rekening bij het exporteren van facturen naar ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Stel in hoe aankopen met bedrijfskaarten worden geëxporteerd naar ${integrationName}.`, vendor: 'Leverancier', - defaultVendorDescription: 'Stel een standaardleverancier in die bij export op alle creditcardtransacties wordt toegepast.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Stel in hoe contante uitgaven worden geëxporteerd naar ${integrationName}.`, exportCheckDescription: 'We maken een gespecificeerde cheque voor elk Expensify-rapport en versturen die vanaf de onderstaande bankrekening.', exportJournalEntryDescription: 'We maken een gespecificeerde journaalpost voor elk Expensify-rapport en boeken deze op de onderstaande rekening.', @@ -5123,8 +5182,6 @@ ${amount} voor ${merchant} - ${date}`, }, noAccountsFound: 'Geen accounts gevonden', noAccountsFoundDescription: 'Voeg het account toe in Xero en synchroniseer de verbinding opnieuw', - defaultSupplier: 'Standaardleverancier', - defaultSupplierDescription: 'Stel een standaardsupplier in die wordt toegepast op alle creditcardtransacties bij het exporteren.', noSuppliersFound: 'Geen leveranciers gevonden', noSuppliersFoundDescription: 'Voeg de leverancier toe in Xero en synchroniseer de koppeling opnieuw.', accountingMethods: { @@ -6341,6 +6398,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ 'We konden voor sommige leden van je werkruimte geen toegang instellen tot Geconsolideerde Reisfacturering. Probeer het later opnieuw of neem contact op met Concierge voor ondersteuning.', sendInvoiceNowCta: 'Verzend factuur nu', }, + setUpNewFeed: 'Nieuwe reiskoppeling instellen', + feedSelectorIntro: + 'Je organisatie heeft al een Consolidated Travel Billing-feed ingesteld. Selecteer die om de reiskosten van deze workspace op dezelfde maandelijkse factuur te houden.', }, disableModal: { title: 'Samengevoegde reiskostenfacturatie uitschakelen?', @@ -6497,6 +6557,10 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ title: 'Boekhouding', subtitle: 'Synchroniseer je grootboekrekeningschema en meer.', }, + mcp: { + title: 'MCP', + subtitle: 'Verbind een AI-assistent met je Expensify-account.', + }, receiptPartners: { title: 'Bonpartners', subtitle: 'Automatisch bonnetjes importeren.', @@ -6560,6 +6624,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ subtitle: 'Rapportvelden zijn van toepassing op alle uitgaven en kunnen nuttig zijn wanneer je om extra informatie wilt vragen.', disableReportFields: 'Rapportvelden uitschakelen', disableReportFieldsConfirmation: 'Weet je het zeker? Tekst- en datumvelden worden verwijderd en lijsten worden uitgeschakeld.', + cannotDisableImportedReportFields: 'Rapportvelden die zijn geïmporteerd via je boekhoudkoppeling kunnen niet worden uitgeschakeld.', importedFromAccountingSoftware: 'De onderstaande rapportvelden worden geïmporteerd uit jouw', textType: 'Tekst', dateType: 'Datum', @@ -6596,6 +6661,29 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ reportFieldInitialValueRequiredError: 'Kies een beginwaarde voor een rapportveld', genericFailureMessage: 'Er is een fout opgetreden bij het bijwerken van het rapportveld. Probeer het opnieuw.', }, + invoiceFields: { + subtitle: 'Factuurvelden kunnen handig zijn als je extra informatie wilt toevoegen.', + importedFromAccountingSoftware: 'De onderstaande factuurvelden zijn geïmporteerd uit je', + disableInvoiceFields: 'Factuurvelden uitschakelen', + disableInvoiceFieldsConfirmation: 'Weet je het zeker? Factuurvelden worden uitgeschakeld op facturen.', + delete: 'Factuurveld verwijderen', + deleteConfirmation: 'Weet je zeker dat je dit factuurveld wilt verwijderen?', + findInvoiceField: 'Factuurveld zoeken', + nameInputSubtitle: 'Kies een naam voor het factuurveld.', + typeInputSubtitle: 'Kies welk type factuurveld je wilt gebruiken.', + initialValueInputSubtitle: 'Voer een beginwaarde in om in het factuurveld weer te geven.', + listValuesInputSubtitle: 'Deze waarden verschijnen in de vervolgkeuzelijst van je factuurveld. Ingeschakelde waarden kunnen door leden worden geselecteerd.', + listInputSubtitle: 'Deze waarden verschijnen in je factuurveldenlijst. Ingeschakelde waarden kunnen door leden worden geselecteerd.', + emptyInvoiceFieldsValues: { + title: 'Nog geen lijstwaarden', + subtitle: 'Voeg aangepaste waarden toe om op facturen te tonen.', + }, + existingInvoiceFieldNameError: 'Er bestaat al een factuurveld met deze naam', + invoiceFieldNameRequiredError: 'Voer een naam voor een factuurveld in', + invoiceFieldTypeRequiredError: 'Kies een veldtype voor de factuur', + invoiceFieldInitialValueRequiredError: 'Kies een beginwaarde voor een factuurveld', + addField: 'Veld toevoegen', + }, vendors: { emptyTitle: 'Nog geen leveranciers', emptySubtitle: 'Leveranciers verschijnen hier zodra je boekhoudkundige synchronisatie is voltooid.', @@ -6989,6 +7077,8 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7067,7 +7157,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, return 'Geïmporteerde gegevens verwerken'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': - return 'Gerapporteerde terugbetalingen en rekeningbetalingen synchroniseren'; + return 'Bezig met synchroniseren van vergoede rapporten en rekeningbetalingen'; case 'quickbooksOnlineSyncTaxCodes': return 'Belastingcodes importeren'; case 'quickbooksOnlineCheckConnection': @@ -7084,13 +7174,13 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, case 'quickbooksDesktopImportTitle': return 'Titel importeren'; case 'quickbooksDesktopImportApproveCertificate': - return 'Bezig met importeren van goedkeuringscertificaat'; + return 'Certificaat voor goedkeuring importeren'; case 'quickbooksDesktopImportDimensions': return 'Dimensies importeren'; case 'quickbooksDesktopImportSavePolicy': - return 'Bezig met importeren van spaarbeleid'; + return 'Beleid voor opslaan importeren'; case 'quickbooksDesktopWebConnectorReminder': - return 'Data wordt nog steeds met QuickBooks gesynchroniseerd... Zorg ervoor dat de Web Connector actief is'; + return 'Gegevens worden nog steeds gesynchroniseerd met QuickBooks... Zorg ervoor dat de Web Connector actief is'; case 'quickbooksOnlineSyncTitle': return `${integrationName}-gegevens synchroniseren`; case 'quickbooksOnlineSyncLoadData': @@ -7114,15 +7204,15 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, case 'xeroSyncImportCustomers': return 'Klanten synchroniseren'; case 'xeroSyncXeroReimbursedReports': - return 'Expensify-rapporten markeren als vergoed'; + return 'Expensify-rapporten als vergoed markeren'; case 'xeroSyncExpensifyReimbursedReports': - return 'Xero-facturen en -rekeningen als betaald markeren'; + return 'Xero-rekeningen en -facturen als betaald markeren'; case 'xeroSyncImportTrackingCategories': return 'Trackingcategorieën synchroniseren'; case 'xeroSyncImportBankAccounts': return 'Bankrekeningen synchroniseren'; case 'xeroSyncImportTaxRates': - return 'Belastingtarieven synchroniseren'; + return 'Belastingspercentages synchroniseren'; case 'xeroCheckConnection': return 'Xero-verbinding controleren'; case 'xeroSyncTitle': @@ -7138,21 +7228,21 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, case 'netSuiteSyncImportItems': return 'Items importeren'; case 'netSuiteSyncData': - return 'Gegevens importeren in Expensify'; + return 'Gegevens in Expensify importeren'; case 'netSuiteSyncAccounts': return 'Accounts synchroniseren'; case 'netSuiteSyncCurrencies': - return "Valuta's synchroniseren"; + return 'Valuta’s synchroniseren'; case 'netSuiteSyncCategories': return 'Categorieën synchroniseren'; case 'netSuiteSyncReportFields': return 'Gegevens importeren als Expensify-rapportvelden'; case 'netSuiteSyncTags': - return 'Gegevens importeren als Expensify-tags'; + return 'Data importeren als Expensify-tags'; case 'netSuiteSyncUpdateConnectionData': - return 'Verbindingsinformatie bijwerken'; + return 'Verbindingsgegevens bijwerken'; case 'netSuiteSyncNetSuiteReimbursedReports': - return 'Expensify-rapporten markeren als vergoed'; + return 'Expensify-rapporten als vergoed markeren'; case 'netSuiteSyncExpensifyReimbursedReports': return 'NetSuite-facturen en -rekeningen als betaald markeren'; case 'netSuiteImportVendorsTitle': @@ -7162,12 +7252,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, case 'netSuiteSyncImportCustomLists': return 'Aangepaste lijsten importeren'; case 'netSuiteSyncImportSubsidiaries': - return 'Subondernemingen importeren'; + return 'Dochterondernemingen importeren'; case 'netSuiteSyncImportVendors': case 'quickbooksDesktopImportVendors': return 'Leveranciers importeren'; case 'intacctCheckConnection': - return 'Sage Intacct-verbinding controleren'; + return 'Verbinding met Sage Intacct controleren'; case 'intacctImportDimensions': return 'Sage Intacct-dimensies importeren'; case 'intacctImportTitle': @@ -7191,7 +7281,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, case 'financialForceSyncDimensions': return 'Dimensies importeren'; case 'financialForceMarkAsReimbursed': - return 'Rapporten als terugbetaald markeren'; + return 'Rapporten als vergoed markeren'; case 'rilletSyncTitle': return 'Rillet-gegevens synchroniseren'; case 'rilletSyncConnection': @@ -7209,7 +7299,19 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, case 'dualEntrySyncCardSettlements': return 'Kaartafrekeningen synchroniseren'; case 'dualEntrySyncTravelSettlements': - return 'Reisafrekeningen synchroniseren'; + return 'Reisverrekeningen synchroniseren'; + case 'campfireSyncTitle': + return 'Campfire-gegevens synchroniseren'; + case 'campfireSyncConnection': + return 'Verbinding met Campfire initialiseren'; + case 'campfireSyncImportData': + return 'Gegevens laden'; + case 'campfireSyncPayments': + return 'Leveranciersbetalingen synchroniseren'; + case 'campfireSyncCardSettlements': + return 'Kaartafrekeningen synchroniseren'; + case 'campfireSyncTravelSettlements': + return 'Reisverrekeningen synchroniseren'; default: { return `Vertaling ontbreekt voor fase: ${stage}`; } @@ -7225,11 +7327,11 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, exportCompanyCard: 'Bedrijfspaskosten exporteren als', exportDate: 'Exportdatum', defaultVendor: 'Standaardleverancier', - defaultVendorHelperText: (isSet: boolean) => - isSet + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName ? `Declaraties die niet automatisch worden gematcht, worden standaard aan deze leverancier gekoppeld.` - : `Bonnetjes die niet automatisch worden gekoppeld, worden standaard aan deze leverancier toegewezen. Anders worden ze geëxporteerd als Credit Card Misc.`, - defaultVendorSelectHeader: (connectionName: string) => `Kies een standaard ${connectionName}-leverancier voor uitgaven die niet automatisch worden gematcht.`, + : `Declaraties die niet automatisch worden gekoppeld, worden standaard aan deze leverancier toegewezen. Anders worden ze geëxporteerd als ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Kies een standaardleverancier voor uitgaven die niet automatisch worden herkend.`, defaultAccount: 'Standaardrekening', autoSync: 'Automatisch synchroniseren', autoSyncDescription: 'Synchroniseer NetSuite en Expensify automatisch, elke dag. Exporteer een afgerond rapport in realtime', @@ -7252,6 +7354,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, syncTravelInvoicingSettlements: 'Geconsolideerde Travel Billing-afrekeningen synchroniseren', syncTravelInvoicingSettlementsNoAccountTooltip: 'Om dit te ontgrendelen, stel je een rekening in voor je exporten.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Schakel automatisch synchroniseren in om dit te ontgrendelen.', + campfire: 'Campfire', }, export: { notReadyHeading: 'Niet klaar om te exporteren', @@ -7309,12 +7412,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', optionDisabledHelp: 'Er is geen woon-werkverkeer uit declaraties verwijderd.', - optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', - optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', + optionFixedDistanceTitle: 'Vaste afstand verwijderen', + optionFixedDistanceHelp: 'Trekt dezelfde afstand van elke declaratie af.', distanceLabel: 'Afstand', summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', - optionHomeAndOfficeTitle: 'Berekenen per thuis en kantoor', - optionHomeAndOfficeHelp: 'Gebruik het thuisadres van het lid, de werkregeling en de kantoorindeling om woon-werkuitzonderingen te berekenen.', + optionHomeAndOfficeTitle: 'Gebruikelijke woon-werkrit uitsluiten', + optionHomeAndOfficeHelp: 'Trekt de gebruikelijke woon-werkrit van elk lid af op basis van hun thuisadres, werkafspraak en kantoor.', workspaceAddressRequired: { title: 'Niet zo snel...', promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', @@ -7509,6 +7612,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Rapportvelden zijn alleen beschikbaar in het Control-abonnement, vanaf ${formattedPrice} ${hasTeam2025Pricing ? `per lid per maand.` : `per actieve deelnemer per maand.`}`, }, + invoiceFields: { + title: 'Factuurvelden', + description: `Met factuurvelden kun je extra gegevens op factuurniveau toevoegen aan facturen.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Factuurvelden zijn alleen beschikbaar in het Control-abonnement, vanaf ${formattedPrice} ${hasTeam2025Pricing ? `per lid per maand.` : `per actieve deelnemer per maand.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Profiteer van automatische synchronisatie en verminder handmatige invoer met de Expensify + NetSuite-integratie. Krijg diepgaande realtime financiële inzichten met ondersteuning voor native en aangepaste segmenten, inclusief project- en klanttoewijzing.`, @@ -7551,6 +7660,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Onze DualEntry-integratie is alleen beschikbaar in het Control-abonnement, vanaf ${formattedPrice} ${hasTeam2025Pricing ? `per lid per maand.` : `per actieve deelnemer per maand.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Profiteer van automatische synchronisatie en verminder handmatige invoer met de Expensify + Campfire-integratie. Stem uitgavendimensies en belastingsynchronisatie af op je Campfire-configuratie voor helderder financieel inzicht.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Onze Campfire-integratie is alleen beschikbaar in het Control-abonnement, vanaf ${formattedPrice} ${hasTeam2025Pricing ? `per lid per maand.` : `per actieve deelnemer per maand.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Geavanceerde goedkeuringen', description: `Als je extra lagen van goedkeuring wilt toevoegen – of gewoon zeker wilt weten dat de hoogste uitgaven een extra controle krijgen – dan ben je bij ons aan het juiste adres. Geavanceerde goedkeuringen helpen je om op elk niveau de juiste controles in te bouwen, zodat je de uitgaven van je team onder controle houdt.`, @@ -7841,7 +7956,6 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar alwaysReimbursableDescription: 'Onkosten worden altijd terugbetaald aan medewerkers', alwaysNonReimbursable: 'Altijd niet-declarabel', alwaysNonReimbursableDescription: 'Onkosten worden nooit aan medewerkers terugbetaald', - billableDefault: 'Factureerbaar als standaard', billableDefaultDescription: 'Kies of contante en creditcarduitgaven standaard factureerbaar moeten zijn.', billable: 'Factureerbaar', billableDescription: 'Uitgaven worden meestal doorbelast aan klanten', @@ -7868,10 +7982,12 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar publicReceiptVisibilityHintDisabled: 'Bonnetjes zijn alleen zichtbaar voor Expensify-leden met toegang tot het rapport dat het bonnetje bevat.', enableTagsToUnlockTitle: 'Tags inschakelen?', enableTagsToUnlockPrompt: 'Schakel Labels in (onder Meer functies) om te ontgrendelen.', - enableTagsAndRequirePrompt: 'Weet je zeker dat je tags wilt inschakelen en ze verplicht wilt maken voor alle uitgaven?', enableCategoriesToUnlockTitle: 'Categorieën inschakelen?', enableCategoriesToUnlockPrompt: 'Schakel Categorieën (onder Meer functies) in om te ontgrendelen.', enableCategoriesAndRequirePrompt: 'Weet je zeker dat je categorieën wilt inschakelen en ze verplicht wilt maken voor alle uitgaven?', + enableTagsPrompt: 'Weet je zeker dat je labels wilt inschakelen? Je kunt ze verplicht maken voor alle uitgaven zodra je minstens één label hebt.', + noTagsToRequirePrompt: 'Je hebt nog geen tags. Maak alsjeblieft een tag aan.', + noCategoriesToRequirePrompt: 'Je hebt nog geen categorieën. Maak een categorie aan.', }, expenseReportRules: { title: 'Geavanceerd', @@ -7944,6 +8060,20 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar 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', + confirmErrorConditionAndDefault: 'Voer een leverancier in en stel minstens één standaard in', + turnOnTaxesFirstTitle: 'Zet eerst belastingen aan', + turnOnTaxesFirstPrompt: 'Categorischregels stellen een standaardbelastingtarief in. Schakel belastingen in bij de instellingen van je workspace om ze te gebruiken.', + categoryRulesApplyGoingForwardTitle: 'Categoriegregels gelden vanaf nu', + categoryRulesApplyGoingForwardPrompt: 'Een standaardbelastingtarief is van toepassing op nieuwe uitgaven in deze categorie. Bestaande uitgaven veranderen niet.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Het geselecteerde belastingtarief is nu de standaard voor je werkruimte, waardoor deze regel niet meer geldig is. Kies een ander belastingtarief.', + addTaxRateFirstTitle: 'Voeg eerst een belastingtarief toe', + addTaxRateFirstPrompt: 'Categorielimieten stellen een standaardbelastingtarief in. Voeg een ander belastingtarief toe dan de standaard van je werkruimte om ze te gebruiken.', + createRuleFromExpenseAction: 'Maak een regel', + createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, categoryRules: { title: 'Categorisatieregels', @@ -7964,7 +8094,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar flagAmountsOverSubtitle: 'Dit overschrijft het maximale bedrag voor alle onkosten.', expenseLimitTypes: { expense: 'Individuele uitgave', - expenseSubtitle: 'Markeer onkostbedragen per categorie. Deze regel overschrijft de algemene werkruimteregel voor het maximale onkostbedrag.', + expenseSubtitle: + 'Markeer onkostbedragen per categorie. Deze regel overschrijft de algemene werkruimteregel voor het maximale onkostbedrag. Reserveringen voor meerdere dagen worden beoordeeld op basis van het gemiddelde per nacht.', daily: 'Categorietotaal', dailySubtitle: 'Markeer totale dagelijkse categorie-uitgaven per onkostendeclaratie.', }, @@ -8242,10 +8373,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, applyExpenseDefaultsDescription: 'Velden bijwerken zonder dat de indiener iets hoeft te doen', flagForReview: 'Markeren voor controle', flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', - requireFields: 'Veldvereisten', + requireFields: 'Veldvereisten instellen', requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', - createAgentRule: 'Agentregel', - createAgentRuleDescription: 'Beschrijf flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', + createAgentRule: 'Agentregel beschrijven', + createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', }, expenseDefaultsTable: { tableColumnType: 'Type', @@ -8256,6 +8387,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, update: 'Bijwerken', merchantIs: (merchant: string) => `Handelaar is "${merchant}"`, merchantTypeIs: (merchantType: string) => `Type leverancier is "${merchantType}"`, + categoryIs: (category: string) => `Categorie is "${category}"`, }, merchantTypeRule: {merchantType: 'Type handelaar', saveRule: 'Regel opslaan', confirmErrorCategory: 'Selecteer een categorie.'}, requireFieldsTable: { @@ -8272,6 +8404,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, requireReceipt: 'Bon nodig', doNotRequireReceipt: 'Bon niet verplicht stellen', doNotRequireItemizedReceipt: 'Geen gespecificeerde bon vereist', + typeLabel: 'Vereisen', }, requireFieldsEmptyState: { title: 'Signaleer ontbrekende gegevens direct', @@ -8320,6 +8453,19 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, agentRulesEmptyState: {title: 'Geen agentregels toegevoegd', subtitle: 'Maak een regel om je werkruimtebeleid te automatiseren.', cta: 'AI-regel toevoegen'}, categoriesDisabledEmptyState: {title: 'Categorieën zijn niet ingeschakeld', subtitle: 'Schakel categorieën in om meer controle te krijgen over je uitgaven.'}, + expenseDefaultType: { + title: 'Standaardwaarden voor uitgaven toepassen', + subtitle: 'Waar moet deze regel op overeenkomen?', + merchant: 'Winkelier', + merchantDescription: 'Veldwaarden bij uitgaven van een specifieke verkoper bijwerken', + category: 'Categorie', + categoryDescription: 'Stel een standaardbelastingtarief in voor een specifieke categorie', + }, + taxesDisabledEmptyState: { + title: 'Belastingen zijn niet ingeschakeld', + subtitle: 'Activeer belastingen om in aanmerking komende belastingen vast te leggen en terug te vorderen.', + cta: 'Belastingen inschakelen', + }, }, planTypePage: { planTypes: { @@ -8346,37 +8492,26 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, hr: { title: 'HR', - connections: 'Verbindingen', connectionsSubtitle: 'Verbind met je HR-systeem om werknemersgegevens te synchroniseren, terugbetalingen automatisch aan de juiste personen te koppelen en de uitgaven van je team nauwkeurig te houden zonder handmatig werk.', subtitle: 'Koppel HR-tools en houd goedkeuringen van medewerkers gesynchroniseerd.', - connect: 'Verbinden', - syncNow: 'Nu synchroniseren', - disconnect: 'Ontkoppelen', - disconnectTitle: (providerName: string) => `${providerName} ontkoppelen`, - disconnectPrompt: (providerName: string) => `Weet je zeker dat je ${providerName} wilt ontkoppelen?`, alreadyConnectedTitle: 'Kan niet met meerdere HR-platformen verbinden', alreadyConnectedPrompt: 'Je moet je huidige HR-platform loskoppelen voordat je een ander kunt verbinden.', - lastSync: (relativeDate: string) => `Laatst gesynchroniseerd ${relativeDate}`, - syncError: (providerName: string) => `Kan geen verbinding maken met ${providerName}`, connectionDescription: (providerName: string) => `Verbind ${providerName} om goedkeuringen van werknemers gesynchroniseerd te houden met je werkruimte.`, - approvalMode: 'Goedkeuringsmodus', providerApprovalMode: (providerName: string) => `${providerName}-goedkeuringsmodus`, - finalApprover: 'Eindgoedkeurder', providerFinalApprover: (providerName: string) => `Laatste ${providerName}-fiatteur`, - notSet: 'Niet ingesteld', + syncing: 'Werknemers synchroniseren', + syncingModalTitle: 'Je verbinding wordt gesynchroniseerd', + syncingModalDescription: 'De eerste verbinding kan even duren. Je krijgt een melding als er fouten optreden.', approvalModeDescription: (providerName: string) => `Leden en managers zijn ingesteld om te synchroniseren met ${providerName}.`, approvalModeWarningTitle: 'Goedkeuringsmodus wijzigen?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Weet je zeker dat je de goedkeuringsmodus voor deze werkruimte wilt wijzigen? Lees meer over de verschillende ${providerName}-ondersteunde workflowmodi op onze help-site.`, approvalModeWarningConfirm: 'Goedkeuringsmodus wijzigen', - approvalModes: { - basic: {label: 'Basisgoedkeuring', description: 'Alle gebruikers dienen bij één persoon in voor verwerking en goedkeuring.'}, - manager: { - label: 'Managergoedkeuring', - description: (providerName: string) => `Werknemers dienen rapporten in bij hun direct leidinggevende die is ingesteld in ${providerName}.`, - }, - custom: {label: 'Aangepaste goedkeuring', description: 'Ik stel goedkeuringsworkflows handmatig in in Expensify.'}, + approvalModeDescriptions: { + basic: 'Alle gebruikers dienen bij één persoon in voor verwerking en goedkeuring.', + manager: (providerName: string) => `Werknemers dienen rapporten in bij hun direct leidinggevende die is ingesteld in ${providerName}.`, + custom: 'Ik stel goedkeuringsworkflows handmatig in in Expensify.', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8416,25 +8551,63 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, zenefits: { title: 'TriNet', }, - syncingModalTitle: 'Je verbinding wordt gesynchroniseerd', - syncingModalDescription: 'De eerste verbinding kan even duren. Je krijgt een melding als er fouten optreden.', - syncing: 'Werknemers synchroniseren', + setupIncomplete: (setupLink: string | undefined) => + `Verbonden. ${setupLink ? `Instellen voltooien` : 'Configuratie voltooien'} om werknemers te importeren.`, mergeHR: { - completeSetup: 'Configuratie voltooien', - setupIncomplete: (setupLink: string | undefined) => - `Verbonden. ${setupLink ? `Instellen voltooien` : 'Configuratie voltooien'} om werknemers te importeren.`, groups: {title: 'Groepen', description: 'Kies de groepen werknemers die je met deze workspace wilt synchroniseren'}, }, + }, + recruiting: { + title: 'Werving', + connectionsSubtitle: + 'Koppel je recruitmentsysteem om kandidaatgegevens te synchroniseren, vergoedingen automatisch aan de juiste personen te koppelen en de uitgaven van je team nauwkeurig te houden zonder handmatig werk.', + alreadyConnectedTitle: 'Kan niet met meerdere ATS-platformen verbinden', + alreadyConnectedPrompt: 'Je moet je huidige ATS loskoppelen voordat je een andere verbindt.', + syncing: 'Kandidaten synchroniseren', + setupIncomplete: (setupLink: string | undefined) => + `Verbonden. ${setupLink ? `Installatie voltooien` : 'Voltooi installatie'} om kandidaten te importeren.`, + 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'}, + }, + merge: { + connections: 'Verbindingen', + connect: 'Verbinden', + findIntegration: 'Integratie zoeken', + syncNow: 'Nu synchroniseren', + disconnect: 'Ontkoppelen', + disconnectTitle: (providerName: string) => `${providerName} ontkoppelen`, + disconnectPrompt: (providerName: string) => `Weet je zeker dat je ${providerName} wilt ontkoppelen?`, + lastSync: (relativeDate: string) => `Laatst gesynchroniseerd ${relativeDate}`, notSync: 'Niet gesynchroniseerd', + syncError: (providerName: string) => `Kan geen verbinding maken met ${providerName}`, authenticationError: (providerName: string) => `Kan geen verbinding maken met ${providerName} vanwege een verlopen koppeling.`, reconnect: 'Opnieuw verbinden', reconnectLink: 'Opnieuw verbinden.', - findIntegration: 'Integratie zoeken', - }, - merge: { + notSet: 'Niet ingesteld', + completeSetup: 'Configuratie voltooien', + approvalMode: 'Goedkeuringsmodus', + finalApprover: 'Eindgoedkeurder', + approvalModes: { + basic: 'Basisgoedkeuring', + manager: 'Managergoedkeuring', + custom: 'Aangepaste goedkeuring', + advanced: 'Geavanceerde goedkeuring', + }, syncLimitReached: {title: 'Probeer het morgen opnieuw', prompt: 'Je hebt je synchronisatielimiet voor vandaag bereikt.'}, }, emptyDomain: {title: 'Verbeter je beveiliging met domeinen', subtitle: 'Vereis dat leden op je domein inloggen via single sign-on, beperk het aanmaken van werkruimtes en meer.'}, + campfire: { + campfireSetup: 'Campfire-configuratie', + enterCredentials: 'Voer je Campfire API-sleutel in', + howToFindAPIKey: + 'Je API-sleutel vinden.
  1. Log in bij Campfire
  2. Ga naar Instellingen -> API-sleutels
  3. Maak een API-sleutel aan
  4. Plak de API-sleutel hieronder
', + subsidiary: 'Dochteronderneming', + subsidiarySelectDescription: 'Kies de dochteronderneming in Campfire waarvan je gegevens wilt importeren.', + noSubsidiariesFound: 'Geen dochterondernemingen gevonden', + noSubsidiariesFoundDescription: 'Voeg alsjeblieft een entiteit toe in Campfire en synchroniseer de verbinding opnieuw', + }, }, getAssistancePage: { title: 'Hulp krijgen', @@ -9339,7 +9512,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Geconsolideerde reisfacturatie', }, is: 'Is', - has: {submittedViolation: 'Ingediende overtreding'}, + has: {submittedViolation: 'Ingediende overtreding', approvedViolation: 'Goedgekeurde schending'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Verzenden', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Goedkeuren', @@ -9401,6 +9574,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, exportedTo: 'Geëxporteerd naar', exportAll: { selectAllMatchingItems: 'Selecteer alle overeenkomende items', + allMatchingItemsSelected: 'Alle overeenkomende items geselecteerd', selectAllOnThisPage: 'Selecteer alles op deze pagina', }, errors: { @@ -9438,6 +9612,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, topMerchants: 'Topverkopers', violationsBySubmitter: 'Overtredingen door indiener', }, + mergeReports: { + title: 'Rapporten samenvoegen', + description: 'Selecteer het rapport dat je wilt behouden. Alle uitgaven worden daarheen verplaatst en de andere rapporten worden verwijderd.', + }, }, genericErrorPage: { title: 'Oeps, er is iets misgegaan!', @@ -9521,6 +9699,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `geëxporteerd naar ${translatedLabel}`; @@ -9557,6 +9736,16 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(${count} keer herhaald.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `De verbinding met ${feedName} is verbroken. Log in bij je bank om kaartimports te herstellen.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `De ${feedName}-verbinding is al 30 dagen verbroken. Log in bij je bank om dit op te lossen of verwijder de verbinding als deze niet meer wordt gebruikt. Je verliest geen ingediende uitgaven als je de verbinding verwijdert.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `de Plaid-verbinding met je zakelijke bankrekening is verbroken. Verbind je bankrekening ${maskedAccountNumber} opnieuw zodat je je Expensify Kaarten kunt blijven gebruiken.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10146,6 +10335,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Tarief is alleen geldig vanaf ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Tarief is alleen geldig tot ${endDate}`, cannotMergeDuplicates: 'Je kunt alleen onkosten samenvoegen op concept- of openstaande rapporten. Trek het rapport in en probeer het opnieuw.', + overCategoryLimitPerNight: (formattedLimit: string) => `Nachtelijk tarief boven de categorielimiet van ${formattedLimit}/persoon`, shortName: { allTagLevelsRequired: 'Alle labels verplicht', autoReportedRejectedExpense: 'Uitgave afgekeurd', @@ -10756,6 +10946,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, reportLevelExport: 'Alle gegevens - rapportniveau', expenseLevelExport: 'Alle gegevens - uitgaveniveau', multipleTaxExport: 'Canadese export met meerdere belastingen', + reconciliationAllExpenses: 'Afstemming - Alle uitgaven', exportInProgress: 'Export bezig', conciergeWillSend: 'Concierge stuurt je het bestand zo meteen.', currentView: 'Huidige weergave', @@ -10787,6 +10978,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, notVerified: 'Niet geverifieerd', retry: 'Opnieuw proberen', requestSent: 'Aanvraag verzonden', + requestAccessError: 'We konden je aanvraag niet verzenden. Probeer het opnieuw.', verifyDomain: { title: 'Domein verifiëren', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10847,12 +11039,12 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, setMetadataGenericError: 'Kon SAML-metadata niet instellen', }, accessRestricted: { - title: 'Toegang beperkt', - subtitle: (domainName: string) => `Verifieer jezelf als bevoegde bedrijfsbeheerder voor ${domainName} als je beheer nodig hebt over:`, - companyCardManagement: 'Beheer van bedrijfskaarten', - accountCreationAndDeletion: 'Account aanmaken en verwijderen', - workspaceCreation: 'Werkruimte aanmaken', - samlSSO: 'SAML-SSO', + headerTitle: 'Toegang beperkt', + title: 'Verificatie vereist', + description: (domainName: string) => + `Verifieer jezelf als bevoegde bedrijfsbeheerder voor ${domainName} of vraag toegang aan bij bestaande beheerders.`, + requestAdminAccess: 'Beheerderstoegang aanvragen', + verifyYourself: 'Verifieer jezelf', }, addDomain: { title: 'Domein toevoegen', @@ -10866,7 +11058,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, title: 'Domein al ingesteld. Toegang aanvragen?', description: 'Iemand heeft dit domein al ingesteld in Expensify. Wil je beheerderstoegang aanvragen?', requestAccess: 'Beheerderstoegang aanvragen', - requestAccessError: 'We konden je aanvraag niet verzenden. Probeer het opnieuw.', }, domainAdded: { title: 'Domein toegevoegd', @@ -10891,6 +11082,9 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, consolidatedDomainBillingError: 'Gefactureerde geconsolideerde domeinen konden niet worden gewijzigd. Probeer het later opnieuw.', addAdmin: 'Beheerder toevoegen', addAdminError: 'Kan dit lid niet als beheerder toevoegen. Probeer het opnieuw.', + requests: 'Verzoeken', + approveRequestError: 'Kan dit verzoek niet goedkeuren. Probeer het opnieuw.', + declineRequestError: 'Kan dit verzoek niet weigeren. Probeer het opnieuw.', revokeAdminAccess: 'Beheerdersrechten intrekken', cantRevokeAdminAccess: 'Kan beheerdersrechten niet intrekken van de technische contactpersoon', error: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b362520d0c05..9403c8a34383 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -58,6 +58,8 @@ const translations: TranslationDeepObject = { unshare: 'Cofnij udostępnianie', yes: 'Tak', no: 'Nie', + approve: 'Zatwierdź', + deny: 'Odrzuć', dontChange: 'Nie zmieniaj', ok: 'OK', notNow: 'Nie teraz', @@ -79,6 +81,7 @@ const translations: TranslationDeepObject = { search: 'Szukaj', reports: 'Raporty', spend: 'Wydatki', + insights: 'Analityka', find: 'Znajdź', searchWithThreeDots: 'Szukaj...', next: 'Dalej', @@ -468,6 +471,8 @@ const translations: TranslationDeepObject = { none: 'Brak', unstableInternetConnection: 'Niestabilne połączenie internetowe. Sprawdź swoją sieć i spróbuj ponownie.', enableGlobalReimbursements: 'Włącz globalne zwroty', + corpayPayModalTitle: 'Zapłać raport', + corpayPayModalPrompt: 'Ta osoba zgłaszająca ma konto bankowe inne niż USD. Włącz globalne zwroty, aby zapłacić raport, lub poproś o dodanie konta bankowego w USD.', purchaseAmount: 'Kwota zakupu', originalAmount: 'Kwota pierwotna', frequency: 'Częstotliwość', @@ -958,13 +963,21 @@ const translations: TranslationDeepObject = { tooltip: 'Zacznij tutaj!', }, homePage: { - forYou: 'Dla ciebie', + toDos: 'Zadania', timeSensitiveSection: { title: 'Wymaga szybkiej reakcji', - addShippingAddress: {title: 'Potrzebujemy Twojego adresu wysyłki', subtitle: 'Podaj adres, na który mamy wysłać twoją Kartę Expensify.', cta: 'Dodaj adres'}, + 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'}, activateCard: {title: 'Aktywuj swoją Kartę Expensify', subtitle: 'Zatwierdź swoją kartę i zacznij wydawać.', cta: 'Aktywuj'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `Dodanie karty ${walletName} wymaga twojej akceptacji`, + subtitle: 'Karta Expensify', + cta: 'Przegląd', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Portfel cyfrowy', + }, reviewCardFraud: { title: 'Sprawdź potencjalne oszustwo na swojej Karcie Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Przejrzyj ${amount} potencjalnie oszukańczych transakcji u ${merchant}`, @@ -987,7 +1000,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portfel', }, validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, - addHomeAddress: {title: 'Dodaj swój adres domowy do śledzenia odległości', subtitle: 'Konto', cta: 'Dodaj adres'}, + addHomeAddress: {title: 'Dodaj swój adres domowy do śledzenia odległości', subtitle: 'Konto', cta: 'Dodaj'}, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, unlockBankAccount: { workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', @@ -995,7 +1008,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'Portfel', }, - addVirtualCardPersonalDetails: {title: 'Dodaj swoje dane, aby wyświetlić i zacząć używać swojej Karty Expensify.', subtitle: 'Karta Expensify', cta: 'Dodaj szczegóły'}, + addVirtualCardPersonalDetails: {title: 'Dodaj swoje dane, aby wyświetlić i zacząć używać swojej Karty Expensify.', subtitle: 'Karta Expensify', cta: 'Dodaj'}, enterSignerInfo: {title: 'Wymagane dane podpisującego', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Konto bankowe ${bankAccountLastFour}`}, }, discoverSection: { @@ -1110,6 +1123,8 @@ const translations: TranslationDeepObject = { inviteAccountant: 'Zaproś swojego księgowego', customizeSpendCategories: 'Dostosuj kategorie wydatków', customizeSpendCategoriesSubText: 'Porządkuj i klasyfikuj wydatki', + customizeExpenseCategories: 'Dostosuj swoje kategorie wydatków', + customizeExpenseCategoriesSubText: 'Dodaj kategorie swojej firmy, aby kodować wydatki', createExpense: 'Dodaj wydatek', createExpenseSubText: 'Zeskanuj, przeciągnij i upuść lub ręcznie wprowadź wydatek za pomocą przycisku +', linkPersonalCard: 'Połącz kartę osobistą', @@ -1135,7 +1150,6 @@ const translations: TranslationDeepObject = { title: 'Twoje wydatki', awaitingApproval: 'Oczekuje na zatwierdzenie', repaidLast30Days: 'Spłacono ostatnie 30 dni', - recentTransactions: ({lastFour}: {lastFour: string}) => `Ostatnie transakcje • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `Zobacz jeszcze ${count}`, recentlyAddedSection: { @@ -1303,6 +1317,7 @@ const translations: TranslationDeepObject = { phrase1: 'Dodaj paragon', phrase2: 'lub przeciągnij i upuść tutaj', }, + pageCount: ({pageCount}: {pageCount: number}) => `Strona 1 z ${pageCount}`, }, quickAction: { scanReceipt: 'Zeskanuj paragon', @@ -1688,11 +1703,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Wszystko gotowe!', noDuplicatesDescription: 'Nie ma tutaj zduplikowanych transakcji do sprawdzenia.', confirmApprove: 'Potwierdź kwotę zatwierdzenia', - confirmApprovalAmount: 'Zatwierdź tylko zgodne wydatki lub zatwierdź cały raport.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Ten wydatek jest wstrzymany. Czy mimo to chcesz go zatwierdzić?', - other: 'Te wydatki są wstrzymane. Czy mimo to chcesz je zatwierdzić?', - }), + confirmApprovalWithHeldAmount: 'Raport zawiera wstrzymane wydatki. Zatwierdzić tylko zgodne wydatki, czy zatwierdzić cały raport?', + confirmApprovalAllHoldAmount: 'Wszystkie wydatki są wstrzymane. Zatwierdzić mimo to?', confirmPay: 'Potwierdź kwotę płatności', confirmPayAmount: 'Zapłać to, co nie jest wstrzymane, lub zapłać cały raport.', confirmPayAllHoldAmount: () => ({ @@ -2324,6 +2336,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Wyróżnione nazwy zakresów', sentryHighlightedSpanOpsPlaceholder: 'kliknięcie interfejsu, nawigacja, ładowanie interfejsu', showBranchNameInTitle: 'Pokaż nazwę gałęzi w tytule przeglądarki', + betaOverrides: 'Nadpisywanie bet', + betaOverridesDescription: + 'Nadpisywanie działa tylko na tym urządzeniu i tylko po stronie frontendu. Beta zachowuje nadpisanie tylko wtedy, gdy różni się od bet z Twojego konta, więc przełączenie jej z powrotem usuwa nadpisanie. „Zresetuj wszystkie nadpisania” przywraca wartości z Twojego konta. Niektóre bety są też kontrolowane przez backend, więc mogą w pełni nie działać zwłaszcza przy żądaniach wysyłanych do backendu.', + resetAllOverrides: 'Zresetuj wszystkie nadpisania', + overridden: 'Nadpisana', qaAuth: 'Uwierzytelnianie QA (Cloudflare)', qaAuthRunProbe: 'Uruchom test', qaAuthSession: 'Sesja uwierzytelniania QA', @@ -2643,6 +2660,10 @@ const translations: TranslationDeepObject = { connectionLink ? `Połączenie Twojej karty ${cardName} jest przerwane. Zaloguj się do swojego banku, aby naprawić kartę.` : `Połączenie Twojej karty ${cardName} jest przerwane. Zaloguj się do swojego banku, aby naprawić kartę.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Twoje połączenie z kartą ${cardName} jest zerwane od 30 dni. Zaloguj się do swojego banku, żeby to naprawić albo usuń kartę, jeśli już jej nie używasz. Nie stracisz żadnych wysłanych wydatków, jeśli ją usuniesz.` + : `Twoje połączenie z kartą ${cardName} jest zerwane od 30 dni. Zaloguj się do swojego banku, żeby to naprawić, albo usuń kartę, jeśli już jej nie używasz. Nie stracisz żadnych wysłanych wydatków, jeśli ją usuniesz.`, addAdditionalCards: 'Dodaj kolejne karty', upgradeDescription: 'Potrzebujesz dodać więcej kart? Utwórz obszar roboczy, aby dodać kolejne karty osobiste lub przypisać karty firmowe całemu zespołowi.', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2738,6 +2759,23 @@ const translations: TranslationDeepObject = { unlock: 'Odblokuj', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Dodaj kartę do ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'portfel cyfrowy', + confirmHeading: 'Potwierdź swoje żądanie', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Czy chcesz dodać swoją Kartę Expensify (kończącą się na ${lastFourDigits}) do portfela ${walletName}?`, + deny: 'Odrzuć', + confirm: 'Potwierdź', + verifyTitle: 'Potwierdź, że to ty', + enterSecurityCode: (contactMethod: string) => `Wpisz kod bezpieczeństwa wysłany na ${contactMethod}, aby potwierdzić to żądanie. Powinien dotrzeć w ciągu minuty lub dwóch.`, + successHeading: 'Sukces!', + successDescription: ({walletName}: {walletName: string}) => `Twoja karta jest już aktywna do użycia w ${walletName}.`, + deniedHeading: 'Żądanie odrzucone', + deniedDescription: ({walletName}: {walletName: string}) => `Twoja karta nie została dodana do twojego portfela ${walletName}.`, + }, cardPage: { expensifyCard: 'Karta Expensify', expensifyTravelCard: 'Karta podróżna Expensify', @@ -3182,6 +3220,7 @@ ${amount} dla ${merchant} - ${date}`, unread: 'Nieprzeczytane', markAllAsRead: 'Oznacz wszystkie jako przeczytane', markAllAsReadConfirmationPrompt: 'Czy na pewno chcesz oznaczyć wszystkie czaty jako przeczytane?', + markAllTodosAsReadConfirmationPrompt: 'Czy na pewno chcesz oznaczyć wszystkie czaty do zrobienia jako przeczytane?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, @@ -3362,6 +3401,7 @@ ${amount} dla ${merchant} - ${date}`, subtitle: (workEmail: string | undefined) => `Nie udało się dodać ${workEmail}. Spróbuj ponownie później w Ustawieniach lub porozmawiaj z Concierge, aby uzyskać pomoc.`, workAccountClosedSubtitle: 'Konto służbowe powiązane z tym adresem e‑mail jest zamknięte. Skontaktuj się z administratorem w swojej firmie, żeby je ponownie aktywować, albo zarejestruj się przy użyciu innego adresu e‑mail.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} to login kontrolowany przez domenę dla istniejącego konta Expensify.`, }, tasks: { testDriveAdminTask: { @@ -4190,8 +4230,7 @@ ${amount} dla ${merchant} - ${date}`, legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', enterTheDateOfBirthOfTheOwner: 'Jaka jest data urodzenia właściciela?', - enterTheLast4: 'Jakie są ostatnie 4 cyfry numeru Social Security właściciela?', - last4SSN: 'Ostatnie 4 cyfry numeru SSN', + enterTheSSN: 'Jaki jest numer Social Security właściciela?', dontWorry: 'Spokojnie, nie przeprowadzamy żadnych osobistych kontroli kredytowych!', enterTheOwnersAddress: 'Jaki jest adres właściciela?', letsDoubleCheck: 'Sprawdźmy jeszcze raz, czy wszystko wygląda poprawnie.', @@ -4218,13 +4257,12 @@ ${amount} dla ${merchant} - ${date}`, whatsYourDOB: 'Jaka jest twoja data urodzenia?', whatsTheOwnersAddress: 'Jaki jest adres właściciela?', whatsYourAddress: 'Jaki jest twój adres?', - whatAreTheLast: 'Jakie są ostatnie 4 cyfry numeru Social Security właściciela?', - whatsYourLast: 'Jakie są ostatnie 4 cyfry Twojego numeru Social Security?', + whatsTheOwnersSSN: 'Jaki jest numer Social Security właściciela?', + whatsYourSSN: 'Jaki jest twój numer ubezpieczenia społecznego?', whatsYourNationality: 'Jaki jest Twój kraj obywatelstwa?', whatsTheOwnersNationality: 'Jaki jest kraj obywatelstwa właściciela?', countryOfCitizenship: 'Kraj obywatelstwa', dontWorry: 'Spokojnie, nie przeprowadzamy żadnych osobistych kontroli kredytowych!', - last4: 'Ostatnie 4 cyfry numeru SSN', whyDoWeAsk: 'Dlaczego o to prosimy?', letsDoubleCheck: 'Sprawdźmy jeszcze raz, czy wszystko wygląda poprawnie.', legalName: 'Imię i nazwisko (pełne)', @@ -4248,6 +4286,8 @@ ${amount} dla ${merchant} - ${date}`, proofOfBeneficialOwner: 'Dowód beneficjenta rzeczywistego', proofOfBeneficialOwnerDescription: 'Prosimy o dostarczenie podpisanego oświadczenia oraz schematu organizacyjnego od biegłego rewidenta, notariusza lub prawnika, potwierdzających posiadanie 25% lub więcej udziałów w firmie. Dokument musi być opatrzony datą z ostatnich trzech miesięcy i zawierać numer licencji osoby podpisującej.', + proofOfBeneficialOwnerDescriptionCA: + 'Podaj formularz Schedule 50 (informacje o akcjonariuszach) z Twojego najnowszego zeznania T2 od podatku dochodowego od osób prawnych, pokazujący każdą osobę fizyczną, która posiada 25% lub więcej udziałów w firmie.', copyOfID: 'Kopia dokumentu tożsamości rzeczywistego właściciela', copyOfIDDescription: 'Przykłady: paszport, prawo jazdy itp.', proofOfAddress: 'Potwierdzenie adresu rzeczywistego właściciela', @@ -4623,6 +4663,7 @@ ${amount} dla ${merchant} - ${date}`, customFieldHint: 'Dodaj niestandardowe kodowanie, które będzie stosowane do wszystkich wydatków tego członka.', reports: 'Raporty', reportFields: 'Pola raportu', + invoiceFields: 'Pola faktury', reportTitle: 'Tytuł raportu', reportField: 'Pole raportu', taxes: 'Podatki', @@ -4632,6 +4673,7 @@ ${amount} dla ${merchant} - ${date}`, travel: 'Podróże', members: 'Członkowie', accounting: 'Księgowość', + mcp: 'MCP', receiptPartners: 'Partnerzy paragonów', rules: 'Zasady', displayedAs: 'Wyświetlane jako', @@ -4755,6 +4797,7 @@ ${amount} dla ${merchant} - ${date}`, travelInvoicingVendor: 'Dostawca usług turystycznych', travelInvoicingPayableAccount: 'Konto zobowiązań z tytułu podróży', hr: 'HR', + recruiting: 'Rekrutacja', rooms: 'Pokoje', findDomain: 'Znajdź domenę', cardAdminAlternateText: 'Zarządzaj kartami przestrzeni roboczej.', @@ -4768,6 +4811,25 @@ ${amount} dla ${merchant} - ${date}`, title: 'Utworzyłeś przestrzeń roboczą dla swojego klienta!', description: 'Świetna wiadomość 🎉. Skontaktuj się z nami, jeśli potrzebują pomocy przy konfiguracji.', }, + mcp: { + connectors: 'Konektory', + connectorsSubtitle: 'Połącz asystenta AI ze swoim kontem Expensify.', + connect: 'Połącz', + helpPrompt: 'Potrzebujesz pomocy z połączeniem?', + helpLink: 'Przeczytaj nasz przewodnik.', + claude: { + title: 'Claude', + subtitle: 'od Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'od Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'od OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -4984,7 +5046,6 @@ ${amount} dla ${merchant} - ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Użyj tego konta podczas eksportowania faktur do ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Ustaw, w jaki sposób zakupy kartą firmową są eksportowane do ${integrationName}.`, vendor: 'Dostawca', - defaultVendorDescription: 'Ustaw domyślnego dostawcę, który zostanie zastosowany do wszystkich transakcji kartą kredytową podczas eksportu.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Ustaw sposób eksportu wydatków z własnej kieszeni do ${integrationName}.`, exportCheckDescription: 'Utworzymy wyszczególniony czek dla każdego raportu Expensify i wyślemy go z poniższego konta bankowego.', exportJournalEntryDescription: 'Utworzymy szczegółowy zapis księgowy dla każdego raportu Expensify i zaksięgujemy go na koncie poniżej.', @@ -5148,8 +5209,6 @@ ${amount} dla ${merchant} - ${date}`, }, noAccountsFound: 'Nie znaleziono kont', noAccountsFoundDescription: 'Dodaj proszę konto w Xero i zsynchronizuj połączenie ponownie', - defaultSupplier: 'Domyślny dostawca', - defaultSupplierDescription: 'Ustaw domyślnego dostawcę, który zostanie zastosowany do wszystkich transakcji kartą kredytową podczas eksportu.', noSuppliersFound: 'Nie znaleziono dostawców', noSuppliersFoundDescription: 'Dodaj dostawcę w Xero i zsynchronizuj połączenie ponownie.', accountingMethods: { @@ -6363,6 +6422,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy 'Nie udało nam się skonfigurować części członków twojego miejsca pracy do skonsolidowanego rozliczania podróży. Spróbuj ponownie później albo skontaktuj się z Concierge, żeby uzyskać pomoc.', sendInvoiceNowCta: 'Wyślij fakturę teraz', }, + setUpNewFeed: 'Skonfiguruj nowy kanał podróży', + feedSelectorIntro: + 'Twoja organizacja ma już skonfigurowany skonsolidowany kanał rozliczeń podróży. Wybierz go, żeby wydatki z podróży w tym miejscu pracy były na tym samym miesięcznym rachunku.', }, disableModal: { title: 'Wyłączyć zbiorcze rozliczanie podróży?', @@ -6520,6 +6582,10 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy title: 'Księgowość', subtitle: 'Synchronizuj swój plan kont i więcej.', }, + mcp: { + title: 'MCP', + subtitle: 'Połącz asystenta AI ze swoim kontem Expensify.', + }, receiptPartners: { title: 'Partnerzy paragonów', subtitle: 'Automatycznie importuj paragony.', @@ -6583,6 +6649,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy subtitle: 'Pola raportu mają zastosowanie do wszystkich wydatków i mogą być pomocne, gdy chcesz poprosić o dodatkowe informacje.', disableReportFields: 'Wyłącz pola raportu', disableReportFieldsConfirmation: 'Na pewno? Pola tekstowe i daty zostaną usunięte, a listy wyłączone.', + cannotDisableImportedReportFields: 'Pól raportu zaimportowanych z połączenia księgowego nie można wyłączyć.', importedFromAccountingSoftware: 'Pola raportu poniżej są importowane z Twojego', textType: 'Tekst', dateType: 'Data', @@ -6619,6 +6686,29 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy reportFieldInitialValueRequiredError: 'Wybierz początkową wartość pola raportu', genericFailureMessage: 'Wystąpił błąd podczas aktualizowania pola raportu. Spróbuj ponownie.', }, + invoiceFields: { + subtitle: 'Pola faktury mogą być pomocne, gdy chcesz dodać dodatkowe informacje.', + importedFromAccountingSoftware: 'Poniższe pola faktury są importowane z Twojego', + disableInvoiceFields: 'Wyłącz pola faktury', + disableInvoiceFieldsConfirmation: 'Czy na pewno? Pola faktury zostaną wyłączone na fakturach.', + delete: 'Usuń pole faktury', + deleteConfirmation: 'Czy na pewno chcesz usunąć to pole faktury?', + findInvoiceField: 'Znajdź pole faktury', + nameInputSubtitle: 'Wybierz nazwę pola faktury.', + typeInputSubtitle: 'Wybierz typ pola faktury, którego chcesz użyć.', + initialValueInputSubtitle: 'Wprowadź wartość początkową, która ma być wyświetlana w polu faktury.', + listValuesInputSubtitle: 'Te wartości pojawią się na liście rozwijanej pola faktury. Członkowie mogą wybierać włączone wartości.', + listInputSubtitle: 'Te wartości pojawią się na liście pola faktury. Członkowie mogą wybierać włączone wartości.', + emptyInvoiceFieldsValues: { + title: 'Brak wartości listy', + subtitle: 'Dodaj niestandardowe wartości, które mają pojawiać się na fakturach.', + }, + existingInvoiceFieldNameError: 'Pole faktury o tej nazwie już istnieje', + invoiceFieldNameRequiredError: 'Wprowadź nazwę pola faktury', + invoiceFieldTypeRequiredError: 'Wybierz typ pola faktury', + invoiceFieldInitialValueRequiredError: 'Wybierz początkową wartość pola faktury', + addField: 'Dodaj pole', + }, vendors: { emptyTitle: 'Brak dostawców', emptySubtitle: 'Dostawcy pojawią się tutaj po zakończeniu synchronizacji z księgowością.', @@ -7012,6 +7102,8 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7090,7 +7182,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, return 'Przetwarzanie zaimportowanych danych'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': - return 'Synchronizowanie zrefundowanych raportów i płatności rachunków'; + return 'Synchronizowanie rozliczonych raportów i płatności rachunków'; case 'quickbooksOnlineSyncTaxCodes': return 'Importowanie kodów podatkowych'; case 'quickbooksOnlineCheckConnection': @@ -7111,7 +7203,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, case 'quickbooksDesktopImportDimensions': return 'Importowanie wymiarów'; case 'quickbooksDesktopImportSavePolicy': - return 'Importowanie zapisanej polityki'; + return 'Importowanie polityki zapisu'; case 'quickbooksDesktopWebConnectorReminder': return 'Trwa nadal synchronizowanie danych z QuickBooks... Upewnij się, że Web Connector jest uruchomiony'; case 'quickbooksOnlineSyncTitle': @@ -7129,7 +7221,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, case 'quickbooksOnlineSyncApplyClassesLocations': return 'Aktualizowanie pól raportu'; case 'jobDone': - return 'Oczekiwanie na wczytanie zaimportowanych danych'; + return 'Oczekiwanie na załadowanie zaimportowanych danych'; case 'xeroSyncImportChartOfAccounts': return 'Synchronizowanie planu kont'; case 'xeroSyncImportCategories': @@ -7137,7 +7229,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, case 'xeroSyncImportCustomers': return 'Synchronizowanie klientów'; case 'xeroSyncXeroReimbursedReports': - return 'Oznaczanie raportów Expensify jako rozliczonych'; + return 'Oznaczanie raportów Expensify jako zwrócone'; case 'xeroSyncExpensifyReimbursedReports': return 'Oznaczanie rachunków i faktur Xero jako opłaconych'; case 'xeroSyncImportTrackingCategories': @@ -7171,11 +7263,11 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, case 'netSuiteSyncReportFields': return 'Importowanie danych jako pola raportu Expensify'; case 'netSuiteSyncTags': - return 'Importowanie danych jako tagów Expensify'; + return 'Importowanie danych jako tagi Expensify'; case 'netSuiteSyncUpdateConnectionData': return 'Aktualizowanie informacji o połączeniu'; case 'netSuiteSyncNetSuiteReimbursedReports': - return 'Oznaczanie raportów Expensify jako rozliczonych'; + return 'Oznaczanie raportów Expensify jako zwrócone'; case 'netSuiteSyncExpensifyReimbursedReports': return 'Oznaczanie rachunków i faktur w NetSuite jako opłaconych'; case 'netSuiteImportVendorsTitle': @@ -7214,7 +7306,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, case 'financialForceSyncDimensions': return 'Importowanie wymiarów'; case 'financialForceMarkAsReimbursed': - return 'Oznaczanie raportów jako zwrócone'; + return 'Oznaczanie raportów jako rozliczone'; case 'rilletSyncTitle': return 'Synchronizowanie danych Rillet'; case 'rilletSyncConnection': @@ -7233,6 +7325,18 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, return 'Synchronizowanie rozliczeń karty'; case 'dualEntrySyncTravelSettlements': return 'Synchronizowanie rozliczeń podróży'; + case 'campfireSyncTitle': + return 'Synchronizowanie danych Campfire'; + case 'campfireSyncConnection': + return 'Inicjowanie połączenia z Campfire'; + case 'campfireSyncImportData': + return 'Wczytywanie danych'; + case 'campfireSyncPayments': + return 'Synchronizowanie płatności dla dostawców'; + case 'campfireSyncCardSettlements': + return 'Synchronizowanie rozliczeń karty'; + case 'campfireSyncTravelSettlements': + return 'Synchronizowanie rozliczeń podróży'; default: { return `Brak tłumaczenia dla etapu: ${stage}`; } @@ -7248,11 +7352,11 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, exportCompanyCard: 'Eksportuj wydatki z firmowej karty jako', exportDate: 'Data eksportu', defaultVendor: 'Domyślny dostawca', - defaultVendorHelperText: (isSet: boolean) => - isSet - ? `Wydatki, które nie dopasują się automatycznie, będą domyślnie przypisane do tego dostawcy.` - : `Wydatki, które nie dopasują się automatycznie, zostaną domyślnie przypisane do tego dostawcy. W przeciwnym razie zostaną wyeksportowane jako Credit Card Misc.`, - defaultVendorSelectHeader: (connectionName: string) => `Wybierz domyślnego dostawcę ${connectionName} dla wydatków, które nie zostaną dopasowane automatycznie.`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName + ? `Wydatki, które nie zostaną automatycznie dopasowane, zostaną domyślnie przypisane do tego dostawcy.` + : `Wydatki, które nie zostaną dopasowane automatycznie, będą domyślnie przypisane do tego dostawcy. W przeciwnym razie zostaną wyeksportowane jako ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Wybierz domyślnego dostawcę dla wydatków, które nie dopasują się automatycznie.`, defaultAccount: 'Domyślne konto', autoSync: 'Automatyczna synchronizacja', autoSyncDescription: 'Synchronizuj NetSuite i Expensify automatycznie, każdego dnia. Eksportuj sfinalizowany raport w czasie rzeczywistym', @@ -7274,6 +7378,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, syncTravelInvoicingSettlements: 'Synchronizuj rozliczenia skonsolidowanego rozliczania podróży', syncTravelInvoicingSettlementsNoAccountTooltip: 'Aby odblokować, ustaw konto dla swoich eksportów.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Aby odblokować, włącz automatyczną synchronizację.', + campfire: 'Campfire', }, export: { notReadyHeading: 'Niegotowe do eksportu', @@ -7330,12 +7435,12 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', optionDisabledHelp: 'Żaden dojazd do pracy nie jest usuwany z rozliczeń.', - optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', - optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', + optionFixedDistanceTitle: 'Usuń stały dystans', + optionFixedDistanceHelp: 'Odejmuje tę samą odległość od każdego zgłoszenia.', distanceLabel: 'Dystans', summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', - optionHomeAndOfficeTitle: 'Obliczaj według domu i biura', - optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, żeby obliczyć wyłączenia z dojazdów.', + optionHomeAndOfficeTitle: 'Wyklucz codzienne dojazdy', + optionHomeAndOfficeHelp: 'Odejmuje standardowy dojazd każdego członka zespołu na podstawie jego domu, modelu pracy i biura.', workspaceAddressRequired: { title: 'Nie tak szybko…', promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w', @@ -7530,6 +7635,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Pola raportu są dostępne tylko w planie Control, od ${formattedPrice} ${hasTeam2025Pricing ? `za użytkownika miesięcznie.` : `na aktywnego członka miesięcznie.`}`, }, + invoiceFields: { + title: 'Pola faktury', + description: `Pola faktury pozwalają dodać dodatkowe szczegóły na poziomie faktury.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Pola faktury są dostępne tylko w planie Control, od ${formattedPrice} ${hasTeam2025Pricing ? `za użytkownika miesięcznie.` : `na aktywnego członka miesięcznie.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Korzystaj z automatycznej synchronizacji i ograniczaj ręczne wprowadzanie danych dzięki integracji Expensify + NetSuite. Uzyskaj dogłębny, aktualny w czasie rzeczywistym wgląd w finanse dzięki obsłudze natywnych i niestandardowych segmentów, w tym mapowaniu projektów i klientów.`, @@ -7572,6 +7683,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Integracja z DualEntry jest dostępna tylko w planie Control, zaczynającym się od ${formattedPrice} ${hasTeam2025Pricing ? `za użytkownika miesięcznie.` : `na aktywnego członka miesięcznie.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Korzystaj z automatycznej synchronizacji i ogranicz ręczne wprowadzanie danych dzięki integracji Expensify + Campfire. Dopasuj wymiary kategoryzacji wydatków i synchronizację podatków do konfiguracji Campfire, aby uzyskać lepszą widoczność finansową.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Integracja z Campfire jest dostępna tylko w planie Control, zaczynającym się od ${formattedPrice} ${hasTeam2025Pricing ? `za użytkownika miesięcznie.` : `na aktywnego członka miesięcznie.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Zaawansowane zatwierdzanie', description: `Jeśli chcesz dodać więcej poziomów akceptacji – albo po prostu upewnić się, że największe wydatki zostaną przejrzane przez kolejną osobę – mamy na to rozwiązanie. Zaawansowane zatwierdzanie pomaga wdrożyć odpowiednie mechanizmy kontrolne na każdym poziomie, aby utrzymać wydatki zespołu pod kontrolą.`, @@ -7866,7 +7983,6 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i alwaysReimbursableDescription: 'Wydatki są zawsze zwracane pracownikom', alwaysNonReimbursable: 'Zawsze niepodlegające zwrotowi', alwaysNonReimbursableDescription: 'Wydatki nigdy nie są zwracane pracownikom', - billableDefault: 'Domyślne obciążenie klienta', billableDefaultDescription: 'Wybierz, czy wydatki gotówkowe i kartą kredytową mają być domyślnie fakturowalne.', billable: 'Fakturowalne', billableDescription: 'Wydatki są najczęściej refakturowane klientom', @@ -7893,10 +8009,12 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i publicReceiptVisibilityHintDisabled: 'Paragony są widoczne tylko dla członków Expensify, którzy mają dostęp do raportu zawierającego paragon.', enableTagsToUnlockTitle: 'Włączyć tagi?', enableTagsToUnlockPrompt: 'Włącz Tagi (w sekcji Więcej funkcji), aby odblokować.', - enableTagsAndRequirePrompt: 'Na pewno chcesz włączyć tagi i wymagać ich dla wszystkich wydatków?', enableCategoriesToUnlockTitle: 'Włączyć kategorie?', enableCategoriesToUnlockPrompt: 'Włącz Kategorie (w sekcji Więcej funkcji), aby odblokować.', enableCategoriesAndRequirePrompt: 'Na pewno chcesz włączyć kategorie i wymagać ich dla wszystkich wydatków?', + enableTagsPrompt: 'Na pewno chcesz włączyć tagi? Będziesz mógł wymagać ich dla wszystkich wydatków, gdy będziesz mieć co najmniej jeden tag.', + noTagsToRequirePrompt: 'Nie masz żadnych tagów. Utwórz tag.', + noCategoriesToRequirePrompt: 'Nie masz żadnych kategorii. Utwórz kategorię.', }, expenseReportRules: { title: 'Zaawansowane', @@ -7966,6 +8084,20 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i 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ę', + confirmErrorConditionAndDefault: 'Wpisz sprzedawcę i zastosuj co najmniej jedną domyślną opcję', + turnOnTaxesFirstTitle: 'Najpierw włącz podatki', + turnOnTaxesFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Włącz podatki w ustawieniach swojego workspace, aby z nich korzystać.', + categoryRulesApplyGoingForwardTitle: 'Reguły kategorii będą stosowane od teraz', + categoryRulesApplyGoingForwardPrompt: 'Domyślna stawka podatku będzie stosowana do nowych wydatków w tej kategorii. Istniejące wydatki nie zostaną zmienione.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Wybrana stawka podatku jest teraz domyślną stawką w twoim zespole, więc ta reguła nie jest już prawidłowa. Wybierz inną stawkę podatku.', + addTaxRateFirstTitle: 'Najpierw dodaj stawkę podatku', + addTaxRateFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Dodaj stawkę podatku inną niż domyślna w twoim obszarze roboczym, żeby z nich korzystać.', + createRuleFromExpenseAction: 'Utwórz regułę', + createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, categoryRules: { title: 'Reguły kategorii', @@ -7986,7 +8118,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i flagAmountsOverSubtitle: 'To zastępuje maksymalną kwotę dla wszystkich wydatków.', expenseLimitTypes: { expense: 'Pojedynczy wydatek', - expenseSubtitle: 'Oznaczaj kwoty wydatków według kategorii. Ta reguła zastępuje ogólną regułę maksymalnej kwoty wydatku dla przestrzeni roboczej.', + expenseSubtitle: + 'Oznaczaj kwoty wydatków według kategorii. Ta reguła zastępuje ogólną regułę obszaru roboczego dotyczącą maksymalnej kwoty wydatku. Rezerwacje wielodniowe są oceniane na podstawie średniej za noc.', daily: 'Suma kategorii', dailySubtitle: 'Oznaczaj łączną dzienną kwotę wydatków według kategorii dla każdego raportu wydatków.', }, @@ -8264,10 +8397,10 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, applyExpenseDefaultsDescription: 'Aktualizuj pola bez wymagania działania od osoby zgłaszającej', flagForReview: 'Oznacz do przejrzenia', flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', - requireFields: 'Wymagania pola', + requireFields: 'Ustaw wymagania pola', requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', - createAgentRule: 'Reguła agenta', - createAgentRuleDescription: 'Opisuj elastyczne reguły, które uruchamiają się wtedy, kiedy tego potrzebujesz.', + createAgentRule: 'Opisz regułę agenta', + createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', }, expenseDefaultsTable: { tableColumnType: 'Typ', @@ -8278,6 +8411,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, update: 'Zaktualizuj', merchantIs: (merchant: string) => `Sprzedawca to „${merchant}”`, merchantTypeIs: (merchantType: string) => `Typ sprzedawcy to „${merchantType}"`, + categoryIs: (category: string) => `Kategoria to „${category}”`, }, merchantTypeRule: {merchantType: 'Typ sprzedawcy', saveRule: 'Zapisz regułę', confirmErrorCategory: 'Wybierz kategorię.'}, requireFieldsTable: { @@ -8294,6 +8428,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, requireReceipt: 'Wymagaj paragonu', doNotRequireReceipt: 'Nie wymagaj paragonu', doNotRequireItemizedReceipt: 'Nie wymagaj wyszczególnionego paragonu', + typeLabel: 'Wymagane', }, requireFieldsEmptyState: { title: 'Wychwytuj brakujące szczegóły od razu', @@ -8342,6 +8477,15 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, agentRulesEmptyState: {title: 'Nie dodano reguł agenta', subtitle: 'Utwórz regułę, żeby zautomatyzować zasady swojego workspace’u.', cta: 'Dodaj regułę AI'}, categoriesDisabledEmptyState: {title: 'Kategorie nie są włączone', subtitle: 'Włącz kategorie, żeby mieć większą kontrolę nad wydatkami.'}, + expenseDefaultType: { + title: 'Zastosuj domyślne ustawienia wydatków', + subtitle: 'Do czego ta reguła powinna pasować?', + merchant: 'Sprzedawca', + merchantDescription: 'Zaktualizuj pola w wydatkach od konkretnego sprzedawcy', + category: 'Kategoria', + categoryDescription: 'Ustaw domyślną stawkę podatku dla konkretnej kategorii', + }, + taxesDisabledEmptyState: {title: 'Podatki nie są włączone', subtitle: 'Włącz podatki, aby udokumentować i odzyskać kwalifikujące się podatki.', cta: 'Włącz podatki'}, }, planTypePage: { planTypes: { @@ -8368,37 +8512,26 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, hr: { title: 'HR', - connections: 'Połączenia', connectionsSubtitle: 'Połącz się ze swoim systemem HR, aby synchronizować dane pracowników, automatycznie dopasowywać zwroty do właściwych osób i utrzymywać wydatki zespołu w porządku bez ręcznej pracy.', subtitle: 'Połącz narzędzia HR i utrzymuj zgody pracowników w synchronizacji.', - connect: 'Połącz', - syncNow: 'Synchronizuj teraz', - disconnect: 'Odłącz', - disconnectTitle: (providerName: string) => `Odłącz ${providerName}`, - disconnectPrompt: (providerName: string) => `Czy na pewno chcesz odłączyć ${providerName}?`, alreadyConnectedTitle: 'Nie można połączyć się z wieloma platformami HR', alreadyConnectedPrompt: 'Musisz odłączyć swoją obecną platformę HR, zanim podłączysz inną.', - lastSync: (relativeDate: string) => `Ostatnia synchronizacja ${relativeDate}`, - syncError: (providerName: string) => `Nie można połączyć z ${providerName}`, connectionDescription: (providerName: string) => `Połącz ${providerName}, aby synchronizować akceptacje pracowników z Twoim miejscem pracy.`, - approvalMode: 'Tryb zatwierdzania', providerApprovalMode: (providerName: string) => `Tryb zatwierdzania ${providerName}`, - finalApprover: 'Ostateczny zatwierdzający', providerFinalApprover: (providerName: string) => `Ostateczny zatwierdzający ${providerName}`, - notSet: 'Nie ustawiono', + syncing: 'Synchronizowanie pracowników', + syncingModalTitle: 'Twoje połączenie jest synchronizowane', + syncingModalDescription: 'Pierwsze połączenie może chwilę potrwać. Zostaniesz powiadomiony o wszelkich błędach.', approvalModeDescription: (providerName: string) => `Członkowie i menedżerowie są skonfigurowani do synchronizacji z ${providerName}.`, approvalModeWarningTitle: 'Zmienić tryb zatwierdzania?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Czy na pewno chcesz zmienić tryb zatwierdzania dla tego obszaru roboczego? Dowiedz się więcej o różnych trybach przepływu pracy obsługiwanych przez ${providerName} w naszej witrynie pomocy.`, approvalModeWarningConfirm: 'Zmień tryb zatwierdzania', - approvalModes: { - basic: {label: 'Podstawowe zatwierdzanie', description: 'Wszyscy użytkownicy przesyłają wydatki do jednej osoby w celu ich przetworzenia i zatwierdzenia.'}, - manager: { - label: 'Zatwierdzanie przez menedżera', - description: (providerName: string) => `Pracownicy przesyłają raporty do swojego bezpośredniego menedżera skonfigurowanego w ${providerName}.`, - }, - custom: {label: 'Niestandardowe zatwierdzanie', description: 'Ręcznie skonfiguruję procesy zatwierdzania w Expensify.'}, + approvalModeDescriptions: { + basic: 'Wszyscy użytkownicy przesyłają wydatki do jednej osoby w celu ich przetworzenia i zatwierdzenia.', + manager: (providerName: string) => `Pracownicy przesyłają raporty do swojego bezpośredniego menedżera skonfigurowanego w ${providerName}.`, + custom: 'Ręcznie skonfiguruję procesy zatwierdzania w Expensify.', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8438,28 +8571,66 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, zenefits: { title: 'TriNet', }, - syncingModalTitle: 'Twoje połączenie jest synchronizowane', - syncingModalDescription: 'Pierwsze połączenie może chwilę potrwać. Zostaniesz powiadomiony o wszelkich błędach.', - syncing: 'Synchronizowanie pracowników', + setupIncomplete: (setupLink: string | undefined) => + `Połączono. ${setupLink ? `Zakończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować pracowników.`, mergeHR: { - completeSetup: 'Zakończ konfigurację', - setupIncomplete: (setupLink: string | undefined) => - `Połączono. ${setupLink ? `Zakończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować pracowników.`, groups: {title: 'Grupy', description: 'Wybierz grupy pracowników, które chcesz zsynchronizować z tą przestrzenią roboczą'}, }, + }, + recruiting: { + title: 'Rekrutacja', + connectionsSubtitle: + 'Połącz się ze swoim systemem rekrutacyjnym, aby synchronizować dane kandydatów, automatycznie dopasowywać zwroty kosztów do właściwych osób i utrzymywać poprawność wydatków zespołu bez ręcznej pracy.', + alreadyConnectedTitle: 'Nie można połączyć się z wieloma platformami ATS', + alreadyConnectedPrompt: 'Musisz odłączyć swój obecny ATS, zanim podłączysz inny.', + syncing: 'Synchronizowanie kandydatów', + setupIncomplete: (setupLink: string | undefined) => + `Połączono. ${setupLink ? `Dokończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować kandydatów.`, + 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'}, + }, + merge: { + connections: 'Połączenia', + connect: 'Połącz', + findIntegration: 'Znajdź integrację', + syncNow: 'Synchronizuj teraz', + disconnect: 'Odłącz', + disconnectTitle: (providerName: string) => `Odłącz ${providerName}`, + disconnectPrompt: (providerName: string) => `Czy na pewno chcesz odłączyć ${providerName}?`, + lastSync: (relativeDate: string) => `Ostatnia synchronizacja ${relativeDate}`, notSync: 'Niesynchronizowane', + syncError: (providerName: string) => `Nie można połączyć z ${providerName}`, authenticationError: (providerName: string) => `Nie można połączyć z ${providerName} z powodu wygasłego połączenia.`, reconnect: 'Połącz ponownie', reconnectLink: 'Połącz ponownie.', - findIntegration: 'Znajdź integrację', - }, - merge: { + notSet: 'Nie ustawiono', + completeSetup: 'Zakończ konfigurację', + approvalMode: 'Tryb zatwierdzania', + finalApprover: 'Ostateczny zatwierdzający', + approvalModes: { + basic: 'Podstawowe zatwierdzanie', + manager: 'Zatwierdzanie przez menedżera', + custom: 'Niestandardowe zatwierdzanie', + advanced: 'Zaawansowane zatwierdzanie', + }, syncLimitReached: {title: 'Spróbuj ponownie jutro', prompt: 'Osiągnąłeś dzienny limit synchronizacji.'}, }, emptyDomain: { title: 'Zwiększ swoje bezpieczeństwo dzięki domenom', subtitle: 'Wymagaj od członków swojej domeny logowania jednokrotnym logowaniem (SSO), ograniczaj tworzenie przestrzeni roboczych i nie tylko.', }, + campfire: { + campfireSetup: 'Konfiguracja Campfire', + enterCredentials: 'Wpisz swój klucz API Campfire', + howToFindAPIKey: + 'Znajdowanie klucza API.
  1. Zaloguj się do Campfire
  2. Przejdź do Ustawienia -> Klucze API
  3. Utwórz klucz API
  4. Wklej klucz API poniżej
', + subsidiary: 'Spółka zależna', + subsidiarySelectDescription: 'Wybierz spółkę zależną w Campfire, z której chcesz zaimportować dane.', + noSubsidiariesFound: 'Nie znaleziono żadnych spółek zależnych', + noSubsidiariesFoundDescription: 'Dodaj proszę jednostkę w Campfire i zsynchronizuj połączenie ponownie', + }, }, getAssistancePage: { title: 'Uzyskaj pomoc', @@ -9364,7 +9535,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Zbiorcze rozliczanie podróży', }, is: 'Jest', - has: {submittedViolation: 'Zgłoszone naruszenie'}, + has: {submittedViolation: 'Zgłoszone naruszenie', approvedViolation: 'Zatwierdzone naruszenie'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Wyślij', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Zatwierdź', @@ -9438,6 +9609,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, exportedTo: 'Wyeksportowano do', exportAll: { selectAllMatchingItems: 'Zaznacz wszystkie pasujące elementy', + allMatchingItemsSelected: 'Zaznaczono wszystkie pasujące elementy', selectAllOnThisPage: 'Zaznacz wszystko na tej stronie', }, errors: { @@ -9457,12 +9629,13 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, unapprovedCard: 'Rozliczenia narosłe na kartach', statements: 'Wyciągi z karty', reconciliation: 'Uzgadnianie bankowe', - insights: 'Wnioski', + insights: 'Analityka', topSpenders: 'Najwięksi wydający', topCategories: 'Najpopularniejsze kategorie', topMerchants: 'Najważniejsi sprzedawcy', violationsBySubmitter: 'Naruszenia przez zgłaszającego', }, + mergeReports: {title: 'Połącz raporty', description: 'Wybierz raport, który chcesz zachować. Wszystkie wydatki zostaną do niego przeniesione, a pozostałe raporty zostaną usunięte.'}, }, genericErrorPage: { title: 'Ups, coś poszło nie tak!', @@ -9545,6 +9718,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `wyeksportowano do ${translatedLabel}`; @@ -9581,6 +9755,16 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Powtórzono ${count} razy.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `Połączenie ${feedName} jest przerwane. Aby przywrócić importy kart, zaloguj się do swojego banku.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `Połączenie ${feedName} jest zerwane od 30 dni. Zaloguj się do swojego banku, żeby je naprawić albo usuń połączenie, jeśli nie jest już używane. Nie stracisz żadnych przesłanych wydatków, jeśli je usuniesz.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `połączenie Plaid z twoim firmowym kontem bankowym jest przerwane. Proszę, połącz ponownie swoje konto bankowe ${maskedAccountNumber}, aby móc dalej używać Kart Expensify.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10167,6 +10351,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Stawka jest ważna tylko od ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Stawka jest ważna tylko do ${endDate}`, cannotMergeDuplicates: 'Możesz łączyć wydatki tylko w szkicach lub otwartych raportach. Wycofaj go i spróbuj ponownie.', + overCategoryLimitPerNight: (formattedLimit: string) => `Cena za noc powyżej limitu kategorii ${formattedLimit}/osoba`, shortName: { allTagLevelsRequired: 'Wymagane są wszystkie tagi', autoReportedRejectedExpense: 'Wydatek odrzucony', @@ -10782,6 +10967,7 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, reportLevelExport: 'Wszystkie dane – poziom raportu', expenseLevelExport: 'Wszystkie dane – poziom wydatku', multipleTaxExport: 'Kanadyjski eksport z wieloma podatkami', + reconciliationAllExpenses: 'Uzgodnienie - Wszystkie wydatki', exportInProgress: 'Trwa eksport', conciergeWillSend: 'Concierge wkrótce wyśle Ci plik.', currentView: 'Bieżący widok', @@ -10813,6 +10999,7 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, notVerified: 'Niezweryfikowane', retry: 'Ponów próbę', requestSent: 'Wysłano prośbę', + requestAccessError: 'Nie udało się wysłać Twojej prośby. Spróbuj ponownie.', verifyDomain: { title: 'Zweryfikuj domenę', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10873,12 +11060,12 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, setMetadataGenericError: 'Nie można było ustawić metadanych SAML', }, accessRestricted: { - title: 'Dostęp ograniczony', - subtitle: (domainName: string) => `Zwierzyń się proszę jako upoważniony administrator firmy dla ${domainName}, jeśli potrzebujesz kontroli nad:`, - companyCardManagement: 'Zarządzanie kartami służbowymi', - accountCreationAndDeletion: 'Tworzenie i usuwanie konta', - workspaceCreation: 'Tworzenie przestrzeni roboczej', - samlSSO: 'SSO SAML', + headerTitle: 'Dostęp ograniczony', + title: 'Wymagana weryfikacja', + description: (domainName: string) => + `Zweryfikuj się jako upoważniony administrator firmy dla ${domainName} lub poproś o dostęp obecnych administratorów.`, + requestAdminAccess: 'Poproś o dostęp administratora', + verifyYourself: 'Zweryfikuj się', }, addDomain: { title: 'Dodaj domenę', @@ -10892,7 +11079,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, title: 'Domena jest już skonfigurowana. Poprosić o dostęp?', description: 'Ktoś już skonfigurował tę domenę w Expensify. Chcesz poprosić o dostęp administratora?', requestAccess: 'Poproś o dostęp administratora', - requestAccessError: 'Nie udało się wysłać Twojej prośby. Spróbuj ponownie.', }, domainAdded: { title: 'Dodano domenę', @@ -10917,6 +11103,9 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, consolidatedDomainBillingError: 'Nie można było zmienić zbiorczego rozliczania domeny. Spróbuj ponownie później.', addAdmin: 'Dodaj administratora', addAdminError: 'Nie można dodać tego członka jako administratora. Spróbuj ponownie.', + requests: 'Prośby', + approveRequestError: 'Nie można zatwierdzić tej prośby. Spróbuj ponownie.', + declineRequestError: 'Nie można odrzucić tej prośby. Spróbuj ponownie.', revokeAdminAccess: 'Wycofaj dostęp administratora', cantRevokeAdminAccess: 'Nie można odebrać dostępu administratora osobie kontaktowej ds. technicznych', error: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index be2fce4c20a0..7cde0d05774a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: 'Deixar de compartilhar', yes: 'Sim', no: 'Não', + approve: 'Aprovar', + deny: 'Negar', dontChange: 'Não alterar', ok: 'OK', notNow: 'Agora não', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: 'Buscar', reports: 'Relatórios', spend: 'Gastos', + insights: 'Insights', find: 'Localizar', searchWithThreeDots: 'Buscar...', next: 'Próximo', @@ -463,6 +466,9 @@ const translations: TranslationDeepObject = { none: 'Nenhum', unstableInternetConnection: 'Conexão de internet instável. Verifique sua rede e tente novamente.', enableGlobalReimbursements: 'Ativar reembolsos globais', + corpayPayModalTitle: 'Pagar relatório', + corpayPayModalPrompt: + 'Esta pessoa que enviou o relatório tem uma conta bancária que não é em USD. Ative os reembolsos globais para pagar o relatório, ou peça para adicionar uma conta bancária em USD.', purchaseAmount: 'Valor da compra', originalAmount: 'Valor original', frequency: 'Frequência', @@ -950,13 +956,21 @@ const translations: TranslationDeepObject = { tooltip: 'Comece aqui!', }, homePage: { - forYou: 'Para você', + toDos: 'Pendências', timeSensitiveSection: { title: 'Urgente', - addShippingAddress: {title: 'Precisamos do seu endereço de entrega', subtitle: 'Informe um endereço para receber seu Cartão Expensify.', cta: 'Adicionar endereço'}, + 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'}, activateCard: {title: 'Ative seu Cartão Expensify', subtitle: 'Valide seu cartão e comece a gastar.', cta: 'Ativar'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `A adição do cartão ${walletName} precisa da sua aprovação`, + subtitle: 'Cartão Expensify', + cta: 'Revisar', + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'Carteira digital', + }, reviewCardFraud: { title: 'Analisar possível fraude no seu Cartão Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Analisar ${amount} em possível fraude em ${merchant}`, @@ -979,7 +993,7 @@ const translations: TranslationDeepObject = { subtitle: 'Carteira', }, validateAccount: {title: 'Valide sua conta', subtitle: 'Conta', cta: 'Validar'}, - addHomeAddress: {title: 'Adicione seu endereço residencial para rastrear distâncias', subtitle: 'Conta', cta: 'Adicionar endereço'}, + addHomeAddress: {title: 'Adicione seu endereço residencial para rastrear distâncias', subtitle: 'Conta', cta: 'Adicionar'}, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, unlockBankAccount: { workspaceTitle: 'Sua conta bancária comercial foi bloqueada', @@ -987,11 +1001,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: 'Carteira', }, - addVirtualCardPersonalDetails: { - title: 'Adicione seus dados para visualizar e começar a usar seu Cartão Expensify.', - subtitle: 'Cartão Expensify', - cta: 'Adicionar detalhes', - }, + addVirtualCardPersonalDetails: {title: 'Adicione seus dados para visualizar e começar a usar seu Cartão Expensify.', subtitle: 'Cartão Expensify', cta: 'Adicionar'}, enterSignerInfo: {title: 'Informações do signatário necessárias', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Conta bancária ${bankAccountLastFour}`}, }, discoverSection: { @@ -1094,6 +1104,8 @@ const translations: TranslationDeepObject = { inviteAccountant: 'Convide seu contador', customizeSpendCategories: 'Personalizar categorias de gastos', customizeSpendCategoriesSubText: 'Organize e classifique despesas', + customizeExpenseCategories: 'Personalize suas categorias de despesas', + customizeExpenseCategoriesSubText: 'Adicione as categorias da sua empresa para codificar suas despesas', createExpense: 'Criar uma despesa', createExpenseSubText: 'Escaneie, arraste e solte ou insira manualmente uma despesa usando o botão +', linkPersonalCard: 'Vincular cartão pessoal', @@ -1119,7 +1131,6 @@ const translations: TranslationDeepObject = { title: 'Seus gastos', awaitingApproval: 'Aguardando aprovação', repaidLast30Days: 'Reembolsado últimos 30 dias', - recentTransactions: ({lastFour}: {lastFour: string}) => `Transações recentes • ${lastFour}`, }, seeMore: ({count}: {count: number}) => `Ver mais ${count}`, recentlyAddedSection: { @@ -1274,6 +1285,7 @@ const translations: TranslationDeepObject = { phrase1: 'Adicionar um recibo', phrase2: 'ou arraste e solte um aqui', }, + pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, }, quickAction: { scanReceipt: 'Digitalizar recibo', @@ -1659,11 +1671,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: 'Tudo pronto!', noDuplicatesDescription: 'Não há transações duplicadas para revisar aqui.', confirmApprove: 'Confirmar valor da aprovação', - confirmApprovalAmount: 'Aprove apenas as despesas em conformidade ou aprove o relatório inteiro.', - confirmApprovalAllHoldAmount: () => ({ - one: 'Esta despesa está em espera. Deseja aprovar mesmo assim?', - other: 'Essas despesas estão em espera. Deseja aprovar mesmo assim?', - }), + confirmApprovalWithHeldAmount: 'O relatório contém despesas em espera. Aprovar apenas despesas em conformidade, ou aprovar o relatório inteiro?', + confirmApprovalAllHoldAmount: 'Todas as despesas estão em espera. Aprovar mesmo assim?', confirmPay: 'Confirmar valor do pagamento', confirmPayAmount: 'Pague o que não está retido ou pague o relatório inteiro.', confirmPayAllHoldAmount: () => ({ @@ -2287,6 +2296,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Nomes de trechos destacados', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click, navegação, ui.carregar', showBranchNameInTitle: 'Mostrar nome da branch no título do navegador', + betaOverrides: 'Substituições de betas', + betaOverridesDescription: + 'As substituições se aplicam apenas a este dispositivo e afetam somente as verificações do frontend. Uma beta mantém uma substituição apenas enquanto ela difere das betas da sua conta, então alternar de volta remove a substituição. “Redefinir todas as substituições” restaura os valores da sua conta. Algumas betas também são controladas pelo backend, então podem falhar no nível da solicitação.', + resetAllOverrides: 'Redefinir todas as substituições', + overridden: 'Substituída', qaAuth: 'Autenticação de QA (Cloudflare)', qaAuthRunProbe: 'Executar sondagem', qaAuthSession: 'Sessão de autenticação de QA', @@ -2602,6 +2616,10 @@ const translations: TranslationDeepObject = { connectionLink ? `A conexão do seu cartão ${cardName} está com problemas. Acesse seu banco para corrigir o cartão.` : `A conexão do seu cartão ${cardName} está com problemas. Acesse seu banco para corrigir o cartão.`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `Sua conexão com o ${cardName} foi interrompida há 30 dias. Acesse seu banco para corrigir isso ou remova o cartão se não estiver mais em uso. Você não perderá nenhuma despesa enviada se removê-lo.` + : `Sua conexão com o ${cardName} foi interrompida há 30 dias. Acesse o seu banco para corrigir isso ou remova o cartão se ele não estiver mais em uso. Você não perderá nenhuma despesa enviada se o remover.`, addAdditionalCards: 'Adicionar outros cartões', upgradeDescription: 'Precisa adicionar mais cartões? Crie um espaço de trabalho para adicionar cartões pessoais ou atribuir cartões corporativos a toda a equipe.', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => @@ -2701,6 +2719,24 @@ const translations: TranslationDeepObject = { unlock: 'Desbloquear', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `Adicionar cartão à ${walletName}`, + appleWallet: 'Apple Wallet', + googleWallet: 'Google Wallet', + digitalWallet: 'carteira digital', + confirmHeading: 'Confirme sua solicitação', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => + `Você quer adicionar seu Cartão Expensify (com final ${lastFourDigits}) à sua ${walletName}?`, + deny: 'Negar', + confirm: 'Confirmar', + verifyTitle: 'Vamos verificar se é você', + enterSecurityCode: (contactMethod: string) => + `Digite o código de segurança enviado para ${contactMethod} para confirmar esta solicitação. Ele deve chegar dentro de um ou dois minutos.`, + successHeading: 'Sucesso!', + successDescription: ({walletName}: {walletName: string}) => `Seu cartão agora está ativo para uso na sua ${walletName}.`, + deniedHeading: 'Solicitação negada', + deniedDescription: ({walletName}: {walletName: string}) => `Seu cartão não foi adicionado à sua ${walletName}.`, + }, cardPage: { expensifyCard: 'Cartão Expensify', expensifyTravelCard: 'Cartão de Viagem Expensify', @@ -3147,6 +3183,7 @@ ${amount} para ${merchant} - ${date}`, unread: 'Não lidas', markAllAsRead: 'Marcar tudo como lido', markAllAsReadConfirmationPrompt: 'Tem certeza de que deseja marcar todos os chats como lidos?', + markAllTodosAsReadConfirmationPrompt: 'Tem certeza de que deseja marcar como lidos todos os chats pendentes?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, @@ -3324,6 +3361,7 @@ ${amount} para ${merchant} - ${date}`, `Não foi possível adicionar ${workEmail}. Tente novamente mais tarde em Configurações ou converse com o Concierge para obter orientação.`, workAccountClosedSubtitle: 'A conta de trabalho associada a este e-mail foi encerrada. Entre em contato com o administrador da sua empresa para reativá-la ou inscreva-se com um e-mail diferente.', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} é um login controlado por domínio de uma conta existente do Expensify.`, }, tasks: { testDriveAdminTask: { @@ -4152,8 +4190,7 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', enterTheDateOfBirthOfTheOwner: 'Qual é a data de nascimento do proprietário?', - enterTheLast4: 'Quais são os últimos 4 dígitos do CPF do proprietário?', - last4SSN: 'Últimos 4 do SSN', + enterTheSSN: 'Qual é o número do Social Security do proprietário?', dontWorry: 'Não se preocupe, não fazemos nenhuma análise de crédito pessoal!', enterTheOwnersAddress: 'Qual é o endereço do proprietário?', letsDoubleCheck: 'Vamos conferir se está tudo certo.', @@ -4180,13 +4217,12 @@ ${amount} para ${merchant} - ${date}`, whatsYourDOB: 'Qual é a sua data de nascimento?', whatsTheOwnersAddress: 'Qual é o endereço do proprietário?', whatsYourAddress: 'Qual é o seu endereço?', - whatAreTheLast: 'Quais são os últimos 4 dígitos do Número de Seguridade Social do proprietário?', - whatsYourLast: 'Quais são os últimos 4 dígitos do seu Número de Seguridade Social?', + whatsTheOwnersSSN: 'Qual é o número do Social Security do proprietário?', + whatsYourSSN: 'Qual é o seu número do seguro social?', whatsYourNationality: 'Qual é o seu país de cidadania?', whatsTheOwnersNationality: 'Qual é o país de cidadania do proprietário?', countryOfCitizenship: 'País de cidadania', dontWorry: 'Não se preocupe, não fazemos nenhuma análise de crédito pessoal!', - last4: 'Últimos 4 do SSN', whyDoWeAsk: 'Por que pedimos isso?', letsDoubleCheck: 'Vamos conferir se está tudo certo.', legalName: 'Nome legal', @@ -4209,6 +4245,8 @@ ${amount} para ${merchant} - ${date}`, proofOfBeneficialOwner: 'Comprovante de beneficiário final', proofOfBeneficialOwnerDescription: 'Forneça uma declaração assinada e um organograma emitidos por um contador público, tabelião ou advogado comprovando a titularidade de 25% ou mais do negócio. Eles devem estar datados dos últimos três meses e incluir o número da licença do signatário.', + proofOfBeneficialOwnerDescriptionCA: + 'Forneça o seu Anexo 50 (Informações dos Acionistas) da declaração de imposto de renda corporativo T2 mais recente, mostrando todas as pessoas físicas que possuem 25% ou mais do negócio.', copyOfID: 'Cópia do documento de identificação do beneficiário final', copyOfIDDescription: 'Exemplos: Passaporte, carteira de motorista, etc.', proofOfAddress: 'Comprovante de endereço do beneficiário final', @@ -4592,6 +4630,7 @@ ${amount} para ${merchant} - ${date}`, reportFields: 'Campos do relatório', reportTitle: 'Título do relatório', reportField: 'Campo de relatório', + invoiceFields: 'Campos de fatura', taxes: 'Impostos', bills: 'Contas', invoices: 'Faturas', @@ -4599,6 +4638,7 @@ ${amount} para ${merchant} - ${date}`, travel: 'Viagens', members: 'Membros', accounting: 'Contabilidade', + mcp: 'MCP', receiptPartners: 'Parceiros de recibos', rules: 'Regras', displayedAs: 'Exibido como', @@ -4722,6 +4762,7 @@ ${amount} para ${merchant} - ${date}`, travelInvoicingVendor: 'Fornecedor de viagem', travelInvoicingPayableAccount: 'Conta a pagar de viagens', hr: 'RH', + recruiting: 'Recrutamento', rooms: 'Salas', findDomain: 'Encontrar domínio', cardAdminAlternateText: 'Gerenciar cartões do workspace.', @@ -4735,6 +4776,25 @@ ${amount} para ${merchant} - ${date}`, title: 'Você criou um espaço de trabalho para seu cliente!', description: 'Ótimas notícias 🎉. Entre em contato conosco se precisarem de ajuda com a configuração.', }, + mcp: { + connectors: 'Conectores', + connectorsSubtitle: 'Conecte um assistente de IA à sua conta do Expensify.', + connect: 'Conectar', + helpPrompt: 'Precisa de ajuda para conectar?', + helpLink: 'Leia nosso guia.', + claude: { + title: 'Claude', + subtitle: 'da Anthropic', + }, + cursor: { + title: 'Cursor', + subtitle: 'da Anysphere', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: 'da OpenAI', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -4953,7 +5013,6 @@ ${amount} para ${merchant} - ${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `Use esta conta ao exportar faturas para o ${integrationName}.`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `Defina como as compras com cartão corporativo serão exportadas para o ${integrationName}.`, vendor: 'Fornecedor', - defaultVendorDescription: 'Defina um fornecedor padrão que será aplicado a todas as transações de cartão de crédito na exportação.', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `Defina como as despesas reembolsáveis serão exportadas para o ${integrationName}.`, exportCheckDescription: 'Vamos criar um cheque detalhado para cada relatório do Expensify e enviá-lo a partir da conta bancária abaixo.', exportJournalEntryDescription: 'Vamos criar um lançamento contábil detalhado para cada relatório do Expensify e lançá-lo na conta abaixo.', @@ -5119,8 +5178,6 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'Nenhuma conta encontrada', noAccountsFoundDescription: 'Adicione a conta no Xero e sincronize a conexão novamente', - defaultSupplier: 'Fornecedor padrão', - defaultSupplierDescription: 'Defina um fornecedor padrão que será aplicado a todas as transações de cartão de crédito na exportação.', noSuppliersFound: 'Nenhum fornecedor encontrado', noSuppliersFoundDescription: 'Adicione o fornecedor no Xero e sincronize a conexão novamente.', accountingMethods: { @@ -6345,6 +6402,9 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS 'Não foi possível provisionar alguns membros do seu workspace para o Faturamento Consolidado de Viagens. Tente novamente mais tarde ou entre em contato com o Concierge para obter ajuda.', sendInvoiceNowCta: 'Enviar fatura agora', }, + setUpNewFeed: 'Configurar um novo feed de viagem', + feedSelectorIntro: + 'Sua organização já tem um feed de Faturamento Consolidado de Viagens configurado. Selecione-o para manter os gastos de viagem deste workspace na mesma fatura mensal.', }, disableModal: { title: 'Desativar Faturamento Consolidado de Viagens?', @@ -6501,6 +6561,10 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS title: 'Contabilidade', subtitle: 'Sincronize seu plano de contas e muito mais.', }, + mcp: { + title: 'MCP', + subtitle: 'Conecte um assistente de IA à sua conta do Expensify.', + }, receiptPartners: { title: 'Parceiros de recibos', subtitle: 'Importe recibos automaticamente.', @@ -6564,6 +6628,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS subtitle: 'Os campos de relatório se aplicam a todos os gastos e podem ser úteis quando você quiser solicitar informações extras.', disableReportFields: 'Desativar campos de relatório', disableReportFieldsConfirmation: 'Tem certeza? Campos de texto e data serão excluídos e listas serão desativadas.', + cannotDisableImportedReportFields: 'Campos de relatório importados da sua conexão contábil não podem ser desativados.', importedFromAccountingSoftware: 'Os campos de relatório abaixo são importados do seu', textType: 'Texto', dateType: 'Data', @@ -6600,6 +6665,29 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS reportFieldInitialValueRequiredError: 'Escolha um valor inicial para o campo de relatório', genericFailureMessage: 'Ocorreu um erro ao atualizar o campo do relatório. Tente novamente.', }, + invoiceFields: { + subtitle: 'Os campos da fatura podem ser úteis quando você quiser incluir informações extras.', + importedFromAccountingSoftware: 'Os campos da fatura abaixo são importados do seu', + disableInvoiceFields: 'Desativar campos da fatura', + disableInvoiceFieldsConfirmation: 'Tem certeza? Os campos da fatura serão desativados nas faturas.', + delete: 'Excluir campo da fatura', + deleteConfirmation: 'Tem certeza de que deseja excluir este campo da fatura?', + findInvoiceField: 'Encontrar campo da fatura', + nameInputSubtitle: 'Escolha um nome para o campo da fatura.', + typeInputSubtitle: 'Escolha o tipo de campo da fatura que deseja usar.', + initialValueInputSubtitle: 'Insira um valor inicial para mostrar no campo da fatura.', + listValuesInputSubtitle: 'Esses valores aparecerão no menu suspenso do campo da fatura. Valores ativados podem ser selecionados pelos membros.', + listInputSubtitle: 'Esses valores aparecerão na lista do campo da fatura. Valores ativados podem ser selecionados pelos membros.', + emptyInvoiceFieldsValues: { + title: 'Ainda não há valores de lista', + subtitle: 'Adicione valores personalizados para aparecerem nas faturas.', + }, + existingInvoiceFieldNameError: 'Já existe um campo da fatura com este nome', + invoiceFieldNameRequiredError: 'Insira um nome para o campo da fatura', + invoiceFieldTypeRequiredError: 'Escolha um tipo de campo da fatura', + invoiceFieldInitialValueRequiredError: 'Escolha um valor inicial para o campo da fatura', + addField: 'Adicionar campo', + }, vendors: { emptyTitle: 'Ainda não há fornecedores', emptySubtitle: 'Os fornecedores vão aparecer aqui depois que a sincronização contábil for concluída.', @@ -6993,6 +7081,8 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -7073,9 +7163,9 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, case 'intacctImportSyncBillPayments': return 'Sincronizando relatórios reembolsados e pagamentos de contas'; case 'quickbooksOnlineSyncTaxCodes': - return 'Importando códigos de imposto'; + return 'Importando códigos de impostos'; case 'quickbooksOnlineCheckConnection': - return `Verificando conexão com ${integrationName}`; + return `Verificando a conexão com ${integrationName}`; case 'quickbooksOnlineImportMain': return `Importando dados de ${integrationName}`; case 'startingImportXero': @@ -7096,7 +7186,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, case 'quickbooksDesktopWebConnectorReminder': return 'Ainda sincronizando dados com o QuickBooks... Certifique-se de que o Web Connector está em execução'; case 'quickbooksOnlineSyncTitle': - return `Sincronizando dados do ${integrationName}`; + return `Sincronizando dados de ${integrationName}`; case 'quickbooksOnlineSyncLoadData': case 'xeroSyncStep': case 'intacctImportData': @@ -7128,7 +7218,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, case 'xeroSyncImportTaxRates': return 'Sincronizando taxas de imposto'; case 'xeroCheckConnection': - return 'Verificando conexão com o Xero'; + return 'Verificando conexão com Xero'; case 'xeroSyncTitle': return 'Sincronizando dados do Xero'; case 'netSuiteSyncConnection': @@ -7177,7 +7267,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, case 'intacctImportTitle': return 'Importando dados do Sage Intacct'; case 'financialForceSyncTitle': - return 'Sincronizando dados do Certinia'; + return 'Sincronizando dados Certinia'; case 'financialForceSyncStep': return 'Sincronizando conexão Certinia'; case 'financialForceSyncCategories': @@ -7195,7 +7285,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, case 'financialForceSyncDimensions': return 'Importando dimensões'; case 'financialForceMarkAsReimbursed': - return 'Marcando relatórios como reembolsados'; + return 'Marcar relatórios como reembolsados'; case 'rilletSyncTitle': return 'Sincronizando dados do Rillet'; case 'rilletSyncConnection': @@ -7211,11 +7301,23 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, case 'dualEntrySyncPayments': return 'Sincronizando pagamentos a fornecedores'; case 'dualEntrySyncCardSettlements': - return 'Sincronizando liquidações do cartão'; + return 'Sincronizando liquidações de cartão'; case 'dualEntrySyncTravelSettlements': return 'Sincronizando acertos de viagem'; + case 'campfireSyncTitle': + return 'Sincronizando dados do Campfire'; + case 'campfireSyncConnection': + return 'Inicializando conexão com o Campfire'; + case 'campfireSyncImportData': + return 'Carregando dados'; + case 'campfireSyncPayments': + return 'Sincronizando pagamentos a fornecedores'; + case 'campfireSyncCardSettlements': + return 'Sincronizando liquidações de cartão'; + case 'campfireSyncTravelSettlements': + return 'Sincronizando acertos de viagem'; default: { - return `Tradução ausente para etapa: ${stage}`; + return `Tradução ausente para o estágio: ${stage}`; } } }, @@ -7229,11 +7331,11 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, exportCompanyCard: 'Exportar despesas de cartão corporativo como', exportDate: 'Data de exportação', defaultVendor: 'Fornecedor padrão', - defaultVendorHelperText: (isSet: boolean) => - isSet - ? `Despesas que não forem correspondidas automaticamente terão este fornecedor como padrão.` - : `Despesas que não forem conciliadas automaticamente serão atribuídas a este fornecedor por padrão. Caso contrário, serão exportadas como Credit Card Misc.`, - defaultVendorSelectHeader: (connectionName: string) => `Escolha um fornecedor padrão do ${connectionName} para despesas que não sejam correspondidas automaticamente.`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName + ? `Despesas que não tiverem correspondência automática serão associadas a este fornecedor por padrão.` + : `Despesas que não forem conciliadas automaticamente serão atribuídas a este fornecedor por padrão. Caso contrário, serão exportadas como ${fallbackVendorName}.`, + defaultVendorSelectHeader: `Escolha um fornecedor padrão para as despesas que não forem correspondidas automaticamente.`, defaultAccount: 'Conta padrão', autoSync: 'Sincronização automática', autoSyncDescription: 'Sincronize NetSuite e Expensify automaticamente, todos os dias. Exporte relatórios finalizados em tempo real', @@ -7256,6 +7358,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, syncTravelInvoicingSettlements: 'Sincronizar liquidações de Faturamento de Viagens Consolidado', syncTravelInvoicingSettlementsNoAccountTooltip: 'Para desbloquear, defina uma conta para suas exportações.', syncTravelInvoicingSettlementsNoAutoSyncTooltip: 'Para desbloquear, ative a sincronização automática.', + campfire: 'Campfire', }, export: { notReadyHeading: 'Não está pronto para exportar', @@ -7310,14 +7413,14 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, title: 'Excluir deslocamentos para o trabalho', summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, - optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', + optionDisabledTitle: 'Não excluir deslocamento para o trabalho', optionDisabledHelp: 'Nenhum deslocamento é removido das solicitações.', - optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', - optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', + optionFixedDistanceTitle: 'Remover distância fixa', + optionFixedDistanceHelp: 'Subtrai a mesma distância de cada solicitação.', distanceLabel: 'Distância', summaryHomeAndOffice: 'Usar endereços de casa e escritório', - optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a designação de escritório da pessoa colaboradora para calcular as exclusões de deslocamento.', + optionHomeAndOfficeTitle: 'Excluir trajeto habitual', + optionHomeAndOfficeHelp: 'Subtrai o trajeto diário habitual de cada membro com base em sua casa, tipo de trabalho e escritório.', workspaceAddressRequired: { title: 'Calma lá...', promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até primeiro adicionar um endereço de escritório em', @@ -7516,6 +7619,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Os campos de relatório estão disponíveis apenas no plano Control, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por membro por mês.` : `por membro ativo por mês.`}`, }, + invoiceFields: { + title: 'Campos de fatura', + description: `Os campos da fatura permitem incluir detalhes extras no nível da fatura.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Os campos da fatura estão disponíveis apenas no plano Control, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por membro por mês.` : `por membro ativo por mês.`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Aproveite a sincronização automática e reduza lançamentos manuais com a integração Expensify + NetSuite. Obtenha insights financeiros profundos e em tempo real com suporte a segmentos nativos e personalizados, incluindo mapeamento de projetos e clientes.`, @@ -7558,6 +7667,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `Nossa integração com a DualEntry está disponível apenas no plano Control, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por membro por mês.` : `por membro ativo por mês.`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `Aproveite a sincronização automática e reduza lançamentos manuais com a integração Expensify + Campfire. Alinhe dimensões de categorização de despesas e a sincronização de impostos à sua configuração Campfire para maior visibilidade financeira.`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `Nossa integração com a Campfire está disponível apenas no plano Control, a partir de ${formattedPrice} ${hasTeam2025Pricing ? `por membro por mês.` : `por membro ativo por mês.`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Aprovações Avançadas', description: `Se você quiser adicionar mais camadas de aprovação ao processo – ou apenas garantir que as maiores despesas recebam uma segunda revisão – nós ajudamos você. As aprovações avançadas ajudam a colocar os controles certos em cada nível, para manter os gastos da sua equipe sob controle.`, @@ -7849,7 +7964,6 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e alwaysReimbursableDescription: 'As despesas são sempre reembolsadas aos funcionários', alwaysNonReimbursable: 'Sempre não reembolsável', alwaysNonReimbursableDescription: 'Despesas nunca são reembolsadas aos funcionários', - billableDefault: 'Cobrável por padrão', billableDefaultDescription: 'Escolha se as despesas em dinheiro e cartão de crédito devem ser faturáveis por padrão.', billable: 'Faturável', billableDescription: 'As despesas são mais frequentemente refaturadas aos clientes', @@ -7876,10 +7990,12 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e publicReceiptVisibilityHintDisabled: 'Os recibos só podem ser vistos por membros da Expensify com acesso ao relatório que contém o recibo.', enableTagsToUnlockTitle: 'Ativar tags?', enableTagsToUnlockPrompt: 'Ative as Tags (em Mais recursos) para desbloquear.', - enableTagsAndRequirePrompt: 'Tem certeza de que quer ativar etiquetas e exigi-las para todas as despesas?', enableCategoriesToUnlockTitle: 'Ativar categorias?', enableCategoriesToUnlockPrompt: 'Ative Categorias (em Mais recursos) para desbloquear.', enableCategoriesAndRequirePrompt: 'Tem certeza de que deseja ativar categorias e torná-las obrigatórias para todas as despesas?', + enableTagsPrompt: 'Tem certeza de que quer ativar tags? Você poderá exigi-las para todas as despesas assim que tiver pelo menos uma tag.', + noTagsToRequirePrompt: 'Você não tem nenhuma tag. Crie uma tag.', + noCategoriesToRequirePrompt: 'Você não tem nenhuma categoria. Crie uma categoria.', }, expenseReportRules: { title: 'Avançado', @@ -7952,6 +8068,20 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e 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', + confirmErrorConditionAndDefault: 'Insira um comerciante e aplique pelo menos um padrão', + turnOnTaxesFirstTitle: 'Ative os impostos primeiro', + turnOnTaxesFirstPrompt: 'As regras de categoria definem uma alíquota de imposto padrão. Ative os impostos nas configurações do seu workspace para usá-los.', + categoryRulesApplyGoingForwardTitle: 'As regras de categoria se aplicam daqui em diante', + categoryRulesApplyGoingForwardPrompt: 'Uma taxa de imposto padrão se aplica às novas despesas desta categoria. As despesas que já existem não serão alteradas.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'A alíquota de imposto selecionada agora é o padrão do seu workspace, então esta regra não é mais válida. Escolha uma alíquota de imposto diferente.', + addTaxRateFirstTitle: 'Adicione uma alíquota de imposto primeiro', + addTaxRateFirstPrompt: 'As regras de categoria definem uma taxa de imposto padrão. Adicione uma taxa diferente da padrão do seu espaço de trabalho para usá-las.', + createRuleFromExpenseAction: 'Criar regra', + createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, categoryRules: { title: 'Regras de categoria', @@ -7972,7 +8102,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e flagAmountsOverSubtitle: 'Isso substitui o valor máximo para todas as despesas.', expenseLimitTypes: { expense: 'Despesa individual', - expenseSubtitle: 'Sinalize valores de despesas por categoria. Esta regra substitui a regra geral do workspace para valor máximo de despesa.', + expenseSubtitle: + 'Sinalize valores de despesas por categoria. Esta regra substitui a regra geral do espaço de trabalho para o valor máximo de despesa. Reservas de vários dias são avaliadas usando a média por noite.', daily: 'Total da categoria', dailySubtitle: 'Sinalizar o gasto total diário por categoria em cada relatório de despesas.', }, @@ -8250,10 +8381,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, applyExpenseDefaultsDescription: 'Atualizar campos sem o responsável pelo envio fazer nada', flagForReview: 'Marcar para revisão', flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', - requireFields: 'Requisitos do campo', + requireFields: 'Definir requisitos de campo', requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', - createAgentRule: 'Regra do agente', - createAgentRuleDescription: 'Descreva regras flexíveis que são executadas quando você precisar.', + createAgentRule: 'Descrever regra do agente', + createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', }, expenseDefaultsTable: { tableColumnType: 'Tipo', @@ -8264,6 +8395,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, update: 'Atualizar', merchantIs: (merchant: string) => `O comerciante é "${merchant}"`, merchantTypeIs: (merchantType: string) => `O tipo de estabelecimento é "${merchantType}"`, + categoryIs: (category: string) => `A categoria é "${category}"`, }, merchantTypeRule: {merchantType: 'Tipo de comerciante', saveRule: 'Salvar regra', confirmErrorCategory: 'Selecione uma categoria.'}, requireFieldsTable: { @@ -8280,6 +8412,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, requireReceipt: 'Exigir recibo', doNotRequireReceipt: 'Não exigir recibo', doNotRequireItemizedReceipt: 'Não exigir recibo detalhado', + typeLabel: 'Exigir', }, requireFieldsEmptyState: { title: 'Identifique detalhes faltando desde o início', @@ -8328,6 +8461,15 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, agentRulesEmptyState: {title: 'Nenhuma regra de agente adicionada', subtitle: 'Crie uma regra para automatizar as políticas do seu workspace.', cta: 'Adicionar regra de IA'}, categoriesDisabledEmptyState: {title: 'Categorias não estão ativadas', subtitle: 'Ative categorias para ter mais controle sobre seus gastos.'}, + expenseDefaultType: { + title: 'Aplicar padrões de despesa', + subtitle: 'Com o que esta regra deve corresponder?', + merchant: 'Comerciante', + merchantDescription: 'Atualizar campos em despesas de um comerciante específico', + category: 'Categoria', + categoryDescription: 'Definir uma taxa de imposto padrão para uma categoria específica', + }, + taxesDisabledEmptyState: {title: 'Impostos não estão ativados', subtitle: 'Ative impostos para documentar e recuperar impostos elegíveis.', cta: 'Ativar impostos'}, }, planTypePage: { planTypes: { @@ -8354,37 +8496,26 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, hr: { title: 'RH', - connections: 'Conexões', connectionsSubtitle: 'Conecte-se ao seu sistema de RH para sincronizar os dados de funcionários, associar automaticamente os reembolsos às pessoas corretas e manter as despesas da sua equipe precisas, sem trabalho manual.', subtitle: 'Conecte ferramentas de RH e mantenha as aprovações de funcionários em sincronia.', - connect: 'Conectar', - syncNow: 'Sincronizar agora', - disconnect: 'Desconectar', - disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, - disconnectPrompt: (providerName: string) => `Tem certeza de que deseja desconectar ${providerName}?`, alreadyConnectedTitle: 'Não é possível conectar a várias plataformas de RH', alreadyConnectedPrompt: 'Você precisa desconectar sua plataforma de RH atual antes de conectar outra.', - lastSync: (relativeDate: string) => `Última sincronização ${relativeDate}`, - syncError: (providerName: string) => `Não é possível conectar ao ${providerName}`, connectionDescription: (providerName: string) => `Conecte ${providerName} para manter as aprovações de funcionários sincronizadas com seu workspace.`, - approvalMode: 'Modo de aprovação', providerApprovalMode: (providerName: string) => `Modo de aprovação do ${providerName}`, - finalApprover: 'Aprovador final', providerFinalApprover: (providerName: string) => `Aprovador final de ${providerName}`, - notSet: 'Não definido', + syncing: 'Sincronizando funcionários', + syncingModalTitle: 'Sua conexão está sincronizando', + syncingModalDescription: 'A primeira conexão pode levar algum tempo. Você será notificado sobre quaisquer erros.', approvalModeDescription: (providerName: string) => `Membros e gerentes estão configurados para sincronizar com ${providerName}.`, approvalModeWarningTitle: 'Alterar modo de aprovação?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `Tem certeza de que deseja alterar o modo de aprovação deste workspace? Saiba mais sobre os diferentes modos de fluxo de trabalho com ${providerName} em nosso site de ajuda.`, approvalModeWarningConfirm: 'Alterar modo de aprovação', - approvalModes: { - basic: {label: 'Aprovação básica', description: 'Todos os usuários enviam para uma única pessoa para processamento e aprovação.'}, - manager: { - label: 'Aprovação do gerente', - description: (providerName: string) => `Os funcionários enviam relatórios ao gestor direto configurado no ${providerName}.`, - }, - custom: {label: 'Aprovação personalizada', description: 'Vou configurar manualmente os fluxos de aprovação no Expensify.'}, + approvalModeDescriptions: { + basic: 'Todos os usuários enviam para uma única pessoa para processamento e aprovação.', + manager: (providerName: string) => `Os funcionários enviam relatórios ao gestor direto configurado no ${providerName}.`, + custom: 'Vou configurar manualmente os fluxos de aprovação no Expensify.', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8424,28 +8555,66 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, zenefits: { title: 'TriNet', }, - syncingModalTitle: 'Sua conexão está sincronizando', - syncingModalDescription: 'A primeira conexão pode levar algum tempo. Você será notificado sobre quaisquer erros.', - syncing: 'Sincronizando funcionários', + setupIncomplete: (setupLink: string | undefined) => + `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar funcionários.`, mergeHR: { - completeSetup: 'Concluir configuração', - setupIncomplete: (setupLink: string | undefined) => - `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar funcionários.`, groups: {title: 'Grupos', description: 'Escolha os grupos de funcionários que você gostaria de sincronizar com este workspace'}, }, + }, + recruiting: { + title: 'Recrutamento', + connectionsSubtitle: + 'Conecte-se ao seu sistema de recrutamento para sincronizar os dados de candidatos, associar automaticamente os reembolsos às pessoas certas e manter as despesas da sua equipe precisas sem trabalho manual.', + alreadyConnectedTitle: 'Não é possível conectar a várias plataformas de ATS', + alreadyConnectedPrompt: 'Você precisa desconectar o seu ATS atual antes de conectar outro.', + syncing: 'Sincronizando candidatos', + setupIncomplete: (setupLink: string | undefined) => + `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar candidatos.`, + 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'}, + }, + merge: { + connections: 'Conexões', + connect: 'Conectar', + findIntegration: 'Encontrar integração', + syncNow: 'Sincronizar agora', + disconnect: 'Desconectar', + disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, + disconnectPrompt: (providerName: string) => `Tem certeza de que deseja desconectar ${providerName}?`, + lastSync: (relativeDate: string) => `Última sincronização ${relativeDate}`, notSync: 'Não sincronizado', + syncError: (providerName: string) => `Não é possível conectar ao ${providerName}`, authenticationError: (providerName: string) => `Não é possível conectar a ${providerName} devido a uma conexão expirada.`, reconnect: 'Reconectar', reconnectLink: 'Reconectar.', - findIntegration: 'Encontrar integração', - }, - merge: { + notSet: 'Não definido', + completeSetup: 'Concluir configuração', + approvalMode: 'Modo de aprovação', + finalApprover: 'Aprovador final', + approvalModes: { + basic: 'Aprovação básica', + manager: 'Aprovação do gerente', + custom: 'Aprovação personalizada', + advanced: 'Aprovação avançada', + }, syncLimitReached: {title: 'Tente novamente amanhã', prompt: 'Você atingiu o limite de sincronizações de hoje.'}, }, emptyDomain: { title: 'Aumente sua segurança com domínios', subtitle: 'Exija que os membros do seu domínio façam login via logon único, restrinja a criação de workspaces e muito mais.', }, + campfire: { + campfireSetup: 'Configuração do Campfire', + enterCredentials: 'Insira sua chave de API do Campfire', + howToFindAPIKey: + 'Encontrando sua chave de API.
  1. Faça login no Campfire
  2. Vá para Configurações -> Chaves de API
  3. Crie a chave de API
  4. Cole a chave de API abaixo
', + subsidiary: 'Subsidiária', + subsidiarySelectDescription: 'Escolha a subsidiária no Campfire da qual você gostaria de importar dados.', + noSubsidiariesFound: 'Nenhuma subsidiária encontrada', + noSubsidiariesFoundDescription: 'Adicione uma entidade no Campfire e sincronize a conexão novamente', + }, }, getAssistancePage: { title: 'Obter ajuda', @@ -9343,7 +9512,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: 'Faturamento de Viagens Consolidado', }, is: 'É', - has: {submittedViolation: 'Violação enviada'}, + has: {submittedViolation: 'Violação enviada', approvedViolation: 'Violação aprovada'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: 'Enviar', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: 'Aprovar', @@ -9404,6 +9573,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, exportedTo: 'Exportado para', exportAll: { selectAllMatchingItems: 'Selecionar todos os itens correspondentes', + allMatchingItemsSelected: 'Todos os itens correspondentes selecionados', selectAllOnThisPage: 'Selecionar tudo nesta página', }, chartTitles: { @@ -9442,6 +9612,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, topMerchants: 'Principais estabelecimentos', violationsBySubmitter: 'Violações por quem enviou', }, + mergeReports: { + title: 'Mesclar relatórios', + description: 'Selecione o relatório que você quer manter. Todas as despesas serão movidas para ele e os outros relatórios serão excluídos.', + }, }, genericErrorPage: { title: 'Opa, algo deu errado!', @@ -9525,6 +9699,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `exportado para ${translatedLabel}`; @@ -9561,6 +9736,16 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(Repetido ${count} vezes.)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `A conexão de ${feedName} está interrompida. Para restaurar as importações do cartão, faça login no seu banco.`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `A conexão ${feedName} foi interrompida há 30 dias. Acesse seu banco para corrigi-la ou remova a conexão se ela não estiver mais em uso. Você não perderá nenhuma despesa enviada se removê-la.`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `a conexão Plaid com a sua conta bancária empresarial foi interrompida. Por favor, reconecte sua conta bancária ${maskedAccountNumber} para continuar usando seus Cartões Expensify.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -10146,6 +10331,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `A tarifa só é válida a partir de ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `A tarifa só é válida até ${endDate}`, cannotMergeDuplicates: 'Você só pode mesclar despesas em relatórios rascunho ou pendentes. Retraia o relatório e tente novamente.', + overCategoryLimitPerNight: (formattedLimit: string) => `Tarifa por noite acima do limite de categoria de ${formattedLimit}/pessoa`, shortName: { allTagLevelsRequired: 'Todas as tags obrigatórias', autoReportedRejectedExpense: 'Despesa rejeitada', @@ -10755,6 +10941,7 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, reportLevelExport: 'Todos os dados - nível de relatório', expenseLevelExport: 'Todos os dados - nível de despesa', multipleTaxExport: 'Exportação canadense de múltiplos impostos', + reconciliationAllExpenses: 'Conciliação - Todas as despesas', exportInProgress: 'Exportação em andamento', conciergeWillSend: 'O Concierge enviará o arquivo para você em breve.', currentView: 'Visualização atual', @@ -10786,6 +10973,7 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, notVerified: 'Não verificado', retry: 'Tentar novamente', requestSent: 'Solicitação enviada', + requestAccessError: 'Não foi possível enviar sua solicitação. Tente novamente.', verifyDomain: { title: 'Verificar domínio', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10846,12 +11034,12 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, setMetadataGenericError: 'Não foi possível definir os metadados SAML', }, accessRestricted: { - title: 'Acesso restrito', - subtitle: (domainName: string) => `Verifique se você é um administrador autorizado da empresa para ${domainName} se precisar de controle sobre:`, - companyCardManagement: 'Gerenciamento de cartão corporativo', - accountCreationAndDeletion: 'Criação e exclusão de conta', - workspaceCreation: 'Criação de workspace', - samlSSO: 'SSO SAML', + headerTitle: 'Acesso restrito', + title: 'Verificação necessária', + description: (domainName: string) => + `Verifique-se como administrador autorizado da empresa para ${domainName} ou solicite acesso aos administradores existentes.`, + requestAdminAccess: 'Solicitar acesso de administrador', + verifyYourself: 'Verifique-se', }, addDomain: { title: 'Adicionar domínio', @@ -10865,7 +11053,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, title: 'Domínio já configurado. Solicitar acesso?', description: 'Alguém já configurou este domínio no Expensify. Quer solicitar acesso de administrador?', requestAccess: 'Solicitar acesso de administrador', - requestAccessError: 'Não foi possível enviar sua solicitação. Tente novamente.', }, domainAdded: { title: 'Domínio adicionado', @@ -10890,6 +11077,9 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, consolidatedDomainBillingError: 'A cobrança consolidada de domínios não pôde ser alterada. Tente novamente mais tarde.', addAdmin: 'Adicionar administrador', addAdminError: 'Não foi possível adicionar este membro como administrador. Tente novamente.', + requests: 'Solicitações', + approveRequestError: 'Não foi possível aprovar esta solicitação. Tente novamente.', + declineRequestError: 'Não foi possível negar esta solicitação. Tente novamente.', revokeAdminAccess: 'Revogar acesso de administrador', cantRevokeAdminAccess: 'Não é possível revogar o acesso de administrador do contato técnico', error: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5955b5edf9c8..e4c4427028c2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -54,6 +54,8 @@ const translations: TranslationDeepObject = { unshare: '取消共享', yes: '是', no: '否', + approve: '批准', + deny: '拒绝', dontChange: '不更改', ok: 'OK', notNow: '暂不处理', @@ -75,6 +77,7 @@ const translations: TranslationDeepObject = { search: '搜索', reports: '报表', spend: '支出', + insights: '洞察', find: '查找', searchWithThreeDots: '搜索…', next: '下一步', @@ -460,6 +463,8 @@ const translations: TranslationDeepObject = { none: '无', unstableInternetConnection: '网络连接不稳定。请检查您的网络后重试。', enableGlobalReimbursements: '启用全球报销', + corpayPayModalTitle: '支付报告', + corpayPayModalPrompt: '此提交者使用的是非美元银行账户。请启用全球报销以支付该报告,或请其添加美元银行账户。', purchaseAmount: '购买金额', originalAmount: '原始金额', frequency: '频率', @@ -917,13 +922,21 @@ const translations: TranslationDeepObject = { tooltip: '从这里开始!', }, homePage: { - forYou: '为你', + toDos: '待办事项', timeSensitiveSection: { title: '时间敏感', - addShippingAddress: {title: '我们需要您的收货地址', subtitle: '请提供一个地址以接收您的 Expensify 卡。', cta: '添加地址'}, + addShippingAddress: {title: '我们需要您的收货地址', subtitle: '请提供一个地址以接收您的 Expensify 卡。', cta: '添加'}, addPaymentCard: {title: '添加支付卡以继续使用 Expensify', subtitle: '账户 > 订阅', cta: '添加'}, addBankAccount: {title: '添加银行账户以接收报销'}, activateCard: {title: '激活你的 Expensify 卡', subtitle: '验证您的银行卡并开始消费。', cta: '启用'}, + confirmDigitalWalletAddition: { + title: ({walletName}: {walletName: string}) => `${walletName} 卡添加需要您的批准`, + subtitle: 'Expensify 卡', + cta: '审核', + appleWallet: 'Apple 钱包', + googleWallet: 'Google 钱包', + digitalWallet: '数字钱包', + }, reviewCardFraud: { title: '审查您 Expensify 卡上的潜在欺诈交易', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `查看 ${merchant} 可能存在的 ${amount} 欺诈交易`, @@ -943,7 +956,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, - addHomeAddress: {title: '添加您的家庭住址以进行距离跟踪', subtitle: '账户', cta: '添加地址'}, + addHomeAddress: {title: '添加您的家庭住址以进行距离跟踪', subtitle: '账户', cta: '添加'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -951,7 +964,7 @@ const translations: TranslationDeepObject = { workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, personalSubtitle: '钱包', }, - addVirtualCardPersonalDetails: {title: '添加你的详细信息以查看并开始使用 Expensify 卡。', subtitle: 'Expensify 卡', cta: '添加详细信息'}, + addVirtualCardPersonalDetails: {title: '添加你的详细信息以查看并开始使用 Expensify 卡。', subtitle: 'Expensify 卡', cta: '添加'}, enterSignerInfo: {title: '需要签署人信息', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `银行账户 ${bankAccountLastFour}`}, }, discoverSection: { @@ -1054,6 +1067,8 @@ const translations: TranslationDeepObject = { inviteAccountant: '邀请你的会计', customizeSpendCategories: '自定义支出类别', customizeSpendCategoriesSubText: '整理并分类支出', + customizeExpenseCategories: '自定义您的支出类别', + customizeExpenseCategoriesSubText: '添加贵公司的类别以对支出进行编码', createExpense: '创建报销费用', createExpenseSubText: '使用 + 按钮扫描、拖放或手动输入支出', linkPersonalCard: '关联个人银行卡', @@ -1075,7 +1090,7 @@ const translations: TranslationDeepObject = { setupTravel: '设置差旅', setupTravelSubText: '配置差旅专用规则', }, - yourSpend: {title: '您的支出', awaitingApproval: '等待审批', repaidLast30Days: '过去30天已偿还', recentTransactions: ({lastFour}: {lastFour: string}) => `最近交易 • ${lastFour}`}, + yourSpend: {title: '您的支出', awaitingApproval: '等待审批', repaidLast30Days: '过去30天已偿还'}, seeMore: ({count}: {count: number}) => `再查看 ${count} 个`, recentlyAddedSection: {title: '最近添加', viewAll: '查看所有报销费用', emptyStateTitle: '最近没有报销记录', emptyStateMessage: '创建一个或将收据拖到这里'}, insightsSection: {chartUnavailable: '图表不可用', notEnoughData: '我们目前没有足够的数据来填充此图表'}, @@ -1220,6 +1235,7 @@ const translations: TranslationDeepObject = { phrase1: '添加收据', phrase2: '或将文件拖放到此处', }, + pageCount: ({pageCount}: {pageCount: number}) => `第 1 页,共 ${pageCount} 页`, }, quickAction: { scanReceipt: '扫描收据', @@ -1599,11 +1615,8 @@ const translations: TranslationDeepObject = { noDuplicatesTitle: '全部完成!', noDuplicatesDescription: '这里没有需要审核的重复交易。', confirmApprove: '确认批准金额', - confirmApprovalAmount: '仅批准合规报销,或批准整份报销报告。', - confirmApprovalAllHoldAmount: () => ({ - one: '此报销已被搁置。仍要批准吗?', - other: '这些报销当前被搁置。你仍然想要批准吗?', - }), + confirmApprovalWithHeldAmount: '报告包含暂停的费用。仅批准合规的费用,还是批准整个报告?', + confirmApprovalAllHoldAmount: '所有费用都已被暂停。仍要批准吗?', confirmPay: '确认付款金额', confirmPayAmount: '支付未冻结的部分,或支付整份报告。', confirmPayAllHoldAmount: () => ({ @@ -2215,6 +2228,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: '高亮的跨度名称', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click,navigation,ui.load', showBranchNameInTitle: '在浏览器标题中显示分支名称', + betaOverrides: 'Beta 覆盖', + betaOverridesDescription: + '覆盖仅适用于此设备,且仅影响前端检查。仅当 Beta 与你账户中的 Beta 不同时,才会保留覆盖,因此切换回原值会移除该覆盖。“重置所有覆盖”会恢复你账户中的值。部分 Beta 同时受后端控制,因此仍可能在请求层面失败。', + resetAllOverrides: '重置所有覆盖', + overridden: '已覆盖', qaAuth: 'QA 认证(Cloudflare)', qaAuthRunProbe: '运行探针', qaAuthSession: 'QA 身份验证会话', @@ -2521,6 +2539,10 @@ const translations: TranslationDeepObject = { brokenConnection: '您的银行卡连接已断开。', conciergeBrokenConnection: (cardName: string, connectionLink?: string) => connectionLink ? `您的 ${cardName} 卡连接已中断。登录您的网上银行以修复该卡。` : `您的 ${cardName} 卡连接已中断。登录您的网上银行以修复该卡。`, + conciergeBrokenConnection30Days: (cardName: string, connectionLink?: string) => + connectionLink + ? `您的 ${cardName} 连接已中断 30 天。请登录您的银行以修复连接,或者如果该卡已不再使用,请移除该卡。如果您移除它,已提交的报销不会丢失。` + : `您的 ${cardName} 连接已中断 30 天。请登录您的网上银行进行修复,或者如果该卡已不再使用,请将其移除。移除后,您已提交的报销不会丢失。`, addAdditionalCards: '添加其他卡片', upgradeDescription: '需要添加更多卡片吗?创建工作区以添加其他个人卡片或将公司卡片分配给整个团队。', onlyAvailableOnPlan: ({formattedPrice}: {formattedPrice: string}) => `此功能在 Collect 套餐中可用,每位成员每月费用为 ${formattedPrice}。`, @@ -2609,6 +2631,22 @@ const translations: TranslationDeepObject = { unlock: '解锁', }, }, + addCardToDigitalWallet: { + title: ({walletName}: {walletName: string}) => `将卡添加到 ${walletName}`, + appleWallet: 'Apple 钱包', + googleWallet: 'Google 钱包', + digitalWallet: '数字钱包', + confirmHeading: '确认你的请求', + confirmDescription: ({walletName, lastFourDigits}: {walletName: string; lastFourDigits: string}) => `是否要将尾号为 ${lastFourDigits} 的 Expensify 卡添加到您的 ${walletName}?`, + deny: '拒绝', + confirm: '确认', + verifyTitle: '让我们确认是你本人', + enterSecurityCode: (contactMethod: string) => `请输入发送至 ${contactMethod} 的安全码以确认此请求。验证码应会在一两分钟内送达。`, + successHeading: '成功!', + successDescription: ({walletName}: {walletName: string}) => `您的卡片现已在您的 ${walletName} 中启用并可使用。`, + deniedHeading: '请求被拒绝', + deniedDescription: ({walletName}: {walletName: string}) => `您的卡尚未添加到您的 ${walletName}。`, + }, cardPage: { expensifyCard: 'Expensify 卡', expensifyTravelCard: 'Expensify 差旅卡', @@ -3041,7 +3079,14 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '欢迎使用 #focus 模式!', prompt: (priorityModePageUrl: string) => `通过只查看未读聊天或需要你关注的聊天,随时掌握最新进展。别担心,你可以随时在设置中更改此项。`, }, - inboxTabs: {all: '全部', todo: '待办事项', unread: '未读', markAllAsRead: '全部标记为已读', markAllAsReadConfirmationPrompt: '确定要将所有聊天标记为已读吗?'}, + inboxTabs: { + all: '全部', + todo: '待办事项', + unread: '未读', + markAllAsRead: '全部标记为已读', + markAllAsReadConfirmationPrompt: '确定要将所有聊天标记为已读吗?', + markAllTodosAsReadConfirmationPrompt: '确定要将所有待办事项聊天标记为已读吗?', + }, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, generatingPDF: '生成 PDF', @@ -3209,6 +3254,7 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '无法添加工作邮箱', subtitle: (workEmail: string | undefined) => `我们无法添加 ${workEmail}。请稍后在设置中重试,或与 Concierge 聊天以获取指导。`, workAccountClosedSubtitle: '与此邮箱关联的工作账户已被关闭。请联系您公司的管理员以重新激活该账户,或使用其他邮箱注册。', + domainControlledSubtitle: (workEmail: string | undefined) => `${workEmail} 是现有 Expensify 账户的域控制登录。`, }, tasks: { testDriveAdminTask: { @@ -4029,8 +4075,7 @@ ${amount},商户:${merchant} - 日期:${date}`, legalFirstName: '法定名(名)', legalLastName: '法定姓氏', enterTheDateOfBirthOfTheOwner: '所有者的出生日期是什么?', - enterTheLast4: '所有者社会安全号码的后四位数字是多少?', - last4SSN: '社保号后4位', + enterTheSSN: '所有者的社会安全号码是多少?', dontWorry: '别担心,我们不会进行任何个人信用检查!', enterTheOwnersAddress: '所有者的地址是什么?', letsDoubleCheck: '让我们再仔细检查一下,确保一切都正确。', @@ -4057,13 +4102,12 @@ ${amount},商户:${merchant} - 日期:${date}`, whatsYourDOB: '你的出生日期是?', whatsTheOwnersAddress: '所有者的地址是什么?', whatsYourAddress: '你的地址是什么?', - whatAreTheLast: '所有者社会安全号码的后 4 位数字是多少?', - whatsYourLast: '您的社会安全号码的后四位是多少?', + whatsTheOwnersSSN: '所有者的社会安全号码是多少?', + whatsYourSSN: '您的社会安全号码是多少?', whatsYourNationality: '您的国籍是哪个国家?', whatsTheOwnersNationality: '所有者的国籍是哪个国家?', countryOfCitizenship: '国籍', dontWorry: '别担心,我们不会进行任何个人信用检查!', - last4: '社保号后4位', whyDoWeAsk: '我们为什么要询问这个?', letsDoubleCheck: '让我们再仔细检查一下,确保一切都正确。', legalName: '法定姓名', @@ -4085,6 +4129,7 @@ ${amount},商户:${merchant} - 日期:${date}`, proofOfBeneficialOwner: '受益所有人证明', proofOfBeneficialOwnerDescription: '请提供由注册会计师、公证人或律师出具并签署的声明和组织结构图,以核实对该企业25%或以上的持股情况。文件日期必须在最近三个月内,并包含签署人的执业证书号码。', + proofOfBeneficialOwnerDescriptionCA: '请提供您最近一次 T2 公司所得税申报表中的 Schedule 50(股东信息),需显示所有持有公司 25% 或以上股份的个人。', copyOfID: '受益所有人身份证复印件', copyOfIDDescription: '示例:护照、驾驶证等。', proofOfAddress: '实益所有人地址证明', @@ -4442,6 +4487,7 @@ ${amount},商户:${merchant} - 日期:${date}`, customFieldHint: '为该成员的所有支出添加适用的自定义编码。', reports: '报表', reportFields: '报表字段', + invoiceFields: '发票字段', reportTitle: '报表标题', reportField: '报表字段', taxes: '税费', @@ -4451,6 +4497,7 @@ ${amount},商户:${merchant} - 日期:${date}`, travel: '差旅', members: '成员', accounting: '会计', + mcp: 'MCP', receiptPartners: '收据合作伙伴', rules: '规则', displayedAs: '显示为', @@ -4572,6 +4619,7 @@ ${amount},商户:${merchant} - 日期:${date}`, travelInvoicingVendor: '差旅供应商', travelInvoicingPayableAccount: '差旅应付账户', hr: '人力资源', + recruiting: '招聘', rooms: '房间', findDomain: '查找域名', cardAdminAlternateText: '管理工作区卡片。', @@ -4585,6 +4633,25 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '您已为客户创建了工作区!', description: '好消息 🎉。如果他们在设置方面需要帮助,请联系我们。', }, + mcp: { + connectors: '连接器', + connectorsSubtitle: '将 AI 助手连接到您的 Expensify 账户。', + connect: '连接', + helpPrompt: '需要帮助连接吗?', + helpLink: '阅读我们的指南。', + claude: { + title: 'Claude', + subtitle: '由 Anthropic 提供', + }, + cursor: { + title: 'Cursor', + subtitle: '由 Anysphere 提供', + }, + chatgpt: { + title: 'ChatGPT', + subtitle: '由 OpenAI 提供', + }, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `已连接到 ${organizationName}` : '在整个组织内自动化管理差旅和外卖餐饮报销。'), @@ -4791,7 +4858,6 @@ ${amount},商户:${merchant} - 日期:${date}`, exportInvoicesDescription: (integrationName = 'QuickBooks Online') => `在将发票导出到 ${integrationName} 时使用此账户。`, exportCompanyCardsDescription: (integrationName = 'QuickBooks Online') => `设置公司卡消费如何导出到 ${integrationName}。`, vendor: '供应商', - defaultVendorDescription: '设置一个默认供应商,在导出时应用于所有信用卡交易。', exportOutOfPocketExpensesDescription: (integrationName = 'QuickBooks Online') => `设置自付费用导出到 ${integrationName} 的方式。`, exportCheckDescription: '我们会为每份 Expensify 报告创建一张分项支票,并从下方的银行账户寄出。', exportJournalEntryDescription: '我们将为每份 Expensify 报告创建分录明细的日记账分录,并将其过账到下方的账户。', @@ -4945,8 +5011,6 @@ ${amount},商户:${merchant} - 日期:${date}`, }, noAccountsFound: '未找到账户', noAccountsFoundDescription: '请在 Xero 中添加该账户,然后再次同步连接', - defaultSupplier: '默认供应商', - defaultSupplierDescription: '设置一个默认供应商,在导出时应用于所有信用卡交易。', noSuppliersFound: '未找到供应商', noSuppliersFoundDescription: '请在 Xero 中添加该供应商,然后再次同步连接。', accountingMethods: { @@ -6123,6 +6187,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM provisioningError: '我们无法为您的工作区中部分成员开通合并差旅结算功能。请稍后重试,或联系 Concierge 获取帮助。', sendInvoiceNowCta: '立即发送发票', }, + setUpNewFeed: '设置新的差旅数据源', + feedSelectorIntro: '您的组织已设置合并差旅账单数据源。请选择该数据源,以将此工作区的差旅支出合并到同一月度账单中。', }, disableModal: {title: '关闭合并差旅结算?', body: '即将到来的酒店和汽车租赁预订可能需要使用不同的付款方式重新预订,以避免被取消。', confirm: '关闭'}, outstandingBalanceModal: {title: '无法关闭“合并出行结算”', body: '你仍有未结清的差旅余额。请先支付该余额。', confirm: '明白了'}, @@ -6264,6 +6330,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM title: '会计', subtitle: '同步您的科目表等数据。', }, + mcp: { + title: 'MCP', + subtitle: '将 AI 助手连接到您的 Expensify 账户。', + }, receiptPartners: { title: '收据合作伙伴', subtitle: '自动导入收据。', @@ -6326,6 +6396,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM subtitle: '报表字段适用于所有支出,当你想提示填写额外信息时会很有帮助。', disableReportFields: '禁用报表字段', disableReportFieldsConfirmation: '确定要这样做吗?文本和日期字段将被删除,列表将被禁用。', + cannotDisableImportedReportFields: '从您的会计连接导入的报表字段无法被禁用。', importedFromAccountingSoftware: '以下报表字段是从你的', textType: '文本', dateType: '日期', @@ -6362,6 +6433,29 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM reportFieldInitialValueRequiredError: '请选择报表字段的初始值', genericFailureMessage: '更新报表字段时出错。请重试。', }, + invoiceFields: { + subtitle: '当你想添加更多信息时,发票字段会很有帮助。', + importedFromAccountingSoftware: '以下发票字段是从你的', + disableInvoiceFields: '禁用发票字段', + disableInvoiceFieldsConfirmation: '确定吗?发票字段将在发票中被禁用。', + delete: '删除发票字段', + deleteConfirmation: '确定要删除此发票字段吗?', + findInvoiceField: '查找发票字段', + nameInputSubtitle: '为发票字段选择一个名称。', + typeInputSubtitle: '选择要使用的发票字段类型。', + initialValueInputSubtitle: '输入要在发票字段中显示的起始值。', + listValuesInputSubtitle: '这些值将显示在你的发票字段下拉菜单中。成员可以选择已启用的值。', + listInputSubtitle: '这些值将显示在你的发票字段列表中。成员可以选择已启用的值。', + emptyInvoiceFieldsValues: { + title: '还没有列表值', + subtitle: '添加要显示在发票上的自定义值。', + }, + existingInvoiceFieldNameError: '已存在同名发票字段', + invoiceFieldNameRequiredError: '请输入发票字段名称', + invoiceFieldTypeRequiredError: '请选择发票字段类型', + invoiceFieldInitialValueRequiredError: '请选择发票字段的初始值', + addField: '添加字段', + }, vendors: {emptyTitle: '尚无供应商', emptySubtitle: '会计同步完成后,供应商将显示在此处。', findVendor: '查找供应商', managedInAccountingSoftware: '供应商在您的'}, tags: { tagName: '标签名称', @@ -6741,6 +6835,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM return 'Rillet'; case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: return 'DualEntry'; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return 'Campfire'; default: { return ''; } @@ -6805,10 +6901,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM case 'netSuiteSyncImportEmployees': case 'intacctImportEmployees': case 'quickbooksDesktopImportEmployees': - return '导入员工'; + return '正在导入员工'; case 'quickbooksOnlineImportAccounts': case 'quickbooksDesktopImportAccounts': - return '导入账户'; + return '正在导入账户'; case 'quickbooksOnlineImportClasses': case 'quickbooksDesktopImportClasses': return '导入类别'; @@ -6818,7 +6914,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM return '正在处理导入的数据'; case 'quickbooksOnlineSyncBillPayments': case 'intacctImportSyncBillPayments': - return '正在同步已报销报表和账单付款'; + return '同步已报销报告和账单付款'; case 'quickbooksOnlineSyncTaxCodes': return '正在导入税码'; case 'quickbooksOnlineCheckConnection': @@ -6841,7 +6937,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM case 'quickbooksDesktopImportSavePolicy': return '正在导入保存的策略'; case 'quickbooksDesktopWebConnectorReminder': - return '仍在与 QuickBooks 同步数据…请确保 Web Connector 正在运行'; + return '仍在与 QuickBooks 同步数据……请确保 Web Connector 正在运行'; case 'quickbooksOnlineSyncTitle': return `正在同步 ${integrationName} 数据`; case 'quickbooksOnlineSyncLoadData': @@ -6885,7 +6981,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM case 'netSuiteSyncInitData': return '正在从 NetSuite 检索数据'; case 'netSuiteSyncImportTaxes': - return '正在导入税费'; + return '正在导入税务数据'; case 'netSuiteSyncImportItems': return '正在导入项目'; case 'netSuiteSyncData': @@ -6899,13 +6995,13 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM case 'netSuiteSyncReportFields': return '将数据导入为 Expensify 报告字段'; case 'netSuiteSyncTags': - return '将数据导入为 Expensify 标签'; + return '以 Expensify 标签的形式导入数据'; case 'netSuiteSyncUpdateConnectionData': return '正在更新连接信息'; case 'netSuiteSyncNetSuiteReimbursedReports': return '将 Expensify 报告标记为已报销'; case 'netSuiteSyncExpensifyReimbursedReports': - return '将 NetSuite 账单和发票标记为已支付'; + return '将 NetSuite 账单和发票标记为已付款'; case 'netSuiteImportVendorsTitle': return '正在导入供应商'; case 'netSuiteImportCustomListsTitle': @@ -6958,11 +7054,23 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM case 'dualEntrySyncPayments': return '正在同步供应商付款'; case 'dualEntrySyncCardSettlements': - return '正在同步卡片结算'; + return '正在同步信用卡结算'; case 'dualEntrySyncTravelSettlements': - return '正在同步差旅报销'; + return '正在同步差旅结算'; + case 'campfireSyncTitle': + return '正在同步 Campfire 数据'; + case 'campfireSyncConnection': + return '正在初始化与 Campfire 的连接'; + case 'campfireSyncImportData': + return '正在加载数据'; + case 'campfireSyncPayments': + return '正在同步供应商付款'; + case 'campfireSyncCardSettlements': + return '正在同步信用卡结算'; + case 'campfireSyncTravelSettlements': + return '正在同步差旅结算'; default: { - return `缺少阶段的翻译:${stage}`; + return `阶段缺少翻译:${stage}`; } } }, @@ -6975,8 +7083,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM exportCompanyCard: '导出公司卡费用为', exportDate: '导出日期', defaultVendor: '默认供应商', - defaultVendorHelperText: (isSet: boolean) => (isSet ? `未自动匹配的报销将默认归属到此供应商。` : `未自动匹配的报销将默认为此供应商,否则将以“信用卡杂项”导出。`), - defaultVendorSelectHeader: (connectionName: string) => `为未能自动匹配的报销选择一个默认的 ${connectionName} 供应商。`, + defaultVendorHelperText: (isSet: boolean, fallbackVendorName?: string) => + isSet || !fallbackVendorName ? `未自动匹配的报销将默认归属为此供应商。` : `未自动匹配的报销将默认归属于此供应商。否则,它们将按“${fallbackVendorName}.”导出。`, + defaultVendorSelectHeader: `为未自动匹配的费用选择一个默认供应商。`, defaultAccount: '默认账户', autoSync: '自动同步', autoSyncDescription: '每天自动同步 NetSuite 和 Expensify。实时导出已完成报表', @@ -6997,6 +7106,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM syncTravelInvoicingSettlements: '同步合并差旅结算', syncTravelInvoicingSettlementsNoAccountTooltip: '要解锁,请为导出设置一个账户。', syncTravelInvoicingSettlementsNoAutoSyncTooltip: '若要解锁,请启用自动同步。', + campfire: 'Campfire', }, export: { notReadyHeading: '尚未准备好导出', @@ -7052,12 +7162,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', optionDisabledHelp: '未从报销中移除任何通勤费用。', - optionFixedDistanceTitle: '为每笔报销排除固定距离', - optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', + optionFixedDistanceTitle: '移除固定距离', + optionFixedDistanceHelp: '从每一笔报销中减去相同的距离。', distanceLabel: '距离', summaryHomeAndOffice: '使用家庭和办公地点', - optionHomeAndOfficeTitle: '按家庭和办公室计算', - optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤免税额。', + optionHomeAndOfficeTitle: '排除日常通勤', + optionHomeAndOfficeHelp: '根据每位成员的家庭住址、工作安排和办公地点,扣除其日常通勤距离。', workspaceAddressRequired: { title: '别急……', promptStart: '在您先添加一个办公地点之前,无法启用按家庭和办公室计算的设置,添加位置于', @@ -7246,6 +7356,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `报表字段仅在 Control 方案中提供,起价为 ${formattedPrice} ${hasTeam2025Pricing ? `每位成员每月。` : `每位活跃成员每月。`}`, }, + invoiceFields: { + title: '发票字段', + description: `发票字段可用于在发票上包含额外的发票级别详情。`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `发票字段仅在 Control 方案中提供,起价为 ${formattedPrice} ${hasTeam2025Pricing ? `每位成员每月。` : `每位活跃成员每月。`}`, + }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `通过 Expensify + NetSuite 集成实现自动同步,减少手动录入。借助对原生和自定义维度的支持(包括项目和客户映射),获取深入的实时财务洞察。`, @@ -7288,6 +7404,12 @@ ${reportName}`, onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => `我们的 DualEntry 集成仅适用于 Control 方案,起价为 ${formattedPrice} ${hasTeam2025Pricing ? `每位成员每月。` : `每位活跃成员每月。`}`, }, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + title: 'Campfire', + description: `通过 Expensify 与 Campfire 的集成,享受自动同步,减少手动录入。将费用编码维度与税务同步与您的 Campfire 配置对齐,以获得更清晰的财务可见性。`, + onlyAvailableOnPlan: ({formattedPrice, hasTeam2025Pricing}: {formattedPrice: string; hasTeam2025Pricing: boolean}) => + `我们的 Campfire 集成仅适用于 Control 方案,起价为 ${formattedPrice} ${hasTeam2025Pricing ? `每位成员每月。` : `每位活跃成员每月。`}`, + }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: '高级审批', description: `如果你想在审批流程中增加更多层级,或者只是想确保金额最大的报销能再多一道审核,我们都能满足你的需求。高级审批功能帮助你在各个层级设置合适的审批规则,从而有效控制团队支出。`, @@ -7568,7 +7690,6 @@ ${reportName}`, alwaysReimbursableDescription: '费用始终报销给员工', alwaysNonReimbursable: '始终不予报销', alwaysNonReimbursableDescription: '从不向员工报销费用', - billableDefault: '默认计费', billableDefaultDescription: '选择现金和信用卡报销是否默认为可计费。', billable: '可计费', billableDescription: '费用通常会重新向客户计费', @@ -7595,10 +7716,12 @@ ${reportName}`, publicReceiptVisibilityHintDisabled: '只有拥有包含该收据的报表访问权限的 Expensify 成员才能查看收据。', enableTagsToUnlockTitle: '启用标签?', enableTagsToUnlockPrompt: '启用“标签”(位于“更多功能”下)以解锁。', - enableTagsAndRequirePrompt: '确定要启用标签,并将其设为所有报销的必填项吗?', enableCategoriesToUnlockTitle: '启用类别?', enableCategoriesToUnlockPrompt: '启用“类别”(位于“更多功能”下)以解锁。', enableCategoriesAndRequirePrompt: '确定要启用类别,并要求所有报销都必须选择类别吗?', + enableTagsPrompt: '确定要启用标签吗?在至少有一个标签后,您可以要求所有报销都必须选择标签。', + noTagsToRequirePrompt: '您还没有任何标签。请创建一个标签。', + noCategoriesToRequirePrompt: '您还没有任何类别。请创建一个类别。', }, expenseReportRules: { title: '高级', @@ -7666,6 +7789,19 @@ ${reportName}`, thenApplyFollowingDefaults: '然后应用以下默认设置:', vendorUnavailable: '供应商不可用', supplierUnavailable: '供应商不可用', + confirmErrorCategory: '请选择一个类别', + confirmErrorCategoryTax: '请选择税率', + confirmErrorCondition: '请输入商家', + confirmErrorConditionAndDefault: '输入商家,并至少应用一个默认设置', + turnOnTaxesFirstTitle: '请先启用税费', + turnOnTaxesFirstPrompt: '类别规则会设置默认税率。请在工作区设置中启用税费以使用此功能。', + categoryRulesApplyGoingForwardTitle: '类别规则将从现在起生效', + categoryRulesApplyGoingForwardPrompt: '此类别中的新报销将应用默认税率,已存在的报销不会改变。', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: '所选税率现在已成为您的工作区默认税率,因此此规则不再有效。请选择其他税率。', + addTaxRateFirstTitle: '请先添加税率', + addTaxRateFirstPrompt: '类别规则会设置一个默认税率。请添加一个不同于工作区默认值的税率以使用类别规则。', + createRuleFromExpenseAction: '创建规则', + createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, categoryRules: { title: '类别规则', @@ -7685,7 +7821,7 @@ ${reportName}`, flagAmountsOverSubtitle: '这将覆盖所有报销的最高金额限制。', expenseLimitTypes: { expense: '单笔报销', - expenseSubtitle: '按类别标记报销金额。此规则会覆盖工作区的一般最高报销金额规则。', + expenseSubtitle: '按类别标记报销金额。此规则会覆盖工作区的一般最高报销金额规则。多天预订将按每晚平均金额进行评估。', daily: '类别总计', dailySubtitle: '标记每份报销单中各类别的每日总支出。', }, @@ -7949,10 +8085,10 @@ ${reportName}`, applyExpenseDefaultsDescription: '在提交人无须执行任何操作的情况下更新字段', flagForReview: '标记以供审核', flagForReviewDescription: '当费用超出类别限额时通知审批人', - requireFields: '字段要求', + requireFields: '设置字段要求', requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', - createAgentRule: '代理规则', - createAgentRuleDescription: '按需运行的灵活规则描述', + createAgentRule: '描述代理规则', + createAgentRuleDescription: '创建可按需运行的灵活规则。', }, expenseDefaultsTable: { tableColumnType: '类型', @@ -7963,6 +8099,7 @@ ${reportName}`, update: '更新', merchantIs: (merchant: string) => `商户为“${merchant}”`, merchantTypeIs: (merchantType: string) => `商户类型为"${merchantType}"`, + categoryIs: (category: string) => `类别为“${category}”`, }, merchantTypeRule: {merchantType: '商户类型', saveRule: '保存规则', confirmErrorCategory: '请选择一个类别。'}, requireFieldsTable: { @@ -7979,6 +8116,7 @@ ${reportName}`, requireReceipt: '需要收据', doNotRequireReceipt: '不需要收据', doNotRequireItemizedReceipt: '不要求明细收据', + typeLabel: '必填', }, requireFieldsEmptyState: {title: '提前补全缺失信息', subtitle: '为特定类别设置报销字段为必填或可选。', cta: '创建字段必填规则'}, requireFieldsRule: { @@ -8019,6 +8157,15 @@ ${reportName}`, }, agentRulesEmptyState: {title: '未添加代理规则', subtitle: '创建规则以自动化您的工作区策略。', cta: '添加 AI 规则'}, categoriesDisabledEmptyState: {title: '类别未启用', subtitle: '启用类别以更好地控制您的支出。'}, + expenseDefaultType: { + title: '应用报销默认值', + subtitle: '此规则应匹配什么内容?', + merchant: '商户', + merchantDescription: '更新来自特定商家的报销项目字段', + category: '类别', + categoryDescription: '为特定类别设置默认税率', + }, + taxesDisabledEmptyState: {title: '税费未启用', subtitle: '启用税费以记录并追回符合条件的税款。', cta: '启用税费'}, }, planTypePage: { planTypes: { @@ -8045,36 +8192,25 @@ ${reportName}`, }, hr: { title: '人力资源', - connections: '连接', connectionsSubtitle: '连接您的 HR 系统以同步员工数据,自动将报销匹配到正确的员工,并在无需手动操作的情况下保持团队报销的准确性。', subtitle: '连接人力资源工具,保持员工审批同步。', - connect: '连接', - syncNow: '立即同步', - disconnect: '断开连接', - disconnectTitle: (providerName: string) => `断开 ${providerName}`, - disconnectPrompt: (providerName: string) => `确定要断开 ${providerName} 吗?`, alreadyConnectedTitle: '无法连接到多个人力资源平台', alreadyConnectedPrompt: '在连接其他人力资源平台之前,您必须先断开当前的人力资源平台。', - lastSync: (relativeDate: string) => `上次同步 ${relativeDate}`, - syncError: (providerName: string) => `无法连接到 ${providerName}`, connectionDescription: (providerName: string) => `连接 ${providerName},以在您的工作区中同步员工审批。`, - approvalMode: '审批模式', providerApprovalMode: (providerName: string) => `${providerName} 审批模式`, - finalApprover: '最终审批人', providerFinalApprover: (providerName: string) => `${providerName} 最终审批人`, - notSet: '未设置', + syncing: '正在同步员工', + syncingModalTitle: '您的连接正在同步', + syncingModalDescription: '首次连接可能需要一些时间。若发生任何错误,我们会通知你。', approvalModeDescription: (providerName: string) => `成员和管理员已设置为与 ${providerName} 同步。`, approvalModeWarningTitle: '更改审批模式?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => `您确定要更改此工作区的审批模式吗?在我们的帮助网站中了解更多关于不同 ${providerName} 启用的工作流模式的信息。`, approvalModeWarningConfirm: '更改审批模式', - approvalModes: { - basic: {label: '基本审批', description: '所有用户都提交给同一个人进行处理和审批。'}, - manager: { - label: '经理审批', - description: (providerName: string) => `员工会将报销单提交给在 ${providerName} 中配置的直属经理。`, - }, - custom: {label: '自定义审批', description: '我将在 Expensify 中手动设置审批工作流程。'}, + approvalModeDescriptions: { + basic: '所有用户都提交给同一个人进行处理和审批。', + manager: (providerName: string) => `员工会将报销单提交给在 ${providerName} 中配置的直属经理。`, + custom: '我将在 Expensify 中手动设置审批工作流程。', }, syncStageName: (stage: PolicyConnectionSyncStage) => { switch (stage) { @@ -8114,25 +8250,61 @@ ${reportName}`, zenefits: { title: 'TriNet', }, - syncingModalTitle: '您的连接正在同步', - syncingModalDescription: '首次连接可能需要一些时间。若发生任何错误,我们会通知你。', - syncing: '正在同步员工', + setupIncomplete: (setupLink: string | undefined) => + `已连接。${setupLink ? `完成设置` : '完成设置'} 用于导入员工。`, mergeHR: { - completeSetup: '完成设置', - setupIncomplete: (setupLink: string | undefined) => - `已连接。${setupLink ? `完成设置` : '完成设置'} 用于导入员工。`, groups: {title: '群组', description: '选择要与此工作区同步的员工分组'}, }, + }, + recruiting: { + title: '招聘', + connectionsSubtitle: '连接招聘系统以同步候选人数据,自动将报销匹配到正确的人员,并在无需手动操作的情况下保持团队报销数据的准确。', + alreadyConnectedTitle: '无法连接到多个 ATS 平台', + alreadyConnectedPrompt: '在连接其他 ATS 之前,必须先断开当前的 ATS。', + syncing: '正在同步候选人', + setupIncomplete: (setupLink: string | undefined) => + `已连接。${setupLink ? `完成设置` : '完成设置'} 个可导入候选项。`, + dontSeeYourATS: `没有看到你的 ATS?询问 Concierge,我们可以帮你添加。`, + importSettings: '导入设置', + defaultApprover: '默认审批人', + approverFields: {recruiter: '招聘人员', recruitingCoordinator: '招聘协调员'}, + }, + merge: { + connections: '连接', + connect: '连接', + findIntegration: '查找集成', + syncNow: '立即同步', + disconnect: '断开连接', + disconnectTitle: (providerName: string) => `断开 ${providerName}`, + disconnectPrompt: (providerName: string) => `确定要断开 ${providerName} 吗?`, + lastSync: (relativeDate: string) => `上次同步 ${relativeDate}`, notSync: '未同步', + syncError: (providerName: string) => `无法连接到 ${providerName}`, authenticationError: (providerName: string) => `由于连接已过期,无法连接到 ${providerName}。`, reconnect: '重新连接', reconnectLink: '重新连接。', - findIntegration: '查找集成', - }, - merge: { + notSet: '未设置', + completeSetup: '完成设置', + approvalMode: '审批模式', + finalApprover: '最终审批人', + approvalModes: { + basic: '基本审批', + manager: '经理审批', + custom: '自定义审批', + advanced: '高级审批', + }, syncLimitReached: {title: '请明天再试', prompt: '您已达到今日的同步上限。'}, }, emptyDomain: {title: '通过域名提升安全性', subtitle: '要求您域中的成员通过单点登录登录、限制工作区创建等。'}, + campfire: { + campfireSetup: '营火设置', + enterCredentials: '输入你的 Campfire API 密钥', + howToFindAPIKey: '查找您的 API 密钥。
  1. 登录 Campfire
  2. 前往“设置” -> “API 密钥”
  3. 创建 API 密钥
  4. 将 API 密钥粘贴到下方
', + subsidiary: '子公司', + subsidiarySelectDescription: '选择要从中导入数据的 Campfire 子公司。', + noSubsidiariesFound: '未找到子公司', + noSubsidiariesFoundDescription: '请在 Campfire 中添加一个实体,然后再次同步连接', + }, }, getAssistancePage: { title: '获取帮助', @@ -9001,7 +9173,7 @@ ${reportName}`, [CONST.SEARCH.WITHDRAWAL_TYPE.TRAVEL_BILLING]: '合并差旅账单', }, is: '是', - has: {submittedViolation: '已提交违规'}, + has: {submittedViolation: '已提交违规', approvedViolation: '已批准违规'}, action: { [CONST.SEARCH.ACTION_FILTERS.SUBMIT]: '提交', [CONST.SEARCH.ACTION_FILTERS.APPROVE]: '批准', @@ -9067,7 +9239,7 @@ ${reportName}`, description: '哇,项目真不少!我们会把它们打包好,Concierge 很快就会给你发送一个文件。', }, exportedTo: '已导出到', - exportAll: {selectAllMatchingItems: '选择所有匹配的项目', selectAllOnThisPage: '选择本页全部内容'}, + exportAll: {selectAllMatchingItems: '选择所有匹配的项目', allMatchingItemsSelected: '已选择所有匹配的项目', selectAllOnThisPage: '选择本页全部内容'}, errors: { pleaseSelectDatesForBothFromAndTo: '请选择起始和结束日期', }, @@ -9091,6 +9263,7 @@ ${reportName}`, topMerchants: '热门商家', violationsBySubmitter: '提交人违规', }, + mergeReports: {title: '合并报表', description: '选择要保留的报表。所有费用都将移入该报表,其他报表将被删除。'}, }, genericErrorPage: { title: '哎呀,出错了!', @@ -9172,6 +9345,7 @@ ${reportName}`, const labelTranslations: Record = { [CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT]: translations.export.expenseLevelExport, [CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT]: translations.export.reportLevelExport, + [CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES]: translations.export.reconciliationAllExpenses, }; const translatedLabel = labelTranslations[label] || label; return `已导出到 ${translatedLabel}`; @@ -9208,6 +9382,16 @@ ${reportName}`, integrationSyncFailedRecurrence: ({count}: {count: number}) => `(重复 ${count} 次。)`, companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => `${feedName} 连接已中断。要恢复银行卡导入,请登录您的银行账户。`, + companyCardConnectionBroken30Days: ({ + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }: { + feedName: string; + workspaceCompanyCardRoute: string; + workspaceCompanyCardSettingsRoute: string; + }) => + `${feedName} 连接已中断 30 天。请登录您的银行进行修复,或者如果不再使用,请移除该连接。移除后,您已提交的报销不会丢失。`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `您与企业银行账户的 Plaid 连接已中断。请重新连接您的银行账户 ${maskedAccountNumber},以便继续使用 Expensify 卡。`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { @@ -9781,6 +9965,7 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `费率仅自 ${startDate} 起有效`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `该费率仅在 ${endDate} 之前有效`, cannotMergeDuplicates: '您只能在草稿或未结报销单中合并报销。请先撤回后重试。', + overCategoryLimitPerNight: (formattedLimit: string) => `每晚房价超过每人类别限额 ${formattedLimit}`, shortName: { allTagLevelsRequired: '所有标签为必填项', autoReportedRejectedExpense: '报销已被拒绝', @@ -10377,6 +10562,7 @@ ${reportName}`, reportLevelExport: '所有数据 - 报告级别', expenseLevelExport: '所有数据 - 报销级别', multipleTaxExport: '加拿大多种税导出', + reconciliationAllExpenses: '对账 - 所有费用', exportInProgress: '导出进行中', conciergeWillSend: 'Concierge 将很快把文件发送给你。', currentView: '当前视图', @@ -10407,6 +10593,7 @@ ${reportName}`, notVerified: '未验证', retry: '重试', requestSent: '请求已发送', + requestAccessError: '我们无法发送你的请求。请重试。', verifyDomain: { title: '验证域名', beforeProceeding: ({domainName}: {domainName: string}) => `在继续之前,请通过更新其 DNS 设置来验证您拥有 ${domainName}。`, @@ -10466,12 +10653,12 @@ ${reportName}`, setMetadataGenericError: '无法设置 SAML 元数据', }, accessRestricted: { - title: '访问受限', - subtitle: (domainName: string) => `如果你需要管理以下内容,请先验证你自己是 ${domainName} 的授权公司管理员:`, - companyCardManagement: '公司卡管理', - accountCreationAndDeletion: '账户创建和删除', - workspaceCreation: '工作区创建', - samlSSO: 'SAML 单点登录', + headerTitle: '访问受限', + title: '需要验证', + description: (domainName: string) => + `请验证你自己是 ${domainName} 的授权公司管理员,或向现有管理员申请访问权限。`, + requestAdminAccess: '申请管理员权限', + verifyYourself: '验证自己', }, addDomain: { title: '添加域名', @@ -10485,7 +10672,6 @@ ${reportName}`, title: '该域名已被设置。要申请访问权限吗?', description: '有人已经在 Expensify 中设置了此域名。要申请管理员权限吗?', requestAccess: '申请管理员权限', - requestAccessError: '我们无法发送你的请求。请重试。', }, domainAdded: { title: '已添加域名', @@ -10510,6 +10696,9 @@ ${reportName}`, consolidatedDomainBillingError: '无法更改合并域账单。请稍后再试。', addAdmin: '添加管理员', addAdminError: '无法将此成员添加为管理员。请重试。', + requests: '请求', + approveRequestError: '无法批准此请求。请重试。', + declineRequestError: '无法拒绝此请求。请重试。', revokeAdminAccess: '撤销管理员访问权限', cantRevokeAdminAccess: '无法撤销技术联系人的管理员权限', error: { diff --git a/src/libs/API/parameters/AddAdminToDomainParams.ts b/src/libs/API/parameters/AddAdminToDomainParams.ts index 7bfe51b1ad31..5de8bc415cc1 100644 --- a/src/libs/API/parameters/AddAdminToDomainParams.ts +++ b/src/libs/API/parameters/AddAdminToDomainParams.ts @@ -1,6 +1,7 @@ type AddAdminToDomainParams = { domainName: string; targetEmail: string; + domainAccountID?: number; }; export default AddAdminToDomainParams; diff --git a/src/libs/API/parameters/ApproveDigitalWalletCardAdditionParams.ts b/src/libs/API/parameters/ApproveDigitalWalletCardAdditionParams.ts new file mode 100644 index 000000000000..d85fef3d22bf --- /dev/null +++ b/src/libs/API/parameters/ApproveDigitalWalletCardAdditionParams.ts @@ -0,0 +1,6 @@ +type ApproveDigitalWalletCardAdditionParams = { + cardID: number; + isApproved: boolean; + validateCode?: string; +}; +export default ApproveDigitalWalletCardAdditionParams; diff --git a/src/libs/API/parameters/ApproveMoneyRequestParams.ts b/src/libs/API/parameters/ApproveMoneyRequestParams.ts index 620c0fbdf8b3..377bf4a59195 100644 --- a/src/libs/API/parameters/ApproveMoneyRequestParams.ts +++ b/src/libs/API/parameters/ApproveMoneyRequestParams.ts @@ -21,7 +21,6 @@ type ApproveMoneyRequestParams = { */ optimisticReportActionCopyIDs?: string; - /** The optimistic action ID for the report created for unapproved transactions */ optimisticCreatedReportForUnapprovedTransactionsActionID?: string; }; diff --git a/src/libs/API/parameters/ChangeTransactionsReportParams.ts b/src/libs/API/parameters/ChangeTransactionsReportParams.ts index deae34bec13a..0c668f1fc394 100644 --- a/src/libs/API/parameters/ChangeTransactionsReportParams.ts +++ b/src/libs/API/parameters/ChangeTransactionsReportParams.ts @@ -1,6 +1,6 @@ /** * A map linking the optimistic MOVED_TRANSACTION or UNREPORTED_TRANSACTION reportActionID to the transactionID. - * movedReportActionID is omitted when the move doesn't create a system message (moves out of a draft report). + * movedReportActionID is omitted when the move doesn't create a system message (moves into a draft report). * If we're creating the transactionThread as part of moving the transaction, we should also send the optimistic * transactionThreadReportID and transactionThreadCreatedReportActionID. If the transaction is held before moving to selfDM, * we should also send the unholdReportActionID. diff --git a/src/libs/API/parameters/ConnectPolicyToCampfireParams.ts b/src/libs/API/parameters/ConnectPolicyToCampfireParams.ts new file mode 100644 index 000000000000..71e5e1edca2f --- /dev/null +++ b/src/libs/API/parameters/ConnectPolicyToCampfireParams.ts @@ -0,0 +1,6 @@ +type ConnectPolicyToCampfireParams = { + policyID: string; + apiKey: string; +}; + +export default ConnectPolicyToCampfireParams; diff --git a/src/libs/API/parameters/ConnectPolicyToMergeParams.ts b/src/libs/API/parameters/ConnectPolicyToMergeParams.ts index 9f7561436ae5..b3b9bb0c4fde 100644 --- a/src/libs/API/parameters/ConnectPolicyToMergeParams.ts +++ b/src/libs/API/parameters/ConnectPolicyToMergeParams.ts @@ -1,12 +1,17 @@ +import type CONST from '@src/CONST'; import type {MergeATSProviderSlug} from '@src/CONST/MERGE_ATS_PROVIDERS'; import type {MergeHRProviderSlug} from '@src/CONST/MERGE_HR_PROVIDERS'; +import type {ValueOf} from 'type-fest'; + type ConnectPolicyToMergeParams = { - /** The ID of the policy to connect */ policyID: string; /** The Merge HR or ATS provider slug identifying which HR or ATS system to integrate with via merge dev */ integration: MergeHRProviderSlug | MergeATSProviderSlug; + + /** The Merge category the integration belongs to */ + category: ValueOf; }; export default ConnectPolicyToMergeParams; diff --git a/src/libs/API/parameters/ConnectPolicyToQuickBooksDesktopParams.ts b/src/libs/API/parameters/ConnectPolicyToQuickBooksDesktopParams.ts index 0fef162fa3c3..7d62806a0cc5 100644 --- a/src/libs/API/parameters/ConnectPolicyToQuickBooksDesktopParams.ts +++ b/src/libs/API/parameters/ConnectPolicyToQuickBooksDesktopParams.ts @@ -1,5 +1,4 @@ type ConnectPolicyToQuickBooksDesktopParams = { - /** ID of the policy */ policyID: string; }; diff --git a/src/libs/API/parameters/CreateDomainSecurityGroupParams.ts b/src/libs/API/parameters/CreateDomainSecurityGroupParams.ts index 6efbc440db67..1a481bb89e43 100644 --- a/src/libs/API/parameters/CreateDomainSecurityGroupParams.ts +++ b/src/libs/API/parameters/CreateDomainSecurityGroupParams.ts @@ -1,5 +1,4 @@ type CreateDomainSecurityGroupParams = { - /** Account ID of the domain */ domainAccountID: number; /** Security group key, prefixed with the domain security group prefix */ diff --git a/src/libs/API/parameters/CreateWorkspaceInvoiceFieldListValueParams.ts b/src/libs/API/parameters/CreateWorkspaceInvoiceFieldListValueParams.ts new file mode 100644 index 000000000000..fa22805f4e71 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceInvoiceFieldListValueParams.ts @@ -0,0 +1,10 @@ +type CreateWorkspaceInvoiceFieldListValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + invoiceFields: string; +}; + +export default CreateWorkspaceInvoiceFieldListValueParams; diff --git a/src/libs/API/parameters/CreateWorkspaceInvoiceFieldParams.ts b/src/libs/API/parameters/CreateWorkspaceInvoiceFieldParams.ts new file mode 100644 index 000000000000..5cce12e49273 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceInvoiceFieldParams.ts @@ -0,0 +1,10 @@ +type CreateWorkspaceInvoiceFieldParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + invoiceFields: string; +}; + +export default CreateWorkspaceInvoiceFieldParams; diff --git a/src/libs/API/parameters/DeclineDomainAdminshipRequestParams.ts b/src/libs/API/parameters/DeclineDomainAdminshipRequestParams.ts new file mode 100644 index 000000000000..26eb694b2a41 --- /dev/null +++ b/src/libs/API/parameters/DeclineDomainAdminshipRequestParams.ts @@ -0,0 +1,6 @@ +type DeclineDomainAdminshipRequestParams = { + domainAccountID: number; + targetAccountID: number; +}; + +export default DeclineDomainAdminshipRequestParams; diff --git a/src/libs/API/parameters/DeleteDomainSecurityGroupParams.ts b/src/libs/API/parameters/DeleteDomainSecurityGroupParams.ts index 514a476dd6ab..aba50afa1a8f 100644 --- a/src/libs/API/parameters/DeleteDomainSecurityGroupParams.ts +++ b/src/libs/API/parameters/DeleteDomainSecurityGroupParams.ts @@ -1,5 +1,4 @@ type DeleteDomainSecurityGroupParams = { - /** The account ID of the domain */ domainAccountID: number; /** The key of the security group (e.g domain_securityGroup_12345) */ diff --git a/src/libs/API/parameters/DeletePolicyInvoiceField.ts b/src/libs/API/parameters/DeletePolicyInvoiceField.ts new file mode 100644 index 000000000000..5b3c37fb1e07 --- /dev/null +++ b/src/libs/API/parameters/DeletePolicyInvoiceField.ts @@ -0,0 +1,10 @@ +type DeletePolicyInvoiceField = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + invoiceFields: string; +}; + +export default DeletePolicyInvoiceField; diff --git a/src/libs/API/parameters/DeleteSavedSearch.ts b/src/libs/API/parameters/DeleteSavedSearch.ts index 23b20204bcd2..f0f4b4a02138 100644 --- a/src/libs/API/parameters/DeleteSavedSearch.ts +++ b/src/libs/API/parameters/DeleteSavedSearch.ts @@ -1,5 +1,5 @@ type DeleteSavedSearchParams = { - hash: number; + savedSearchID: string; }; export default DeleteSavedSearchParams; diff --git a/src/libs/API/parameters/EnableGlobalReimbursementsForUSDBankAccountParams.ts b/src/libs/API/parameters/EnableGlobalReimbursementsForUSDBankAccountParams.ts index 5c5877880542..6d93609f422b 100644 --- a/src/libs/API/parameters/EnableGlobalReimbursementsForUSDBankAccountParams.ts +++ b/src/libs/API/parameters/EnableGlobalReimbursementsForUSDBankAccountParams.ts @@ -3,6 +3,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; type EnableGlobalReimbursementsForUSDBankAccountParams = { inputs: string; achAuthorizationForm?: FileObject; + bankStatement?: FileObject; bankAccountID: number; }; diff --git a/src/libs/API/parameters/EnablePolicyInvoiceFieldsParams.ts b/src/libs/API/parameters/EnablePolicyInvoiceFieldsParams.ts new file mode 100644 index 000000000000..5afc5c912d90 --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyInvoiceFieldsParams.ts @@ -0,0 +1,6 @@ +type EnablePolicyInvoiceFieldsParams = { + policyID: string; + enabled: boolean; +}; + +export default EnablePolicyInvoiceFieldsParams; diff --git a/src/libs/API/parameters/EnablePolicyMCPParams.ts b/src/libs/API/parameters/EnablePolicyMCPParams.ts new file mode 100644 index 000000000000..589a16facf03 --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyMCPParams.ts @@ -0,0 +1,9 @@ +type EnablePolicyMCPParams = { + /** ID of the policy to enable or disable MCP for */ + policyID: string; + + /** Whether MCP is enabled for the policy */ + enabled: boolean; +}; + +export default EnablePolicyMCPParams; diff --git a/src/libs/API/parameters/EnableWorkspaceInvoiceFieldListValueParams.ts b/src/libs/API/parameters/EnableWorkspaceInvoiceFieldListValueParams.ts new file mode 100644 index 000000000000..a52405efad54 --- /dev/null +++ b/src/libs/API/parameters/EnableWorkspaceInvoiceFieldListValueParams.ts @@ -0,0 +1,10 @@ +type EnableWorkspaceInvoiceFieldListValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + invoiceFields: string; +}; + +export default EnableWorkspaceInvoiceFieldListValueParams; diff --git a/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts b/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts index e62cbe684cf6..e25aca5d72ce 100644 --- a/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts +++ b/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts @@ -1,5 +1,4 @@ type ExportCategoriesSpreadsheetParams = { - /** ID of the policy */ policyID: string; }; diff --git a/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts b/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts index 04a114cddaf5..a7653bff5085 100644 --- a/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts +++ b/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts @@ -1,5 +1,4 @@ type ExportMembersSpreadsheetParams = { - /** ID of the policy */ policyID: string; }; diff --git a/src/libs/API/parameters/ExportMultiLevelTagsSpreadSheetParams.ts b/src/libs/API/parameters/ExportMultiLevelTagsSpreadSheetParams.ts index 07f984b48656..87caecc0510c 100644 --- a/src/libs/API/parameters/ExportMultiLevelTagsSpreadSheetParams.ts +++ b/src/libs/API/parameters/ExportMultiLevelTagsSpreadSheetParams.ts @@ -1,5 +1,4 @@ type ExportMultiLevelTagsSpreadSheetParams = { - /** ID of the policy */ policyID: string; }; diff --git a/src/libs/API/parameters/ExportPerDiemCSVParams.ts b/src/libs/API/parameters/ExportPerDiemCSVParams.ts index 553621534df5..6bf250de2f9d 100644 --- a/src/libs/API/parameters/ExportPerDiemCSVParams.ts +++ b/src/libs/API/parameters/ExportPerDiemCSVParams.ts @@ -1,5 +1,4 @@ type ExportPerDiemCSVParams = { - /** ID of the policy */ policyID: string; }; diff --git a/src/libs/API/parameters/ExportTagsSpreadsheet.ts b/src/libs/API/parameters/ExportTagsSpreadsheet.ts index fc4e78968511..8d713461ba22 100644 --- a/src/libs/API/parameters/ExportTagsSpreadsheet.ts +++ b/src/libs/API/parameters/ExportTagsSpreadsheet.ts @@ -1,5 +1,4 @@ type ExportTagsSpreadsheetParams = { - /** ID of the policy */ policyID: string; }; diff --git a/src/libs/API/parameters/GetTransactionsConvertedAmount.ts b/src/libs/API/parameters/GetTransactionsConvertedAmount.ts index 3379d7202897..68132354bde4 100644 --- a/src/libs/API/parameters/GetTransactionsConvertedAmount.ts +++ b/src/libs/API/parameters/GetTransactionsConvertedAmount.ts @@ -4,7 +4,6 @@ type GetTransactionsConvertedAmountParams = { /** The serialized search query whose transactions should be converted */ jsonQuery: SearchQueryString; - /** The currency the amounts should be converted to */ targetCurrency: string; /** Comma-separated transaction IDs to scope the conversion to a specific selection; omitted for the whole-search total */ diff --git a/src/libs/API/parameters/ImportCategoriesSpreadsheet.ts b/src/libs/API/parameters/ImportCategoriesSpreadsheet.ts index 089bc264eef7..d62b5195a5bd 100644 --- a/src/libs/API/parameters/ImportCategoriesSpreadsheet.ts +++ b/src/libs/API/parameters/ImportCategoriesSpreadsheet.ts @@ -1,5 +1,4 @@ type ImportCategoriesSpreadsheetParams = { - /** ID of the policy */ policyID: string; /** diff --git a/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts b/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts index 52ef8cb3505b..e367194c1bad 100644 --- a/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts +++ b/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts @@ -1,5 +1,4 @@ type ImportMerchantRulesSpreadsheetParams = { - /** ID of the policy */ policyID: string; /** diff --git a/src/libs/API/parameters/ImportPerDiemRatesParams.ts b/src/libs/API/parameters/ImportPerDiemRatesParams.ts index b67517150f2d..e2164449d7c2 100644 --- a/src/libs/API/parameters/ImportPerDiemRatesParams.ts +++ b/src/libs/API/parameters/ImportPerDiemRatesParams.ts @@ -1,8 +1,5 @@ type ImportPerDiemRatesParams = { - /** ID of the policy */ policyID: string; - - /** Custom Unit ID of the per diem unit */ customUnitID: string; /** diff --git a/src/libs/API/parameters/InviteWorkspaceEmployeesToUberParams.ts b/src/libs/API/parameters/InviteWorkspaceEmployeesToUberParams.ts index c8740d536361..8efe564f58bc 100644 --- a/src/libs/API/parameters/InviteWorkspaceEmployeesToUberParams.ts +++ b/src/libs/API/parameters/InviteWorkspaceEmployeesToUberParams.ts @@ -1,5 +1,4 @@ type InviteWorkspaceEmployeesToUberParams = { - /** Policy ID */ policyID: string; /** The list of emails to invite to Uber for Business in CSV format */ emailList: string; diff --git a/src/libs/API/parameters/MergeReportsParams.ts b/src/libs/API/parameters/MergeReportsParams.ts index 46359b5397ff..f2dc2ffd0a59 100644 --- a/src/libs/API/parameters/MergeReportsParams.ts +++ b/src/libs/API/parameters/MergeReportsParams.ts @@ -1,6 +1,6 @@ type MergeReportsParams = { - destinationReportID: string; - sourceReportIDs: string[]; + reportID: string; + sourceReportIDList: string[]; transactionIDToReportActionAndThreadData: string; // A map of transactionID to TransactionThreadInfo }; diff --git a/src/libs/API/parameters/MergeTransactionParams.ts b/src/libs/API/parameters/MergeTransactionParams.ts index 2da99cc0608b..22882d052675 100644 --- a/src/libs/API/parameters/MergeTransactionParams.ts +++ b/src/libs/API/parameters/MergeTransactionParams.ts @@ -20,28 +20,16 @@ type MergeTransactionParams = { /** Description which user want to keep */ comment: string; - /** Whether the transaction is reimbursable */ reimbursable: boolean; - - /** Whether the transaction is billable */ billable: boolean; /** The receiptID we want to keep */ receiptID: number | undefined; - /** The amount of the transaction */ amount: number; - - /** The currency of the transaction */ currency: string; - - /** The report ID of the transaction */ reportID: string; - - /** The tax code of the transaction */ taxCode: string; - - /** The tax policy ID of the transaction */ taxPolicyID: string; /** The created IOU report action ID of the transaction */ diff --git a/src/libs/API/parameters/OpenPolicyRecruitingPageParams.ts b/src/libs/API/parameters/OpenPolicyRecruitingPageParams.ts new file mode 100644 index 000000000000..1605645ac743 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyRecruitingPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyRecruitingPageParams = { + policyID: string; +}; + +export default OpenPolicyRecruitingPageParams; diff --git a/src/libs/API/parameters/PusherPingParams.ts b/src/libs/API/parameters/PusherPingParams.ts deleted file mode 100644 index b156d6fa6039..000000000000 --- a/src/libs/API/parameters/PusherPingParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -type PusherPingParams = { - pingID: string; - pingTimestamp: number; -}; - -export default PusherPingParams; diff --git a/src/libs/API/parameters/QueueBulkPayReportsParams.ts b/src/libs/API/parameters/QueueBulkPayReportsParams.ts new file mode 100644 index 000000000000..845b2d6a67fd --- /dev/null +++ b/src/libs/API/parameters/QueueBulkPayReportsParams.ts @@ -0,0 +1,8 @@ +import type {SearchQueryString} from '@components/Search/types'; + +type QueueBulkPayReportsParams = { + /** Serialized search query describing every report to pay, so the backend can page through all matches instead of relying on a client-side list */ + jsonQuery: SearchQueryString; +}; + +export default QueueBulkPayReportsParams; diff --git a/src/libs/API/parameters/RemoveWorkspaceInvoiceFieldListValueParams.ts b/src/libs/API/parameters/RemoveWorkspaceInvoiceFieldListValueParams.ts new file mode 100644 index 000000000000..c793b4bdf10a --- /dev/null +++ b/src/libs/API/parameters/RemoveWorkspaceInvoiceFieldListValueParams.ts @@ -0,0 +1,10 @@ +type RemoveWorkspaceInvoiceFieldListValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + invoiceFields: string; +}; + +export default RemoveWorkspaceInvoiceFieldListValueParams; diff --git a/src/libs/API/parameters/ResendValidateCodeParams.ts b/src/libs/API/parameters/ResendValidateCodeParams.ts index 2d7b692d763a..a3dda491f89f 100644 --- a/src/libs/API/parameters/ResendValidateCodeParams.ts +++ b/src/libs/API/parameters/ResendValidateCodeParams.ts @@ -1,3 +1,5 @@ +import type CONST_APP from '@src/CONST'; + import type {CONST} from 'expensify-common'; import type {ValueOf} from 'type-fest'; @@ -24,6 +26,12 @@ type ResendValidateCodeForReportCardFraudParams = { reasonCardID: number; }; +// Lives in our own CONST until the reason is added to expensify-common's VALIDATE_CODE_REASONS +type ResendValidateCodeForApproveDigitalWalletParams = { + reasonCode: typeof CONST_APP.EXPENSIFY_CARD.APPROVE_DIGITAL_WALLET_VALIDATE_CODE_REASON; + reasonCardID: number; +}; + // Will be removed eventually type ResendValidateCodeNotYetImplementedParams = { reasonCode: null; @@ -34,6 +42,7 @@ type ResendValidateCodeParams = | ResendValidateCodeNotYetImplementedParams | ResendValidateCodeForRevealCardDetailsParams | ResendValidateCodeForReplaceCardParams - | ResendValidateCodeForReportCardFraudParams; + | ResendValidateCodeForReportCardFraudParams + | ResendValidateCodeForApproveDigitalWalletParams; export default ResendValidateCodeParams; diff --git a/src/libs/API/parameters/SaveSearch.ts b/src/libs/API/parameters/SaveSearch.ts index 9dd3416320c7..ef3c12972b09 100644 --- a/src/libs/API/parameters/SaveSearch.ts +++ b/src/libs/API/parameters/SaveSearch.ts @@ -2,6 +2,7 @@ import type {SearchQueryString} from '@components/Search/types'; type SaveSearchParams = { jsonQuery: SearchQueryString; + savedSearchID: string; newName?: string; }; diff --git a/src/libs/API/parameters/SyncPolicyToCampfireParams.ts b/src/libs/API/parameters/SyncPolicyToCampfireParams.ts new file mode 100644 index 000000000000..d5c9ef26edb0 --- /dev/null +++ b/src/libs/API/parameters/SyncPolicyToCampfireParams.ts @@ -0,0 +1,6 @@ +type SyncPolicyToCampfireParams = { + policyID: string; + idempotencyKey: string; +}; + +export default SyncPolicyToCampfireParams; diff --git a/src/libs/API/parameters/SyncPolicyToMergeParams.ts b/src/libs/API/parameters/SyncPolicyToMergeParams.ts index a36aca7f6c9e..038915ec23cf 100644 --- a/src/libs/API/parameters/SyncPolicyToMergeParams.ts +++ b/src/libs/API/parameters/SyncPolicyToMergeParams.ts @@ -1,7 +1,6 @@ import type {MergeConnectionName} from '@libs/merge/MergeUtils'; type SyncPolicyToMergeParams = { - /** The ID of the policy to sync */ policyID: string; /** The Merge connection to sync (Merge HR or Merge ATS) */ diff --git a/src/libs/API/parameters/UpdateCampfireSubsidiaryParams.ts b/src/libs/API/parameters/UpdateCampfireSubsidiaryParams.ts new file mode 100644 index 000000000000..e8e4327fccd6 --- /dev/null +++ b/src/libs/API/parameters/UpdateCampfireSubsidiaryParams.ts @@ -0,0 +1,6 @@ +type UpdateCampfireSubsidiaryParams = { + policyID: string; + subsidiaryID: string; +}; + +export default UpdateCampfireSubsidiaryParams; diff --git a/src/libs/API/parameters/UpdateMergeApprovalModeParams.ts b/src/libs/API/parameters/UpdateMergeApprovalModeParams.ts index 060a153c7b9d..6a33a30215c2 100644 --- a/src/libs/API/parameters/UpdateMergeApprovalModeParams.ts +++ b/src/libs/API/parameters/UpdateMergeApprovalModeParams.ts @@ -5,7 +5,6 @@ import type CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; type UpdateMergeApprovalModeParams = { - /** The ID of the policy to update */ policyID: string; /** The Merge connection to update (Merge HR or Merge ATS) */ diff --git a/src/libs/API/parameters/UpdateMergeFinalApproverParams.ts b/src/libs/API/parameters/UpdateMergeFinalApproverParams.ts index b20c4217cf53..0c7eb93c4eef 100644 --- a/src/libs/API/parameters/UpdateMergeFinalApproverParams.ts +++ b/src/libs/API/parameters/UpdateMergeFinalApproverParams.ts @@ -1,7 +1,6 @@ import type {MergeConnectionName} from '@libs/merge/MergeUtils'; type UpdateMergeFinalApproverParams = { - /** The ID of the policy to update */ policyID: string; /** The Merge connection to update (Merge HR or Merge ATS) */ diff --git a/src/libs/API/parameters/UpdateMergeGroupsParams.ts b/src/libs/API/parameters/UpdateMergeGroupsParams.ts index c92231589376..0db720e6d116 100644 --- a/src/libs/API/parameters/UpdateMergeGroupsParams.ts +++ b/src/libs/API/parameters/UpdateMergeGroupsParams.ts @@ -1,5 +1,4 @@ type UpdateMergeGroupsParams = { - /** The ID of the policy to update. */ policyID: string; /** The Merge group ids to import employees from. */ diff --git a/src/libs/API/parameters/UpdateWorkspaceInvoiceFieldInitialValueParams.ts b/src/libs/API/parameters/UpdateWorkspaceInvoiceFieldInitialValueParams.ts new file mode 100644 index 000000000000..eeb3cd5106aa --- /dev/null +++ b/src/libs/API/parameters/UpdateWorkspaceInvoiceFieldInitialValueParams.ts @@ -0,0 +1,10 @@ +type UpdateWorkspaceInvoiceFieldInitialValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + invoiceFields: string; +}; + +export default UpdateWorkspaceInvoiceFieldInitialValueParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index fbc281232d2b..839171906e8a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -4,6 +4,7 @@ export type {default as ImportMultiLevelTagsParams} from './ImportMultiLevelTags export type {default as ImportCSVCompanyCardsParams} from './ImportCSVCompanyCardsParams'; export type {default as CleanPolicyTagsParams} from './CleanPolicyTagsParams'; export type {default as ActivatePhysicalExpensifyCardParams} from './ActivatePhysicalExpensifyCardParams'; +export type {default as ApproveDigitalWalletCardAdditionParams} from './ApproveDigitalWalletCardAdditionParams'; export type {default as AddNewContactMethodParams} from './AddNewContactMethodParams'; export type {default as AddPaymentCardParams} from './AddPaymentCardParams'; export type {default as VerifySetupIntentParams} from './VerifySetupIntentParams'; @@ -27,6 +28,7 @@ export type {default as ConnectPolicyToMergeParams} from './ConnectPolicyToMerge export type {default as ConnectPolicyToZenefitsParams} from './ConnectPolicyToZenefitsParams'; export type {default as ConnectPolicyToRilletParams} from './ConnectPolicyToRilletParams'; export type {default as ConnectPolicyToDualEntryParams} from './ConnectPolicyToDualEntryParams'; +export type {default as ConnectPolicyToCampfireParams} from './ConnectPolicyToCampfireParams'; export type {default as ConnectPolicyToBusinessCentralParams} from './ConnectPolicyToBusinessCentralParams'; export type {default as OpenPolicyProfilePageParams} from './OpenPolicyProfilePageParams'; export type {default as OpenPolicyInitialPageParams} from './OpenPolicyInitialPageParams'; @@ -77,6 +79,8 @@ export type {default as UpdateDualEntrySettlementsAccountParams} from './UpdateD export type {default as UpdateDualEntrySyncTravelInvoicingSettlementsParams} from './UpdateDualEntrySyncTravelInvoicingSettlementsParams'; export type {default as UpdateDualEntryTravelInvoicingSettlementsAccountParams} from './UpdateDualEntryTravelInvoicingSettlementsAccountParams'; export type {default as UpdateDualEntryTravelInvoicingPayableAccountParams} from './UpdateDualEntryTravelInvoicingPayableAccountParams'; +export type {default as SyncPolicyToCampfireParams} from './SyncPolicyToCampfireParams'; +export type {default as UpdateCampfireSubsidiaryParams} from './UpdateCampfireSubsidiaryParams'; export type {default as SyncPolicyToBusinessCentralParams} from './SyncPolicyToBusinessCentralParams'; export type {default as UpdateBusinessCentralCompanyParams} from './UpdateBusinessCentralCompanyParams'; export type {default as UpdateBusinessCentralEnableNewCategoriesParams} from './UpdateBusinessCentralEnableNewCategoriesParams'; @@ -122,9 +126,9 @@ export type {default as OpenRoomMembersPageParams} from './OpenRoomMembersPagePa export type {default as OpenPlaidCompanyCardLoginParams} from './OpenPlaidCompanyCardLoginParams'; export type {default as OpenPolicyReceiptPartnersPageParams} from './OpenPolicyReceiptPartnersPageParams'; export type {default as OpenPolicyHRPageParams} from './OpenPolicyHRPageParams'; +export type {default as OpenPolicyRecruitingPageParams} from './OpenPolicyRecruitingPageParams'; export type {default as PaymentCardParams} from './PaymentCardParams'; export type {default as AddPersonalPlaidCardParams} from './AddPersonalPlaidCardParams'; -export type {default as PusherPingParams} from './PusherPingParams'; export type {default as ReconnectAppParams} from './ReconnectAppParams'; export type {default as ReferTeachersUniteVolunteerParams} from './ReferTeachersUniteVolunteerParams'; export type {default as ReportVirtualExpensifyCardFraudParams} from './ReportVirtualExpensifyCardFraudParams'; @@ -301,6 +305,7 @@ export type {default as TrackExpenseParams} from './TrackExpenseParams'; export type {default as EnablePolicyCategoriesParams} from './EnablePolicyCategoriesParams'; export type {default as EnablePolicyConnectionsParams} from './EnablePolicyConnectionsParams'; export type {default as EnablePolicyHRParams} from './EnablePolicyHRParams'; +export type {default as EnablePolicyMCPParams} from './EnablePolicyMCPParams'; export type {default as TogglePolicyReceiptPartnersParams} from './TogglePolicyReceiptPartnersParams'; export type {default as EnablePolicyDistanceRatesParams} from './EnablePolicyDistanceRatesParams'; export type {default as EnablePolicyTravelParams} from './EnablePolicyTravelParams'; @@ -308,6 +313,7 @@ export type {default as SetPolicyTravelSettingsParams} from './SetPolicyTravelSe export type {default as EnablePolicyTagsParams} from './EnablePolicyTagsParams'; export type {default as SetPolicyTagsEnabled} from './SetPolicyTagsEnabled'; export type {default as EnablePolicyWorkflowsParams} from './EnablePolicyWorkflowsParams'; +export type {default as EnablePolicyInvoiceFieldsParams} from './EnablePolicyInvoiceFieldsParams'; export type {default as EnablePolicyReportFieldsParams} from './EnablePolicyReportFieldsParams'; export type {default as EnablePolicyExpensifyCardsParams} from './EnablePolicyExpensifyCardsParams'; export type {default as UpdateGustoApprovalModeParams} from './UpdateGustoApprovalModeParams'; @@ -389,15 +395,21 @@ export type {default as UpgradeToCorporateParams} from './UpgradeToCorporatePara export type {default as DowngradeToTeamParams} from './DowngradeToTeamParams'; export type {default as RejectMoneyRequestInBulkParams} from './RejectMoneyRequestInBulkParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; +export type {default as DeletePolicyInvoiceField} from './DeletePolicyInvoiceField'; export type {default as DeletePolicyReportField} from './DeletePolicyReportField'; export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; +export type {default as CreateWorkspaceInvoiceFieldParams} from './CreateWorkspaceInvoiceFieldParams'; export type {default as UpdateWorkspaceReportFieldInitialValueParams} from './UpdateWorkspaceReportFieldInitialValueParams'; +export type {default as UpdateWorkspaceInvoiceFieldInitialValueParams} from './UpdateWorkspaceInvoiceFieldInitialValueParams'; export type {default as EnableWorkspaceReportFieldListValueParams} from './EnableWorkspaceReportFieldListValueParams'; +export type {default as EnableWorkspaceInvoiceFieldListValueParams} from './EnableWorkspaceInvoiceFieldListValueParams'; export type {default as EnablePolicyInvoicingParams} from './EnablePolicyInvoicingParams'; export type {default as EnablePolicyTimeTrackingParams} from './EnablePolicyTimeTrackingParams'; export type {default as CreateWorkspaceReportFieldListValueParams} from './CreateWorkspaceReportFieldListValueParams'; +export type {default as CreateWorkspaceInvoiceFieldListValueParams} from './CreateWorkspaceInvoiceFieldListValueParams'; export type {default as RemoveWorkspaceReportFieldListValueParams} from './RemoveWorkspaceReportFieldListValueParams'; +export type {default as RemoveWorkspaceInvoiceFieldListValueParams} from './RemoveWorkspaceInvoiceFieldListValueParams'; export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; export type {default as OpenPolicyTravelPageParams} from './OpenPolicyTravelPageParams'; export type {default as OpenPolicyEditCardLimitTypePageParams} from './OpenPolicyEditCardLimitTypePageParams'; @@ -568,6 +580,7 @@ export type {default as OpenUnreportedExpensesPageParams} from './OpenUnreported export type {default as ExportSearchWithTemplateParams} from './ExportSearchWithTemplateParams'; export type {default as QueueExportSearchItemsToCSVParams} from './QueueExportSearchItemsToCSVParams'; export type {default as QueueExportSearchWithTemplateParams} from './QueueExportSearchWithTemplateParams'; +export type {default as QueueBulkPayReportsParams} from './QueueBulkPayReportsParams'; export type {default as AssignReportToMeParams} from './AssignReportToMeParams'; export type {default as AddReportApproverParams} from './AddReportApproverParams'; export type {default as EnableGlobalReimbursementsForUSDBankAccountParams} from './EnableGlobalReimbursementsForUSDBankAccountParams'; @@ -588,6 +601,7 @@ export type {default as SetPolicyRequireCompanyCardsEnabledParams} from './SetPo export type {default as SetTechnicalContactEmailParams} from './SetTechnicalContactEmailParams'; export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleConsolidatedDomainBillingParams'; export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; +export type {default as DeclineDomainAdminshipRequestParams} from './DeclineDomainAdminshipRequestParams'; export type {default as DeleteDomainMemberParams} from './DeleteDomainMemberParams'; export type {default as ChangeDomainSecurityGroupParams} from './ChangeDomainSecurityGroupParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d83f40b48233..381e45f39f2b 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -53,6 +53,7 @@ const WRITE_COMMANDS = { REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD: 'ReportVirtualExpensifyCardFraud', REQUEST_REPLACEMENT_EXPENSIFY_CARD: 'RequestReplacementExpensifyCard', ACTIVATE_PHYSICAL_EXPENSIFY_CARD: 'ActivatePhysicalExpensifyCard', + APPROVE_DIGITAL_WALLET_CARD_ADDITION: 'ApproveDigitalWalletCardAddition', RESOLVE_FRAUD_ALERT: 'ResolveFraudAlert', UPDATE_EXPENSIFY_CARD_LIMIT: 'UpdateExpensifyCardLimit', UPDATE_EXPENSIFY_CARD_TITLE: 'UpdateExpensifyCardTitle', @@ -194,6 +195,7 @@ const WRITE_COMMANDS = { UPDATE_POLICY_CATEGORY_GL_CODE: 'UpdatePolicyCategoryGLCode', DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories', DELETE_POLICY_REPORT_FIELD: 'DeletePolicyReportField', + DELETE_POLICY_INVOICE_FIELD: 'DeletePolicyInvoiceField', SET_POLICY_TAGS_REQUIRED: 'SetPolicyTagsRequired', SET_POLICY_TAG_LISTS_REQUIRED: 'SetPolicyTagListsRequired', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', @@ -264,6 +266,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_CATEGORIES: 'EnablePolicyCategories', ENABLE_POLICY_CONNECTIONS: 'EnablePolicyConnections', ENABLE_POLICY_HR: 'EnablePolicyHR', + ENABLE_POLICY_MCP: 'EnablePolicyMCP', TOGGLE_RECEIPT_PARTNERS: 'TogglePolicyReceiptPartners', INVITE_WORKSPACE_EMPLOYEES_TO_UBER: 'InviteWorkspaceEmployeesToUber', ENABLE_POLICY_DISTANCE_RATES: 'EnablePolicyDistanceRates', @@ -273,6 +276,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_TAXES: 'EnablePolicyTaxes', ENABLE_POLICY_WORKFLOWS: 'EnablePolicyWorkflows', ENABLE_POLICY_REPORT_FIELDS: 'EnablePolicyReportFields', + ENABLE_POLICY_INVOICE_FIELDS: 'EnablePolicyInvoiceFields', ENABLE_POLICY_EXPENSIFY_CARDS: 'EnablePolicyExpensifyCards', TOGGLE_POLICY_PER_DIEM: 'TogglePolicyPerDiem', ENABLE_POLICY_COMPANY_CARDS: 'EnablePolicyCompanyCards', @@ -419,9 +423,14 @@ const WRITE_COMMANDS = { REJECT_MONEY_REQUEST_IN_BULK: 'RejectMoneyRequestInBulk', REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', + CREATE_WORKSPACE_INVOICE_FIELD: 'CreatePolicyInvoiceField', + CREATE_WORKSPACE_INVOICE_FIELD_LIST_VALUE: 'CreatePolicyInvoiceFieldOption', CREATE_WORKSPACE_REPORT_FIELD: 'CreatePolicyReportField', + UPDATE_WORKSPACE_INVOICE_FIELD_INITIAL_VALUE: 'SetPolicyInvoiceFieldDefault', UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE: 'SetPolicyReportFieldDefault', + ENABLE_WORKSPACE_INVOICE_FIELD_LIST_VALUE: 'EnablePolicyInvoiceFieldOption', ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE: 'EnablePolicyReportFieldOption', + REMOVE_WORKSPACE_INVOICE_FIELD_LIST_VALUE: 'RemovePolicyInvoiceFieldOption', CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE: 'CreatePolicyReportFieldOption', REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE: 'RemovePolicyReportFieldOption', UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION: 'UpdateNetSuiteSyncTaxConfiguration', @@ -561,12 +570,15 @@ const WRITE_COMMANDS = { UPDATE_BUSINESS_CENTRAL_FIELD_MAPPING: 'UpdateBusinessCentralFieldMapping', UPDATE_BUSINESS_CENTRAL_SYNC_TAX_RATES: 'UpdateBusinessCentralSyncTaxRates', UPDATE_BUSINESS_CENTRAL_SYNC_ITEMS: 'UpdateBusinessCentralSyncItems', + CONNECT_POLICY_TO_CAMPFIRE: 'ConnectPolicyToCampfire', + UPDATE_CAMPFIRE_SUBSIDIARY: 'UpdateCampfireSubsidiary', SET_PROMO_CODE: 'User_SetPromoCode', REQUEST_TAX_EXEMPTION: 'RequestTaxExemption', EXPORT_SEARCH_ITEMS_TO_CSV: 'ExportSearchToCSV', QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV: 'QueueExportSearchToCSV', QUEUE_EXPORT_SEARCH_WITH_TEMPLATE: 'QueueExportSearchWithTemplate', + QUEUE_BULK_PAY_REPORTS: 'QueueBulkPayReports', CREATE_WORKSPACE_APPROVAL: 'CreateWorkspaceApproval', UPDATE_WORKSPACE_APPROVAL: 'UpdateWorkspaceApproval', REMOVE_WORKSPACE_APPROVAL: 'RemoveWorkspaceApproval', @@ -676,6 +688,7 @@ const WRITE_COMMANDS = { ADD_DOMAIN_ADMIN: 'AddDomainAdmin', REMOVE_DOMAIN_ADMIN: 'RemoveDomainAdmin', REQUEST_DOMAIN_ADMINSHIP: 'RequestDomainAdminship', + DECLINE_DOMAIN_ADMINSHIP_REQUEST: 'DeclineDomainAdminshipRequest', DELETE_DOMAIN: 'DeleteDomain', DELETE_DOMAIN_MEMBER: 'DeleteDomainMember', TOGGLE_TWO_FACTOR_AUTH_REQUIRED_FOR_DOMAIN: 'ToggleTwoFactorAuthRequiredForDomain', @@ -700,6 +713,9 @@ const WRITE_COMMANDS = { JOIN_REPORT_VIA_SECURE_LINK: 'JoinReportViaSecureLink', } as const; +/** `payMoneyRequest` sends the wallet command for Expensify Wallet payments and the plain one for everything else, both built from the same params. */ +const PAYMENT_COMMANDS = new Set([WRITE_COMMANDS.PAY_MONEY_REQUEST, WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET]); + type WriteCommand = ValueOf; type WriteCommandParameters = { @@ -724,6 +740,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD]: Parameters.ReportVirtualExpensifyCardFraudParams; [WRITE_COMMANDS.REQUEST_REPLACEMENT_EXPENSIFY_CARD]: Parameters.RequestReplacementExpensifyCardParams; [WRITE_COMMANDS.ACTIVATE_PHYSICAL_EXPENSIFY_CARD]: Parameters.ActivatePhysicalExpensifyCardParams; + [WRITE_COMMANDS.APPROVE_DIGITAL_WALLET_CARD_ADDITION]: Parameters.ApproveDigitalWalletCardAdditionParams; [WRITE_COMMANDS.RESOLVE_FRAUD_ALERT]: Parameters.ResolveFraudAlertParams; [WRITE_COMMANDS.UPDATE_EXPENSIFY_CARD_LIMIT]: Parameters.UpdateExpensifyCardLimitParams; [WRITE_COMMANDS.UPDATE_EXPENSIFY_CARD_TITLE]: Parameters.UpdateExpensifyCardTitleParams; @@ -873,6 +890,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_PAYROLL_CODE]: Parameters.UpdatePolicyCategoryPayrollCodeParams; [WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE]: Parameters.UpdatePolicyCategoryGLCodeParams; [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.DeletePolicyReportField; + [WRITE_COMMANDS.DELETE_POLICY_INVOICE_FIELD]: Parameters.DeletePolicyInvoiceField; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.SET_POLICY_SHOW_TAG_GL_CODES]: Parameters.SetPolicyShowTagGLCodesParams; [WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired; @@ -962,6 +980,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES]: Parameters.EnablePolicyCategoriesParams; [WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS]: Parameters.EnablePolicyConnectionsParams; [WRITE_COMMANDS.ENABLE_POLICY_HR]: Parameters.EnablePolicyHRParams; + [WRITE_COMMANDS.ENABLE_POLICY_MCP]: Parameters.EnablePolicyMCPParams; [WRITE_COMMANDS.TOGGLE_RECEIPT_PARTNERS]: Parameters.TogglePolicyReceiptPartnersParams; [WRITE_COMMANDS.INVITE_WORKSPACE_EMPLOYEES_TO_UBER]: Parameters.InviteWorkspaceEmployeesToUberParams; [WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES]: Parameters.EnablePolicyDistanceRatesParams; @@ -971,6 +990,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_TAXES]: Parameters.EnablePolicyTaxesParams; [WRITE_COMMANDS.ENABLE_POLICY_WORKFLOWS]: Parameters.EnablePolicyWorkflowsParams; [WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS]: Parameters.EnablePolicyReportFieldsParams; + [WRITE_COMMANDS.ENABLE_POLICY_INVOICE_FIELDS]: Parameters.EnablePolicyInvoiceFieldsParams; [WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS]: Parameters.EnablePolicyExpensifyCardsParams; [WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM]: Parameters.TogglePolicyPerDiemParams; [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; @@ -1229,6 +1249,9 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_TAX_RATES]: Parameters.UpdateBusinessCentralSyncTaxRatesParams; [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_ITEMS]: Parameters.UpdateBusinessCentralSyncItemsParams; + [WRITE_COMMANDS.CONNECT_POLICY_TO_CAMPFIRE]: Parameters.ConnectPolicyToCampfireParams; + [WRITE_COMMANDS.UPDATE_CAMPFIRE_SUBSIDIARY]: Parameters.UpdateCampfireSubsidiaryParams; + [WRITE_COMMANDS.UPGRADE_TO_CORPORATE]: Parameters.UpgradeToCorporateParams; [WRITE_COMMANDS.DOWNGRADE_TO_TEAM]: Parameters.DowngradeToTeamParams; [WRITE_COMMANDS.UPGRADE_SUBMIT]: Parameters.UpgradeSubmitParams; @@ -1238,10 +1261,15 @@ type WriteCommandParameters = { // Workspace report field parameters [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD]: Parameters.CreateWorkspaceReportFieldParams; + [WRITE_COMMANDS.CREATE_WORKSPACE_INVOICE_FIELD]: Parameters.CreateWorkspaceInvoiceFieldParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE]: Parameters.UpdateWorkspaceReportFieldInitialValueParams; + [WRITE_COMMANDS.UPDATE_WORKSPACE_INVOICE_FIELD_INITIAL_VALUE]: Parameters.UpdateWorkspaceInvoiceFieldInitialValueParams; [WRITE_COMMANDS.ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE]: Parameters.EnableWorkspaceReportFieldListValueParams; + [WRITE_COMMANDS.ENABLE_WORKSPACE_INVOICE_FIELD_LIST_VALUE]: Parameters.EnableWorkspaceInvoiceFieldListValueParams; [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE]: Parameters.CreateWorkspaceReportFieldListValueParams; + [WRITE_COMMANDS.CREATE_WORKSPACE_INVOICE_FIELD_LIST_VALUE]: Parameters.CreateWorkspaceInvoiceFieldListValueParams; [WRITE_COMMANDS.REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE]: Parameters.RemoveWorkspaceReportFieldListValueParams; + [WRITE_COMMANDS.REMOVE_WORKSPACE_INVOICE_FIELD_LIST_VALUE]: Parameters.RemoveWorkspaceInvoiceFieldListValueParams; [WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_TAX_SOLUTION_ID]: Parameters.UpdateNetSuiteGenericTypeParams<'taxSolutionID', string>; @@ -1303,6 +1331,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV]: Parameters.ExportSearchItemsToCSVParams; [WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV]: Parameters.QueueExportSearchItemsToCSVParams; [WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_WITH_TEMPLATE]: Parameters.QueueExportSearchWithTemplateParams; + [WRITE_COMMANDS.QUEUE_BULK_PAY_REPORTS]: Parameters.QueueBulkPayReportsParams; [WRITE_COMMANDS.EXPORT_REPORT_TO_CSV]: Parameters.ExportReportCSVParams; [WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL]: Parameters.CreateWorkspaceApprovalParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL]: Parameters.UpdateWorkspaceApprovalParams; @@ -1397,6 +1426,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_DOMAIN]: Parameters.DeleteDomainParams; [WRITE_COMMANDS.ADD_DOMAIN_ADMIN]: Parameters.AddAdminToDomainParams; [WRITE_COMMANDS.REQUEST_DOMAIN_ADMINSHIP]: Parameters.RequestDomainAdminshipParams; + [WRITE_COMMANDS.DECLINE_DOMAIN_ADMINSHIP_REQUEST]: Parameters.DeclineDomainAdminshipRequestParams; [WRITE_COMMANDS.ADD_DOMAIN_MEMBER]: Parameters.AddMemberToDomainParams; [WRITE_COMMANDS.TOGGLE_TWO_FACTOR_AUTH_REQUIRED_FOR_DOMAIN]: Parameters.ToggleTwoFactorAuthRequiredForDomainParams; [WRITE_COMMANDS.SET_TWO_FACTOR_AUTH_EXEMPT_EMAIL_FOR_DOMAIN]: Parameters.SetTwoFactorAuthExemptEmailForDomainParams; @@ -1435,12 +1465,14 @@ const READ_COMMANDS = { SYNC_POLICY_TO_FINANCIAL_FORCE: 'SyncPolicyToFinancialForce', SYNC_POLICY_TO_RILLET: 'SyncPolicyToRillet', SYNC_POLICY_TO_DUALENTRY: 'SyncPolicyToDualEntry', + SYNC_POLICY_TO_CAMPFIRE: 'SyncPolicyToCampfire', SYNC_POLICY_TO_BUSINESS_CENTRAL: 'SyncPolicyToBusinessCentral', CONNECT_POLICY_TO_FINANCIAL_FORCE: 'ConnectPolicyToFinancialForce', OPEN_REIMBURSEMENT_ACCOUNT_PAGE: 'OpenReimbursementAccountPage', OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', OPEN_PAYMENTS_PAGE: 'OpenPaymentsPage', + GET_EXPENSIFY_CARD_PENDING_WALLET_APPROVAL: 'GetExpensifyCardPendingWalletApproval', OPEN_BANK_ACCOUNT_SHARE_PAGE: 'OpenBankAccountSharePage', OPEN_PUBLIC_PROFILE_PAGE: 'OpenPublicProfilePage', OPEN_PLAID_BANK_LOGIN: 'OpenPlaidBankLogin', @@ -1480,6 +1512,7 @@ const READ_COMMANDS = { OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', OPEN_POLICY_TAXES_PAGE: 'OpenPolicyTaxesPage', OPEN_POLICY_REPORT_FIELDS_PAGE: 'OpenPolicyReportFieldsPage', + OPEN_POLICY_INVOICES_PAGE: 'OpenPolicyInvoicesPage', OPEN_POLICY_RULES_PAGE: 'OpenPolicyRulesPage', OPEN_POLICY_EXPENSIFY_CARDS_PAGE: 'OpenPolicyExpensifyCardsPage', OPEN_POLICY_TRAVEL_PAGE: 'OpenPolicyTravelPage', @@ -1498,6 +1531,7 @@ const READ_COMMANDS = { OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage', OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage', OPEN_POLICY_HR_PAGE: 'OpenPolicyHRPage', + OPEN_POLICY_RECRUITING_PAGE: 'OpenPolicyRecruitingPage', OPEN_POLICY_PROFILE_PAGE: 'OpenPolicyProfilePage', OPEN_DUPLICATE_POLICY_PAGE: 'OpenDuplicatePolicyPage', OPEN_POLICY_INITIAL_PAGE: 'OpenPolicyInitialPage', @@ -1547,11 +1581,13 @@ type ReadCommandParameters = { [READ_COMMANDS.SYNC_POLICY_TO_FINANCIAL_FORCE]: Parameters.SyncPolicyToFinancialForceParams; [READ_COMMANDS.SYNC_POLICY_TO_RILLET]: Parameters.SyncPolicyToRilletParams; [READ_COMMANDS.SYNC_POLICY_TO_DUALENTRY]: Parameters.SyncPolicyToDualEntryParams; + [READ_COMMANDS.SYNC_POLICY_TO_CAMPFIRE]: Parameters.SyncPolicyToCampfireParams; [READ_COMMANDS.SYNC_POLICY_TO_BUSINESS_CENTRAL]: Parameters.SyncPolicyToBusinessCentralParams; [READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE]: Parameters.OpenReimbursementAccountPageParams; [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: Parameters.OpenPaymentsPageParams | null; + [READ_COMMANDS.GET_EXPENSIFY_CARD_PENDING_WALLET_APPROVAL]: null; [READ_COMMANDS.OPEN_BANK_ACCOUNT_SHARE_PAGE]: null; [READ_COMMANDS.OPEN_PUBLIC_PROFILE_PAGE]: Parameters.OpenPublicProfilePageParams; [READ_COMMANDS.OPEN_PLAID_BANK_LOGIN]: Parameters.OpenPlaidBankLoginParams; @@ -1590,6 +1626,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_TAGS_PAGE]: Parameters.OpenPolicyTagsPageParams; [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; [READ_COMMANDS.OPEN_POLICY_RULES_PAGE]: Parameters.OpenPolicyRulesPageParams; [READ_COMMANDS.OPEN_WORKSPACE_INVITE_PAGE]: Parameters.OpenWorkspaceInvitePageParams; [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; @@ -1600,6 +1637,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; [READ_COMMANDS.OPEN_POLICY_HR_PAGE]: Parameters.OpenPolicyHRPageParams; + [READ_COMMANDS.OPEN_POLICY_RECRUITING_PAGE]: Parameters.OpenPolicyRecruitingPageParams; [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; [READ_COMMANDS.OPEN_POLICY_TRAVEL_PAGE]: Parameters.OpenPolicyTravelPageParams; [READ_COMMANDS.GET_TRAVEL_BILLING_STATEMENT_PDF]: Parameters.GetTravelBillingStatementPDFParams; @@ -1649,7 +1687,6 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { IMPORT_PER_DIEM_RATES: 'ImportPerDiemRates', IMPORT_TAGS_SPREADSHEET: 'ImportTagsSpreadsheet', OPEN_OLD_DOT_LINK: 'OpenOldDotLink', - PUSHER_PING: 'PusherPing', RECONNECT_APP: 'ReconnectApp', REVEAL_EXPENSIFY_TRAVEL_CARD_DETAILS: 'RevealExpensifyTravelCardDetails', TWO_FACTOR_AUTH_VALIDATE: 'TwoFactorAuth_Validate', @@ -1698,7 +1735,6 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_PER_DIEM_RATES]: Parameters.ImportPerDiemRatesParams; [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_TAGS_SPREADSHEET]: Parameters.ImportTagsSpreadsheetParams; [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_OLD_DOT_LINK]: Parameters.OpenOldDotLinkParams; - [SIDE_EFFECT_REQUEST_COMMANDS.PUSHER_PING]: Parameters.PusherPingParams; [SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_EXPENSIFY_TRAVEL_CARD_DETAILS]: Parameters.RevealExpensifyCardDetailsParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES]: Parameters.GetMissingOnyxMessagesParams; [SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP]: Parameters.ReconnectAppParams; @@ -1738,7 +1774,7 @@ type SideEffectRequestCommandParameters = { type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; -export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, AUTHENTICATION_COMMAND}; +export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, AUTHENTICATION_COMMAND, PAYMENT_COMMANDS}; type ApiCommand = WriteCommand | ReadCommand | SideEffectRequestCommand; type CommandOfType = TRequestType extends typeof CONST.API_REQUEST_TYPE.WRITE diff --git a/src/libs/API/writeWhenReady.ts b/src/libs/API/writeWhenReady.ts index 492667e7d5ba..cea0e28d296d 100644 --- a/src/libs/API/writeWhenReady.ts +++ b/src/libs/API/writeWhenReady.ts @@ -1,5 +1,6 @@ import Log from '@libs/Log'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {claimReadGateForDeferredWrite} from '@libs/Network/SequentialQueue'; import CONST from '@src/CONST'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -27,6 +28,9 @@ type WriteWhenReadyOptions = { /** Fires only after `write()` has been called and returned without throwing. A throwing handler is logged, not thrown. */ onWriteStarted?: () => void; + + /** Whether READs wait for this write. Defaults to `true`.*/ + shouldClaimReadGate?: boolean; }; // Must stay longer than the default barrier's worst case (a unit test pins that); exported so that test asserts the real value. @@ -115,6 +119,10 @@ function armTransitionBarrier(waitFor: true | 'navigation' = true): ArmedTransit * - Does not support `write()`'s `conflictResolver`: a deferred request isn't in the sequential * queue or considered for conflict resolution until it actually executes. * - Call order isn't preserved across independent `writeWhenReady` calls - their barriers race. + * - READs wait on this the same way they wait on a queued write, just for longer, since the barrier + * runs before the request does. Pass `{shouldClaimReadGate: false}` to opt out. While the gate is on, + * a barrier must not wait on a READ of its own - that READ parks behind this write, so the barrier only + * releases on the safety timeout. * * Caution: * - The default barrier waits for any transition (~2s worst case if none starts). Pass @@ -144,7 +152,7 @@ function writeWhenReady( Log.info('[API] Called API writeWhenReady', false, buildLogParams(command, apiCommandParameters ?? {})); // A bare number for `options` is treated as `safetyTimeoutMs`. - const {safetyTimeoutMs = SAFETY_TIMEOUT_MS, onRelease, onWriteStarted} = typeof options === 'number' ? {safetyTimeoutMs: options} : options; + const {safetyTimeoutMs = SAFETY_TIMEOUT_MS, onRelease, onWriteStarted, shouldClaimReadGate = true} = typeof options === 'number' ? {safetyTimeoutMs: options} : options; return new Promise((resolve, reject) => { let hasExecuted = false; @@ -153,6 +161,9 @@ function writeWhenReady( const abortController = new AbortController(); let barrierError: unknown; + // Claimed before the barrier is even built, so a READ on the very next line already parks behind us. + const settleReadGateClaim = shouldClaimReadGate ? claimReadGateForDeferredWrite() : () => {}; + const execute = (reason: ReleaseReason) => { if (hasExecuted) { return; @@ -181,7 +192,9 @@ function writeWhenReady( Log.warn('[API] writeWhenReady onRelease threw', {command, error}); } - write(command, apiCommandParameters, onyxData).then(resolve, reject); + // Settled off the write promise, so the handover doesn't depend on push() still being + // reached synchronously inside write(). + write(command, apiCommandParameters, onyxData).then(resolve, reject).finally(settleReadGateClaim); // Isolated so a throwing side effect can't be mistaken for a failed write. try { @@ -193,6 +206,8 @@ function writeWhenReady( }); } } catch (error) { + // write() never returned a promise to settle the claim off, so release it here. + settleReadGateClaim(); reject(error); } }; diff --git a/src/libs/AccountingUtils.ts b/src/libs/AccountingUtils.ts index a11069005f8b..b3c8063aa928 100644 --- a/src/libs/AccountingUtils.ts +++ b/src/libs/AccountingUtils.ts @@ -20,6 +20,7 @@ const ROUTE_NAME_MAPPING = { [CONST.POLICY.CONNECTIONS.ROUTE.MERGE_HR]: CONST.POLICY.CONNECTIONS.NAME.MERGE_HR, [CONST.POLICY.CONNECTIONS.ROUTE.MERGE_ATS]: CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS, [CONST.POLICY.CONNECTIONS.ROUTE.DUALENTRY]: CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, + [CONST.POLICY.CONNECTIONS.ROUTE.CAMPFIRE]: CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, }; const NAME_ROUTE_MAPPING = { @@ -35,12 +36,14 @@ const NAME_ROUTE_MAPPING = { [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: CONST.POLICY.CONNECTIONS.ROUTE.MERGE_HR, [CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS]: CONST.POLICY.CONNECTIONS.ROUTE.MERGE_ATS, [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]: CONST.POLICY.CONNECTIONS.ROUTE.DUALENTRY, + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: CONST.POLICY.CONNECTIONS.ROUTE.CAMPFIRE, }; const STANDARD_EXPORT_TEMPLATE_NAME_MAPPING = { [CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT]: CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT, [CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT]: CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT, [CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT]: CONST.REPORT.EXPORT_OPTION_LABELS.MULTIPLE_TAX_EXPORT, + [CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES]: CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES, }; const STANDARD_EXPORT_TEMPLATE_LABELS = new Set(Object.values(STANDARD_EXPORT_TEMPLATE_NAME_MAPPING)); diff --git a/src/libs/AgentAccountIDMapping.ts b/src/libs/AgentAccountIDMapping.ts new file mode 100644 index 000000000000..8ac8a7c2e9b5 --- /dev/null +++ b/src/libs/AgentAccountIDMapping.ts @@ -0,0 +1,19 @@ +/** + * In-memory record of the {optimisticAccountID: realAccountID} agent mappings consumed this session. The Onyx + * mapping entry is cleared once consumed, so this is the only way a late caller (e.g. an agent settings screen + * opened before the redirect) can still translate an optimistic accountID. + * + * Own module because the middleware also registers mappings and cannot import the lazy-loaded + * replaceOptimisticAgentWithActualAgent without pulling navigation into the startup path. + */ +const consumedOptimisticAccountIDs = new Map(); + +function registerAgentAccountIDMapping(optimisticAccountID: number, realAccountID: number) { + consumedOptimisticAccountIDs.set(optimisticAccountID, realAccountID); +} + +function resolveAgentAccountID(accountID: number): number { + return consumedOptimisticAccountIDs.get(accountID) ?? accountID; +} + +export {registerAgentAccountIDMapping, resolveAgentAccountID}; diff --git a/src/libs/ApiUtils.ts b/src/libs/ApiUtils.ts index 120457c0b60c..fef6b1a242f8 100644 --- a/src/libs/ApiUtils.ts +++ b/src/libs/ApiUtils.ts @@ -13,37 +13,83 @@ import getEnvironment from './Environment/getEnvironment'; // To avoid rebuilding native apps, native apps use production config for both staging and prod // We use the async environment check because it works on all platforms -let ENV_NAME: ValueOf = CONST.ENVIRONMENT.PRODUCTION; -let shouldUseStagingServer = false; -getEnvironment().then((envName) => { - ENV_NAME = envName; - - // We connect here, so we have the updated ENV_NAME when Onyx callback runs - // We only use the value of shouldUseStagingServer to determine which server we should point to. - // Since they aren't connected to a UI anywhere, it's OK to use connectWithoutView() - Onyx.connectWithoutView({ - key: ONYXKEYS.SHOULD_USE_STAGING_SERVER, - callback: (value) => { - // Toggling between APIs is not allowed on production and internal dev environment - if (ENV_NAME === CONST.ENVIRONMENT.PRODUCTION || CONFIG.IS_USING_LOCAL_WEB) { - shouldUseStagingServer = false; - return; - } - - const defaultToggleState = ENV_NAME === CONST.ENVIRONMENT.STAGING || ENV_NAME === CONST.ENVIRONMENT.ADHOC; - shouldUseStagingServer = value ?? defaultToggleState; - }, - }); +let envName: ValueOf = CONST.ENVIRONMENT.PRODUCTION; +let storedServer: ValueOf | undefined; +let hasReadStoredServer = false; + +// Stored verbatim, so the preference and the environment can arrive in either order. Onyx calls back even for +// an empty key, so the flag means the preference has been read, not that one was set. Since it isn't connected +// to a UI anywhere, it's OK to use connectWithoutView() +Onyx.connectWithoutView({ + key: ONYXKEYS.ACTIVE_SERVER, + callback: (value) => { + storedServer = value; + hasReadStoredServer = true; + }, +}); + +getEnvironment().then((value) => { + envName = value; }); +/** + * Which server requests are sent to. + * + * Derived on demand rather than cached, so that a preference stored before the environment resolved is still + * applied once it does. + */ +function resolveActiveServer(): ValueOf { + // Selecting QA with no QA root leaves getApiRoot returning an empty string, and getCommandURL turns + // that into a relative `api/Command?` the browser resolves against the app's own origin + const isQAConfigured = !!CONFIG.EXPENSIFY.QA_API_ROOT; + + // The environment is baked into the bundle, and there is no meaningful way + // to point qa.new.exops.io at production + if (envName === CONST.ENVIRONMENT.QA && isQAConfigured) { + return CONST.SERVER.QA; + } + + // A stored 'qa' outlives the config that produced it: clearing QA_EXPENSIFY_URL hides the switch and + // turns the QA gate off, but leaves the old Onyx value behind. A production bundle is the same case, + // since it has no QA host to reach whatever is stored + const isQASelectable = isQAConfigured && envName !== CONST.ENVIRONMENT.PRODUCTION; + const server = storedServer === CONST.SERVER.QA && !isQASelectable ? undefined : storedServer; + + if (CONFIG.IS_USING_LOCAL_WEB && server !== CONST.SERVER.QA) { + return CONST.SERVER.PRODUCTION; + } + + // An unread preference looks the same as an unset one, and defaulting to staging would ignore an opt-out + if (!hasReadStoredServer) { + return CONST.SERVER.PRODUCTION; + } + + const defaultServer = envName === CONST.ENVIRONMENT.STAGING || envName === CONST.ENVIRONMENT.ADHOC ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION; + return server ?? defaultServer; +} + /** * Get the currently used API endpoint, unless forceProduction is set to true * (Non-production environments allow for dynamically switching the API) */ function getApiRoot(request?: Partial, 'shouldUseSecure' | 'shouldSkipWebProxy' | 'command'>>, forceProduction = false): string { const shouldUseSecure = request?.shouldUseSecure ?? false; + const server = forceProduction ? CONST.SERVER.PRODUCTION : resolveActiveServer(); + + if (server === CONST.SERVER.QA) { + // No web-proxy branch: Cloudflare Access answers the preflight and matches the bearer against the + // real origin, so routing QA through a same-origin proxy path would defeat both + if (!shouldUseSecure) { + return CONFIG.EXPENSIFY.QA_API_ROOT; + } + + if (!CONFIG.EXPENSIFY.QA_SECURE_API_ROOT) { + throw new Error(`The QA server has no secure host, so it cannot serve ${request?.command ?? 'a secure command'}. Set QA_SECURE_EXPENSIFY_URL to reach one.`); + } - if (shouldUseStagingServer && forceProduction !== true) { + return CONFIG.EXPENSIFY.QA_SECURE_API_ROOT; + } + if (server === CONST.SERVER.STAGING) { if (CONFIG.IS_USING_WEB_PROXY && !request?.shouldSkipWebProxy) { return shouldUseSecure ? proxyConfig.STAGING_SECURE : proxyConfig.STAGING; } @@ -64,11 +110,12 @@ function getCommandURL(request: Request): string { return `${getApiRoot(request)}api/${request.command}${request.command.includes('?') ? '' : '?'}`; } -/** - * Check if we're currently using the staging API root - */ -function isUsingStagingApi(): boolean { - return shouldUseStagingServer; +function isQAServerActive(): boolean { + return resolveActiveServer() === CONST.SERVER.QA; +} + +function getActiveServer(): ValueOf { + return resolveActiveServer(); } -export {getApiRoot, getCommandURL, isUsingStagingApi}; +export {getActiveServer, getApiRoot, getCommandURL, isQAServerActive}; diff --git a/src/libs/AppState/RequestsQueuesState/types.ts b/src/libs/AppState/RequestsQueuesState/types.ts index b79432d1cc45..eab4ac155a06 100644 --- a/src/libs/AppState/RequestsQueuesState/types.ts +++ b/src/libs/AppState/RequestsQueuesState/types.ts @@ -23,7 +23,6 @@ type PersistedRequestsInfo = { /** Commands of queued requests for debugging */ queuedCommands: string[]; - /** Currently ongoing request state */ ongoingRequestInfo?: OngoingRequestInfo; }; @@ -50,13 +49,8 @@ type LeaderInfo = { * Request queues and processing state. */ type RequestQueuesInfo = { - /** Main queue state */ mainQueue: MainQueueInfo; - - /** Sequential queue state */ sequentialQueue: SequentialQueueInfo; - - /** Persisted requests state */ persistedRequests: PersistedRequestsInfo; /** Leader state affecting requests */ diff --git a/src/libs/AppState/types.ts b/src/libs/AppState/types.ts index 4011a991f7ee..003d15f2cd4a 100644 --- a/src/libs/AppState/types.ts +++ b/src/libs/AppState/types.ts @@ -24,13 +24,8 @@ type NavigationStateInfo = { * Session and authentication state. */ type SessionStateInfo = { - /** Whether session is currently loading */ isSessionLoading: boolean; - - /** Whether authenticating with short-lived token */ isAuthenticatingWithShortLivedToken: boolean; - - /** Whether authenticating from network store */ isAuthenticatingFromNetworkStore: boolean; }; diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index b6084cc5a40c..1191df9e5b33 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -1,7 +1,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {LoginToAccountIDMapDerivedValue, PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {LoginToAccountIDMapDerivedValue, PersonalDetailsList, PolicyCategories} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; @@ -34,29 +34,6 @@ function normalizeAttendees(attendees: Attendee[] | undefined): Attendee[] { return (attendees ?? []).map(normalizeAttendee); } -/** Formats the title for requiredFields menu item based on which fields are enabled in the policy category */ -function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], policyCategory: PolicyCategory, isAttendeeTrackingEnabled = false): string { - const enabledFields: string[] = []; - - // Attendees field should show first when both are selected and attendee tracking is enabled - if (isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { - enabledFields.push(translate('iou.attendees')); - } - - if (policyCategory.areCommentsRequired) { - enabledFields.push(translate('common.description')); - } - - if (enabledFields.length === 0) { - return ''; - } - - const [first, ...rest] = enabledFields; - const capitalizedFirst = first.charAt(0).toUpperCase() + first.slice(1); - const lowercasedRest = rest.map((field) => field.charAt(0).toLowerCase() + field.slice(1)); - return [capitalizedFirst, ...lowercasedRest].join(', '); -} - /** * Converts raw attendees value to an array. * Onyx may deserialize arrays as plain objects, so both shapes are handled. @@ -183,4 +160,4 @@ function enrichAndSortAttendees( ); } -export {enrichAndSortAttendees, formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation, convertAttendeesToArray}; +export {enrichAndSortAttendees, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation, convertAttendeesToArray}; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index b40e4ccc5a58..66a9fbffe219 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1694,6 +1694,22 @@ function isCardPendingActivate(card?: Card) { return card?.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED; } +/** True when this card has a wallet addition waiting for the cardholder to confirm or deny. */ +function isCardPendingDigitalWalletApproval(card?: Card) { + return !!card?.nameValuePairs?.pendingDigitalWalletApproval; +} + +/** Maps the card provider's wallet name. Google Wallet comes back as ANDROID_PAY. */ +function getWalletProviderNameKey(walletProvider?: ValueOf): 'appleWallet' | 'googleWallet' | 'digitalWallet' { + if (walletProvider === CONST.EXPENSIFY_CARD.WALLET_PROVIDER.APPLE_PAY) { + return 'appleWallet'; + } + if (walletProvider === CONST.EXPENSIFY_CARD.WALLET_PROVIDER.ANDROID_PAY) { + return 'googleWallet'; + } + return 'digitalWallet'; +} + function isCardWithCustomZeroLimit(card: Card): boolean { return !!card.nameValuePairs?.hasCustomUnapprovedExpenseLimit && card.nameValuePairs?.unapprovedExpenseLimit === 0; } @@ -2249,6 +2265,8 @@ export { getPersonalBankCardDetailsImage, isCardPendingIssue, isCardPendingActivate, + isCardPendingDigitalWalletApproval, + getWalletProviderNameKey, isCardPendingReplace, isCardWithCustomZeroLimit, hasPendingExpensifyCardAction, diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts new file mode 100644 index 000000000000..ce1ac8097367 --- /dev/null +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -0,0 +1,208 @@ +/** + * Helpers for reading, shaping and identifying the category tax default rules stored in `policy.rules.expenseRules`. + */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {ExpenseDefaultTableItem} from '@components/Tables/WorkspaceExpenseDefaultsTable'; + +import CONST from '@src/CONST'; +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 {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ExpenseRule} from '@src/types/onyx/Policy'; + +import {getDecodedCategoryName} from './CategoryUtils'; +import {getTaxByID} from './PolicyUtils'; + +const CATEGORY_TAX_RULE_KEY_PREFIX = 'category-tax:'; + +/** + * Whether the rule being written is a category tax default rather than a merchant rule. + * + * The editor and every picker it opens have to agree on this — the editor picks its rows from it, the tax picker which + * rates it may offer — so they read it here rather than each rebuilding the same disjunction. A saved rule arrives by + * category name, an unsaved one carries the type the chooser put in its draft, and a draft that already holds a + * category counts either way. + */ +function isCategoryRuleDraft(form: MerchantRuleForm | undefined, editingCategoryName?: string): boolean { + return !!editingCategoryName || form?.ruleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY || !!form?.categoriesToMatch?.length; +} + +/** Whether a rule has a tax rate to apply. Tracking being on isn't enough on its own, it needs a rate to choose from. */ +function hasUsableTaxRates(policy: Policy | undefined): boolean { + return !!policy?.tax?.trackingEnabled && Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; +} + +/** + * Whether a rule may pick this rate. A disabled rate, or one on its way out, is not an option for any rule, and the + * workspace default is not one for a category rule, since writing that rate is what deletes the rule. + * + * The tax picker and the chooser that decides whether to offer a category rule at all both read this, so a locked + * option and an empty picker can't disagree about what counts as a usable rate. + */ +function isSelectableTaxRate(policy: Policy | undefined, taxID: string, taxRate: TaxRate, isCategoryRule: boolean): boolean { + return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(isCategoryRule && taxID === policy?.taxRates?.defaultExternalID); +} + +/** + * 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. + */ +function hasSelectableCategoryTaxRate(policy: Policy | undefined): boolean { + if (!hasUsableTaxRates(policy)) { + return false; + } + return Object.entries(policy?.taxRates?.taxes ?? {}).some(([taxID, taxRate]) => isSelectableTaxRate(policy, taxID, taxRate, true)); +} + +function getCategoryTaxRuleKey(categoryName: string) { + return `${CATEGORY_TAX_RULE_KEY_PREFIX}${categoryName}`; +} + +function isCategoryTaxRuleKey(key: string) { + return key.startsWith(CATEGORY_TAX_RULE_KEY_PREFIX); +} + +/** The category a rule matches on. A category tax default only ever carries the one `category matches ` condition. */ +function getRuleCategoryName(rule: ExpenseRule): string | undefined { + return rule.applyWhen?.find(({condition, field}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.CATEGORY)?.value; +} + +/** Only rules with an explicit tax default. `getCategoryDefaultTaxRate` falls back to the workspace default, which + * would make every category look covered. */ +function getCategoryTaxRules(expenseRules: ExpenseRule[] | undefined): ExpenseRule[] { + return (expenseRules ?? []).filter((rule) => !!rule.tax?.field_id_TAX?.externalID && !!getRuleCategoryName(rule)); +} + +/** Whether an expense rule is this category's tax default. Unlike `getCategoryTaxRule` it doesn't require a rate yet, so + * a write can find the rule it is about to set one on. */ +function matchesCategoryTaxRule(rule: ExpenseRule, categoryName: string): boolean { + return getRuleCategoryName(rule) === categoryName; +} + +function getCategoryTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): ExpenseRule | undefined { + return getCategoryTaxRules(expenseRules).find((rule) => matchesCategoryTaxRule(rule, categoryName)); +} + +function categoryHasTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): boolean { + return !!getCategoryTaxRule(expenseRules, categoryName); +} + +function getCategoryTaxRuleTaxID(expenseRules: ExpenseRule[] | undefined, categoryName: string): string | undefined { + return getCategoryTaxRule(expenseRules, categoryName)?.tax?.field_id_TAX?.externalID; +} + +/** The `Name (Value)` tax label. Prefers the workspace rate so renames read correctly, then the label the rule saved + * inline, then the raw ID. Callers with no inline label should check `getTaxByID` first: an ID is a fallback for + * a merchant rule that carries its own name, not something to show an admin. */ +function getTaxRateDisplayName(policy: Policy | undefined, taxID: string | undefined, savedTaxRate?: {name?: string; value?: string}): string { + if (!taxID) { + return ''; + } + const taxRate = policy?.taxRates?.taxes?.[taxID]; + if (taxRate) { + return `${taxRate.name} (${taxRate.value})`; + } + if (savedTaxRate?.name && savedTaxRate.value) { + return `${savedTaxRate.name} (${savedTaxRate.value})`; + } + return taxID; +} + +/** + * Marks a rule row as deleting while the category or tax rate it depends on is itself being deleted. + * + * The backend drops the rule along with them, so this only borrows their pending state instead of writing one of its + * own — nothing to roll back, and the row clears when the delete lands. Only deletion is borrowed: a disabled category + * or tax keeps its rule, which fires again once it is re-enabled. + */ +function getRuleDeletionPendingAction( + policy: Policy | undefined, + policyCategories: PolicyCategories | undefined, + categoryName: string | undefined, + taxID: string | undefined, +): PendingAction | undefined { + const isCategoryDeleting = !!categoryName && policyCategories?.[categoryName]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const isTaxDeleting = !!taxID && policy?.taxRates?.taxes?.[taxID]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + return isCategoryDeleting || isTaxDeleting ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : undefined; +} + +function getCategoryTaxRulesTableData({ + policy, + policyCategories, + translate, + isOffline, + onNavigate, +}: { + policy: Policy | undefined; + policyCategories: PolicyCategories | undefined; + translate: LocaleContextProps['translate']; + isOffline: boolean; + onNavigate: (route: Route) => void; +}): ExpenseDefaultTableItem[] { + if (!policy?.id) { + return []; + } + + const policyID = policy.id; + const typeLabel = translate('workspace.rules.expenseDefaultsTable.update'); + const fieldLabel = translate('common.tax').toLowerCase(); + // The rule is both its category and its tax rate, so deleting either one takes it down. + const getPendingAction = (rule: ExpenseRule) => getRuleDeletionPendingAction(policy, policyCategories, getRuleCategoryName(rule), rule.tax?.field_id_TAX?.externalID); + + return ( + getCategoryTaxRules(policy.rules?.expenseRules) + // Only about the category or tax rate going away, never the rule's own delete: `expenseRules` is a plain + // array, so a rule carries no pending state of its own and `deletePolicyCategoryTaxes` drops it outright. + // While its category or rate is being deleted, online the delete resolves in a moment, so the row goes + // rather than flashing greyed; offline it stays, styled as deleting, since there is nothing to wait for. + .filter((rule) => isOffline || getPendingAction(rule) !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((rule) => { + // `getCategoryTaxRules` already dropped the rules without a category, so this is always set. + const categoryName = getRuleCategoryName(rule) ?? ''; + const decodedCategoryName = getDecodedCategoryName(categoryName); + const taxID = rule.tax?.field_id_TAX?.externalID; + // Blank rather than the raw ID once the rate is gone from the workspace, matching the editor this row + // opens. The rule still holds the ID, so it starts naming the rate again if the rate comes back. + const taxDisplayName = taxID && getTaxByID(policy, taxID) ? getTaxRateDisplayName(policy, taxID) : ''; + const conditionText = translate('workspace.rules.expenseDefaultsTable.categoryIs', decodedCategoryName); + const ruleDescription = translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabel, taxDisplayName); + const pendingAction = getPendingAction(rule); + + return { + keyForList: getCategoryTaxRuleKey(categoryName), + ruleID: getCategoryTaxRuleKey(categoryName), + section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES, + isRename: false, + typeLabel, + conditionText, + ruleDescription, + searchTokens: [decodedCategoryName, conditionText, ruleDescription, taxDisplayName], + pendingAction, + disabled: pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + action: () => onNavigate(ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, categoryName)), + }; + }) + ); +} + +/** The category a rule key from the Expense defaults table refers to. */ +function getCategoryNameFromTaxRuleKey(key: string): string { + return key.slice(CATEGORY_TAX_RULE_KEY_PREFIX.length); +} + +export { + categoryHasTaxRule, + getCategoryNameFromTaxRuleKey, + getCategoryTaxRulesTableData, + getCategoryTaxRuleTaxID, + getRuleCategoryName, + getTaxRateDisplayName, + hasSelectableCategoryTaxRate, + hasUsableTaxRates, + isCategoryRuleDraft, + isCategoryTaxRuleKey, + isSelectableTaxRate, + matchesCategoryTaxRule, +}; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index ee9e72b6cb4c..2fae95808f1b 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -31,30 +31,6 @@ function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], ta return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; } -function formatRequireReceiptsOverText( - translate: LocaleContextProps['translate'], - policy: Policy, - categoryMaxAmountNoReceipt: number | null | undefined, - convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], -) { - const isAlwaysSelected = categoryMaxAmountNoReceipt === 0; - const isNeverSelected = categoryMaxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; - - if (isAlwaysSelected) { - return translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`); - } - - if (isNeverSelected) { - return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`); - } - - if (policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE || policy?.maxExpenseAmountNoReceipt === undefined) { - return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`); - } - - return translate(`workspace.rules.categoryRules.requireReceiptsOverList.default`, convertToDisplayString(policy.maxExpenseAmountNoReceipt, policy?.outputCurrency ?? CONST.CURRENCY.USD)); -} - function formatRequireItemizedReceiptsOverText( translate: LocaleContextProps['translate'], policy: Policy, @@ -97,7 +73,10 @@ function getCategoryExpenseRule(expenseRules: ExpenseRule[], categoryName: strin } function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: string, defaultTaxRate?: string) { - const categoryDefaultTaxRate = expenseRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.tax?.field_id_TAX?.externalID; + // Matched the same way the rules are written: on a `category matches ` condition rather than on the value + // alone. Matching any condition carrying the name could read a rule that a save or delete never targets, so the + // rate an expense picks up would not be the one the admin set. + const categoryDefaultTaxRate = getCategoryExpenseRule(expenseRules, categoryName)?.tax?.field_id_TAX?.externalID; // If the default taxRate is not found in expenseRules, use the default value for policy if (!categoryDefaultTaxRate) { @@ -246,10 +225,8 @@ function hasAnyCategoryRules(categories: PolicyCategories | undefined): boolean export { formatDefaultTaxRateText, - formatRequireReceiptsOverText, formatRequireItemizedReceiptsOverText, getCategoryApproverRule, - getCategoryExpenseRule, getCategoryDefaultTaxRate, updateCategoryInMccGroup, getEnabledCategoriesCount, diff --git a/src/libs/CloudflareAccess/fetchWithQAAuth.ts b/src/libs/CloudflareAccess/fetchWithQAAuth.ts index f7140ca48eb9..95e95ffd7f4b 100644 --- a/src/libs/CloudflareAccess/fetchWithQAAuth.ts +++ b/src/libs/CloudflareAccess/fetchWithQAAuth.ts @@ -1,7 +1,6 @@ /** * `fetch` against the Cloudflare Access-protected QA origin: attaches the bearer token and recovers from an - * expired one. Standalone on purpose. Nothing in the app routes to QA yet, so HttpUtils stays untouched. - * This logic moves there once QA routing lands. + * expired one. */ import {getCloudflareSession, refreshCloudflareSession} from '@userActions/CloudflareSession'; diff --git a/src/libs/CollatorUtils.ts b/src/libs/CollatorUtils.ts new file mode 100644 index 000000000000..09a4dd78c5ca --- /dev/null +++ b/src/libs/CollatorUtils.ts @@ -0,0 +1,25 @@ +import type Locale from '@src/types/onyx/Locale'; + +/** + * Options shared by every collator in the app so that sorting stays consistent everywhere. + */ +const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'}; + +const collators = new Map(); + +/** + * Returns a cached collator for the given locale. Collators are cached because constructing an Intl.Collator + * loads locale data, which is far too expensive to repeat per comparison inside a sort. + */ +function getCollator(locale: Locale | undefined): Intl.Collator { + const key = locale ?? ''; + const cachedCollator = collators.get(key); + if (cachedCollator) { + return cachedCollator; + } + const collator = new Intl.Collator(locale, COLLATOR_OPTIONS); + collators.set(key, collator); + return collator; +} + +export default getCollator; diff --git a/src/libs/CopyPolicySettingsUtils.ts b/src/libs/CopyPolicySettingsUtils.ts index f05da6d91cae..a37abe35552f 100644 --- a/src/libs/CopyPolicySettingsUtils.ts +++ b/src/libs/CopyPolicySettingsUtils.ts @@ -309,13 +309,14 @@ function isCopyPolicySettingsPartEnabledOnSource(part: Part, context: CopyPolicy * determined by `canPolicyAccessFeature` (the single source of truth for which features require a * Control plan). Returns empty when there are no Collect targets. */ -function getControlOnlySelectedParts(targetPolicies: ReadonlyArray, selectedParts: readonly Part[]): Part[] { +function getControlOnlySelectedParts(targetPolicies: ReadonlyArray, selectedParts: readonly Part[], sourcePolicy?: Policy | null): Part[] { const collectTargets = targetPolicies.filter((policy): policy is Policy => isCollectPolicy(policy)); if (collectTargets.length === 0) { return []; } + const hasInvoiceFields = !!sourcePolicy?.areInvoiceFieldsEnabled || Object.values(sourcePolicy?.fieldList ?? {}).some((field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE); return selectedParts.filter((part) => { - const featureName = PART_TO_POLICY_FEATURE[part]; + const featureName = part === 'invoices' && hasInvoiceFields ? CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED : PART_TO_POLICY_FEATURE[part]; if (!featureName) { return false; } @@ -328,16 +329,16 @@ function getControlOnlySelectedParts(targetPolicies: ReadonlyArray, selectedParts: readonly Part[]): Policy[] { - if (getControlOnlySelectedParts(targetPolicies, selectedParts).length === 0) { +function getCollectTargetsToUpgrade(targetPolicies: ReadonlyArray, selectedParts: readonly Part[], sourcePolicy?: Policy | null): Policy[] { + if (getControlOnlySelectedParts(targetPolicies, selectedParts, sourcePolicy).length === 0) { return []; } return targetPolicies.filter((policy): policy is Policy => isCollectPolicy(policy)); } /** Whether the Upgrade step should be shown between Select Features and Confirm. */ -function shouldShowCopyPolicySettingsUpgradeStep(targetPolicies: ReadonlyArray, selectedParts: readonly Part[]): boolean { - return getCollectTargetsToUpgrade(targetPolicies, selectedParts).length > 0; +function shouldShowCopyPolicySettingsUpgradeStep(targetPolicies: ReadonlyArray, selectedParts: readonly Part[], sourcePolicy?: Policy | null): boolean { + return getCollectTargetsToUpgrade(targetPolicies, selectedParts, sourcePolicy).length > 0; } /** Subtitle for the receipt partners row when Uber is connected on the source. */ diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index e1aed9d419c7..04d68496b28d 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -95,13 +95,6 @@ function getLocalizedCurrencySymbol(locale: Locale | undefined, currencyCode: st return parts.find((part) => part.type === 'currency')?.value; } -/** - * Get the currency symbol for a currency(ISO 4217) Code - */ -function getCurrencySymbol(currencyCode: string, currencies?: CurrencyList): string | undefined { - return getCurrencyList(currencies)?.[currencyCode]?.symbol; -} - /** * Takes an amount as a floating point number and converts it to an integer equivalent to the amount in "cents". * This is because the backend always stores amounts in "cents". The backend works in integer cents to avoid precision errors @@ -132,43 +125,6 @@ function convertToFrontendAmountAsString(amountAsInt: number | null | undefined, return (Math.trunc(amountAsInt) / 100.0).toFixed(decimals); } -/** - * Given an amount in the "cents", convert it to a string for display in the UI. - * The backend always handle things in "cents" (subunit equal to 1/100) - * - * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. - * @param currency - IOU currency - */ -function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD, shouldUseLocalCurrencySymbol = false, currencies?: CurrencyList): string { - const currencyWithFallback = sanitizeCurrencyCode(currency); - const decimals = getCurrencyDecimals(currencyWithFallback, currencies); - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); - - if (shouldUseLocalCurrencySymbol) { - const currencySymbol = getCurrencySymbol(currencyWithFallback, currencies); - - if (currencySymbol) { - const formattedNumber = format(IntlStore.getCurrentLocale(), convertedAmount, { - style: 'decimal', - minimumFractionDigits: decimals, - maximumFractionDigits: 2, - }); - return `${currencySymbol}${formattedNumber}`; - } - } - - return format(IntlStore.getCurrentLocale(), convertedAmount, { - style: 'currency', - currency: currencyWithFallback, - - // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies - // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: decimals, - // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. - maximumFractionDigits: 2, - }); -} - /** * Same as convertToDisplayString but always formats with the `en` locale. Used for building optimistic * report action messages, which are stored on the action in English regardless of the viewer's locale. @@ -193,11 +149,11 @@ function convertToDisplayStringEnLocale(amountInCents: number, currency: string } /** - * Same as convertToDisplayStringWithoutCurrency but always formats with the `en` locale, with decimals - * injected. Used alongside convertToDisplayStringEnLocale for stored values (e.g. formula-computed - * report titles) that must not depend on the viewer's locale or this module's Onyx fallback. + * Given an amount in "cents", format it for the passed locale without the currency symbol. Decimals are + * injected so this function does not depend on this module's Onyx fallback. */ -function convertToDisplayStringWithoutCurrencyEnLocale( +function convertToDisplayStringWithoutCurrencyForLocale( + locale: Locale | undefined, amountInCents: number, currency: string | undefined, getCurrencyDecimalsImpl: CurrencyListActionsContextType['getCurrencyDecimals'], @@ -205,7 +161,7 @@ function convertToDisplayStringWithoutCurrencyEnLocale( const sanitizedCurrency = sanitizeCurrencyCode(currency); const decimals = getCurrencyDecimalsImpl(sanitizedCurrency); const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); - return formatToParts(CONST.LOCALES.EN, convertedAmount, { + return formatToParts(locale, convertedAmount, { style: 'currency', currency: sanitizedCurrency, @@ -221,12 +177,17 @@ function convertToDisplayStringWithoutCurrencyEnLocale( .join(''); } -/** Same intended use as convertToDisplayString, but purposely omit currency symbol if not provided */ -function convertToDisplayStringWithExplicitCurrency(amountInCents: number, currency: string | undefined, currencies?: CurrencyList): string { - if (!currency) { - return convertToDisplayStringWithoutCurrency(amountInCents, undefined, currencies); - } - return convertToDisplayString(amountInCents, currency, false, currencies); +/** + * Same as convertToDisplayStringWithoutCurrency but always formats with the `en` locale, with decimals + * injected. Used alongside convertToDisplayStringEnLocale for stored values (e.g. formula-computed + * report titles) that must not depend on the viewer's locale or this module's Onyx fallback. + */ +function convertToDisplayStringWithoutCurrencyEnLocale( + amountInCents: number, + currency: string | undefined, + getCurrencyDecimalsImpl: CurrencyListActionsContextType['getCurrencyDecimals'], +): string { + return convertToDisplayStringWithoutCurrencyForLocale(CONST.LOCALES.EN, amountInCents, currency, getCurrencyDecimalsImpl); } /** @@ -266,44 +227,18 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE }); } -/** - * Acts the same as `convertAmountToDisplayString` but the result string does not contain currency - */ -function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD, currencies?: CurrencyList) { - const sanitizedCurrency = sanitizeCurrencyCode(currency); - const decimals = getCurrencyDecimals(sanitizedCurrency, currencies); - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); - return formatToParts(IntlStore.getCurrentLocale(), convertedAmount, { - style: 'currency', - currency: sanitizedCurrency, - - // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies - // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: decimals, - // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. - maximumFractionDigits: 2, - }) - .filter((x) => x.type !== 'currency') - .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) - .map((x) => x.value) - .join(''); -} - export { isValidCurrencyCode, sanitizeCurrencyCode, resetInvalidCurrencyWarningsForTesting, getCurrencyDecimals, - getCurrencySymbol, getLocalizedCurrencySymbol, convertToBackendAmount, convertToFrontendAmountAsInteger, convertToFrontendAmountAsString, - convertToDisplayString, convertToDisplayStringEnLocale, convertAmountToDisplayString, - convertToDisplayStringWithoutCurrency, convertToDisplayStringWithoutCurrencyEnLocale, - convertToDisplayStringWithExplicitCurrency, + convertToDisplayStringWithoutCurrencyForLocale, convertToShortDisplayString, }; diff --git a/src/libs/CustomEmojiUtils.ts b/src/libs/CustomEmojiUtils.ts new file mode 100644 index 000000000000..852bc7a5e6fb --- /dev/null +++ b/src/libs/CustomEmojiUtils.ts @@ -0,0 +1,26 @@ +import CONST from '@src/CONST'; + +/** + * Custom emojis are the private use area code points we render with our own font. These two predicates live + * outside EmojiUtils so that components can use them without importing EmojiUtils, which renders components + * of its own and would close an import cycle. + */ +function containsCustomEmoji(text?: string): boolean { + if (!text) { + return false; + } + + const privateUseAreaRegex = CONST.REGEX.PRIVATE_USER_AREA; + return privateUseAreaRegex.test(text); +} + +function containsOnlyCustomEmoji(text?: string): boolean { + if (!text) { + return false; + } + + const privateUseAreaRegex = CONST.REGEX.ONLY_PRIVATE_USER_AREA; + return privateUseAreaRegex.test(text); +} + +export {containsCustomEmoji, containsOnlyCustomEmoji}; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index b20854c9b1d3..65106ad0fd01 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -371,6 +371,9 @@ function validateObject>(value: string, type: } for (const [key, val] of Object.entries(test)) { + if (val === null) { + continue; + } const expectedValueType = type[key]; // val is a constant enum if (typeof expectedValueType === 'object') { @@ -384,6 +387,22 @@ function validateObject>(value: string, type: } } +/** + * Validates that a value is a flat object mapping string keys to string values (e.g. Record). + */ +function validateStringRecord(value: string) { + if (isEmptyValue(value)) { + return; + } + + const object = parseJSON(value); + if (typeof object !== 'object' || object === null || Array.isArray(object) || Object.values(object).some((val) => typeof val !== 'string')) { + throw new SyntaxError('debug.invalidValue', { + cause: {expectedValues: 'Record | undefined'}, + }); + } +} + /** * Validates if a string is a valid representation of a string. */ @@ -572,7 +591,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa requiredDepositCurrency: 'string', }); case 'tripData': - return validateObject>(value, { + return validateObject>(value, { startDate: 'string', endDate: 'string', tripID: 'string', @@ -837,6 +856,10 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin isTestDriveReceipt: 'boolean', thumbnail: 'string', receiptTraceId: 'string', + receiptEnqueuedAt: 'number', + hotelReservationStartDate: 'string', + hotelReservationEndDate: 'string', + pageCount: 'number', }); case 'childRecentReceiptTransactionIDs': return validateObject>(value, {}, 'string'); @@ -1041,6 +1064,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) return validateConstantEnum(value, CONST.IOU.REQUEST_TYPE); case 'selectedTransactionIDs': return validateArray(value, 'string'); + case 'bulkEditTagChanges': + return validateStringRecord(value); case 'participants': return validateArray>(value, { accountID: 'number', @@ -1145,6 +1170,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) routeDistanceMeters: CONST.RED_BRICK_ROAD_PENDING_ACTION, transactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, selectedTransactionIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION, + bulkEditTagChanges: CONST.RED_BRICK_ROAD_PENDING_ACTION, tag: CONST.RED_BRICK_ROAD_PENDING_ACTION, transactionType: CONST.RED_BRICK_ROAD_PENDING_ACTION, isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1206,6 +1232,10 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) isTestDriveReceipt: 'boolean', thumbnail: 'string', receiptTraceId: 'string', + receiptEnqueuedAt: 'number', + hotelReservationStartDate: 'string', + hotelReservationEndDate: 'string', + pageCount: 'number', }); case 'taxRate': return validateObject>(value, { @@ -1403,6 +1433,7 @@ function validateTransactionViolationDraftProperty(key: keyof TransactionViolati isSupplierViolation: 'boolean', startDate: 'string', endDate: 'string', + nights: 'number', }); case 'showInReview': return validateBoolean(value); @@ -1620,6 +1651,7 @@ const DebugUtils = { validateDate, validateConstantEnum, validateArray, + validateStringRecord, validateObject, validateString, validateReportDraftProperty, diff --git a/src/libs/DomainUtils.ts b/src/libs/DomainUtils.ts index b8a6fee97444..292e1426f52f 100644 --- a/src/libs/DomainUtils.ts +++ b/src/libs/DomainUtils.ts @@ -1,9 +1,14 @@ import CONST from '@src/CONST'; +import type {Domain} from '@src/types/onyx'; import type DomainErrors from '@src/types/onyx/DomainErrors'; import type {DomainMemberErrors, DomainSecurityGroupErrors} from '@src/types/onyx/DomainErrors'; import type DomainPendingAction from '@src/types/onyx/DomainPendingActions'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {adminshipRequesterPendingActionSelector, isAdminSelector, pendingAdminRequesterAccountIDsSelector} from '@selectors/Domain'; + import {getLatestError} from './ErrorUtils'; /** @@ -35,20 +40,72 @@ function hasDomainGroupsErrors(domainErrors?: DomainErrors): boolean { /** * Checks if domain has any errors. Used to determine whether to show a red brick road indicator on domain row. + * + * Pass the domain to skip adminship request errors that no longer have a row, see `hasDomainAdminsErrors`. */ -function hasDomainErrors(domainErrors?: DomainErrors): boolean { +function hasDomainErrors(domainErrors?: DomainErrors, domain?: OnyxEntry): boolean { if (!domainErrors) { return false; } - return !isEmptyObject(domainErrors.errors) || hasDomainAdminsErrors(domainErrors) || hasDomainMembersErrors(domainErrors) || hasDomainGroupsErrors(domainErrors); + return !isEmptyObject(domainErrors.errors) || hasDomainAdminsErrors(domainErrors, domain) || hasDomainMembersErrors(domainErrors) || hasDomainGroupsErrors(domainErrors); +} + +/** + * Checks if domain has any admin-related errors (admin errors, adminship request errors, or settings errors like technical contact/billing card). + */ +function hasDomainAdminsErrors(domainErrors?: DomainErrors, domain?: OnyxEntry): boolean { + const pendingRequesterAccountIDs = domain ? pendingAdminRequesterAccountIDsSelector(domain) : undefined; + + return ( + Object.values(domainErrors?.adminErrors ?? {}).some((admin) => !isEmptyObject(admin?.errors)) || + Object.entries(domainErrors?.adminshipRequesterErrors ?? {}).some( + ([accountID, requester]) => !isEmptyObject(requester?.errors) && (!pendingRequesterAccountIDs || pendingRequesterAccountIDs.includes(Number(accountID))), + ) || + hasDomainAdminsSettingsErrors(domainErrors) + ); +} + +/** + * Requesters that have just left the pending list while still carrying an approve or decline error. + * + * Those errors are keyed by requester accountID with nothing to tell one request from the next, so an error left behind by a request another + * admin already handled would land on a brand new row if that account asked for access again. Comparing the two lists, instead of looking for + * an error next to a missing requester, is what keeps a failed approve safe: it puts the requester back next to the error it has just set. + */ +function getStaleAdminshipRequesterErrorAccountIDs(previousRequesterAccountIDs: number[], requesterAccountIDs: number[], domainErrors: OnyxEntry): number[] { + const requesterErrors = domainErrors?.adminshipRequesterErrors; + + return previousRequesterAccountIDs.filter((accountID) => !requesterAccountIDs.includes(accountID) && !isEmptyObject(requesterErrors?.[accountID]?.errors)); +} + +/** + * Checks if the given account is a domain admin with pending adminship requests to review. + * + * A requester that has just been denied keeps its entry until the decline lands, so the row can render offline and on + * failure, see `declineDomainAdminshipRequest`. There is nothing left to review next to it, so its `DELETE` pending + * action takes it out of the count. + */ +function hasPendingDomainAdminRequestsToReview(domain: OnyxEntry, currentUserAccountID: number | undefined, domainPendingActions?: OnyxEntry): boolean { + if (!isAdminSelector(currentUserAccountID)(domain)) { + return false; + } + + const requesterPendingActions = adminshipRequesterPendingActionSelector(domainPendingActions); + + return pendingAdminRequesterAccountIDsSelector(domain).some((accountID) => requesterPendingActions[accountID]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } /** - * Checks if domain has any admin-related errors (admin errors or settings errors like technical contact/billing card). + * Resolves the brick road indicator for a domain section, prioritizing errors over pending admin requests. */ -function hasDomainAdminsErrors(domainErrors?: DomainErrors): boolean { - return Object.values(domainErrors?.adminErrors ?? {}).some((admin) => !isEmptyObject(admin?.errors)) || hasDomainAdminsSettingsErrors(domainErrors); +function getDomainBrickRoadIndicator(hasErrors: boolean, hasPendingAdminRequestsToReview?: boolean) { + if (hasErrors) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + if (hasPendingAdminRequestsToReview) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } } /** @@ -116,9 +173,12 @@ export { hasDomainErrors, hasDomainAdminsSettingsErrors, hasDomainAdminsErrors, + hasPendingDomainAdminRequestsToReview, + getStaleAdminshipRequesterErrorAccountIDs, hasDomainMembersErrors, hasDomainMembersSettingsErrors, hasDomainGroupsErrors, hasDomainGroupDetailsErrors, getMemberCustomRowProps, + getDomainBrickRoadIndicator, }; diff --git a/src/libs/DragAndDropUtils.ts b/src/libs/DragAndDropUtils.ts new file mode 100644 index 000000000000..f2c0b55c31da --- /dev/null +++ b/src/libs/DragAndDropUtils.ts @@ -0,0 +1,9 @@ +/** + * Whether the dragged content contains files, which is the only payload our drop zones handle. + */ +function shouldAcceptDrop(event: DragEvent): boolean { + return !!event.dataTransfer?.types.some((type) => type === 'Files'); +} + +// eslint-disable-next-line import/prefer-default-export +export {shouldAcceptDrop}; diff --git a/src/libs/EmojiUtils.tsx b/src/libs/EmojiUtils.tsx index bbae6bf0729c..34444f4e2062 100644 --- a/src/libs/EmojiUtils.tsx +++ b/src/libs/EmojiUtils.tsx @@ -22,6 +22,7 @@ import React from 'react'; import type {getEmojiTrie as getEmojiTrieType} from './EmojiTrie'; import {isSafari} from './Browser'; +import {containsCustomEmoji, containsOnlyCustomEmoji} from './CustomEmojiUtils'; import memoize from './memoize'; type HeaderIndices = {code: string; index: number; icon: IconAsset}; @@ -873,24 +874,6 @@ function getProcessedText(processedTextArray: TextWithEmoji[], style: StyleProp< ); } -function containsCustomEmoji(text?: string): boolean { - if (!text) { - return false; - } - - const privateUseAreaRegex = CONST.REGEX.PRIVATE_USER_AREA; - return privateUseAreaRegex.test(text); -} - -function containsOnlyCustomEmoji(text?: string): boolean { - if (!text) { - return false; - } - - const privateUseAreaRegex = CONST.REGEX.ONLY_PRIVATE_USER_AREA; - return privateUseAreaRegex.test(text); -} - /** * Insert Variation Selector 15 (FE0E) between digits/symbols and emojis to prevent Safari's automatic keycap sequence bug. * diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 88372ef6ed93..3b025cd63c73 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -10,6 +10,7 @@ import getEnvironment from './getEnvironment'; const ENVIRONMENT_URLS = { [CONST.ENVIRONMENT.DEV]: CONST.DEV_NEW_EXPENSIFY_URL + CONFIG.DEV_PORT, [CONST.ENVIRONMENT.STAGING]: CONST.STAGING_NEW_EXPENSIFY_URL, + [CONST.ENVIRONMENT.QA]: CONST.QA_NEW_EXPENSIFY_URL, [CONST.ENVIRONMENT.PRODUCTION]: CONST.NEW_EXPENSIFY_URL, [CONST.ENVIRONMENT.ADHOC]: CONST.STAGING_NEW_EXPENSIFY_URL, }; @@ -17,6 +18,7 @@ const ENVIRONMENT_URLS = { const OLDDOT_ENVIRONMENT_URLS = { [CONST.ENVIRONMENT.DEV]: CONST.INTERNAL_DEV_EXPENSIFY_URL, [CONST.ENVIRONMENT.STAGING]: CONST.STAGING_EXPENSIFY_URL, + [CONST.ENVIRONMENT.QA]: CONST.QA_EXPENSIFY_URL, [CONST.ENVIRONMENT.PRODUCTION]: CONST.EXPENSIFY_URL, [CONST.ENVIRONMENT.ADHOC]: CONST.STAGING_EXPENSIFY_URL, }; diff --git a/src/libs/Environment/betaChecker/index.android.ts b/src/libs/Environment/betaChecker/index.android.ts index d0c77c5359ae..504266dbc27e 100644 --- a/src/libs/Environment/betaChecker/index.android.ts +++ b/src/libs/Environment/betaChecker/index.android.ts @@ -1,53 +1,29 @@ -import * as AppUpdate from '@libs/actions/AppUpdate'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import Onyx from 'react-native-onyx'; -import semver from 'semver'; +import DeviceInfo from 'react-native-device-info'; import type {IsBetaBuild} from './types'; -import pkg from '../../../../package.json'; - -type GithubReleaseJSON = { - // eslint-disable-next-line @typescript-eslint/naming-convention - tag_name: string | semver.SemVer; -}; - -let isLastSavedBeta = false; -// We have opted for `connectWithoutView` here as this is a strictly non-UI data. -Onyx.connectWithoutView({ - key: ONYXKEYS.IS_BETA, - callback: (value) => { - isLastSavedBeta = !!value; - }, -}); +/** + * Whether the Play Store put this build on the device. Anything else means a tester installed it from a GitHub + * release. `undefined` when the native call cannot say. + */ +function isPlayStoreInstall(): boolean | undefined { + try { + return DeviceInfo.getInstallerPackageNameSync() === CONST.PLAY_STORE_INSTALLER_PACKAGE_NAME; + } catch { + return undefined; + } +} /** - * Check the GitHub releases to see if the current build is a beta build or production build + * Whether this build is a beta (staging) build. + * + * Staging and production ship the same binary, so how the build arrived is the only signal. + * Anything the Play Store did not install is a beta. */ function isBetaBuild(): IsBetaBuild { - return new Promise((resolve) => { - fetch(CONST.GITHUB_RELEASE_URL) - .then((res) => res.json()) - .then((json: GithubReleaseJSON) => { - const productionVersion = json.tag_name; - if (!productionVersion) { - AppUpdate.setIsAppInBeta(false); - resolve(false); - } - - // If the current version we are running is greater than the production version, we are on a beta version of Android - const isBeta = semver.gt(pkg.version, productionVersion); - AppUpdate.setIsAppInBeta(isBeta); - resolve(isBeta); - }) - .catch(() => { - // Use isLastSavedBeta in case we fail to fetch the new one, e.g. when we are offline - resolve(isLastSavedBeta); - }); - }); + return Promise.resolve(isPlayStoreInstall() === false); } export default { diff --git a/src/libs/Environment/getEnvironment/index.native.ts b/src/libs/Environment/getEnvironment/index.native.ts index 5d047a6fd732..119923292a9d 100644 --- a/src/libs/Environment/getEnvironment/index.native.ts +++ b/src/libs/Environment/getEnvironment/index.native.ts @@ -32,10 +32,18 @@ function getEnvironment(): Promise { } // If we haven't set the environment yet and we aren't on dev/adhoc, check to see if this is a beta build - betaChecker.isBetaBuild().then((isBeta) => { - environment = isBeta ? CONST.ENVIRONMENT.STAGING : CONST.ENVIRONMENT.PRODUCTION; - resolve(environment); - }); + betaChecker + .isBetaBuild() + .then((isBeta) => { + environment = isBeta ? CONST.ENVIRONMENT.STAGING : CONST.ENVIRONMENT.PRODUCTION; + resolve(environment); + }) + .catch(() => { + // Everything that waits on the environment (the API root, the network reachability URL) would hang + // forever if this promise never settled, so an unexpected failure has to land on a usable answer. + environment = CONST.ENVIRONMENT.PRODUCTION; + resolve(environment); + }); }); } diff --git a/src/libs/ExpensifyCardFeedSelectorUtils.ts b/src/libs/ExpensifyCardFeedSelectorUtils.ts index afd2c617a252..3e635280aeda 100644 --- a/src/libs/ExpensifyCardFeedSelectorUtils.ts +++ b/src/libs/ExpensifyCardFeedSelectorUtils.ts @@ -16,6 +16,7 @@ import { isPolicyIDInLinkedExpensifyCardPolicyList, } from './CardUtils'; import {getDescriptionForPolicyDomainCard, isPolicyAdmin} from './PolicyUtils'; +import {getIsTravelBillingPayByInvoice, hasTravelBillingSettlementAccount} from './TravelBillingUtils'; type ExpensifyCardFeedEntry = { settingsKey: string; @@ -23,17 +24,29 @@ type ExpensifyCardFeedEntry = { settings: ExpensifyCardSettings; }; -/** A feed qualifies only when its settings NVP has a US or GB program block with a configured settlement bank account. */ -function hasConfiguredExpensifyCardFeed(settings: ExpensifyCardSettings | undefined): boolean { +/** Which program blocks of the card settings NVP count as a configured feed. Regular card feeds use US/GB; Travel Billing uses TRAVEL_US. */ +type ExpensifyCardFeedProgram = 'US' | 'CURRENT' | 'GB' | 'TRAVEL_US'; + +const DEFAULT_CARD_FEED_PROGRAMS: ExpensifyCardFeedProgram[] = [CONST.COUNTRY.US, CONST.COUNTRY.GB]; + +/** A feed qualifies when a program block has a settlement method: a bank account for cards, or a bank account or pay-by-invoice for travel. */ +function hasConfiguredExpensifyCardFeed(settings: ExpensifyCardSettings | undefined, programs: ExpensifyCardFeedProgram[]): boolean { if (!settings) { return false; } - // We only want to show feeds that have either a US or GB program. - // TRAVEL feeds do not show in the UI and are managed on the backend and CURRENT feeds are deprecated and should not be used to determine if a feed is configured or not. - for (const programKey of [CONST.COUNTRY.US, CONST.COUNTRY.GB] as const) { + for (const programKey of programs) { const nested = settings[programKey]; - if (nested && typeof nested === 'object' && !Array.isArray(nested) && nested.paymentBankAccountID != null) { + if (!nested || typeof nested !== 'object' || Array.isArray(nested)) { + continue; + } + if (programKey === CONST.TRAVEL.PROGRAM_TRAVEL_US) { + if (hasTravelBillingSettlementAccount(nested) || getIsTravelBillingPayByInvoice(nested)) { + return true; + } + continue; + } + if (nested.paymentBankAccountID != null) { return true; } } @@ -59,8 +72,9 @@ function isExpensifyCardFeedVisibleToAdmin( fundID: number, domains: OnyxCollection, currentUserAccountID: number, + programs: ExpensifyCardFeedProgram[], ): boolean { - if (!hasConfiguredExpensifyCardFeed(settings)) { + if (!hasConfiguredExpensifyCardFeed(settings, programs)) { return false; } @@ -86,13 +100,14 @@ function getAdminExpensifyCardFeedEntries( policies: OnyxCollection, domains: OnyxCollection, currentUserAccountID: number, + programs: ExpensifyCardFeedProgram[] = DEFAULT_CARD_FEED_PROGRAMS, ): ExpensifyCardFeedEntry[] { return Object.entries(cardSettingsCollection ?? {}).flatMap(([settingsKey, settings]) => { if (!settings) { return []; } const fundID = getFundIdFromSettingsKey(settingsKey); - if (!isExpensifyCardFeedVisibleToAdmin(settings, policies, fundID, domains, currentUserAccountID)) { + if (!isExpensifyCardFeedVisibleToAdmin(settings, policies, fundID, domains, currentUserAccountID, programs)) { return []; } return [{settingsKey, fundID, settings}]; @@ -146,4 +161,4 @@ function getExpensifyCardFeedDescription( return policyOwner ? getDescriptionForPolicyDomainCard(Str.extractEmailDomain(policyOwner), policies) : ''; } -export {getAdminExpensifyCardFeedEntries, getExpensifyCardFeedDescription, partitionExpensifyCardFeedsForSelector, type ExpensifyCardFeedEntry}; +export {getAdminExpensifyCardFeedEntries, getExpensifyCardFeedDescription, partitionExpensifyCardFeedsForSelector, type ExpensifyCardFeedEntry, type ExpensifyCardFeedProgram}; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 746ef57f7b67..4971d9f0f545 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -143,6 +143,11 @@ const ONYX_KEY_EXPORT_RULES: Record = { allowList: ['id', 'type', 'role', 'outputCurrency', 'areCategoriesEnabled', 'areTagsEnabled'], maskList: ['name', 'avatar'], }, + // Vendor names can reveal a workspace's suppliers, so keep the collection structure but mask names in exports. + [ONYXKEYS.COLLECTION.POLICY_VENDORS]: { + allowList: ['externalID', 'enabled', 'origin'], + maskList: ['name'], + }, [ONYXKEYS.USER_WALLET]: { allowList: ['currentBalance', 'availableBalance', 'tierName'], maskList: [], @@ -164,6 +169,7 @@ const ONYX_KEY_EXPORT_RULES: Record = { const safeOnyxKeys = new Set([ ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, ONYXKEYS.ACTIVE_CLIENTS, + ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.AGENT_RULE_SUGGESTIONS, ONYXKEYS.AGENT_TEMPLATES, ONYXKEYS.ARE_AGENTS_LOADED, @@ -171,6 +177,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.ATTACHMENT_RECORD_PATHS_MIGRATED, ONYXKEYS.BETAS, ONYXKEYS.BETA_CONFIGURATION, + ONYXKEYS.BETA_OVERRIDES, ONYXKEYS.CACHED_PDF_PATHS, ONYXKEYS.CARD_SUPPORTED_COUNTRIES, ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST, @@ -197,6 +204,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE, ONYXKEYS.COLLECTION.REPORT_METADATA, ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE, + ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT, ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM, ONYXKEYS.COLLECTION.SELECTED_DISTANCE_REQUEST_TAB, ONYXKEYS.COLLECTION.SELECTED_TAB, @@ -221,7 +229,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.HAS_NON_PERSONAL_POLICY, ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE, ONYXKEYS.INPUT_FOCUSED, - ONYXKEYS.IS_BETA, ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT, ONYXKEYS.IS_COMING_FROM_GLOBAL_REIMBURSEMENTS_FLOW, ONYXKEYS.IS_DEBUG_MODE_ENABLED, @@ -310,16 +317,21 @@ const safeOnyxKeys = new Set([ ONYXKEYS.ONBOARDING_POLICY_ID, ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, + ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, ONYXKEYS.PERSONAL_POLICY_ID, ONYXKEYS.PLAID_CURRENT_EVENT, ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, + ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, + ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, + ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS, ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, + ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, @@ -334,6 +346,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING, ONYXKEYS.SHOULD_MASK_ONYX_STATE, ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE, + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key is still on device until the migration drains it, so it stays in the safe-to-export set ONYXKEYS.SHOULD_USE_STAGING_SERVER, ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT, ONYXKEYS.STATUS_DRAFT_CUSTOM_CLEAR_AFTER_DATE, @@ -489,6 +502,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.SAVED_SEARCHES, ONYXKEYS.SCHEDULE_CALL_DRAFT, ONYXKEYS.SCREEN_SHARE_REQUEST, + ONYXKEYS.SEARCH_FILTERS, ONYXKEYS.SEARCH_FOOTER_CONVERSION, ONYXKEYS.SEARCH_QUERY_BY_HASH, ONYXKEYS.SHARE_BANK_ACCOUNT, diff --git a/src/libs/Fullstory/types.ts b/src/libs/Fullstory/types.ts index a3cf0c4700a8..29f35381f622 100644 --- a/src/libs/Fullstory/types.ts +++ b/src/libs/Fullstory/types.ts @@ -217,9 +217,6 @@ type Fullstory = { */ getSessionURL: () => Promise; - /** - * Sends a custom event to FullStory. - */ event: (eventName: TEventName, eventProperties?: FullstoryEventPropertiesMap[TEventName]) => void; /** diff --git a/src/libs/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index f6244ce4c271..277594267176 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -6,13 +6,13 @@ import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDis import type {GpsDraftDetails} from '@src/types/onyx'; import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Routes, Waypoint} from '@src/types/onyx/Transaction'; -import geodesicDistance from '@src/utils/geodesicDistance'; import type {SetRequired} from 'type-fest'; -import {hasStartedLocationUpdatesAsync, reverseGeocodeAsync, stopLocationUpdatesAsync} from 'expo-location'; +import {hasStartedLocationUpdatesAsync, stopLocationUpdatesAsync} from 'expo-location'; import {removeLastSegment, setEndWaypointAddress, setIsTracking} from './actions/GPSDraftDetails'; +import {addressFromGpsPoint, calculateTrimmedEndPoint, coordinatesToString} from './GPSPointUtils'; import {roundToTwoDecimalPlaces} from './NumberUtils'; type GPSWaypointCollection = Record>; @@ -115,27 +115,6 @@ function getStringifiedGPSCoordinates(gpsDraftDetails: GpsDraftDetails | undefin return JSON.stringify(updatedGpsPoints.map((points) => points.map(({lat, long}) => ({lng: long, lat})))); } -async function addressFromGpsPoint(gpsPoint: {lat: number; long: number}): Promise { - try { - const [location] = await reverseGeocodeAsync({latitude: gpsPoint.lat, longitude: gpsPoint.long}); - - if (!location) { - return null; - } - - const address: string = location?.formattedAddress ?? [location?.name, location?.city, location?.region].filter(Boolean).join(', '); - - return address; - } catch (error) { - console.error('[GPS distance request] Failed to reverse geocode location to postal address: ', error); - return null; - } -} - -function coordinatesToString(gpsPoint: {lat: number; long: number}): string { - return `${gpsPoint.lat},${gpsPoint.long}`; -} - function isLastSegmentEmptyOrHasOnlyOnePoint(lastSegment: GPSPoint[]): boolean { if (lastSegment.length <= 1) { return true; @@ -216,42 +195,6 @@ function getFirstGpsPoint(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoin return gpsDraftDetails?.gpsPoints?.at(0)?.at(0); } -function calculateTrimmedEndPoint(gpsPoints: GPSPoint[][], targetDistanceMeters: number): TrimmedGPSPoint | null { - let distanceTraveled = 0; - - for (let segmentIndex = 0; segmentIndex < gpsPoints.length; segmentIndex++) { - const segment = gpsPoints.at(segmentIndex); - - if (!segment) { - continue; - } - - for (let pointIndex = 1; pointIndex < segment.length; pointIndex++) { - const previousPoint = segment.at(pointIndex - 1); - const currentPoint = segment.at(pointIndex); - - if (!previousPoint || !currentPoint) { - continue; - } - const distanceBetweenPoints = geodesicDistance(previousPoint, currentPoint); - - if (distanceTraveled + distanceBetweenPoints >= targetDistanceMeters) { - const fractionToInclude = distanceBetweenPoints === 0 ? 0 : (targetDistanceMeters - distanceTraveled) / distanceBetweenPoints; - const interpolatedPoint = { - lat: previousPoint.lat + fractionToInclude * (currentPoint.lat - previousPoint.lat), - long: previousPoint.long + fractionToInclude * (currentPoint.long - previousPoint.long), - }; - - return {...interpolatedPoint, segmentIndex, precedingPointIndex: pointIndex - 1}; - } - - distanceTraveled += distanceBetweenPoints; - } - } - - return null; -} - function getTrimmedGpsTrip(gpsDraftDetails: GpsDraftDetails | undefined, trimmedEndPoint?: TrimmedGPSPoint): GPSPoint[][]; function getTrimmedGpsTrip(gpsPoints: GPSPoint[][], trimmedEndPoint: TrimmedGPSPoint | undefined): GPSPoint[][]; function getTrimmedGpsTrip(gpsData: GPSPoint[][] | GpsDraftDetails | undefined, trimmedEndPointProp?: TrimmedGPSPoint | undefined): GPSPoint[][] { diff --git a/src/libs/GPSPointUtils.ts b/src/libs/GPSPointUtils.ts new file mode 100644 index 000000000000..f5b3cd24dd10 --- /dev/null +++ b/src/libs/GPSPointUtils.ts @@ -0,0 +1,68 @@ +import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; +import geodesicDistance from '@src/utils/geodesicDistance'; + +import {reverseGeocodeAsync} from 'expo-location'; + +/** + * Point level helpers for GPS distance requests. These live outside GPSDraftDetailsUtils so that + * actions/GPSDraftDetails can use them without importing GPSDraftDetailsUtils, which imports the action + * module back and would close an import cycle. + */ +async function addressFromGpsPoint(gpsPoint: {lat: number; long: number}): Promise { + try { + const [location] = await reverseGeocodeAsync({latitude: gpsPoint.lat, longitude: gpsPoint.long}); + + if (!location) { + return null; + } + + const address: string = location?.formattedAddress ?? [location?.name, location?.city, location?.region].filter(Boolean).join(', '); + + return address; + } catch (error) { + console.error('[GPS distance request] Failed to reverse geocode location to postal address: ', error); + return null; + } +} + +function coordinatesToString(gpsPoint: {lat: number; long: number}): string { + return `${gpsPoint.lat},${gpsPoint.long}`; +} + +function calculateTrimmedEndPoint(gpsPoints: GPSPoint[][], targetDistanceMeters: number): TrimmedGPSPoint | null { + let distanceTraveled = 0; + + for (let segmentIndex = 0; segmentIndex < gpsPoints.length; segmentIndex++) { + const segment = gpsPoints.at(segmentIndex); + + if (!segment) { + continue; + } + + for (let pointIndex = 1; pointIndex < segment.length; pointIndex++) { + const previousPoint = segment.at(pointIndex - 1); + const currentPoint = segment.at(pointIndex); + + if (!previousPoint || !currentPoint) { + continue; + } + const distanceBetweenPoints = geodesicDistance(previousPoint, currentPoint); + + if (distanceTraveled + distanceBetweenPoints >= targetDistanceMeters) { + const fractionToInclude = distanceBetweenPoints === 0 ? 0 : (targetDistanceMeters - distanceTraveled) / distanceBetweenPoints; + const interpolatedPoint = { + lat: previousPoint.lat + fractionToInclude * (currentPoint.lat - previousPoint.lat), + long: previousPoint.long + fractionToInclude * (currentPoint.long - previousPoint.long), + }; + + return {...interpolatedPoint, segmentIndex, precedingPointIndex: pointIndex - 1}; + } + + distanceTraveled += distanceBetweenPoints; + } + } + + return null; +} + +export {addressFromGpsPoint, coordinatesToString, calculateTrimmedEndPoint}; diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index 90950979ae4d..33a8f98074f9 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -89,6 +89,7 @@ type SubmitAmountArgs = { isOffline?: boolean; // Submit-time Onyx data — supplied by the screen via AmountSubmitDataSync so this module owns no subscriptions. + rules: OnyxCollection; allPersonalDetails: OnyxEntry; allReports: OnyxCollection; allReportDrafts: OnyxCollection; @@ -114,6 +115,7 @@ type SubmitAmountArgs = { conciergeReportID: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; conciergeChat: OnyxEntry; }; @@ -247,7 +249,20 @@ function buildSubmitAmountContext(args: SubmitAmountArgs): SubmitAmountContext { } function buildReportParticipants(args: SubmitAmountArgs) { - const {report, policy, currentUserPersonalDetails, reportAttributesDerivedValue, allReportDrafts, allReportNVPs, allPersonalDetails, conciergeReportID, translate, dateFnsLocale} = args; + const { + report, + policy, + currentUserPersonalDetails, + reportAttributesDerivedValue, + allReportDrafts, + allReportNVPs, + allPersonalDetails, + conciergeReportID, + translate, + dateFnsLocale, + convertToDisplayString, + rules, + } = args; const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); const reportAttributesReports = reportAttributesDerivedValue?.reports; const reportIDToCheck = isMoneyRequestReport(report) ? report?.chatReportID : report?.reportID; @@ -257,10 +272,22 @@ function buildReportParticipants(args: SubmitAmountArgs) { const privateIsArchived = !!allReportNVPs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]?.private_isArchived; return participantAccountID ? getParticipantsOption(participant, allPersonalDetails, translate) - : getReportOption(participant, privateIsArchived, policy, allPersonalDetails, conciergeReportID, reportAttributesReports, reportDraft, currentUserPersonalDetails.accountID, { - translate, - dateFnsLocale, - }); + : getReportOption( + participant, + privateIsArchived, + policy, + allPersonalDetails, + conciergeReportID, + reportAttributesReports, + reportDraft, + currentUserPersonalDetails.accountID, + { + translate, + dateFnsLocale, + convertToDisplayString, + }, + rules, + ); }); } @@ -334,6 +361,7 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, } = args; const {currentUserAccountID, currentUserEmail, existingTransactionID, isASAPSubmitBetaEnabled, newAmount: backendAmount} = ctx; @@ -383,6 +411,7 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount delegateAccountID, reportActionsList: undefined, getCurrencyDecimals, + rules, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -423,6 +452,7 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }); } cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { @@ -611,6 +641,7 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi reportPolicyTags, getCurrencyDecimals, getCurrencySymbol, + rules, } = args; const {currentTransaction, allowNegative, disableOppositeConversion, isSplitBill, currentUserAccountID, currentUserEmail, isASAPSubmitBetaEnabled, newAmount} = ctx; @@ -668,6 +699,7 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); navigateBack(); } diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index b6bbfd07b4c2..ac1dcdfbb5f7 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -3,11 +3,11 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {IOUAction, IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {OnyxInputOrEntry, Policy, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, Report, ReportAction, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {SafeString} from 'expensify-common'; @@ -16,7 +16,7 @@ import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDy import Navigation from './Navigation/Navigation'; import {isGroupPolicy} from './PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from './ReportActionsUtils'; -import {generateReportID, getChatByParticipants, isProcessingReport, isReportOutstanding, isSelfDM} from './ReportUtils'; +import {canAddTransaction, generateReportID, getChatByParticipants, isArchivedReport, isSelfDM} from './ReportUtils'; import {endSpan, getSpan, startSpan} from './telemetry/activeSpans'; import {getTagArrayFromName, hasRoute, isDistanceRequest} from './TransactionUtils'; @@ -507,11 +507,20 @@ function getInitialPerDiemTargetReport( /** * Resolves the chat report ID for navigation, generating an optimistic ID if no existing chat is found. */ -function resolveOptimisticChatReportID(participantAccountIDs: number[], existingReport?: OnyxInputOrEntry) { +function resolveOptimisticChatReportID(participantAccountIDs: number[], existingReport?: OnyxInputOrEntry, optimisticChatReportID?: string) { const existingChat = existingReport?.reportID ? existingReport : getChatByParticipants(participantAccountIDs); - const optimisticChatReportID = existingChat?.reportID ? undefined : generateReportID(); - const chatReportID = existingChat?.reportID ?? optimisticChatReportID; - return {optimisticChatReportID, chatReportID}; + if (existingChat?.reportID) { + return {optimisticChatReportID: undefined, chatReportID: existingChat.reportID}; + } + + const chatReportID = optimisticChatReportID ?? generateReportID(); + return {optimisticChatReportID: chatReportID, chatReportID}; +} + +/** Returns `transactionReportID` if the participant isn't a workspace and has no existing chat, so the ID can be reused for their new chat report; otherwise undefined. */ +function getReusableP2PReportID(participant: Participant, transactionReportID: string | undefined): string | undefined { + const isBrandNewP2PRecipient = !participant.isPolicyExpenseChat && !participant.reportID; + return isBrandNewP2PRecipient && !!transactionReportID && transactionReportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transactionReportID : undefined; } /** @@ -570,20 +579,19 @@ function resolveReportForMoneyRequest({ transaction, transactionReport, routeReport, - policy, reportNameValuePair, + rules, }: { transaction: OnyxEntry; transactionReport: OnyxEntry; routeReport: OnyxEntry; - policy: OnyxEntry; reportNameValuePair: OnyxInputOrEntry; + rules: OnyxCollection; }): OnyxEntry { if (transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { return undefined; } - const canUseTransactionReport = - !(isProcessingReport(transactionReport) && !policy?.harvesting?.enabled) && isReportOutstanding(transactionReport, policy?.id, reportNameValuePair, false); + const canUseTransactionReport = canAddTransaction(transactionReport, rules, isArchivedReport(reportNameValuePair), false); const shouldUseTransactionReport = !!transactionReport && (canUseTransactionReport || !routeReport); if (shouldUseTransactionReport) { return transactionReport; @@ -675,6 +683,7 @@ export { calculateDefaultReimbursable, getInitialPerDiemTargetReport, getIsWorkspacesOnlyForTransaction, + getReusableP2PReportID, isParticipantP2P, isSelfDMSoleDestination, isLookingAroundSearchRoutingActive, diff --git a/src/libs/Log.ts b/src/libs/Log.ts index df137ff1c7bb..1bc4de9a239f 100644 --- a/src/libs/Log.ts +++ b/src/libs/Log.ts @@ -9,7 +9,7 @@ import AppLogs from 'react-native-app-logs'; import pkg from '../../package.json'; import {getCurrentUserEmail} from './CurrentUserStore'; import getPlatform from './getPlatform'; -import {post} from './Network'; +import MainQueueStore from './Network/MainQueueStore'; import requireParameters from './requireParameters'; import forwardLogsToSentry from './telemetry/forwardLogsToSentry'; @@ -26,7 +26,7 @@ function LogCommand(parameters: LogCommandParameters): Promise<{requestID: strin // Note: We are forcing Log to run since it requires no authToken and should only be queued when we are offline. // Non-cancellable request: during logout, when requests are cancelled, we don't want to cancel any remaining logs - return post(commandName, {...parameters, forceNetworkRequest: true, canCancel: false}) as Promise<{requestID: string}>; + return MainQueueStore.enqueue(commandName, {...parameters, forceNetworkRequest: true, canCancel: false}) as Promise<{requestID: string}>; } // eslint-disable-next-line diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index eddafc8decdf..07c18f870ebd 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -1,11 +1,8 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import {PUBLIC_DOMAINS_SET, Str} from 'expensify-common'; -import {clearSignInData, setAccountError} from './actions/Session'; -import Navigation from './Navigation/Navigation'; import {parsePhoneNumber} from './PhoneNumber'; /** @@ -101,15 +98,6 @@ function postSAMLLogin(body: FormData): Promise { }); } -function handleSAMLLoginError(errorMessage: string, shouldClearSignInData: boolean) { - if (shouldClearSignInData) { - clearSignInData(); - } - - setAccountError(errorMessage); - Navigation.goBack(ROUTES.INBOX); -} - function formatE164PhoneNumber(phoneNumber: string, countryCode: number) { const phoneNumberWithCountryCode = appendCountryCode(phoneNumber, countryCode); const parsedPhoneNumber = parsePhoneNumber(phoneNumberWithCountryCode); @@ -141,7 +129,6 @@ export { getPhoneLogin, areEmailsFromSamePrivateDomain, postSAMLLogin, - handleSAMLLoginError, formatE164PhoneNumber, getEmailDomain, isDomainPublic, diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts new file mode 100644 index 000000000000..239c11e38f80 --- /dev/null +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -0,0 +1,115 @@ +import CONST from '@src/CONST'; +import type {MerchantRuleForm} from '@src/types/form'; +import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Parser from './Parser'; +import {resolveCurrentTaxCode} from './PolicyUtils'; +import {trimTag} from './TagUtils'; +import {getBillable, getCategory, getDescription, getMerchant, getReimbursable, getTag, getTagArrayFromName, getTaxCode, isMerchantMissing} from './TransactionUtils'; + +/** Whether a stored offer still stands: it names an expense, was not left behind, and was not dismissed this session. */ +function isMerchantRuleSuggestionLive(suggestion: OnyxEntry): boolean { + if (!suggestion?.transactionID || suggestion.isRetired) { + return false; + } + return !suggestion.dismissedTransactionIDs?.includes(suggestion.transactionID); +} + +/** + * Which levels of a multi-level tag changed. Editing one level of `A:B:C` should seed a rule for that level alone, + * and the update action only sees the whole joined tag, so the levels are worked out by comparing before with after. + */ +function getChangedTagLevels(previousTag: string, nextTag: string): number[] { + const previousLevels = getTagArrayFromName(previousTag); + const nextLevels = getTagArrayFromName(nextTag); + const changed: number[] = []; + + for (let level = 0; level < Math.max(previousLevels.length, nextLevels.length); level++) { + if ((previousLevels.at(level) ?? '') === (nextLevels.at(level) ?? '')) { + continue; + } + changed.push(level); + } + + return changed; +} + +/** The rule draft for one edited field, in the shape the rule form expects. */ +function getDraftForField( + field: MerchantRuleSuggestionField, + transaction: Transaction, + policy: OnyxEntry, + editedTagLevels: Record | undefined, +): Partial { + switch (field) { + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY: { + const category = getCategory(transaction); + return category ? {category} : {}; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG: { + // Multi-level tags keep their colon-joined form, which the rule form expects too. Levels the user did not + // edit are blanked, so a rule from one edited level leaves the rest for the matched expense to decide. + // Without recorded levels, which is any single-level tag, the whole tag is carried over. + const tag = getTag(transaction); + if (!editedTagLevels) { + return tag ? {tag} : {}; + } + const editedTag = trimTag( + getTagArrayFromName(tag) + .map((level, index) => (editedTagLevels[index] ? level : '')) + .join(':'), + ); + return editedTag ? {tag: editedTag} : {}; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { + // A transaction stores the same tax key the rule form uses, but it may have been renamed since. + const storedTaxCode = getTaxCode(transaction); + const taxCode = storedTaxCode ? resolveCurrentTaxCode(policy, storedTaxCode) : undefined; + return taxCode && policy?.taxRates?.taxes?.[taxCode] ? {tax: taxCode} : {}; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION: { + // An expense description is stored as HTML, while the rule form edits markdown + const description = getDescription(transaction); + return description ? {comment: Parser.htmlToMarkdown(description)} : {}; + } + // Use the helpers so an unset value seeds what the expense view shows. Unset `reimbursable` displays as + // reimbursable, but the raw field would seed "Don't change". + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE: + return {billable: getBillable(transaction)}; + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE: + return {reimbursable: getReimbursable(transaction)}; + default: + return {}; + } +} + +/** + * Builds the draft that pre-seeds the rule flow, carrying every field edited so far. Returns undefined when the + * expense has no merchant, since a rule cannot match without one. + */ +function getMerchantRuleDraftFromTransaction( + transaction: OnyxEntry, + fields: MerchantRuleSuggestionField[], + policy: OnyxEntry, + editedTagLevels?: Record, +): Partial | undefined { + if (!transaction || isMerchantMissing(transaction)) { + return undefined; + } + + // The callout only ever creates a merchant rule, so it names the type itself. Without it the editor bounces to the + // type chooser, which would drop the user a step back from where the callout promised to take them. + const draft: Partial = {ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, merchantToMatch: getMerchant(transaction)}; + + // Each field sets a different property, so edit order does not matter. + for (const field of fields) { + Object.assign(draft, getDraftForField(field, transaction, policy, editedTagLevels)); + } + + return draft; +} + +export {getChangedTagLevels, getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive}; diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index a94f03df2e00..9cc18a0e8f47 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -6,12 +6,13 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/MerchantTypeRuleForm'; import type {MerchantTypeRuleForm} from '@src/types/form/MerchantTypeRuleForm'; -import type {Policy} from '@src/types/onyx'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; import type {CodingRule} from '@src/types/onyx/Policy'; import {DEFAULT_MCC_GROUP, isDefaultMccGroupID} from './actions/Policy/Category'; import {setWorkspaceDefaultSpendCategory} from './actions/Policy/Policy'; import {clearPolicyCodingRuleErrors} from './actions/Policy/Rules'; +import {getCategoryTaxRulesTableData, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; import {getDecodedCategoryName} from './CategoryUtils'; import Parser from './Parser'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; @@ -82,7 +83,7 @@ function getMerchantTypeRulesTableData({ keyForList: getMerchantTypeRuleKey(groupID), ruleID: getMerchantTypeRuleKey(groupID), groupID, - isMerchantType: true, + section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES, isRename: false, isSelectionDisabled: true, typeLabel, @@ -124,6 +125,10 @@ function getMerchantCodingRulesTableData({ vendor: translate(isOnXero ? 'common.supplier' : 'common.vendor').toLowerCase(), }; + // A merchant rule outlives the category or tax rate it sets — the backend keeps the rule and only drops that one + // default — so no pending state is borrowed here. Only the rule's own delete counts, and it reads the same way as + // a category rule's: online the delete resolves in a moment, so the row goes rather than flashing greyed, while + // offline it stays and is styled as deleting since there is nothing to wait for. return Object.entries(codingRules) .filter(([, rule]) => !!rule && (isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) .map(([ruleID, rule]: [string, CodingRule]) => { @@ -154,7 +159,13 @@ function getMerchantCodingRulesTableData({ actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.description, commentMarkdown)); } if (rule.tax?.field_id_TAX?.value) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.tax, `${rule.tax.field_id_TAX.name} (${rule.tax.field_id_TAX.value})`)); + actions.push( + translate( + 'workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', + fieldLabels.tax, + getTaxRateDisplayName(policy, rule.tax.field_id_TAX.externalID, rule.tax.field_id_TAX), + ), + ); } if (rule.vendorID) { const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); @@ -168,20 +179,21 @@ function getMerchantCodingRulesTableData({ actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleBillable', rule.billable)); } const ruleDescription = actions.map((action, index) => (index === 0 ? action : action.charAt(0).toLowerCase() + action.slice(1))).join(', '); + const pendingAction = rule.pendingAction; return { keyForList: ruleID, ruleID, - isMerchantType: false, + section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, isRename: hasOnlyMerchantRename, typeLabel, conditionText: translate('workspace.rules.expenseDefaultsTable.merchantIs', merchantName), ruleDescription, searchTokens: [merchantName, ruleDescription], - pendingAction: rule.pendingAction, + pendingAction, errors: rule.errors, onCloseError: () => clearPolicyCodingRuleErrors(policyID, ruleID, rule), - disabled: rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + disabled: pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, action: () => onNavigate(ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID)), }; }); @@ -190,20 +202,24 @@ function getMerchantCodingRulesTableData({ function getExpenseDefaultsTableData({ policy, policyID, + policyCategories, translate, isOffline, onNavigate, }: { policy: Policy | undefined; policyID: string; + /** Read for the pending state of a category a rule depends on, so the rule shows as deleting alongside it. */ + policyCategories: PolicyCategories | undefined; translate: LocaleContextProps['translate']; isOffline: boolean; onNavigate: (route: Route) => void; }): ExpenseDefaultTableItem[] { + const categoryTaxRules = getCategoryTaxRulesTableData({policy, policyCategories, translate, isOffline, onNavigate}); const merchantRules = getMerchantCodingRulesTableData({policy, policyID, translate, isOffline, onNavigate}); const merchantTypeRules = getMerchantTypeRulesTableData({policy, translate, onNavigate}); - return [...merchantRules, ...merchantTypeRules]; + return [...categoryTaxRules, ...merchantRules, ...merchantTypeRules]; } export { diff --git a/src/libs/Middleware/GlobalReimbursementPayError.ts b/src/libs/Middleware/GlobalReimbursementPayError.ts new file mode 100644 index 000000000000..4dc12fdc601c --- /dev/null +++ b/src/libs/Middleware/GlobalReimbursementPayError.ts @@ -0,0 +1,62 @@ +import {PAYMENT_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import {isRecord} from '@libs/ObjectUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyOnyxUpdate, PaginatedRequest} from '@src/types/onyx/Request'; +import type Request from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import type {OnyxKey} from 'react-native-onyx'; + +import type Middleware from './types'; + +/** + * Middleware that detects the Corpay pay modal signal sent by the backend when a pay attempt fails because the + * workspace USD VBBA is not set up on Corpay. The backend sends an Onyx SET on corpayPayModal instead of writing + * an inline error onto the report action. + */ +const GlobalReimbursementPayError: Middleware = (responsePromise: Promise | void>, request: Request | PaginatedRequest) => + responsePromise.then((response) => { + if (!request?.command || !PAYMENT_COMMANDS.has(request.command) || !response || response.jsonCode === CONST.JSON_CODE.SUCCESS) { + return response; + } + + const onyxData = response.onyxData ?? []; + const hasCorpayPayModal = onyxData.some((update) => update.key === ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL); + + if (!hasCorpayPayModal) { + return response; + } + + const iouReportID = request?.data?.iouReportID; + const reportActionID = request?.data?.reportActionID; + + if (typeof iouReportID !== 'string' || typeof reportActionID !== 'string' || !request?.failureData) { + return response; + } + + const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`; + for (const update of request.failureData as AnyOnyxUpdate[]) { + if (update.key !== actionsKey || !update.value) { + continue; + } + + if (!isRecord(update.value)) { + continue; + } + + // Drop the optimistic PAY action instead of merging an error onto it. + update.value = { + ...update.value, + [reportActionID]: null, + }; + } + + Log.info('GlobalReimbursementPayError: replaced optimistic PAY action-error with action-null for corpayPayModal', false, {iouReportID, reportActionID}); + + return response; + }); + +export default GlobalReimbursementPayError; diff --git a/src/libs/Middleware/HandleDeletedAccount.ts b/src/libs/Middleware/HandleDeletedAccount.ts index ec5532baa230..a8823caa42a2 100644 --- a/src/libs/Middleware/HandleDeletedAccount.ts +++ b/src/libs/Middleware/HandleDeletedAccount.ts @@ -1,6 +1,8 @@ import {signOutAndRedirectToSignIn} from '@libs/actions/Session'; import type {Middleware} from '@libs/Request'; +import CONST from '@src/CONST'; + /** * Handles the case when the user's copilot has been deleted. * If the response contains jsonCode 408 and a message indicating copilot deletion, @@ -12,7 +14,7 @@ const handleDeletedAccount: Middleware = (requestResponse) => if (response?.jsonCode !== 408 || !response?.message?.includes('The account you are trying to use is deleted.')) { return response; } - signOutAndRedirectToSignIn(true, false, true, true); + signOutAndRedirectToSignIn(true, false, true, true, CONST.SIGN_OUT_REASON.ACCOUNT_DELETED); }); export default handleDeletedAccount; diff --git a/src/libs/Middleware/HandleMovedScanFailedExpenses.ts b/src/libs/Middleware/HandleMovedScanFailedExpenses.ts new file mode 100644 index 000000000000..e4256102f312 --- /dev/null +++ b/src/libs/Middleware/HandleMovedScanFailedExpenses.ts @@ -0,0 +1,194 @@ +import {PAYMENT_COMMANDS} from '@libs/API/types'; +import {isRecord} from '@libs/ObjectUtils'; +import type {Middleware} from '@libs/Request'; + +import reconcileMovedScanFailedReport, {getMovedScanFailedTransactionIDs} from '@userActions/IOU/reconcileMovedScanFailedReport'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; + +import type {OnyxKey} from 'react-native-onyx'; + +/** The shape of a response update this middleware reads, kept loose so it accepts the updates of any command. */ +type ResponseUpdate = { + key: OnyxKey; + value?: unknown; +}; + +type MovedScanFailedContext = { + optimisticReportID: string; + chatReportID: string | undefined; + iouReportID: string | undefined; +}; + +type RealReport = { + reportID: string; + actionIDByTransactionID: Map; +}; + +function getString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** + * A hold split is the only other flow that sends an optimistic hold report, and it always pays part of the report + * (`full: false`). A full payment carrying one therefore means the client optimistically moved scan-failed expenses out. + */ +function getMovedScanFailedContext(data: Record | undefined): MovedScanFailedContext | undefined { + const optimisticReportID = getString(data?.optimisticHoldReportID); + if (!optimisticReportID || !data?.full) { + return undefined; + } + return { + optimisticReportID, + chatReportID: getString(data.chatReportID), + iouReportID: getString(data.iouReportID), + }; +} + +/** Reads the `transactionID -> reportActionID` pairs out of one report-actions update in the response. */ +function getActionIDByTransactionID(value: Record): Map { + const actionIDByTransactionID = new Map(); + for (const reportAction of Object.values(value)) { + if (!isRecord(reportAction) || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + continue; + } + const reportActionID = getString(reportAction.reportActionID); + const transactionID = isRecord(reportAction.originalMessage) ? getString(reportAction.originalMessage.IOUTransactionID) : undefined; + if (!reportActionID || !transactionID) { + continue; + } + actionIDByTransactionID.set(transactionID, reportActionID); + } + return actionIDByTransactionID; +} + +function isOtherReport(reportID: string, {optimisticReportID, chatReportID, iouReportID}: MovedScanFailedContext): boolean { + return !!reportID && reportID !== optimisticReportID && reportID !== iouReportID && reportID !== chatReportID; +} + +/** + * The strongest signal: a report in the response, other than the one that was paid, already carries an action for an + * expense the client had moved into the optimistic report. Only available when the response happens to include the new + * report's actions. + */ +function findReportClaimingMovedExpense(onyxData: ResponseUpdate[], context: MovedScanFailedContext, movedTransactionIDs: Set): string | undefined { + if (!movedTransactionIDs.size) { + return undefined; + } + for (const update of onyxData) { + if (!update.key?.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS) || !isRecord(update.value)) { + continue; + } + const reportID = update.key.slice(ONYXKEYS.COLLECTION.REPORT_ACTIONS.length); + if (!isOtherReport(reportID, context)) { + continue; + } + const claimsMovedExpense = [...getActionIDByTransactionID(update.value).keys()].some((transactionID) => movedTransactionIDs.has(transactionID)); + if (claimsMovedExpense) { + return reportID; + } + } + return undefined; +} + +/** + * The fallback signal, used because the payment response carries the new report itself far more often than it carries + * that report's actions: the one expense report the response introduces for this workspace chat which is neither the + * report that was paid nor the optimistic one. More than one candidate is treated as no candidate. + */ +function findNewExpenseReportForChat(onyxData: ResponseUpdate[], context: MovedScanFailedContext): string | undefined { + const candidates = new Set(); + for (const update of onyxData) { + if (!update.key?.startsWith(ONYXKEYS.COLLECTION.REPORT) || !isRecord(update.value)) { + continue; + } + const reportID = update.key.slice(ONYXKEYS.COLLECTION.REPORT.length); + if (!isOtherReport(reportID, context) || update.value.type !== CONST.REPORT.TYPE.EXPENSE || update.value.chatReportID !== context.chatReportID) { + continue; + } + candidates.add(reportID); + } + return candidates.size === 1 ? candidates.values().next().value : undefined; +} + +/** + * Whether the response itself already sets `field` on `key`. The reconciliation updates ride in `successData` and are + * merged after the response, so they have to leave anything the backend sent alone instead of overwriting it. + */ +function hasResponseValueFor(onyxData: ResponseUpdate[], key: string, field: string): boolean { + return onyxData.some((update) => update.key === key && isRecord(update.value) && update.value[field] !== undefined); +} + +/** Collects the report actions the response carries for `reportID`, which may be none. */ +function collectActionIDsForReport(onyxData: ResponseUpdate[], reportID: string): Map { + let actionIDByTransactionID = new Map(); + for (const update of onyxData) { + if (update.key !== `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` || !isRecord(update.value)) { + continue; + } + actionIDByTransactionID = new Map([...actionIDByTransactionID, ...getActionIDByTransactionID(update.value)]); + } + return actionIDByTransactionID; +} + +/** + * Identifies the report the backend created for the moved expenses. Returns undefined when the response does not point + * at exactly one such report. The reconciliation is then skipped entirely, because sending the user to a report that + * is only a guess is worse than leaving the optimistic one in place. + */ +function findRealReport(onyxData: ResponseUpdate[], context: MovedScanFailedContext, movedTransactionIDs: Set): RealReport | undefined { + const reportID = findReportClaimingMovedExpense(onyxData, context, movedTransactionIDs) ?? findNewExpenseReportForChat(onyxData, context); + if (!reportID) { + return undefined; + } + return {reportID, actionIDByTransactionID: collectActionIDsForReport(onyxData, reportID)}; +} + +/** + * When a report is paid while it still holds a scan-failed expense, the client optimistically moves that expense into a + * report of its own so the split is visible offline. The backend performs the same split but under its own report ID, + * which leaves the optimistic report stranded, and strands the user with it if they are looking at it. + * + * This middleware pairs the two: it reads the backend's report out of the response and hands it to the reconciliation. + * The resulting updates are appended to the request's `successData` rather than to the response, so they land after the + * pending-state cleanup that `successData` already carries. Merging them the other way round would resurrect the very + * report actions being removed. Running from the response, rather than from a subscription opened when Pay was pressed, + * is what makes this survive a reload between going offline and reconnecting, since the queued request is replayed + * through the same pipeline. + */ +const handleMovedScanFailedExpenses: Middleware = (requestResponse, request) => + requestResponse.then((response) => { + // A failed payment rolls the move back through failureData, which owns the cleanup in that case. + if (!request?.command || !PAYMENT_COMMANDS.has(request.command) || response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + return response; + } + + const context = getMovedScanFailedContext(request.data); + if (!context) { + return response; + } + + const onyxData: ResponseUpdate[] = response.onyxData ?? []; + const realReport = findRealReport(onyxData, context, getMovedScanFailedTransactionIDs(context.optimisticReportID)); + if (!realReport) { + return response; + } + + const updates = reconcileMovedScanFailedReport(context.optimisticReportID, realReport.reportID, realReport.actionIDByTransactionID, (key, field) => + hasResponseValueFor(onyxData, key, field), + ); + if (!updates.length) { + return response; + } + + if (!request.successData) { + request.successData = []; + } + (request.successData as AnyOnyxUpdate[]).push(...updates); + + return response; + }); + +export default handleMovedScanFailedExpenses; diff --git a/src/libs/Middleware/Reauthentication.ts b/src/libs/Middleware/Reauthentication.ts index fd224c8d5b7b..4a2f103a5333 100644 --- a/src/libs/Middleware/Reauthentication.ts +++ b/src/libs/Middleware/Reauthentication.ts @@ -7,6 +7,7 @@ import {isAuthenticating as isAuthenticatingNetworkStore, setIsAuthenticating} f import type {RequestError} from '@libs/Network/SequentialQueue'; import {getIsOffline} from '@libs/NetworkState'; import reauthenticateLibs from '@libs/Reauthentication'; +import type {ReauthenticationResult} from '@libs/Reauthentication'; import {processWithMiddleware} from '@libs/Request'; import RequestThrottle from '@libs/RequestThrottle'; @@ -20,11 +21,11 @@ import type {OnyxKey} from 'react-native-onyx'; import type Middleware from './types'; // We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time. -let isAuthenticating: Promise | null = null; +let isAuthenticating: Promise | null = null; const reauthThrottle = new RequestThrottle('Re-authentication'); -function reauthenticate(commandName?: string): Promise { +function reauthenticate(commandName?: string): Promise { if (isAuthenticating) { return isAuthenticating; } @@ -37,7 +38,7 @@ function reauthenticate(commandName?: string): Promise { return isAuthenticating; } -function retryReauthenticate(commandName?: string): Promise { +function retryReauthenticate(commandName?: string): Promise { return reauthenticateLibs(commandName).catch((error: RequestError) => { return reauthThrottle .sleep(error, 'Authenticate') @@ -45,8 +46,8 @@ function retryReauthenticate(commandName?: string): Promise { .catch(() => { setIsAuthenticating(false); Log.hmmm('[Reauthenticate] Redirecting to Sign In because we failed to reauthenticate after multiple attempts', {error}); - redirectToSignIn('passwordForm.error.fallback'); - return false; + redirectToSignIn(CONST.SIGN_OUT_REASON.REAUTH_FAILED, 'passwordForm.error.fallback'); + return {wasSuccessful: false}; }); }); } @@ -110,8 +111,8 @@ function handleExpiredSession( } return reauthenticate(request?.commandName) - .then((wasSuccessful) => { - if (!wasSuccessful) { + .then((result) => { + if (!result.wasSuccessful) { if (isFromSequentialQueue) { // A resolved 407 reads as success to SequentialQueue, which deletes the persisted write. // Throw on every give-up: the latched short-lived-token abort and the delegate restore return @@ -120,8 +121,12 @@ function handleExpiredSession( } // Reauth already handled the sign-in redirect, so do not briefly show the failed request UI before sign-in. - request.failureData = undefined; - request.finallyData = undefined; + // However, during a delegate auth failure, restoreDelegateSession restores the original account rather than + // signing out. Preserve failureData and finallyData so that loading states are properly cleared. + if (!result.didRestoreOriginalSession) { + request.failureData = undefined; + request.finallyData = undefined; + } return data; } diff --git a/src/libs/Middleware/ReplaceOptimisticAgentAccountID.ts b/src/libs/Middleware/ReplaceOptimisticAgentAccountID.ts new file mode 100644 index 000000000000..617dceeac612 --- /dev/null +++ b/src/libs/Middleware/ReplaceOptimisticAgentAccountID.ts @@ -0,0 +1,161 @@ +import {registerAgentAccountIDMapping} from '@libs/AgentAccountIDMapping'; +import deepReplaceKeysAndValues from '@libs/deepReplaceKeysAndValues'; +import type {Middleware} from '@libs/Request'; + +import {getAll, update} from '@userActions/PersistedRequests'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyOnyxUpdate, AnyRequest} from '@src/types/onyx/Request'; + +import {deepEqual} from 'fast-equals'; +import clone from 'lodash/clone'; + +/** + * Only the server can assign an agent's real accountID, so CreateAgent's success response maps the optimistic + * accountID to the real one on OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING. Queued requests that still use the optimistic + * accountID would 404, so this middleware rewrites them when the mapping arrives. + * + * The CreateAgent request itself is never rewritten: it already succeeded, and a retry sent with the real + * accountID would create a second agent. + */ + +// deepReplaceKeysAndValues only rewrites strings. This extra pass covers the accountID sent as a number +// (e.g. the agentAccountID parameter). +function replaceNumberValues(target: unknown, oldVal: number, newVal: number): unknown { + if (target === oldVal) { + return newVal; + } + + if (!target || typeof target !== 'object' || target instanceof File || target instanceof Blob) { + return target; + } + + if (Array.isArray(target)) { + return target.map((item) => replaceNumberValues(item, oldVal, newVal)); + } + + return replaceNumbersInRecord(target, oldVal, newVal); +} + +// The recursion reaches every non-null object variant, hence the broad `object` type. +// eslint-disable-next-line @typescript-eslint/no-restricted-types +function replaceNumbersInRecord(target: object, oldVal: number, newVal: number): Record { + const newObj: Record = {}; + for (const [key, entryValue] of Object.entries(target)) { + // Object.entries() returns any here. + const val: unknown = entryValue; + newObj[key] = replaceNumberValues(val, oldVal, newVal); + } + return newObj; +} + +// Mirrors the OnyxDataBase fields of the Request type. +const REQUEST_ONYX_DATA_FIELDS = ['successData', 'failureData', 'finallyData', 'optimisticData', 'queueFlushedData'] as const; + +// Stored Onyx updates are rewritten too, or a request succeeding later would resurrect data under the optimistic +// keys that replaceOptimisticAgentWithActualAgent clears. +function rewriteOnyxUpdates( + updates: AnyOnyxUpdate[] | undefined, + optimisticAccountIDKey: string, + realAccountIDString: string, + optimisticAccountID: number, + realAccountID: number, +): AnyOnyxUpdate[] | undefined { + return updates?.map((updateEntry) => { + // AnyOnyxUpdate types key and value as any. + const rawKey: unknown = updateEntry.key; + const rawValue: unknown = updateEntry.value; + // deepReplaceKeysAndValues only accepts records, hence the wrapper. + const valueWithReplacedStrings = deepReplaceKeysAndValues({value: rawValue}, optimisticAccountIDKey, realAccountIDString)?.value; + const rewrittenEntry = { + ...updateEntry, + value: replaceNumberValues(valueWithReplacedStrings, optimisticAccountID, realAccountID), + }; + if (typeof rawKey === 'string') { + rewrittenEntry.key = rawKey.replace(optimisticAccountIDKey, realAccountIDString); + } + return rewrittenEntry; + }); +} + +function rewriteRequest(request: AnyRequest, optimisticAccountIDKey: string, realAccountIDString: string, optimisticAccountID: number, realAccountID: number): AnyRequest { + const requestClone = clone(request); + const dataWithReplacedStrings = deepReplaceKeysAndValues(request.data, optimisticAccountIDKey, realAccountIDString); + requestClone.data = dataWithReplacedStrings ? replaceNumbersInRecord(dataWithReplacedStrings, optimisticAccountID, realAccountID) : dataWithReplacedStrings; + for (const fieldName of REQUEST_ONYX_DATA_FIELDS) { + if (!request[fieldName]) { + continue; + } + requestClone[fieldName] = rewriteOnyxUpdates(request[fieldName], optimisticAccountIDKey, realAccountIDString, optimisticAccountID, realAccountID); + } + return requestClone; +} + +// A short key could match unrelated numbers as substrings. 10 digits exceeds real accountIDs and the small +// integers request data usually holds. A genuine optimistic ID this short (~1 in 9 million) is left to the server. +const MIN_OPTIMISTIC_ACCOUNT_ID_DIGITS = 10; + +function isValidAgentAccountID(accountID: number): boolean { + return Number.isSafeInteger(accountID) && accountID > 0; +} + +// The key is used as a substring pattern across the whole queue, so a malformed key like "1" or "" would corrupt +// it in one pass. The canonical decimal form also keeps the string and number passes on the same ID. +function isValidAgentAccountIDMappingEntry(optimisticAccountIDKey: string, realAccountID: number): boolean { + if (!/^\d+$/.test(optimisticAccountIDKey) || optimisticAccountIDKey.length < MIN_OPTIMISTIC_ACCOUNT_ID_DIGITS) { + return false; + } + const optimisticAccountID = Number(optimisticAccountIDKey); + if (String(optimisticAccountID) !== optimisticAccountIDKey || !isValidAgentAccountID(optimisticAccountID)) { + return false; + } + return isValidAgentAccountID(realAccountID) && optimisticAccountID !== realAccountID; +} + +const replaceOptimisticAgentAccountID: Middleware = (requestResponse) => + requestResponse.then((response) => { + const responseOnyxData = response?.onyxData ?? []; + for (const onyxData of responseOnyxData) { + if (onyxData.key !== ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING) { + continue; + } + + const mapping: unknown = onyxData.value; + if (!mapping || typeof mapping !== 'object') { + continue; + } + + for (const [optimisticAccountIDKey, mappedAccountID] of Object.entries(mapping)) { + // Object.entries() returns any here. + const realAccountID: unknown = mappedAccountID; + // A null entry is a mapping that replaceOptimisticAgentWithActualAgent already consumed and cleared. + if (typeof realAccountID !== 'number') { + continue; + } + if (!isValidAgentAccountIDMappingEntry(optimisticAccountIDKey, realAccountID)) { + continue; + } + const optimisticAccountID = Number(optimisticAccountIDKey); + const realAccountIDString = String(realAccountID); + + // The mapping only reaches replaceOptimisticAgentWithActualAgent once the queue drains and the + // response is flushed to Onyx. Registering it here lets agent actions fired before then already + // resolve the real accountID instead of enqueueing the optimistic one after the sweep below. + registerAgentAccountIDMapping(optimisticAccountID, realAccountID); + + // The request that produced the mapping is no longer in the persisted list here, so only the + // requests queued behind it are rewritten. update() re-persists the queue, so unchanged requests + // are skipped. + for (const [index, persistedRequest] of getAll().entries()) { + const rewrittenRequest = rewriteRequest(persistedRequest, optimisticAccountIDKey, realAccountIDString, optimisticAccountID, realAccountID); + if (deepEqual(rewrittenRequest, persistedRequest)) { + continue; + } + update(index, rewrittenRequest); + } + } + } + return response; + }); + +export default replaceOptimisticAgentAccountID; diff --git a/src/libs/Middleware/index.ts b/src/libs/Middleware/index.ts index 910b4f400e3d..af040dbca8db 100644 --- a/src/libs/Middleware/index.ts +++ b/src/libs/Middleware/index.ts @@ -1,6 +1,8 @@ import FailureTracking from './FailureTracking'; import FraudMonitoring from './FraudMonitoring'; +import GlobalReimbursementPayError from './GlobalReimbursementPayError'; import handleDeletedAccount from './HandleDeletedAccount'; +import HandleMovedScanFailedExpenses from './HandleMovedScanFailedExpenses'; import HandleUnusedOptimisticID from './HandleUnusedOptimisticID'; import LoadPostDataForOpenOrReconnect from './LoadPostDataForOpenOrReconnect'; import LoadTest from './LoadTest'; @@ -8,11 +10,14 @@ import Logging from './Logging'; import {Pagination} from './Pagination'; import Reauthentication from './Reauthentication'; import RecordFullReconnectTime from './RecordFullReconnectTime'; +import ReplaceOptimisticAgentAccountID from './ReplaceOptimisticAgentAccountID'; import SaveResponseInOnyx from './SaveResponseInOnyx'; import SentryServerTiming from './SentryServerTiming'; import SupportalPermission from './SupportalPermission'; export { + ReplaceOptimisticAgentAccountID, + HandleMovedScanFailedExpenses, HandleUnusedOptimisticID, LoadTest, Logging, @@ -23,6 +28,7 @@ export { Pagination, handleDeletedAccount, SupportalPermission, + GlobalReimbursementPayError, FraudMonitoring, LoadPostDataForOpenOrReconnect, SentryServerTiming, diff --git a/src/libs/Middleware/register.ts b/src/libs/Middleware/register.ts index 0d90267adb45..013560ca23d5 100644 --- a/src/libs/Middleware/register.ts +++ b/src/libs/Middleware/register.ts @@ -3,7 +3,9 @@ import {addMiddleware} from '@libs/Request'; import { FailureTracking, FraudMonitoring, + GlobalReimbursementPayError, handleDeletedAccount, + HandleMovedScanFailedExpenses, HandleUnusedOptimisticID, LoadPostDataForOpenOrReconnect, LoadTest, @@ -11,6 +13,7 @@ import { Pagination, Reauthentication, RecordFullReconnectTime, + ReplaceOptimisticAgentAccountID, SaveResponseInOnyx, SentryServerTiming, SupportalPermission, @@ -23,44 +26,63 @@ import { // This lives here rather than in libs/API because six of the middlewares below import user actions, and every // user action imports libs/API. Registering from inside libs/API therefore closes an import cycle: libs/API // imports Middleware, Middleware imports an action, and that action imports libs/API again. Instead the -// composition root imports this module for its side effect, before anything can call processWithMiddleware: -// see src/setup/index.ts. +// composition root calls this explicitly, before anything can call processWithMiddleware: see src/setup/index.ts. +let hasRegistered = false; -// Logging - Logs request details and errors. -addMiddleware(Logging); +function registerMiddlewares() { + if (hasRegistered) { + return; + } + hasRegistered = true; -// Duplicates API calls (tagged with mockRequest=true) when the server sends load-test parameters via the X-Load-Test response header. -addMiddleware(LoadTest); + // Logging - Logs request details and errors. + addMiddleware(Logging); -// FailureTracking - Observes request outcomes and feeds them to FailureTracker for sustained failure detection. -addMiddleware(FailureTracking); + // Duplicates API calls (tagged with mockRequest=true) when the server sends load-test parameters via the X-Load-Test response header. + addMiddleware(LoadTest); -// Reauthentication - Handles jsonCode 407 which indicates an expired authToken. We need to reauthenticate and get a new authToken with our stored credentials. -addMiddleware(Reauthentication); + // FailureTracking - Observes request outcomes and feeds them to FailureTracker for sustained failure detection. + addMiddleware(FailureTracking); -// Handles the case when the copilot has been deleted. The response contains jsonCode 408 and a message indicating account deletion -addMiddleware(handleDeletedAccount); + // Reauthentication - Handles jsonCode 407 which indicates an expired authToken. We need to reauthenticate and get a new authToken with our stored credentials. + addMiddleware(Reauthentication); -// Handle supportal permission denial centrally -addMiddleware(SupportalPermission); + // Handles the case when the copilot has been deleted. The response contains jsonCode 408 and a message indicating account deletion + addMiddleware(handleDeletedAccount); -// If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead. -addMiddleware(HandleUnusedOptimisticID); + // Handle supportal permission denial centrally + addMiddleware(SupportalPermission); -addMiddleware(Pagination); + // Handle the Corpay pay modal signal: when the backend signals that the workspace USD VBBA is not set up on Corpay, replace the optimistic PAY action-error with an action-null so no inline error shows. + addMiddleware(GlobalReimbursementPayError); -// SentryServerTiming - Tracks server round-trip time for configured command groups via Sentry spans. -addMiddleware(SentryServerTiming); + // If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead. + addMiddleware(HandleUnusedOptimisticID); -// RecordFullReconnectTime - Records the full-reconnect time into an OpenApp/full-ReconnectApp response. Must run before SaveResponseInOnyx applies the response. -addMiddleware(RecordFullReconnectTime); + // Rewrites the remaining serialized requests when the response maps an optimistic agent accountID to the server-assigned one. + addMiddleware(ReplaceOptimisticAgentAccountID); -// LoadPostDataForOpenOrReconnect - Sends the reads that OpenApp/ReconnectApp does not return, once per response that reaches the server. -addMiddleware(LoadPostDataForOpenOrReconnect); + addMiddleware(Pagination); -// SaveResponseInOnyx - Merges either the successData or failureData (or finallyData, if included in place of the former two values) into Onyx depending on if the call was successful or not. This must be the last middleware that applies Onyx data -// (middlewares after it, like FraudMonitoring, must not write Onyx), because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state. -addMiddleware(SaveResponseInOnyx); + // SentryServerTiming - Tracks server round-trip time for configured command groups via Sentry spans. + addMiddleware(SentryServerTiming); -// FraudMonitoring - Tags the request with the appropriate Fraud Protection event. -addMiddleware(FraudMonitoring); + // RecordFullReconnectTime - Records the full-reconnect time into an OpenApp/full-ReconnectApp response. Must run before SaveResponseInOnyx applies the response. + addMiddleware(RecordFullReconnectTime); + + // LoadPostDataForOpenOrReconnect - Sends the reads that OpenApp/ReconnectApp does not return, once per response that reaches the server. + addMiddleware(LoadPostDataForOpenOrReconnect); + + // HandleMovedScanFailedExpenses - Retires the optimistic report built for scan-failed expenses moved on payment once the backend answers + // with the report it created for them. Must run before SaveResponseInOnyx so its updates are applied with the response. + addMiddleware(HandleMovedScanFailedExpenses); + + // SaveResponseInOnyx - Merges either the successData or failureData (or finallyData, if included in place of the former two values) into Onyx depending on if the call was successful or not. This must be the last middleware that applies Onyx data + // (middlewares after it, like FraudMonitoring, must not write Onyx), because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state. + addMiddleware(SaveResponseInOnyx); + + // FraudMonitoring - Tags the request with the appropriate Fraud Protection event. + addMiddleware(FraudMonitoring); +} + +export default registerMiddlewares; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index e2570fb8922f..576ab794ec80 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -18,7 +18,6 @@ import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; import {formatList} from './Localize'; import Log from './Log'; -import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; import { arePolicyRulesEnabled, findVendorByID, @@ -153,7 +152,7 @@ function getForDistanceRequest(translate: LocalizedTranslate, newMerchant: strin return translate('iou.updatedTheDistanceMerchant', translatedChangedField, newMerchant, oldMerchant, newAmount, oldAmount); } -function getForExpenseMovedFromSelfDM(translate: LocalizedTranslate, destinationReport: OnyxEntry, currentUserLogin: string, policy: OnyxEntry) { +function getForExpenseMovedFromSelfDM(translate: LocalizedTranslate, destinationReport: OnyxEntry, currentUserAccountID: number | undefined, policy: OnyxEntry) { const rootParentReport = getRootParentReport({report: destinationReport}); // In OldDot, expenses could be moved to a self-DM. Return the corresponding message for this case. if (isSelfDM(rootParentReport)) { @@ -162,7 +161,6 @@ function getForExpenseMovedFromSelfDM(translate: LocalizedTranslate, destination // In NewDot, the "Move report" flow only supports moving expenses from self-DM to: // - A policy expense chat // - A 1:1 DM - const currentUserAccountID = getPersonalDetailByEmail(currentUserLogin)?.accountID; const reportName = isPolicyExpenseChat(rootParentReport) ? getPolicyExpenseChatName({report: rootParentReport, translate}) : buildReportNameFromParticipantNames({report: rootParentReport, currentUserAccountID, translate}); @@ -187,12 +185,12 @@ function getMovedFromOrToReportMessage( translate: LocalizedTranslate, movedFromReport: OnyxEntry | undefined, movedToReport: OnyxEntry | undefined, - currentUserLogin: string, + currentUserAccountID: number | undefined, policy: OnyxEntry, reportAttributes?: ReportAttributesDerivedValue['reports'], ): string | undefined { if (movedToReport) { - return getForExpenseMovedFromSelfDM(translate, movedToReport, currentUserLogin, policy); + return getForExpenseMovedFromSelfDM(translate, movedToReport, currentUserAccountID, policy); } if (movedFromReport) { @@ -276,6 +274,7 @@ function getForReportAction({ movedToReport, policyTags, policyCategories, + currentUserAccountID, currentUserLogin, reportAttributes, }: { @@ -290,6 +289,7 @@ function getForReportAction({ // See https://github.com/Expensify/App/pull/75562 policyTags?: OnyxEntry; policyCategories?: OnyxEntry; + currentUserAccountID: number | undefined; currentUserLogin: string; reportAttributes?: ReportAttributesDerivedValue['reports']; }): string { @@ -297,7 +297,7 @@ function getForReportAction({ return ''; } - const movedFromOrToReportMessage = getMovedFromOrToReportMessage(translate, movedFromReport, movedToReport, currentUserLogin, policy, reportAttributes); + const movedFromOrToReportMessage = getMovedFromOrToReportMessage(translate, movedFromReport, movedToReport, currentUserAccountID, policy, reportAttributes); if (movedFromOrToReportMessage) { return movedFromOrToReportMessage; } diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index f505024895c9..d01dae4a2960 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -8,7 +8,7 @@ import type {ValueOf} from 'type-fest'; import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import replaceAllDigits from './replaceAllDigits'; import {isExpenseReport, isExpenseRequest, isPolicyExpenseChat} from './ReportUtils'; -import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isCreatedMissing, isExpenseUnreported} from './TransactionUtils'; import {getMerchantError} from './ValidationUtils'; /** @@ -244,8 +244,31 @@ function shouldShowConfirmationDate(shouldShowSmartScanFields: boolean, isDistan return shouldShowSmartScanFields || isDistanceRequest; } +/** + * Whether the required amount is still missing on the money request confirmation surface. + * `isAmountSet` is only ever set by the manual flow (scan, per diem, distance and time populate the amount + * programmatically and never set it), so the manual gate is part of the predicate rather than of each call site. + * This is the single source of truth shared by the validation that raises `common.error.fieldRequired`, the effect + * that clears it once the field is filled, and the amount field that renders it inline, so the three never drift. + */ +function isConfirmationAmountMissing(transaction: OnyxEntry>): boolean { + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet; +} + +/** + * Whether the required date is still missing on the money request confirmation surface. + * Gating on the same `shouldShowConfirmationDate && !isReadOnly` condition that renders the inline date picker keeps + * validation, clearing and the UI in sync, and skips read-only/scan flows where the date is populated server-side. + * Shares the same drift-proofing purpose as `isConfirmationAmountMissing`. + */ +function isConfirmationDateMissing(transaction: OnyxEntry, shouldShowDate: boolean, isReadOnly: boolean): boolean { + return shouldShowDate && !isReadOnly && isCreatedMissing(transaction); +} + export { addLeadingZero, + isConfirmationAmountMissing, + isConfirmationDateMissing, shouldShowConfirmationDate, replaceAllDigits, stripCommaFromAmount, diff --git a/src/libs/MultifactorAuthentication/shared/MarqetaValues.ts b/src/libs/MultifactorAuthentication/shared/MarqetaValues.ts index 9e1e6b06d6e5..6a0bb96f039b 100644 --- a/src/libs/MultifactorAuthentication/shared/MarqetaValues.ts +++ b/src/libs/MultifactorAuthentication/shared/MarqetaValues.ts @@ -7,7 +7,6 @@ const AUTHENTICATION_METHOD = { /** Facial recognition authentication */ BIOMETRIC_FACE: 'BIOMETRIC_FACE', - /** Fingerprint authentication */ BIOMETRIC_FINGERPRINT: 'BIOMETRIC_FINGERPRINT', /** Voice pattern recognition authentication */ diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 8caf57b9ada1..eb615e3639bf 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,5 +1,6 @@ import ComposeProviders from '@components/ComposeProviders'; import DelegateNoAccessModalProvider from '@components/DelegateNoAccessModalProvider'; +import EnableGlobalReimbursementsPayModal from '@components/EnableGlobalReimbursementsPayModal'; import ExportDownloadStatusManager from '@components/ExportDownloadStatusManager'; import GPSInProgressModal from '@components/GPSInProgressModal'; import GPSTripStateChecker from '@components/GPSTripStateChecker'; @@ -88,6 +89,7 @@ const loadLogOutPreviousUserPage = () => require('../../.. const loadConciergePage = () => require('../../../pages/ConciergePage').default; const loadTrackExpensePage = () => require('../../../pages/TrackExpensePage').default; const loadSubmitExpensePage = () => require('../../../pages/SubmitExpensePage').default; +const loadPreMountBufferPage = () => require('../../../pages/PreMountBufferPage').default; const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default; const RootStack = createRootStackNavigator(); @@ -163,7 +165,7 @@ function AuthScreens() { }; return ( - <> + @@ -174,7 +176,6 @@ function AuthScreens() { + {/* Internal placeholder screen, not reachable via a URL/deep link. animation: none so it + appears and disappears instantly instead of sliding in. */} + + - + ); } diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index 926754a71e3d..ca226106b58c 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -4,15 +4,18 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useAIFeaturesPromoModal from '@hooks/useAIFeaturesPromoModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useOneTransactionThreadReportID from '@hooks/useOneTransactionThreadReportID'; import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useReconcileHighContrastIntent from '@hooks/useReconcileHighContrastIntent'; import useReportAttributes from '@hooks/useReportAttributes'; import useRootNavigationState from '@hooks/useRootNavigationState'; import {init, isClientTheLeader} from '@libs/ActiveClientManager'; +import {isQAServerActive} from '@libs/ApiUtils'; import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; @@ -39,6 +42,7 @@ import ROUTES from '@src/ROUTES'; import type {ReportAttributesDerivedValue} from '@src/types/onyx'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; +import {accountIDSelector, displayNameSelector} from '@selectors/PersonalDetails'; import {useEffect, useRef} from 'react'; function initializePusher( @@ -47,10 +51,20 @@ function initializePusher( getTopmostOneTransactionThreadReportID: () => string | undefined, getReportAttributes: () => ReportAttributesDerivedValue['reports'] | undefined, ) { + // No fallback: CONFIG.PUSHER.APP_KEY defaults to the production key, so falling back would open a QA socket + // against production Pusher and every channel auth, signed with QA's secret, would be rejected quietly. + const appKey = isQAServerActive() ? CONFIG.PUSHER.QA_APP_KEY : CONFIG.PUSHER.APP_KEY; + + // pusher-js rejects only a null/undefined key, so an empty one builds a socket that never connects while Pusher.init + // resolves solely from its 'connected' handler, leaving every subscribe() and the PUSHER_INIT span pending forever. + if (!appKey) { + Log.alert('[Pusher] Skipping init: no Pusher app key is configured for the active server'); + return Promise.resolve(); + } + return Pusher.init({ - appKey: CONFIG.PUSHER.APP_KEY, + appKey, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }).then(() => { User.subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getTopmostOneTransactionThreadReportID, getReportAttributes); }); @@ -73,6 +87,7 @@ function AuthScreensInitHandler() { const {initialURL, isAuthenticatedAtStartup} = useInitialURLState(); const {setIsAuthenticatedAtStartup} = useInitialURLActions(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const [session] = useOnyx(ONYXKEYS.SESSION); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -82,6 +97,9 @@ function AuthScreensInitHandler() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const lastWorkspaceNumber = useLastWorkspaceNumber(ownerEmail ?? undefined); + const policyOwnerLogin = ownerEmail ?? session?.email; + const policyOwnerAccountID = usePersonalDetailByLogin(policyOwnerLogin, accountIDSelector); + const policyOwnerDisplayName = usePersonalDetailByLogin(policyOwnerLogin, displayNameSelector); const activePolicy = useActivePolicy(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -138,7 +156,7 @@ function AuthScreensInitHandler() { const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS); const isSupportalTransition = currentUrl.includes('authTokenType=support'); if (isLoggingInAsNewUser && isTransitioning) { - Session.signOutAndRedirectToSignIn(false, isSupportalTransition); + Session.signOutAndRedirectToSignIn(false, isSupportalTransition, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); return () => { Session.cleanupSession(); }; @@ -189,18 +207,21 @@ function AuthScreensInitHandler() { App.reconnectApp(initialLastUpdateIDAppliedToClient); } - App.setUpPoliciesAndNavigate( + App.setUpPoliciesAndNavigate({ session, introSelected, - currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + currency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, activePolicy, - guidedSetupAndTourStatus?.isSelfTourViewed, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, lastWorkspaceNumber, translate, conciergeChat, - ); + policyOwnerAccountID, + policyOwnerDisplayName, + }); Download.clearDownloads(); clearStaleExportDownloads(); diff --git a/src/libs/Navigation/AppNavigator/DelegatorConnectGate.tsx b/src/libs/Navigation/AppNavigator/DelegatorConnectGate.tsx index 3ec92b9d6975..506daa8c30f9 100644 --- a/src/libs/Navigation/AppNavigator/DelegatorConnectGate.tsx +++ b/src/libs/Navigation/AppNavigator/DelegatorConnectGate.tsx @@ -6,8 +6,6 @@ import {connect} from '@libs/actions/Delegate'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import {getSearchParamFromUrl} from '@libs/Url'; -import * as App from '@userActions/App'; - import ONYXKEYS from '@src/ONYXKEYS'; import React, {Suspense, use} from 'react'; @@ -40,9 +38,6 @@ function DelegatorConnectGate({children, delegatorEmail}: DelegatorConnectGatePr session, activePolicyID, isFromOldDot: true, - })?.then((success) => { - App.setAppLoading(!!success); - return success; }) ?? Promise.resolve(undefined); use(connectPromise); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 14de8d5b0dc9..af03029a156c 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -77,6 +77,15 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_ADD_NEW]: { animationTypeForReplace: 'push', }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { + animationTypeForReplace: 'push', + }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { + animationTypeForReplace: 'push', + }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { + animationTypeForReplace: 'push', + }, [SCREENS.SEARCH.DYNAMIC_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: { animation: Animations.NONE, }, @@ -95,6 +104,9 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: { animation: Animations.NONE, }, + [SCREENS.SEARCH.MERGE_REPORTS_SEARCH_RHP]: { + animation: Animations.NONE, + }, [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: { animationTypeForReplace: 'push', }, @@ -441,6 +453,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Wallet/ReportVirtualCardFraudVerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD_CONFIRMATION]: () => require('../../../../pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage').default, [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: () => require('../../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, + [SCREENS.SETTINGS.WALLET.CARD_ADD_TO_DIGITAL_WALLET]: () => require('../../../../pages/settings/Wallet/AddCardToDigitalWalletPage').default, [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/ChangePINPage').default, [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN_ATM]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/ChangePINAtATMPage').default, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: () => require('../../../../pages/settings/Wallet/TransferBalancePage').default, @@ -459,6 +472,12 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage').default, [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: () => require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: () => + require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: () => + require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: () => + require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').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, @@ -712,6 +731,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/ReportCardLostConfirmValidateCodePage').default, [SCREENS.SETTINGS.DYNAMIC_KEYBOARD_SHORTCUTS]: () => require('../../../../pages/settings/DynamicKeyboardShortcutsPage').default, + [SCREENS.SETTINGS.DYNAMIC_BETA_OVERRIDES]: () => require('../../../../pages/settings/Troubleshoot/DynamicBetaOverridesPage').default, [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON]: () => require('../../../../pages/settings/ExitSurvey/DynamicExitSurveyReasonPage').default, [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: () => require('../../../../pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage').default, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_SETUP]: () => require('../../../../pages/workspace/accounting/qbo/QuickbooksOnlineSetupPage').default, @@ -945,6 +965,10 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/dualentry/advanced/DualEntryTravelInvoicingSettlementAccountPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT]: () => require('../../../../pages/workspace/accounting/dualentry/advanced/DualEntryTravelInvoicingPayableAccountPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SETUP]: () => require('../../../../pages/workspace/accounting/campfire/CampfireSetupPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXISTING_CONNECTIONS]: () => + require('../../../../pages/workspace/accounting/campfire/CampfireExistingConnectionsPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SUBSIDIARY_SELECTOR]: () => require('../../../../pages/workspace/accounting/campfire/CampfireSubsidiarySelector').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, @@ -961,7 +985,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/taxes/WorkspaceTaxCodePage').default, [SCREENS.WORKSPACE.INVOICES_COMPANY_NAME]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicingDetailsName').default, [SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite').default, - [SCREENS.WORKSPACE.INVOICES_VERIFY_ACCOUNT]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesVerifyAccountPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION]: () => require('../../../../pages/workspace/companyCards/BrokenCardFeedConnectionPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION]: () => require('../../../../pages/workspace/companyCards/RefreshCardFeedConnectionPage').default, [SCREENS.WORKSPACE.COMPANY_CARD_ADD_WORK_EMAIL]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage').default, @@ -997,6 +1020,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/Travel/TravelLegalNamePage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SELECT_FEED]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage').default, + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_TRAVEL_BILLING_SELECT_FEED]: () => + require('../../../../pages/workspace/travel/WorkspaceTravelBillingFeedSelectorPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_SELECTION]: () => require('../../../../pages/workspace/expensifyCard/issueNew/spendRules/SpendRuleSelectionPage').default, @@ -1033,6 +1058,16 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/reports/ReportFieldsInitialValuePage').default, [SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR]: () => require('../../../../pages/workspace/reports/TypeSelector/TypeSelectorPage').default, [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reports/ReportFieldsEditValuePage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_CREATE]: () => require('../../../../pages/workspace/invoices/CreateInvoiceFieldsPage').default, + [SCREENS.WORKSPACE.DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE]: () => + require('../../../../pages/workspace/invoices/DynamicInvoiceFieldsInitialListValuePage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_SETTINGS]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsSettingsPage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsListValuesPage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsAddListValuePage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsValueSettingsPage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsInitialValuePage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_TYPE_SELECTOR]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsTypeSelectorPage').default, + [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/invoices/InvoiceFieldsEditValuePage').default, [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_IMPORT]: () => require('../../../../pages/workspace/accounting/certinia/import/CertiniaImportPage').default, [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_DIMENSION_MAPPING]: () => require('../../../../pages/workspace/accounting/certinia/import/CertiniaDimensionMappingPage').default, @@ -1091,6 +1126,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, + [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportMerchantRulesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage').default, @@ -1134,6 +1170,20 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditMerchantRulePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMatchTypePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTaxPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: () => require('../../../../pages/workspace/rules/MerchantRules/AddVendorPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: () => require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, + [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, + [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_NEW]: () => require('../../../../pages/workspace/rules/AgentRules/AddAgentRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: () => require('../../../../pages/workspace/rules/AgentRules/EditAgentRulePage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, @@ -1252,6 +1302,7 @@ const SearchReportActionsModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: () => require('../../../../pages/Search/SearchRejectReasonPage').default, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: () => require('../../../../pages/Search/SearchTransactionsChangeReport').default, + [SCREENS.SEARCH.MERGE_REPORTS_SEARCH_RHP]: () => require('../../../../pages/Search/SearchReportsMergeReports').default, [SCREENS.SEARCH.CHANGE_APPROVER.ROOT]: () => require('../../../../pages/Search/SearchChangeApproverPage').default, [SCREENS.SEARCH.CHANGE_APPROVER.ADD_APPROVER]: () => require('../../../../pages/Search/SearchAddApproverPage').default, [SCREENS.SEARCH.EDIT_MULTIPLE_TRANSACTIONS_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultiplePage').default, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index 12cbcc54ebe5..43b37502d3fc 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -2,6 +2,7 @@ import {animatedSuperWideRHPWidth, useWideRHPState} from '@components/WideRHPCon import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import enhanceCardStyleInterpolator from '@libs/Navigation/AppNavigator/enhanceCardStyleInterpolator'; @@ -23,6 +24,8 @@ import {Animated} from 'react-native'; function useWideModalStackScreenOptions() { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const modalCardStyleInterpolator = useModalCardStyleInterpolator(); // We have to use isSmallScreenWidth, otherwise the content of RHP 'jumps' on Safari - its width is set to size of screen and only after rerender it is set to the correct value @@ -68,13 +71,13 @@ function useWideModalStackScreenOptions() { contentStyle: styles.navigationScreenCardStyle, }, web: { - cardStyle: styles.navigationScreenCardStyle, + cardStyle: isSmallScreenWidth ? StyleUtils.getStyleWithEnvSafeAreaPadding(styles.navigationScreenCardStyle) : styles.navigationScreenCardStyle, cardStyleInterpolator, transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, }, }; }, - [isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, wideRHPRouteKeys], + [StyleUtils, isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, wideRHPRouteKeys], ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.ios.tsx index faf6c68cbfdd..fa5671f786d2 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.ios.tsx @@ -6,10 +6,7 @@ import React from 'react'; import {View} from 'react-native'; type OnboardingModalNavigatorContentWrapperProps = { - /** Rendered child component */ children: React.ReactNode; - - /** Whether the onboarding is on a medium or larger screen width */ onboardingIsMediumOrLargerScreenWidth: boolean; }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.tsx index 20d65f326fae..61729e3f7329 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigatorContentWrapper/index.tsx @@ -4,10 +4,7 @@ import React from 'react'; import {View} from 'react-native'; type OnboardingModalNavigatorContentWrapperProps = { - /** Rendered child component */ children: React.ReactNode; - - /** Whether the onboarding is on a medium or larger screen width */ onboardingIsMediumOrLargerScreenWidth: boolean; }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index 9039607df582..ca4ae4fe0870 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -14,16 +14,16 @@ import React from 'react'; import {Animated, View} from 'react-native'; type BaseOverlayProps = { - /* Callback to close the modal */ + /** Callback to close the modal */ onPress?: () => void; - /* Override the progress from useCardAnimation. Necessary for the secondary overlay */ + /** Override the progress from useCardAnimation. Necessary for the secondary overlay */ progress?: OverlayStylesParams; - /* Overlay position from the left edge of the container */ + /** Overlay position from the left edge of the container */ positionLeftValue?: number | Animated.Value | Animated.AnimatedAddition; - /* Overlay position from the right edge of the container */ + /** Overlay position from the right edge of the container */ positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 7bd9139e0ef7..c10ea1ea7d30 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -476,6 +476,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { (); * 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. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); +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, @@ -111,6 +112,10 @@ function TabNavigator() { name={NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR} component={SearchFullscreenNavigator} /> + import('@pages/home/HomePage')); const LazyReportsSplitNavigator = lazy(() => import('./ReportsSplitNavigator')); const LazySettingsSplitNavigator = lazy(() => import('./SettingsSplitNavigator')); const LazyWorkspaceNavigator = lazy(() => import('./WorkspaceNavigator')); +const LazyInsightsPage = lazy(() => import('@pages/Insights/InsightsPage')); type LazyFallbackProps = { /** Sentry span to tag when this fallback renders. */ @@ -69,6 +70,7 @@ const HomePageScreen = withSuspense(LazyHomePage); const ReportsSplitNavigatorScreen = withSuspense(LazyReportsSplitNavigator, CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB); const SettingsSplitNavigatorScreen = withSuspense(LazySettingsSplitNavigator); const WorkspaceNavigatorScreen = withSuspense(LazyWorkspaceNavigator); +const InsightsPageScreen = withSuspense(LazyInsightsPage); const renderTabBar = ({state}: BottomTabBarProps) => ; @@ -79,7 +81,7 @@ const Tab = createBottomTabNavigator(); * 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. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); +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, @@ -127,6 +129,10 @@ function TabNavigator() { name={NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR} component={SearchFullscreenNavigator} /> + require('../../../. const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../pages/workspace/WorkspaceOverviewPage').default, - [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default, + [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.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, + [SCREENS.WORKSPACE.RECRUITING]: () => require('../../../../pages/workspace/recruiting/WorkspaceRecruitingPage').default, [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, [SCREENS.WORKSPACE.VENDORS]: () => require('../../../../pages/workspace/vendors/WorkspaceVendorsPage').default, [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../../pages/workspace/WorkspaceMoreFeaturesPage').default, @@ -35,10 +36,11 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, + [SCREENS.WORKSPACE.MCP]: () => require('../../../../pages/workspace/mcp/WorkspaceMCPPage').default, [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../pages/workspace/travel/PolicyTravelPage').default, - [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default, + [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPageRevamp').default, [SCREENS.WORKSPACE.TIME_TRACKING]: () => require('../../../../pages/workspace/timeTracking/WorkspaceTimeTrackingPage').default, } satisfies Screens; diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index 54b95ffb6415..30a79116a813 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -3,7 +3,8 @@ import Log from '@libs/Log'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; import buildTabNavigatorNestedState from '@libs/Navigation/helpers/buildTabNavigatorNestedState'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; -import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; +import hasNativeSwipeBackGesture from '@libs/Navigation/helpers/hasNativeSwipeBackGesture'; +import {isFullScreenName, isPreMountBufferHostName} from '@libs/Navigation/helpers/isNavigatorName'; 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'; @@ -34,6 +35,7 @@ const SCREENS_WITH_NAVIGATION_TAB_BAR = new Set([ SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.HOME, + SCREENS.INSIGHTS, ]); const MODAL_ROUTES_TO_DISMISS = new Set([ @@ -476,9 +478,22 @@ function markFocusedTabRouteForRemount(tabState: TabStateForReplacement, existin * State transition for tab targets: [Tab(A), RHP] -> [Tab(B), RHP] * State transition for other fullscreen targets: [FS, RHP] -> [FS, FS', RHP] * - * @see removePreInsertedFullscreenIfNeeded in Navigation.ts — the caller that cleans up + * @see removePreInsertedFullscreenIfNeeded in helpers/preMountBuffer.ts — the caller that cleans up * the pre-insertion when the user cancels. */ + +/** + * Guards against a native swipe-back gesture popping the RHP before JS cleanup runs; web has no such + * gesture. Only inserted when the caller opts in, since some callers dismiss synchronously with no + * gesture involved and never clear the buffer afterward - inserting one there would leave it stuck on top. + */ +function buildPreMountBufferRoute(rhpRouteKey: string, shouldInsertPreMountBuffer: boolean | undefined): StackNavigationState['routes'][number] | undefined { + if (!hasNativeSwipeBackGesture() || !shouldInsertPreMountBuffer) { + return undefined; + } + return {name: SCREENS.PRE_MOUNT_BUFFER, key: `pre-mount-buffer-${rhpRouteKey}`}; +} + function handleReplaceFullscreenUnderRHP( state: StackNavigationState, action: ReplaceFullscreenUnderRHPActionType, @@ -531,7 +546,16 @@ function handleReplaceFullscreenUnderRHP( preInsertedOriginalTabRoute = existingTabState?.routes?.length ? existingTabRoute : ({...existingTabRoute, state: buildTabNavigatorNestedState({name: TAB_SCREENS[0]})} as StackNavigationState['routes'][number]); - const newRoutes = [...routesWithoutRHP.slice(0, tabNavIndex), updatedTabRoute, ...routesWithoutRHP.slice(tabNavIndex + 1), rhpRoute]; + // Add Buffer to the routes this call returns, rather than as a separate dispatch afterward. A + // second dispatch would re-run state rehydration on top of the placeholder tab state built + // above, permanently locking in that placeholder instead of letting it resolve normally. + const bufferRouteForTab = buildPreMountBufferRoute(rhpRoute.key, action.payload.shouldInsertPreMountBuffer); + const newRoutes = [...routesWithoutRHP]; + newRoutes[tabNavIndex] = updatedTabRoute; + if (bufferRouteForTab) { + newRoutes.push(bufferRouteForTab); + } + newRoutes.push(rhpRoute); return stackRouter.getRehydratedState({...state, routes: newRoutes, index: newRoutes.length - 1}, configOptions); } @@ -558,10 +582,18 @@ function handleReplaceFullscreenUnderRHP( } const rehydratedStateAfterPush = stackRouter.getRehydratedState(stateAfterPush, configOptions); + // Build Buffer into this same dispatch (same reasoning as the tab branch above). + const bufferRouteForPush = buildPreMountBufferRoute(rhpRoute.key, action.payload.shouldInsertPreMountBuffer); + const routesWithRHP = [...rehydratedStateAfterPush.routes]; + if (bufferRouteForPush) { + routesWithRHP.push(bufferRouteForPush); + } + routesWithRHP.push(rhpRoute); + return { ...rehydratedStateAfterPush, - routes: [...rehydratedStateAfterPush.routes, rhpRoute], - index: rehydratedStateAfterPush.routes.length, + routes: routesWithRHP, + index: routesWithRHP.length - 1, }; } @@ -583,11 +615,11 @@ function handleRemoveFullscreenUnderRHP( stackRouter: Router, CommonActions.Action | StackActionType>, ) { const rhpRoute = state.routes.at(-1); - if (rhpRoute?.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + if (!isPreMountBufferHostName(rhpRoute?.name)) { return null; } - const routesWithoutRHP = state.routes.slice(0, -1); + const routesWithoutRHP = state.routes.slice(0, -1).filter((r) => r.name !== SCREENS.PRE_MOUNT_BUFFER); // Tab-switch path: restore the original TAB_NAVIGATOR route saved during pre-insertion. if (preInsertedOriginalTabRoute) { diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts index adf44ae1f7f3..2371a29b3680 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}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; } | { 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}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; }; type RemoveFullscreenUnderRHPActionType = RootStackNavigatorActionType & { diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 93a04cf2ad98..24001b72c315 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -2,7 +2,6 @@ import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; import getParamsFromRoute from '@libs/Navigation/helpers/getParamsFromRoute'; import navigationRef from '@libs/Navigation/navigationRef'; -import type {NavigationPartialRoute} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -18,13 +17,42 @@ import {getPreservedNavigatorState} from './usePreserveNavigatorState'; type StackState = StackNavigationState | PartialState>; -const isAtLeastOneInState = (state: StackState, screenName: string): boolean => state.routes.some((route) => route.name === screenName); - type AdaptStateIfNecessaryArgs = { state: StackState; options: SplitNavigatorRouterOptions; }; +function hasSkipInitialSidebarParam(state: StackState): boolean { + const params = state.routes.at(-1)?.params; + return !!params && typeof params === 'object' && 'shouldSkipInitialSidebar' in params && params.shouldSkipInitialSidebar === true; +} + +function stripSkipInitialSidebarParam(state: StackState): StackState { + const lastRoute = state.routes.at(-1); + if (!lastRoute || !hasSkipInitialSidebarParam(state)) { + return state; + } + + const params = Object.fromEntries(Object.entries(lastRoute.params ?? {}).filter(([key]) => key !== 'shouldSkipInitialSidebar')); + if (state.stale === false) { + const routes = [...state.routes]; + const lastStateRoute = routes.at(-1); + if (!lastStateRoute) { + return state; + } + routes[routes.length - 1] = {...lastStateRoute, params: isEmptyObject(params) ? undefined : params}; + return {...state, routes}; + } + + const routes = [...state.routes]; + const lastStateRoute = routes.at(-1); + if (!lastStateRoute) { + return state; + } + routes[routes.length - 1] = {...lastStateRoute, params: isEmptyObject(params) ? undefined : params}; + return {...state, routes}; +} + /** * Adapts the navigation state of a SplitNavigator to ensure proper screen layout and navigation flow. * This function handles both narrow and wide layouts, ensuring that: @@ -45,21 +73,22 @@ function adaptStateIfNecessary({state, options: {sidebarScreen, defaultCentralSc const isNarrowLayout = getIsNarrowLayout(); const rootState = navigationRef.isReady() ? navigationRef.getRootState() : undefined; - const lastRoute = state.routes.at(-1) as NavigationPartialRoute; - - const routes = [...state.routes]; - let modified = false; + const shouldSkipInitialSidebar = hasSkipInitialSidebarParam(state); + const stateWithoutMarker = stripSkipInitialSidebarParam(state); + const routes = [...stateWithoutMarker.routes]; + let modified = stateWithoutMarker !== state; - // When initializing the app on a small screen with the center screen as the initial screen, the sidebar must also be split to allow users to swipe back. + // Despite the name, this is true for any navigation while TAB_NAVIGATOR is the only root route, not only during app startup. + // `shouldSkipInitialSidebar` lets a direct narrow-layout navigation opt out while wide layouts continue to keep the sidebar. const isInitialRoute = !rootState || rootState.routes.length === 1; - const shouldSplitHaveSidebar = isInitialRoute || !isNarrowLayout; + const shouldSplitHaveSidebar = (isInitialRoute && !shouldSkipInitialSidebar) || !isNarrowLayout; // If the screen is wide, there should be at least two screens inside: // - sidebarScreen to cover left pane. // - defaultCentralScreen to cover central pane. - if (!isAtLeastOneInState(state, sidebarScreen) && shouldSplitHaveSidebar) { + if (!routes.some((route) => route.name === sidebarScreen) && shouldSplitHaveSidebar) { const paramsFromRoute = getParamsFromRoute(sidebarScreen, !isNarrowLayout); - const copiedParams = pick(lastRoute?.params, paramsFromRoute); + const copiedParams = pick(routes.at(-1)?.params, paramsFromRoute); // We don't want to get an empty object as params because it breaks some navigation logic when comparing if routes are the same. const params = isEmptyObject(copiedParams) ? undefined : copiedParams; @@ -86,7 +115,7 @@ function adaptStateIfNecessary({state, options: {sidebarScreen, defaultCentralSc routes.push({ name: previousSelectedCentralScreen ?? defaultCentralScreen, - params: state.routes.at(0)?.params, + params: routes.at(0)?.params, }); modified = true; } @@ -147,7 +176,8 @@ function SplitRouter(options: SplitNavigatorRouterOptions) { const stateAfterPop = stackRouter.getStateForAction(state, StackActions.pop(), configOptions) as StackNavigationState; return stackRouter.getStateForAction(stateAfterPop, StackActions.pop(), configOptions); } - return stackRouter.getStateForAction(state, action, configOptions); + const result = stackRouter.getStateForAction(state, action, configOptions); + return result ? stripSkipInitialSidebarParam(result) : result; }, getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) { diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts index 2c678754b464..1bf7bad237a5 100644 --- a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts @@ -74,6 +74,8 @@ const useRootNavigatorScreenOptions = () => { top: 0, left: 0, position: 'fixed', + paddingLeft: 'env(safe-area-inset-left)', + paddingRight: 'env(safe-area-inset-right)', }, cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: onboardingEnter}), }, @@ -113,7 +115,7 @@ const useRootNavigatorScreenOptions = () => { animation: Animations.NONE, web: { cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: {kind: 'none'}, applySidePanelOffset: true}), - cardStyle: shouldUseNarrowLayout ? {...StyleUtils.getNavigationModalCardStyle(), paddingLeft: 0} : {...themeStyles.h100, width: '100%'}, + cardStyle: shouldUseNarrowLayout ? StyleUtils.getStyleWithEnvSafeAreaPadding(StyleUtils.getNavigationModalCardStyle()) : {...themeStyles.h100, width: '100%'}, }, }, } satisfies RootNavigatorScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts index e375f26b24da..160f3b11504e 100644 --- a/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts @@ -19,6 +19,9 @@ import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; const IS_MOBILE_SAFARI = isMobileSafari(); +// `dvw` instead of `%`: a percentage resolves against the transformed parent card, whose box already has the gutters removed, insetting them twice. +const NARROW_CARD_SAFE_AREA_WIDTH = 'calc(100dvw - env(safe-area-inset-left) - env(safe-area-inset-right))'; + type SplitNavigatorScreenOptions = { sidebarScreen: PlatformStackNavigationOptions; centralScreen: PlatformStackNavigationOptions; @@ -49,7 +52,7 @@ const useSplitNavigatorScreenOptions = () => { cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: {kind: 'slide-from-width'}}), cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), - width: shouldUseNarrowLayout ? '100%' : variables.sideBarWithLHBWidth, + width: shouldUseNarrowLayout ? NARROW_CARD_SAFE_AREA_WIDTH : variables.sideBarWithLHBWidth, marginLeft: shouldUseNarrowLayout ? 0 : -variables.sideBarWithLHBWidth, ...(shouldUseNarrowLayout ? {} : themeStyles.borderRight), }, @@ -66,7 +69,7 @@ const useSplitNavigatorScreenOptions = () => { web: { cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: centralScreenEnter}), cardStyle: shouldUseNarrowLayout - ? StyleUtils.getNavigationModalCardStyle() + ? {...StyleUtils.getNavigationModalCardStyle(), width: NARROW_CARD_SAFE_AREA_WIDTH} : { ...themeStyles.h100, }, diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index c0e74c04ff47..b8fa7f3e1e5d 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -44,7 +44,7 @@ import type { State, } from './types'; -import {clearPreInsertedOriginalTabRoute, getPreInsertedOriginalTabRoute} from './AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import {getPreInsertedOriginalTabRoute} from './AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import originalCloseRHPFlow from './helpers/closeRHPFlow'; import getActiveTabName from './helpers/getActiveTabName'; @@ -59,6 +59,16 @@ import isSideModalNavigator from './helpers/isSideModalNavigator'; import linkTo from './helpers/linkTo'; import getMinimalAction from './helpers/linkTo/getMinimalAction'; import {popAndRealignMfaMarker} from './helpers/mfaModalMarkerPreservation'; +import { + canNativeSwipeDismissRHP, + captureBufferTransaction, + clearFullscreenPreInsertedFlag, + getIsFullscreenPreInsertedUnderRHP, + getPreInsertedFullscreenRouteName, + markFullscreenPreInsertedUnderRHP, + recoverFromPreMountBuffer, + removePreInsertedFullscreenIfNeeded, +} from './helpers/preMountBuffer'; import replaceWithSplitNavigator from './helpers/replaceWithSplitNavigator'; import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionToMicrotaskQueue'; import {linkingConfig} from './linkingConfig'; @@ -337,6 +347,7 @@ function startOpenReportSpan(route: Route) { * @param route - The route to navigate to. * @param options - Optional navigation options. * @param options.forceReplace - If true, the navigation action will replace the current route instead of pushing a new one. + * @param options.shouldSkipInitialSplitNavigatorSidebar - If true, a direct split destination will not add its sidebar as an intermediate Back destination. */ function navigate(route: Route, options?: LinkToOptions) { clearSelectedText(); @@ -826,18 +837,20 @@ function navContainsProtectedRoutes(state: State | undefined): boolean { function waitForProtectedRoutes() { return new Promise((resolve) => { isNavigationReady().then(() => { - const currentState = navigationRef.current?.getState(); - if (navContainsProtectedRoutes(currentState)) { + // `getState()` and the `state` event expose the container's own copy of the state, which has + // `routeNames` stripped until a navigator pushes its state up after mounting. Use `getRootState()`, + // which reads the hydrated state from the navigator and always carries `routeNames`. + if (navContainsProtectedRoutes(navigationRef.getRootState())) { resolve(); return; } - const unsubscribe = navigationRef.current?.addListener('state', ({data}) => { - const state = data?.state; - if (navContainsProtectedRoutes(state)) { - unsubscribe?.(); - resolve(); + const unsubscribe = navigationRef.addListener('state', () => { + if (!navContainsProtectedRoutes(navigationRef.getRootState())) { + return; } + unsubscribe(); + resolve(); }); }); }); @@ -953,6 +966,12 @@ const dismissModalWithReport = ( const isReportsSplitTopmostFullScreen = isReportTopmostSplitNavigator(); if (topmostReportID === reportID && areReportsIDsDefined && isReportsSplitTopmostFullScreen) { options?.onBeforeNavigate?.(false); + // Clear any pre-insert/buffer state for this report before dismissing, or the buffer logic + // sees the RHP disappear unexpectedly and reverts back to whatever was showing before this + // report was pre-inserted, replacing it even though it's already the report we want to end up on. + if (getIsFullscreenPreInsertedUnderRHP()) { + clearFullscreenPreInsertedFlag(); + } dismissModal({afterTransition: options?.afterTransition}); return; } @@ -1128,13 +1147,6 @@ function revealRouteBeforeDismissingModal(route: Route, options?: {afterTransiti }); } -// Module-level state tracking the pre-inserted fullscreen route. This follows the same -// pattern as other module-level navigation state in this file (e.g. pendingRoute). -// It is only mutated from preInsertFullscreenUnderRHP / clearFullscreenPreInsertedFlag / -// removePreInsertedFullscreenIfNeeded, which are always called from the JS thread. -let isFullscreenPreInsertedUnderRHP = false; -let preInsertedFullscreenRouteName: string | undefined; - /** * Pre-inserts a fullscreen route (e.g. Search) underneath the currently open RHP on narrow layout. * The route renders behind the fullscreen RHP so that when the user later submits, @@ -1150,7 +1162,7 @@ function preInsertFullscreenUnderRHP(route: Route) { return; } - if (isFullscreenPreInsertedUnderRHP) { + if (getIsFullscreenPreInsertedUnderRHP()) { return; } @@ -1171,7 +1183,7 @@ function preInsertFullscreenUnderRHP(route: Route) { navigationRef.current.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP, - payload: {route}, + payload: {route, shouldInsertPreMountBuffer: canNativeSwipeDismissRHP()}, }); const stateAfter = navigationRef.current.getRootState(); @@ -1183,99 +1195,11 @@ function preInsertFullscreenUnderRHP(route: Route) { return; } - isFullscreenPreInsertedUnderRHP = true; - preInsertedFullscreenRouteName = targetRouteName; + markFullscreenPreInsertedUnderRHP(targetRouteName); DeviceEventEmitter.emit(CONST.MODAL_EVENTS.DISABLE_RHP_ANIMATION); -} - -function getIsFullscreenPreInsertedUnderRHP() { - return isFullscreenPreInsertedUnderRHP; -} - -function getPreInsertedFullscreenRouteName() { - return preInsertedFullscreenRouteName; -} - -function clearFullscreenPreInsertedFlag() { - isFullscreenPreInsertedUnderRHP = false; - preInsertedFullscreenRouteName = undefined; - clearPreInsertedOriginalTabRoute(); -} - -/** - * Removes a pre-inserted fullscreen route when the user backs out without submitting. - * If the RHP is still on top, the pre-inserted route is popped from under it. - * If the RHP is already gone (back-dismissed), the pre-inserted route is the topmost - * fullscreen and is popped directly. - */ -function removePreInsertedFullscreenIfNeeded() { - if (!isFullscreenPreInsertedUnderRHP) { - return; - } - - const routeNameToRemove = preInsertedFullscreenRouteName; - isFullscreenPreInsertedUnderRHP = false; - preInsertedFullscreenRouteName = undefined; - - DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); - - const rootState = navigationRef.getRootState(); - if (!rootState) { - return; - } - - const topRoute = rootState.routes.at(-1); - const isRHPStillOnTop = topRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; - - if (isRHPStillOnTop && routeNameToRemove) { - navigationRef.current?.dispatch({ - type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, - payload: {expectedRouteName: routeNameToRemove}, - }); - return; - } - - // RHP already dismissed. For the tab-switch path, jump back to the original tab. - // For the push path, pop the pre-inserted route directly. - const originalTabRoute = getPreInsertedOriginalTabRoute(); - if (originalTabRoute) { - clearPreInsertedOriginalTabRoute(); - const originalTabState = originalTabRoute.state; - const originalFocusedTabIndex = originalTabState?.index ?? 0; - const originalTabName = originalTabState?.routes?.[originalFocusedTabIndex]?.name; - if (originalTabName) { - requestAnimationFrame(() => { - const currentState = navigationRef.getRootState(); - const tabNavRoute = currentState?.routes.findLast((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); - if (!tabNavRoute?.state?.key) { - return; - } - navigationRef.current?.dispatch({ - ...TabActions.jumpTo(originalTabName), - target: tabNavRoute.state.key, - }); - }); - } - return; - } - - // Push path: the pre-inserted fullscreen is now the topmost route; pop it. - // Deferred to the next frame to avoid dispatching during a React commit. - // Capture the route key now so the rAF callback can match on identity, not just name. - const targetRouteKey = rootState.routes.at(-1)?.key; - requestAnimationFrame(() => { - const currentState = navigationRef.getRootState(); - const topmostRoute = currentState?.routes.at(-1); - if (!topmostRoute || topmostRoute.key !== targetRouteKey || topmostRoute.name !== routeNameToRemove) { - return; - } - if (!navigationRef.current?.canGoBack()) { - return; - } - navigationRef.current.goBack(); - }); + captureBufferTransaction(stateAfter, wasTabSwitched); } function getTopmostSearchReportRouteParams(state = navigationRef.getRootState()): RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined { @@ -1344,6 +1268,7 @@ export default { getIsFullscreenPreInsertedUnderRHP, getPreInsertedFullscreenRouteName, clearFullscreenPreInsertedFlag, + recoverFromPreMountBuffer, removePreInsertedFullscreenIfNeeded, getTopmostSearchReportID, getTopmostSuperWideRHPReportParams, diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index efae1189e831..a3ddc05d17a1 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -54,7 +54,6 @@ type NavigationRootProps = { /** Stores path of last visited page */ lastVisitedPath: Route; - /** Initial url */ initialUrl: string | null; /** Fired when react-navigation is ready */ @@ -79,7 +78,7 @@ function trackFullstoryPageView(state: NavigationState) { /** * Intercept navigation state changes and log it */ -function parseAndLogRoute(state: NavigationState) { +function parseAndLogRoute(state: NavigationState, authenticated: boolean) { if (!state) { return; } @@ -88,7 +87,8 @@ function parseAndLogRoute(state: NavigationState) { const focusedRoute = findFocusedRoute(state); - if (focusedRoute && !CONST.EXCLUDE_FROM_LAST_VISITED_PATH.includes(focusedRoute?.name)) { + // Signed-out screens are never restored, and saving one would overwrite the page a forced re-auth returns to. + if (authenticated && focusedRoute && !CONST.EXCLUDE_FROM_LAST_VISITED_PATH.includes(focusedRoute?.name)) { updateLastVisitedPath(currentPath); if (currentPath.startsWith(`/${ROUTES.ONBOARDING_ROOT.route}`)) { updateOnboardingLastVisitedPath(currentPath); @@ -280,7 +280,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N Sentry.addBreadcrumb({message: `[NAVIGATION] screen: ${currentRoute?.name}, params: ${JSON.stringify(currentRoute?.params ?? {})}`, category: 'navigation'}); updateCurrentReportID(state); - parseAndLogRoute(state); + parseAndLogRoute(state, authenticated); // We want to clean saved scroll offsets for screens that aren't anymore in the state. cleanStaleScrollOffsets(state); diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 6db589b9092f..181e9a3170d4 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -9,6 +9,8 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; +import getPlatform from '@libs/getPlatform'; import Growl from '@libs/Growl'; import Log from '@libs/Log'; @@ -19,6 +21,7 @@ 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'; @@ -26,7 +29,7 @@ import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListB import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; import {TabActions, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; -import {StyleSheet, View} from 'react-native'; +import {Keyboard, StyleSheet, View} from 'react-native'; import type {RegisterTabSwitchGuard, TabSwitchGuard} from './TabSwitchGuardContext'; @@ -37,12 +40,8 @@ type OnyxTabNavigatorProps = Child /** ID of the tab component to be saved in onyx */ id: string; - /** Name of the selected tab */ defaultSelectedTab?: TTabName; - - /** A function triggered when a tab has been selected */ onTabSelected?: (newTabName: TTabName) => void; - tabBar: (props: TabSelectorProps) => React.ReactNode; screenListeners?: ScreenListeners; @@ -60,7 +59,6 @@ type OnyxTabNavigatorProps = Child */ onTabBarFocusTrapContainerElementChanged?: (containerElement: HTMLElement | null) => void; - /** Whether to show the label when the tab is inactive */ shouldShowLabelWhenInactive?: boolean; /** Whether to lazy load the tab screens */ @@ -72,6 +70,13 @@ type OnyxTabNavigatorProps = Child /** Whether tabs should have equal width */ equalWidth?: boolean; + /** + * Whether to wait for the keyboard to be fully hidden before switching tabs. Opt in from tab screens that hold a + * text input next to tabs whose layout reacts to the keyboard, otherwise the tab changes while the keyboard is + * still up and the incoming tab lays out inside a container that is still reserving space for it. + */ + shouldDismissKeyboardBeforeTabSwitch?: boolean; + /** Whether a tab switch that gets bounced back to the tab it came from should be re-applied once (see #98240). * Opt-in, because it makes a tab switch that another part of the app deliberately undoes stick instead. */ @@ -80,6 +85,34 @@ type OnyxTabNavigatorProps = Child const TopTab = createMaterialTopTabNavigator(); +const DISMISS_KEYBOARD_BEFORE_TAB_SWITCH_TIMEOUT_MS = CONST.MAX_TRANSITION_DURATION_MS; + +/** + * Waits for the keyboard to be fully hidden before resolving, but never blocks for longer than + * `DISMISS_KEYBOARD_BEFORE_TAB_SWITCH_TIMEOUT_MS`. `KeyboardUtils.dismiss` only settles once `keyboardDidHide` fires, so + * without that timeout a missed event would leave the tab press swallowed by `preventDefault` and the tab would never + * change. + */ +function dismissKeyboardBeforeTabSwitch(): Promise { + let timeoutID: ReturnType | undefined; + + return Promise.race([ + // `dismiss` resolves on every path today, so this never runs. It is kept because a rejection would otherwise win + // the race, skip the jump and strand the tab press, and this file can't see changes to that utility. + KeyboardUtils.dismiss().catch((error: unknown) => { + Log.warn('[OnyxTabNavigator] Failed to dismiss the keyboard before switching tabs', {error}); + }), + new Promise((resolve) => { + timeoutID = setTimeout(() => { + Log.warn('[OnyxTabNavigator] Timed out waiting for the keyboard to hide before switching tabs'); + resolve(); + }, DISMISS_KEYBOARD_BEFORE_TAB_SWITCH_TIMEOUT_MS); + }), + ]).finally(() => { + clearTimeout(timeoutID); + }); +} + // The TabFocusTrapContext is to collect the focus trap container element of each tab screen. // This provider is placed in the OnyxTabNavigator component and the consumer is in the TabScreenWithFocusTrapWrapper component. const TabFocusTrapContext = React.createContext<(tabName: string, containerElement: HTMLElement | null) => void>(() => {}); @@ -125,6 +158,7 @@ function OnyxTabNavigator({ lazyLoadEnabled = false, onTabSelect, equalWidth = false, + shouldDismissKeyboardBeforeTabSwitch = false, shouldReapplyInterruptedTabPress = false, ...rest }: OnyxTabNavigatorProps) { @@ -168,6 +202,8 @@ function OnyxTabNavigator({ // 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); @@ -180,6 +216,35 @@ function OnyxTabNavigator({ }; }; + // 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(); + } + } 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) { @@ -209,22 +274,41 @@ function OnyxTabNavigator({ }; const handleTabPress = (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { - if (isDiscardModalOpenRef.current) { + 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; - if (!guard || !guard.getHasUnsavedChanges()) { - return; - } 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)); + }); + }; + 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, @@ -242,9 +326,7 @@ function OnyxTabNavigator({ Growl.error(translate('common.genericErrorMessage')); }) .then(() => { - // This jump replaces the press we prevented above, so it needs the same bounce-back protection. - armTabSwitch(currentRouteName, targetRoute.name); - navigation.dispatch(TabActions.jumpTo(targetRoute.name)); + jumpToTargetTab(); }); }); }; diff --git a/src/libs/Navigation/deferNavigate.ts b/src/libs/Navigation/deferNavigate.ts new file mode 100644 index 000000000000..f8fefa2931e3 --- /dev/null +++ b/src/libs/Navigation/deferNavigate.ts @@ -0,0 +1,15 @@ +/** + * Runs `navigate` on the next microtask instead of synchronously. + * + * A synchronous navigate from inside a touch/press handler unmounts the current screen and mounts the + * next one while the same touch is still being dispatched, so on release the event can land on the new + * screen (e.g. its back button) and pop the flow back. Deferring to a microtask lets the touch finish + * first. + * + * Prefer this over a bare `Promise.resolve().then(...)` when navigating from a press/gesture handler. + */ +function deferNavigate(navigate: () => void) { + Promise.resolve().then(navigate); +} + +export default deferNavigate; diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute.ts index bf2e2b8e42f0..c5628d47958c 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute.ts @@ -4,6 +4,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {Route} from '@src/ROUTES'; import isDynamicRouteSuffix from './isDynamicRouteSuffix'; +import joinPathSegments from './joinPathSegments'; import splitPathAndQuery from './splitPathAndQuery'; /** @@ -50,13 +51,16 @@ const combinePathAndSuffix = (basePath: string, suffixWithQuery: string): Route return suffixWithQuery as Route; } - const combinedPath = normalizedBasePath === '/' ? `/${suffixPath}` : `${normalizedBasePath}/${suffixPath}`; + const combinedPath = joinPathSegments(normalizedBasePath, `${suffixPath}`); const mergedQuery = mergeQueryStrings(baseQuery, suffixQuery); return `${combinedPath}${mergedQuery}` as Route; }; /** Adds dynamic route name (with optional query params) to the current URL and returns it + * + * Without `basePath` this resolves against whatever route is active when it runs, which is only correct at + * interaction time. For a route built during render and followed later, use `useScreenBoundDynamicRoute`. * * @param dynamicRouteSuffixWithParams - The dynamic route suffix with optional query params * @param basePath - The base path to use for the dynamic route diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/joinPathSegments.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/joinPathSegments.ts new file mode 100644 index 000000000000..9abb632e7d3d --- /dev/null +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/joinPathSegments.ts @@ -0,0 +1,12 @@ +/** + * Joins a dynamic-route base path with its suffix, special-casing a root base (`/`) so the two + * aren't concatenated into a `//`-prefixed (protocol-relative) path. + * @param basePath - The base path (e.g., '/' or '/settings/wallet') + * @param suffix - The dynamic route suffix to append (without leading slash) + * @returns The joined path, e.g. '/suffix' or '/settings/wallet/suffix' + */ +function joinPathSegments(basePath: string, suffix: string): string { + return basePath === '/' ? `/${suffix}` : `${basePath}/${suffix}`; +} + +export default joinPathSegments; diff --git a/src/libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils.ts b/src/libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils.ts new file mode 100644 index 000000000000..be8af02ccb43 --- /dev/null +++ b/src/libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils.ts @@ -0,0 +1,95 @@ +/** + * Route helpers for Enable Global Reimbursements in wallet settings or on search and report screens. + */ +import Log from '@libs/Log'; + +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; + +import createDynamicRoute from './dynamicRoutesUtils/createDynamicRoute'; +import findAllMatchingDynamicSuffixes from './dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; +import getStateFromPath from './getStateFromPath'; + +type EnableGlobalReimbursementsRouteParams = { + /** The country of the bank account */ + bankCountry?: string; + + /** The currency of the bank account */ + bankCurrency?: string; +}; + +const ENABLE_GLOBAL_REIMBURSEMENTS_SUFFIX_PATTERNS = new Set([ + DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path, + DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.path, + DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.path, +]); + +const ENABLE_GLOBAL_REIMBURSEMENTS_PATH_PREFIX = DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path.split('/').at(0) ?? ''; + +function getDynamicBasePathFromNavigationPath(path: string | undefined): string { + if (!path) { + return ROUTES.HOME; + } + + const pathWithoutLeadingSlash = path.replaceAll(/^\/+/g, ''); + const suffixMatches = findAllMatchingDynamicSuffixes(pathWithoutLeadingSlash); + const match = suffixMatches.find((suffixMatch) => ENABLE_GLOBAL_REIMBURSEMENTS_SUFFIX_PATTERNS.has(suffixMatch.pattern)); + if (match) { + return getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); + } + + return pathWithoutLeadingSlash; +} + +function getEnableGlobalReimbursementsRootBackPath(dynamicBasePath: string): Route { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- runtime path from getPathFromState; caller must validate with shouldUseDynamicEnableGlobalReimbursementsBase first + return dynamicBasePath as Route; +} + +const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = new Set(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.entryScreens); + +function shouldUseDynamicEnableGlobalReimbursementsBase(basePath: string): boolean { + const pathWithoutQuery = basePath.split('?').at(0) ?? ''; + + if (!pathWithoutQuery || pathWithoutQuery.includes(ENABLE_GLOBAL_REIMBURSEMENTS_PATH_PREFIX)) { + return false; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- path parsed to verify focused route name against entry screens + const focusedRouteName = findFocusedRouteWithOnyxTabGuard(getStateFromPath(pathWithoutQuery as Route) ?? {})?.name; + if (focusedRouteName && ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS.has(focusedRouteName)) { + return true; + } + } catch (error) { + Log.warn('shouldUseDynamicEnableGlobalReimbursementsBase: failed to resolve route state from path', {basePath, error: error instanceof Error ? error.message : String(error)}); + return false; + } + + return false; +} + +function getEnableGlobalReimbursementsBusinessNavigationRoute( + bankAccountID: number, + subPage: string, + params?: EnableGlobalReimbursementsRouteParams, + navigationPathAtSignal?: string, +): Route { + const basePath = navigationPathAtSignal ? getDynamicBasePathFromNavigationPath(navigationPathAtSignal) : undefined; + + if (basePath && shouldUseDynamicEnableGlobalReimbursementsBase(basePath)) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(String(bankAccountID), subPage, undefined, params), basePath); + } + + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(bankAccountID, subPage, undefined, params); +} + +export type {EnableGlobalReimbursementsRouteParams}; +export { + getDynamicBasePathFromNavigationPath, + getEnableGlobalReimbursementsBusinessNavigationRoute, + getEnableGlobalReimbursementsRootBackPath, + shouldUseDynamicEnableGlobalReimbursementsBase, +}; diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index 9a47860057d1..cf606dab8c30 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -8,6 +8,7 @@ import {getPathFromState as RNGetPathFromState} from '@react-navigation/native'; import getDynamicRouteQueryParams from './dynamicRoutesUtils/getDynamicRouteQueryParams'; import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen'; +import joinPathSegments from './dynamicRoutesUtils/joinPathSegments'; import splitPathAndQuery from './dynamicRoutesUtils/splitPathAndQuery'; import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; @@ -164,8 +165,7 @@ function getPathFromStateWithDynamicRoute(state: State): string { } const queryString = mergedParams.toString(); - // Mirror the root-base join in `createDynamicRoute.ts` so a `/` base yields `/suffix`, never `//suffix`. - const combinedPath = basePathWithoutQuery === '/' ? `/${suffixPath}` : `${basePathWithoutQuery}/${suffixPath}`; + const combinedPath = joinPathSegments(`${basePathWithoutQuery}`, `${suffixPath}`); // Safety net for this hand-built dynamic branch: guarantee exactly one leading slash and no internal `//`, // so the browser never parses a segment as a host and `history.pushState` can't throw a SecurityError. diff --git a/src/libs/Navigation/helpers/goBackFromExportConnection.ts b/src/libs/Navigation/helpers/goBackFromExportConnection.ts index ed9eb27efe62..3ff96abf65d0 100644 --- a/src/libs/Navigation/helpers/goBackFromExportConnection.ts +++ b/src/libs/Navigation/helpers/goBackFromExportConnection.ts @@ -9,15 +9,13 @@ import replaceCompanyCardsRoute from './replaceCompanyCardsRoute'; * If export company card value is changed to unsupported - we should redirect user directly to card details view * If not, just regular go back */ -function goBackFromExportConnection(shouldGoBackToSpecificRoute: boolean, backTo?: string, dynamicBackPath?: Route) { +function goBackFromExportConnection(shouldGoBackToSpecificRoute: boolean, dynamicBackPath?: Route) { const feature = CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards; - const effectivePath = dynamicBackPath ?? backTo; - // TODO: Remove `backTo` fallback once all routes using this helper are migrated to dynamic back paths. - if (!(shouldGoBackToSpecificRoute && effectivePath?.includes(feature.alias))) { + if (!(shouldGoBackToSpecificRoute && dynamicBackPath?.includes(feature.alias))) { return Navigation.goBack(dynamicBackPath); } - const companyCardDetailsPage = replaceCompanyCardsRoute(effectivePath); + const companyCardDetailsPage = replaceCompanyCardsRoute(dynamicBackPath); return Navigation.goBack(companyCardDetailsPage, {compareParams: false}); } diff --git a/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.native.ts b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.native.ts new file mode 100644 index 000000000000..6c3e0643cdde --- /dev/null +++ b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.native.ts @@ -0,0 +1,6 @@ +/** iOS and Android can pop a modal with a swipe-back gesture before JS cleanup runs. */ +function hasNativeSwipeBackGesture(): boolean { + return true; +} + +export default hasNativeSwipeBackGesture; diff --git a/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.ts b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.ts new file mode 100644 index 000000000000..7fbc01f2f9d8 --- /dev/null +++ b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.ts @@ -0,0 +1,6 @@ +/** Web and desktop have no swipe-back gesture that could pop a modal outside of JS. */ +function hasNativeSwipeBackGesture(): boolean { + return false; +} + +export default hasNativeSwipeBackGesture; diff --git a/src/libs/Navigation/helpers/isNavigatorName.ts b/src/libs/Navigation/helpers/isNavigatorName.ts index a9dc05407e6b..80914b3a5d22 100644 --- a/src/libs/Navigation/helpers/isNavigatorName.ts +++ b/src/libs/Navigation/helpers/isNavigatorName.ts @@ -4,7 +4,14 @@ import type {FullScreenName, OnboardingFlowName, SplitNavigatorName, SplitNaviga import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -const FULL_SCREENS_SET = new Set([...Object.values(SIDEBAR_TO_SPLIT), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR, SCREENS.HOME, NAVIGATORS.WORKSPACE_NAVIGATOR]); +const FULL_SCREENS_SET = new Set([ + ...Object.values(SIDEBAR_TO_SPLIT), + NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, + NAVIGATORS.TAB_NAVIGATOR, + SCREENS.HOME, + SCREENS.INSIGHTS, + NAVIGATORS.WORKSPACE_NAVIGATOR, +]); // Root-level navigators that render as an overlay/modal on top of the current screen. These do NOT go through the // shared `Modal` component, so they never set `ONYXKEYS.MODAL` — the only way to know one is open is the navigation state. const MODAL_NAVIGATORS_SET = new Set([ @@ -18,6 +25,7 @@ const MODAL_NAVIGATORS_SET = new Set([ NAVIGATORS.SHARE_MODAL_NAVIGATOR, NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR, ]); +const PRE_MOUNT_BUFFER_HOSTS_SET = new Set([NAVIGATORS.RIGHT_MODAL_NAVIGATOR, NAVIGATORS.SHARE_MODAL_NAVIGATOR]); const SIDEBARS_SET = new Set(Object.values(SPLIT_TO_SIDEBAR)); const ONBOARDING_SCREENS_SET = new Set(Object.values(SCREENS.ONBOARDING)); const SPLIT_NAVIGATORS_SET = new Set(Object.values(SIDEBAR_TO_SPLIT)); @@ -47,6 +55,11 @@ function isFullScreenName(screen: string | undefined) { return checkIfScreenHasMatchingNameToSetValues(screen, FULL_SCREENS_SET); } +/** Root modal navigators a fullscreen destination can be pre-inserted under. Insert and teardown must agree on this set. */ +function isPreMountBufferHostName(screen: string | undefined) { + return checkIfScreenHasMatchingNameToSetValues(screen, PRE_MOUNT_BUFFER_HOSTS_SET); +} + function isModalNavigatorName(screen: string | undefined) { return checkIfScreenHasMatchingNameToSetValues(screen, MODAL_NAVIGATORS_SET); } @@ -59,4 +72,4 @@ function isWorkspaceNavigatorRouteName(screen: string | undefined) { return checkIfScreenHasMatchingNameToSetValues(screen, WORKSPACES_TAB_SET); } -export {isFullScreenName, isModalNavigatorName, isOnboardingFlowName, isSidebarScreenName, isSplitNavigatorName, isWorkspaceNavigatorRouteName}; +export {isFullScreenName, isModalNavigatorName, isOnboardingFlowName, isPreMountBufferHostName, isSidebarScreenName, isSplitNavigatorName, isWorkspaceNavigatorRouteName}; diff --git a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts index 1baf267b3f6b..d24291aeaa13 100644 --- a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts +++ b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts @@ -13,6 +13,7 @@ const SCREENS_WITH_TAB_BAR = new Set([ SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, + SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.DOMAINS_LIST, diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index f05f5fd1553e..a2bfa43eec17 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -14,7 +14,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; +import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {getActionFromState} from '@react-navigation/core'; import {CommonActions, findFocusedRoute} from '@react-navigation/native'; @@ -23,15 +23,41 @@ import type {ActionPayloadParams, LinkToOptions} from './types'; import getMinimalAction from './getMinimalAction'; -const defaultLinkToOptions: LinkToOptions = { +const defaultLinkToOptions = { forceReplace: false, -}; + shouldSkipInitialSplitNavigatorSidebar: false, + skipMatchingFullScreenRoute: false, +} satisfies Pick; + +/** + * The split router reads `shouldSkipInitialSidebar` from the innermost screen params. Walk through nested + * navigator payloads so destinations with and without their own params receive the transient marker. + */ +function addSkipInitialSidebarParam(params: ActionPayloadParams | undefined): ActionPayloadParams { + const navigationParams = params ?? {}; + if (typeof navigationParams.screen === 'string') { + return {...navigationParams, params: addSkipInitialSidebarParam(navigationParams.params)}; + } + return {...navigationParams, shouldSkipInitialSidebar: true}; +} + +function hasParamsPayload(action: NavigationAction): action is NavigationAction & {payload: {params?: ActionPayloadParams}} { + return !!action.payload && 'params' in action.payload; +} + +function addSkipInitialSidebarParamToAction(action: NavigationAction): NavigationAction { + if (!hasParamsPayload(action)) { + return action; + } + + return {...action, payload: {...action.payload, params: addSkipInitialSidebarParam(action.payload.params)}}; +} /** * Leaf screen names that represent the root/landing view of each tab. * Used to distinguish plain tab switches from cross-tab deep navigations. */ -const ROOT_TAB_SCREENS = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT, SCREENS.WORKSPACES_LIST]); +const ROOT_TAB_SCREENS = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT, SCREENS.WORKSPACES_LIST]); function areNamesAndParamsEqual(currentState: NavigationState, stateFromPath: PartialState>) { const currentFocusedRoute = findFocusedRoute(currentState); @@ -159,8 +185,7 @@ export default function linkTo(navigation: NavigationContainerRef; + const {forceReplace, shouldSkipInitialSplitNavigatorSidebar, skipMatchingFullScreenRoute} = {...defaultLinkToOptions, ...options}; const normalizedPath = normalizePath(path) as Route; const normalizedPathAfterRedirection = (getMatchingNewRoute(normalizedPath) ?? normalizedPath) as Route; @@ -233,14 +258,14 @@ export default function linkTo(navigation: NavigationContainerRef void; // If true, waits for ongoing transitions to finish before navigating. Defaults to false (navigates immediately). waitForTransition?: boolean; + // If true, skip full-screen route matching when opening an RHP. Use when the central pane should stay on the current tab. + skipMatchingFullScreenRoute?: boolean; }; export type {ActionPayload, ActionPayloadParams, LinkToOptions}; diff --git a/src/libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync.ts b/src/libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync.ts new file mode 100644 index 000000000000..610f5e4d927d --- /dev/null +++ b/src/libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync.ts @@ -0,0 +1,71 @@ +/** + * React Navigation reuses the existing Domain split navigator when Search Router navigation crosses Domains, so its persistent sidebar keeps the previous Domain ID. + * Synchronize that ID before navigating to keep the sidebar and destination page consistent. + */ +import {SPLIT_TO_SIDEBAR} from '@libs/Navigation/linkingConfig/RELATIONS'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import type {Route} from '@src/ROUTES'; + +import {getTabState} from './tabNavigatorUtils'; + +type ActiveDomainSidebarRoute = { + sidebarRouteKey: string; + splitStateKey?: string; + domainAccountID?: number; +}; + +function getDomainAccountIDParam(params: unknown): number | undefined { + if (params && typeof params === 'object' && 'domainAccountID' in params && typeof params.domainAccountID === 'number') { + return params.domainAccountID; + } + + return undefined; +} + +function getActiveDomainSidebarRoute(): ActiveDomainSidebarRoute | undefined { + if (!navigationRef.isReady()) { + return undefined; + } + + const routes = navigationRef.getRootState().routes; + const tabNavigatorRoute = routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); + const workspaceNavigatorRoute = getTabState(tabNavigatorRoute)?.routes.find((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR); + // Wide layouts nest the Domain split under the workspace navigator; fall back to the root for the standalone case. + const domainSplitRoute = + workspaceNavigatorRoute?.state?.routes.findLast((route) => route.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR) ?? + routes.findLast((route) => route.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR); + if (!domainSplitRoute) { + return undefined; + } + + const sidebarRoute = domainSplitRoute.state?.routes.find((route) => route.name === SPLIT_TO_SIDEBAR[NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR]); + if (!sidebarRoute?.key) { + return undefined; + } + + const domainAccountID = getDomainAccountIDParam(sidebarRoute.params); + return { + sidebarRouteKey: sidebarRoute.key, + splitStateKey: domainSplitRoute.state?.key, + domainAccountID, + }; +} + +function navigateToDomainRouteWithSidebarSync(targetRoute: Route, domainAccountID: number, shouldUseNarrowLayout: boolean) { + if (shouldUseNarrowLayout) { + Navigation.navigate(targetRoute); + return; + } + + const domainSidebarRoute = getActiveDomainSidebarRoute(); + if (domainSidebarRoute && domainSidebarRoute.domainAccountID !== domainAccountID) { + Navigation.setParams({domainAccountID}, domainSidebarRoute.sidebarRouteKey, domainSidebarRoute.splitStateKey); + } + + Navigation.navigate(targetRoute); +} + +export default navigateToDomainRouteWithSidebarSync; diff --git a/src/libs/Navigation/helpers/navigateToWorkspaceSettingsRoute.ts b/src/libs/Navigation/helpers/navigateToWorkspaceSettingsRoute.ts new file mode 100644 index 000000000000..7386ca07fa12 --- /dev/null +++ b/src/libs/Navigation/helpers/navigateToWorkspaceSettingsRoute.ts @@ -0,0 +1,67 @@ +/** + * Navigates between Workspace settings while keeping the wide-layout sidebar policy in sync. + */ +import WORKSPACE_TO_RHP from '@libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import {isWorkspaceNavigatorRouteName} from './isNavigatorName'; +import {getTabState} from './tabNavigatorUtils'; + +function isWorkspaceSettingsScreen(screenName: string | undefined): boolean { + return !!screenName && Object.hasOwn(WORKSPACE_TO_RHP, screenName); +} + +function getActiveWorkspaceSidebarRoute(): {sidebarRouteKey: string; splitStateKey?: string; policyID?: string; activeScreenName?: string} | undefined { + if (!navigationRef.isReady()) { + return undefined; + } + + const routes = navigationRef.getRootState().routes; + const tabNavigatorRoute = routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); + const workspaceNavigatorRoute = getTabState(tabNavigatorRoute)?.routes.find((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR); + const workspaceSplitRoute = + workspaceNavigatorRoute?.state?.routes.findLast((route) => isWorkspaceNavigatorRouteName(route.name)) ?? routes.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); + if (workspaceSplitRoute?.name !== NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) { + return undefined; + } + + const sidebarRoute = workspaceSplitRoute.state?.routes.find((route) => route.name === SCREENS.WORKSPACE.INITIAL); + if (!sidebarRoute?.key) { + return undefined; + } + + const params = sidebarRoute.params; + const policyID = params && typeof params === 'object' && 'policyID' in params && typeof params.policyID === 'string' ? params.policyID : undefined; + return {sidebarRouteKey: sidebarRoute.key, splitStateKey: workspaceSplitRoute.state?.key, policyID, activeScreenName: workspaceSplitRoute.state?.routes.at(-1)?.name}; +} + +function navigateToWorkspaceSettingsRoute(targetRoute: Route, policyID: string, shouldUseNarrowLayout: boolean, targetScreenName: string) { + if (shouldUseNarrowLayout) { + Navigation.navigate(targetRoute, {shouldSkipInitialSplitNavigatorSidebar: true}); + return; + } + + const workspaceSidebarRoute = getActiveWorkspaceSidebarRoute(); + const isWorkspaceSettingsTransition = + isWorkspaceSettingsScreen(targetScreenName) && + isWorkspaceSettingsScreen(workspaceSidebarRoute?.activeScreenName) && + (workspaceSidebarRoute?.activeScreenName !== targetScreenName || workspaceSidebarRoute.policyID !== policyID); + + if (!isWorkspaceSettingsTransition) { + Navigation.navigate(targetRoute); + return; + } + + if (workspaceSidebarRoute && workspaceSidebarRoute.policyID !== policyID) { + Navigation.setParams({policyID}, workspaceSidebarRoute.sidebarRouteKey, workspaceSidebarRoute.splitStateKey); + } + + Navigation.navigate(targetRoute); +} + +export default navigateToWorkspaceSettingsRoute; diff --git a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts index 7997d988bf17..d430b3ef016a 100644 --- a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts +++ b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts @@ -87,6 +87,11 @@ const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, poli if (policy?.id && existingTabNavStateKey) { const focusedWorkspaceSplitRouteName = lastWorkspacesTabNavigatorRoute.state ? findFocusedRoute(lastWorkspacesTabNavigatorRoute.state)?.name : undefined; const isOnWorkspaceInitial = focusedWorkspaceSplitRouteName === SCREENS.WORKSPACE.INITIAL; + // On narrow layouts, navigating with the bottom tab bar must always land on a page that still + // displays that tab bar. Workspace sub-pages (Travel, Members, Workflows, Accounting, ...) hide + // it, so restoring one here would switch the tab and hide the tab bar in a single action. + // Intentionally reset the workspace split back to WorkspaceInitialPage instead of restoring the + // last visited sub-page. if (shouldUseNarrowLayout && !isOnWorkspaceInitial) { if (lastWorkspacesTabNavigatorRoute.state?.key) { // Live state: pop the workspace split to WorkspaceInitialPage while the tab is diff --git a/src/libs/Navigation/helpers/preMountBuffer.ts b/src/libs/Navigation/helpers/preMountBuffer.ts new file mode 100644 index 000000000000..bd356bd4a35a --- /dev/null +++ b/src/libs/Navigation/helpers/preMountBuffer.ts @@ -0,0 +1,313 @@ +import {clearPreInsertedOriginalTabRoute, getPreInsertedOriginalTabRoute} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import {CommonActions, TabActions} from '@react-navigation/native'; +import {DeviceEventEmitter} from 'react-native'; + +import hasNativeSwipeBackGesture from './hasNativeSwipeBackGesture'; +import {isPreMountBufferHostName} from './isNavigatorName'; + +// Always set and cleared together - the route name is only meaningful while the flag is true. +let isFullscreenPreInsertedUnderRHP = false; +let preInsertedFullscreenRouteName: string | undefined; + +// Set while a neutral placeholder route sits directly under the RHP, so a native swipe-dismiss +// reveals that placeholder instead of the real destination underneath. +// +// Watches the root 'state' event instead of transitionEnd, since transitionEnd never fires on +// Android when the animation is none - 'state' fires reliably on both platforms. +let bufferTransaction: {rhpRouteKey: string; bufferRouteKey: string; mode: 'push' | 'tab'; destinationRouteKey?: string} | undefined; +let bufferStateListenerUnsubscribe: (() => void) | undefined; + +function clearBufferStateListener() { + bufferStateListenerUnsubscribe?.(); + bufferStateListenerUnsubscribe = undefined; +} + +/** + * No-op while the RHP is still in the stack. Once it is gone without commit/cancel having run, restores the + * origin with a single reset dispatch: the original tab route, or the stack without Buffer and the pushed destination. + */ +function revertPreMountBufferIfRHPClosed() { + if (!bufferTransaction) { + return; + } + const {rhpRouteKey, bufferRouteKey, mode, destinationRouteKey} = bufferTransaction; + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + const stillHasRHP = rootState.routes.some((r) => r.key === rhpRouteKey); + if (stillHasRHP) { + return; + } + + DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + + // Reaching here means the RHP is gone but commit/cancel never ran (they would have cleared + // bufferTransaction and returned above) - something else removed it (native swipe, external + // dismissal). Treat it the same as a normal cancel: end up back at the origin. + bufferTransaction = undefined; + clearBufferStateListener(); + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + + if (mode === 'tab') { + const originalTabRoute = getPreInsertedOriginalTabRoute(); + clearPreInsertedOriginalTabRoute(); + const tabNavIndex = rootState.routes.findLastIndex((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); + if (!originalTabRoute || tabNavIndex < 0) { + const fallbackRoutes = rootState.routes.filter((r) => r.key !== bufferRouteKey); + navigationRef.current?.dispatch(CommonActions.reset({...rootState, routes: fallbackRoutes, index: fallbackRoutes.length - 1})); + return; + } + const withoutBuffer = rootState.routes.filter((r) => r.key !== bufferRouteKey); + const lastTabIndex = withoutBuffer.findLastIndex((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); + const newRoutes = withoutBuffer.map((r, i) => (i === lastTabIndex ? originalTabRoute : r)); + navigationRef.current?.dispatch(CommonActions.reset({...rootState, routes: newRoutes, index: newRoutes.length - 1})); + return; + } + + // Push path: this transaction pushed the destination as a new route rather than swapping it into + // a tab, so remove both it and Buffer in one dispatch - two dispatches would leave a render in + // between where only one of them is gone. + const newRoutes = rootState.routes.filter((r) => r.key !== bufferRouteKey && r.key !== destinationRouteKey); + navigationRef.current?.dispatch( + CommonActions.reset({ + ...rootState, + routes: newRoutes, + index: newRoutes.length - 1, + }), + ); +} + +/** Restores the route that was visible before the pre-mount buffer route */ +function recoverFromPreMountBuffer() { + const rootState = navigationRef.getRootState(); + if (rootState?.routes.at(-1)?.name !== SCREENS.PRE_MOUNT_BUFFER) { + return; + } + + if (bufferTransaction) { + revertPreMountBufferIfRHPClosed(); + return; + } + + // Defensive fallback for a lost transaction: restore the original tab route when pre-mount + // switched tabs, otherwise remove both Buffer and the speculative pushed destination. + const originalTabRoute = getPreInsertedOriginalTabRoute(); + const routesWithoutBuffer = rootState.routes.slice(0, -1); + const tabNavIndex = originalTabRoute ? routesWithoutBuffer.findLastIndex((route) => route.name === NAVIGATORS.TAB_NAVIGATOR) : -1; + const routes = originalTabRoute && tabNavIndex >= 0 ? routesWithoutBuffer.map((route, index) => (index === tabNavIndex ? originalTabRoute : route)) : rootState.routes.slice(0, -2); + if (!routes.length) { + return; + } + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + clearPreInsertedOriginalTabRoute(); + clearBufferStateListener(); + navigationRef.current?.dispatch(CommonActions.reset({...rootState, routes, index: routes.length - 1})); +} + +/** + * Reads the navigation state just after Buffer was inserted (in an earlier dispatch, not here) and, + * if Buffer really did land under the RHP, records it as `bufferTransaction` and starts watching for + * the RHP closing so it can be cleaned up. + */ +function captureBufferTransaction(stateAfter: ReturnType, wasTabSwitched: boolean) { + if (!hasNativeSwipeBackGesture() || !stateAfter) { + return; + } + const rhpRoute = stateAfter.routes.at(-1); + const bufferRoute = stateAfter.routes.at(-2); + if (!isPreMountBufferHostName(rhpRoute?.name) || bufferRoute?.name !== SCREENS.PRE_MOUNT_BUFFER) { + return; + } + + if (wasTabSwitched) { + bufferTransaction = {rhpRouteKey: rhpRoute.key, bufferRouteKey: bufferRoute.key, mode: 'tab'}; + } else { + const destinationRoute = stateAfter.routes.at(-3); + if (!destinationRoute) { + return; + } + bufferTransaction = {rhpRouteKey: rhpRoute.key, bufferRouteKey: bufferRoute.key, mode: 'push', destinationRouteKey: destinationRoute.key}; + } + + clearBufferStateListener(); + bufferStateListenerUnsubscribe = navigationRef.current?.addListener('state', revertPreMountBufferIfRHPClosed); +} + +/** Removes just the Buffer route, leaving the pushed destination in place, and clears the transaction (including its own state listener). */ +function removeBufferRouteOnly() { + if (!bufferTransaction) { + return; + } + const {bufferRouteKey} = bufferTransaction; + bufferTransaction = undefined; + clearBufferStateListener(); + + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + const newRoutes = rootState.routes.filter((r) => r.key !== bufferRouteKey); + if (newRoutes.length === rootState.routes.length) { + return; + } + navigationRef.current?.dispatch( + CommonActions.reset({ + ...rootState, + routes: newRoutes, + index: newRoutes.length - 1, + }), + ); +} + +/** + * Whether a swipe-back gesture would dismiss the whole RHP, rather than just popping one screen off + * its inner stack. Only true when the inner stack has nothing left to pop back to - otherwise the + * swipe stays inside the RHP and never reaches it. + * + * Defaults to true (assume dismissible) when the state can't be read, so the buffer never gets + * silently skipped on uncertain data. + * + * The Share modal is a flat stack, so the nested probe below only sees a depth when a focused Share screen + * renders its own navigator. A pre-insert from a Share screen with inner tabs would then skip the buffer. + */ +function canNativeSwipeDismissRHP(): boolean { + const rootState = navigationRef.getRootState(); + const rhpRoute = rootState?.routes.at(-1); + if (!isPreMountBufferHostName(rhpRoute?.name)) { + return true; + } + + const focusedRHPStackRoute = rhpRoute.state?.routes?.at(rhpRoute.state?.index ?? 0); + const innerFlowState = focusedRHPStackRoute?.state; + if (!innerFlowState) { + return true; + } + + return (innerFlowState.index ?? 0) === 0; +} + +/** Records that `preInsertFullscreenUnderRHP` landed a destination under the RHP, so the cleanup helpers know what to remove later. */ +function markFullscreenPreInsertedUnderRHP(routeName: string | undefined) { + isFullscreenPreInsertedUnderRHP = true; + preInsertedFullscreenRouteName = routeName; +} + +function getIsFullscreenPreInsertedUnderRHP() { + return isFullscreenPreInsertedUnderRHP; +} + +function getPreInsertedFullscreenRouteName() { + return preInsertedFullscreenRouteName; +} + +/** Called once the pre-inserted destination is confirmed, so it should stay - only the Buffer route in front of it needs cleaning up. */ +function clearFullscreenPreInsertedFlag() { + removeBufferRouteOnly(); + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + clearPreInsertedOriginalTabRoute(); +} + +/** + * Removes a pre-inserted fullscreen route when the user backs out without submitting. + * If the RHP is still on top, the pre-inserted route is popped from under it. + * If the RHP is already gone (back-dismissed), the pre-inserted route is the topmost + * fullscreen and is popped directly. + */ +function removePreInsertedFullscreenIfNeeded() { + if (!isFullscreenPreInsertedUnderRHP) { + return; + } + + const routeNameToRemove = preInsertedFullscreenRouteName; + + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + + DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + + const topRoute = rootState.routes.at(-1); + const isModalHostStillOnTop = isPreMountBufferHostName(topRoute?.name); + + if (isModalHostStillOnTop && routeNameToRemove) { + // Call this before dispatching below, so its listener teardown happens before this dispatch + // can trigger it. + removeBufferRouteOnly(); + navigationRef.current?.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, + payload: {expectedRouteName: routeNameToRemove}, + }); + return; + } + + // Skip this if a buffer transaction is still live - something else already resets the tab and + // buffer together in one step. Doing it here too would race that and briefly show the wrong screen. + if (bufferTransaction) { + return; + } + + // RHP already dismissed. For the tab-switch path, jump back to the original tab. + // For the push path, pop the pre-inserted route directly. + const originalTabRoute = getPreInsertedOriginalTabRoute(); + if (originalTabRoute) { + clearPreInsertedOriginalTabRoute(); + const originalTabState = originalTabRoute.state; + const originalFocusedTabIndex = originalTabState?.index ?? 0; + const originalTabName = originalTabState?.routes?.[originalFocusedTabIndex]?.name; + if (originalTabName) { + requestAnimationFrame(() => { + const currentState = navigationRef.getRootState(); + const tabNavRoute = currentState?.routes.findLast((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); + if (!tabNavRoute?.state?.key) { + return; + } + navigationRef.current?.dispatch({ + ...TabActions.jumpTo(originalTabName), + target: tabNavRoute.state.key, + }); + }); + } + return; + } + + // Push path: the pre-inserted fullscreen is now the topmost route; pop it. + // Deferred to the next frame to avoid dispatching during a React commit. + // Capture the route key now so the rAF callback can match on identity, not just name. + const targetRouteKey = rootState.routes.at(-1)?.key; + requestAnimationFrame(() => { + const currentState = navigationRef.getRootState(); + const topmostRoute = currentState?.routes.at(-1); + if (!topmostRoute || topmostRoute.key !== targetRouteKey || topmostRoute.name !== routeNameToRemove) { + return; + } + if (!navigationRef.current?.canGoBack()) { + return; + } + navigationRef.current.goBack(); + }); +} + +export { + canNativeSwipeDismissRHP, + captureBufferTransaction, + clearFullscreenPreInsertedFlag, + getIsFullscreenPreInsertedUnderRHP, + getPreInsertedFullscreenRouteName, + markFullscreenPreInsertedUnderRHP, + recoverFromPreMountBuffer, + removePreInsertedFullscreenIfNeeded, +}; diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index cd736853f587..c3911599856f 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -79,7 +79,6 @@ const oldRoutes: Record = { '/workspaces/*/connections/quickbooks-online/advanced/autosync': '/workspaces/$1/accounting/quickbooks-online/advanced/quickbooks-online-autosync', '/travel/upgrade': '/travel/travel-upgrade', '/travel/upgrade/workspace/confirmation': '/travel/travel-upgrade/workspace-confirmation', - '/travel/verify-account': '/travel/travel-verify-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account-select': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account-select', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account/account-select': diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/HOME_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/HOME_TO_RHP.ts index fd4f230b7cec..8f34a3c1a4cf 100644 --- a/src/libs/Navigation/linkingConfig/RELATIONS/HOME_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/HOME_TO_RHP.ts @@ -2,8 +2,14 @@ import SCREENS from '@src/SCREENS'; const HOME_TO_RHP: Record = { // Transaction/report RHP screens opened from the Recently added slot keep Home underneath on refresh/deep-link. - // The Enter signer info flow is opened from the Time Sensitive section, so it keeps Home underneath too. - [SCREENS.HOME]: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REIMBURSEMENT_ACCOUNT_ENTER_SIGNER_INFO], + // Time Sensitive flows (enter signer info, confirm digital wallet) keep Home underneath too. + [SCREENS.HOME]: [ + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.REIMBURSEMENT_ACCOUNT_ENTER_SIGNER_INFO, + SCREENS.SETTINGS.WALLET.CARD_ADD_TO_DIGITAL_WALLET, + ], }; export default HOME_TO_RHP; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts index 442b7ffa4d10..ade7c9823dae 100644 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts @@ -9,6 +9,7 @@ const SEARCH_TO_RHP: Partial> = { [SCREENS.SETTINGS.PROFILE.ROOT]: [ SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, - SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHODS, SCREENS.SETTINGS.PROFILE.AVATAR, - SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHOD_DETAILS, - SCREENS.SETTINGS.PROFILE.DYNAMIC_NEW_CONTACT_METHOD, - SCREENS.SETTINGS.PROFILE.DYNAMIC_NEW_CONTACT_METHOD_CONFIRM_VALIDATE_CODE, SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_SET_DEFAULT_CONFIRM, SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER, SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 6f0501000639..b72e4bf6ebbb 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -247,6 +247,9 @@ const WORKSPACE_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.path, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.path, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { path: ROUTES.SETTINGS_WALLET_SHARE_BANK_ACCOUNT.route, exact: true, @@ -299,6 +302,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.CARD_ADD_TO_DIGITAL_WALLET]: { + path: ROUTES.SETTINGS_WALLET_CARD_ADD_TO_DIGITAL_WALLET.route, + exact: true, + }, [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN]: { path: ROUTES.SETTINGS_WALLET_CARD_CHANGE_PIN.route, exact: true, @@ -819,6 +826,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT.route}, [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_DUALENTRY_TRAVEL_BILLING_SETTLEMENT_ACCOUNT.route}, [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT.route}, + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SETUP]: {path: ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SETUP.route}, + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXISTING_CONNECTIONS]: {path: ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS.route}, + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SUBSIDIARY_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_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, @@ -848,9 +858,30 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE]: { path: ROUTES.WORKSPACE_INVOICES_COMPANY_WEBSITE.route, }, - [SCREENS.WORKSPACE.INVOICES_VERIFY_ACCOUNT]: { - path: ROUTES.WORKSPACE_INVOICES_VERIFY_ACCOUNT.route, - exact: true, + [SCREENS.WORKSPACE.INVOICE_FIELDS_CREATE]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_CREATE.route, + }, + [SCREENS.WORKSPACE.DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE]: DYNAMIC_ROUTES.WORKSPACE_INVOICE_FIELDS_INITIAL_LIST_VALUE.path, + [SCREENS.WORKSPACE.INVOICE_FIELDS_LIST_VALUES]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_LIST_VALUES.route, + }, + [SCREENS.WORKSPACE.INVOICE_FIELDS_TYPE_SELECTOR]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_TYPE_SELECTOR.route, + }, + [SCREENS.WORKSPACE.INVOICE_FIELDS_ADD_VALUE]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_ADD_VALUE.route, + }, + [SCREENS.WORKSPACE.INVOICE_FIELDS_VALUE_SETTINGS]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_VALUE_SETTINGS.route, + }, + [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_VALUE]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_EDIT_VALUE.route, + }, + [SCREENS.WORKSPACE.INVOICE_FIELDS_SETTINGS]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_SETTINGS.route, + }, + [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_INITIAL_VALUE]: { + path: ROUTES.WORKSPACE_INVOICE_FIELDS_EDIT_INITIAL_VALUE.route, }, [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: { path: ROUTES.WORKSPACE_COMPANY_CARDS_SELECT_FEED.route, @@ -877,6 +908,7 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT]: DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT.path, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SELECT_FEED]: DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SELECT_FEED.path, + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_TRAVEL_BILLING_SELECT_FEED]: DYNAMIC_ROUTES.WORKSPACE_TRAVEL_BILLING_SELECT_FEED.path, [SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL]: { path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL.route, }, @@ -1185,6 +1217,7 @@ const config: LinkingOptions['config'] = { [SCREENS.DYNAMIC_REIMBURSEMENT_ACCOUNT_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.BANK_ACCOUNT_VERIFY_ACCOUNT.path, [SCREENS.REIMBURSEMENT_ACCOUNT_ENTER_SIGNER_INFO]: ROUTES.BANK_ACCOUNT_ENTER_SIGNER_INFO.route, [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.SETTINGS.SHARE_CODE]: { path: ROUTES.SETTINGS_SHARE_CODE, @@ -1280,9 +1313,24 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_NEW]: { path: ROUTES.RULES_NEW.route, }, + [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { + path: ROUTES.RULES_EXPENSE_DEFAULT_TYPE.route, + }, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { path: ROUTES.RULES_MERCHANT_NEW.route, }, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: DYNAMIC_ROUTES.RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: DYNAMIC_ROUTES.RULES_MERCHANT_CATEGORY_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: DYNAMIC_ROUTES.RULES_MERCHANT_TAG_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: DYNAMIC_ROUTES.RULES_MERCHANT_VENDOR_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: DYNAMIC_ROUTES.RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { path: ROUTES.RULES_MERCHANT_IMPORT.route, }, @@ -1388,6 +1436,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { path: ROUTES.RULES_MERCHANT_BILLABLE.route, }, + [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { + path: ROUTES.RULES_CATEGORY_TO_MATCH.route, + }, + [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { + path: ROUTES.RULES_CATEGORY_TAX_EDIT.route, + }, [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { path: ROUTES.RULES_MERCHANT_EDIT.route, }, @@ -1975,6 +2029,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.route, exact: true, }, + [SCREENS.SEARCH.MERGE_REPORTS_SEARCH_RHP]: { + path: ROUTES.MERGE_REPORTS_SEARCH_RHP.route, + exact: true, + }, [SCREENS.SEARCH.CHANGE_APPROVER.ROOT]: { path: ROUTES.CHANGE_APPROVER_SEARCH_RHP.route, exact: true, @@ -2226,6 +2284,10 @@ const config: LinkingOptions['config'] = { }, }, }, + [SCREENS.INSIGHTS]: { + path: ROUTES.INSIGHTS.route, + exact: true, + }, [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: { screens: { [SCREENS.SETTINGS.ROOT]: ROUTES.SETTINGS, @@ -2297,6 +2359,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.PER_DIEM]: { path: ROUTES.WORKSPACE_PER_DIEM.route, }, + [SCREENS.WORKSPACE.MCP]: { + path: ROUTES.WORKSPACE_MCP.route, + }, [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { path: ROUTES.WORKSPACE_RECEIPT_PARTNERS.route, }, @@ -2327,6 +2392,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.HR]: { path: ROUTES.WORKSPACE_HR.route, }, + [SCREENS.WORKSPACE.RECRUITING]: { + path: ROUTES.WORKSPACE_RECRUITING.route, + }, [SCREENS.WORKSPACE.TAGS]: { path: ROUTES.WORKSPACE_TAGS.route, }, diff --git a/src/libs/Navigation/linkingConfig/index.ts b/src/libs/Navigation/linkingConfig/index.ts index ec3c981a392f..4ec0872bd04a 100644 --- a/src/libs/Navigation/linkingConfig/index.ts +++ b/src/libs/Navigation/linkingConfig/index.ts @@ -5,6 +5,7 @@ import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type {LinkingOptions} from '@react-navigation/native'; import {config} from './config'; +import isNativeOAuthCallbackURL from './isNativeOAuthCallbackURL'; import prefixes from './prefixes'; import subscribe from './subscribe'; @@ -14,6 +15,11 @@ const linkingConfig: LinkingOptions = { prefixes, config, subscribe, + // Native only: react-navigation reads `filter` in useLinking.native, not on web. There it covers both the + // initial URL and later `url` events. The native OAuth callback is consumed by the auth session that opened + // it, so routing it would only land on NotFound and tear down the returning screen. The signed-out path goes + // through openReportFromDeepLink instead, which has its own guard. + filter: (url) => !isNativeOAuthCallbackURL(url), }; // eslint-disable-next-line import/prefer-default-export diff --git a/src/libs/Navigation/linkingConfig/isNativeOAuthCallbackURL.ts b/src/libs/Navigation/linkingConfig/isNativeOAuthCallbackURL.ts new file mode 100644 index 000000000000..68d5b6f70f49 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/isNativeOAuthCallbackURL.ts @@ -0,0 +1,15 @@ +import CONST from '@src/CONST'; + +/** + * Exact pathname match only. The apple-app-site-association file claiming this path is served by the production + * and staging NewDot hosts, so a substring match would also swallow unrelated deep links. + */ +function isNativeOAuthCallbackURL(url: string): boolean { + try { + return new URL(url).pathname === CONST.CLOUDFLARE_ACCESS.NATIVE_OAUTH_CALLBACK_PATH; + } catch { + return false; + } +} + +export default isNativeOAuthCallbackURL; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0c71761ca518..3f3c6af5a9e6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2,7 +2,6 @@ import type {MultifactorAuthenticationPromptType} from '@components/MultifactorA import type {SearchQueryString} from '@components/Search/types'; import type {ReplacementReason} from '@libs/actions/Card'; -import type {SaveSearchParams} from '@libs/API/parameters'; import type {ReimbursementAccountStepToOpen} from '@libs/ReimbursementAccountUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; @@ -142,48 +141,42 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_DETAILS]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_EDIT_NAME]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_EDIT_TRANSACTION_START_DATE]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.DOMAIN_CARD_CONFIRM_VALIDATE_CODE]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: { - /** cardID of selected card */ 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 of selected card */ + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.CARD_ADD_TO_DIGITAL_WALLET]: { + /** cardID of the card waiting to be added to a digital wallet */ cardID: string; }; [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: { - /** cardID of selected card */ 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_CHANGE_PIN]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN_ATM]: { - /** cardID of selected card */ cardID: string; }; [SCREENS.SETTINGS.WALLET.EXPENSIFY_CARD_SPEND_RULES]: { @@ -213,12 +206,35 @@ type SettingsNavigatorParamList = { 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; @@ -239,7 +255,6 @@ type SettingsNavigatorParamList = { action?: 'edit'; }; [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: { - /** Name of the currently displayed sub page */ subPage?: string; /** Whether the user is editing one of the sub pages */ @@ -661,38 +676,73 @@ type SettingsNavigatorParamList = { [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; @@ -769,8 +819,6 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT]: { 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_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT]: { policyID: string; @@ -1341,6 +1389,15 @@ type SettingsNavigatorParamList = { [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.CARD_RECONCILIATION]: { policyID: string; connection: ValueOf; @@ -1378,14 +1435,12 @@ type SettingsNavigatorParamList = { role: string; }; [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: { - /** cardID of selected card */ 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 of selected card */ cardID: string; /** Reason for replacing the card */ @@ -1395,6 +1450,7 @@ type SettingsNavigatorParamList = { isFromDomainCardDetail?: string; }; [SCREENS.SETTINGS.DYNAMIC_KEYBOARD_SHORTCUTS]: undefined; + [SCREENS.SETTINGS.DYNAMIC_BETA_OVERRIDES]: undefined; [SCREENS.WORKSPACE.TAX_CREATE]: { policyID: string; }; @@ -1417,9 +1473,6 @@ type SettingsNavigatorParamList = { policyID: string; taxID: string; }; - [SCREENS.WORKSPACE.INVOICES_VERIFY_ACCOUNT]: { - policyID: string; - }; [SCREENS.WORKSPACE.INVOICES_COMPANY_NAME]: { policyID: string; }; @@ -1467,6 +1520,9 @@ type SettingsNavigatorParamList = { [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; }; @@ -1662,10 +1718,64 @@ type SettingsNavigatorParamList = { 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; + /** The callout flow only ever creates merchant rules, never a category tax default */ + categoryName?: undefined; + }; + [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; }; @@ -1795,6 +1905,8 @@ type SettingsNavigatorParamList = { [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; @@ -1820,6 +1932,15 @@ type SettingsNavigatorParamList = { 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; }; @@ -2531,7 +2652,6 @@ type SplitDetailsNavigatorParamList = { type AddPersonalBankAccountNavigatorParamList = { [SCREENS.ADD_PERSONAL_BANK_ACCOUNT_ROOT]: { - /** Name of the currently displayed sub page */ subPage?: string; /** Whether the user is editing one of the sub pages */ @@ -2853,6 +2973,11 @@ type ReportsSplitNavigatorParamList = { referrer?: string; /** Submit-via-PDF secure access link key. When present, the viewer is validated and joined to the report. */ secureKey?: string; + /** + * Set when reportID is a client-generated optimistic ID for a chat that doesn't exist on the server yet + * Suppresses openReport until the report exists locally. + */ + isPendingCreation?: string; /** When 'true', a money-request report opens scrolled to its latest message instead of the top (used by the "X Replies" link). */ shouldScrollToLatest?: 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 @@ -2900,6 +3025,9 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { policyID: string; }; + [SCREENS.WORKSPACE.MCP]: { + policyID: string; + }; [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { policyID: string; }; @@ -2979,9 +3107,6 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.INVOICES]: { policyID: string; }; - [SCREENS.WORKSPACE.INVOICES_VERIFY_ACCOUNT]: { - policyID: string; - }; [SCREENS.WORKSPACE.MEMBERS]: { policyID: string; }; @@ -3007,6 +3132,9 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.HR]: { policyID: string; }; + [SCREENS.WORKSPACE.RECRUITING]: { + policyID: string; + }; [SCREENS.WORKSPACE.TAGS]: { 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 @@ -3156,6 +3284,9 @@ type TabNavigatorParamList = { [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.WORKSPACE_NAVIGATOR]: NavigatorScreenParams; + [SCREENS.INSIGHTS]: { + dashboardID: string; + }; }; type SharedScreensParamList = { @@ -3313,6 +3444,7 @@ type AuthScreensParamList = SharedScreensParamList & [NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams; [SCREENS.BANK_CONNECTION_COMPLETE]: undefined; [NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR]: NavigatorScreenParams; + [SCREENS.PRE_MOUNT_BUFFER]: undefined; }; type SearchReportActionsParamList = { @@ -3360,7 +3492,9 @@ type SearchAdvancedFiltersParamList = { }; type SearchSavedSearchParamList = { - [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: SaveSearchParams; + [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: { + id: string; + }; }; type SearchColumnsParamList = { @@ -3473,7 +3607,13 @@ type SplitNavigatorName = keyof SplitNavigatorParamList; type SearchFullscreenNavigatorName = typeof NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; -type FullScreenName = SplitNavigatorName | SearchFullscreenNavigatorName | typeof NAVIGATORS.TAB_NAVIGATOR | typeof SCREENS.HOME | typeof NAVIGATORS.WORKSPACE_NAVIGATOR; +type FullScreenName = + | SplitNavigatorName + | SearchFullscreenNavigatorName + | typeof NAVIGATORS.TAB_NAVIGATOR + | typeof SCREENS.HOME + | typeof SCREENS.INSIGHTS + | typeof NAVIGATORS.WORKSPACE_NAVIGATOR; type WorkspaceNavigatorRouteName = keyof WorkspaceNavigatorParamList; diff --git a/src/libs/Network/MainQueue.ts b/src/libs/Network/MainQueue.ts index f58a3775b34b..46eb94675424 100644 --- a/src/libs/Network/MainQueue.ts +++ b/src/libs/Network/MainQueue.ts @@ -6,12 +6,10 @@ import type {AnyRequest} from '@src/types/onyx/Request'; import type {OnyxKey} from 'react-native-onyx'; +import MainQueueStore from './MainQueueStore'; import {isAuthenticating} from './NetworkStore'; import {isRunning as sequentialQueueIsRunning} from './SequentialQueue'; -// Queue for network requests so we don't lose actions done by the user while offline -let networkRequestQueue: AnyRequest[] = []; - /** * Checks to see if a request can be made. */ @@ -21,12 +19,8 @@ function canMakeRequest(request: OnyxRequest): boole return request.data?.forceNetworkRequest === true || (!isAuthenticating() && !sequentialQueueIsRunning()); } -function push(request: OnyxRequest) { - networkRequestQueue.push(request as AnyRequest); -} - function replay(request: OnyxRequest) { - push(request); + MainQueueStore.push(request); process(); } @@ -39,6 +33,8 @@ function process() { return; } + const networkRequestQueue = MainQueueStore.getAll(); + // When the queue length is empty an early return is performed since nothing needs to be processed if (networkRequestQueue.length === 0) { return; @@ -67,19 +63,10 @@ function process() { // We clear the request queue at the end by setting the queue to requestsToProcessOnNextRun which will either have some // requests we want to retry or an empty array - networkRequestQueue = requestsToProcessOnNextRun; + MainQueueStore.replaceAll(requestsToProcessOnNextRun); } -/** - * Clear the queue and cancels all pending requests - * Non-cancellable requests like Log would not be cleared - */ -function clear() { - networkRequestQueue = networkRequestQueue.filter((request) => !request.data?.canCancel); -} - -function getAll(): AnyRequest[] { - return networkRequestQueue; -} +// Re-exported so the queue keeps a single entry point for MainQueue's consumers +const {clear, getAll} = MainQueueStore; -export {clear, replay, push, process, getAll}; +export {clear, replay, process, getAll}; diff --git a/src/libs/Network/MainQueueStore.ts b/src/libs/Network/MainQueueStore.ts new file mode 100644 index 000000000000..ebaacca66c8f --- /dev/null +++ b/src/libs/Network/MainQueueStore.ts @@ -0,0 +1,63 @@ +import CONST from '@src/CONST'; +import type {Request} from '@src/types/onyx'; +import type OnyxRequest from '@src/types/onyx/Request'; +import type {AnyRequest} from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import type {OnyxKey} from 'react-native-onyx'; + +import pkg from '../../../package.json'; + +// Queue for network requests so we don't lose actions done by the user while offline +let networkRequestQueue: AnyRequest[] = []; + +function push(request: OnyxRequest) { + networkRequestQueue.push(request as AnyRequest); +} + +/** + * Clear the queue and cancels all pending requests + * Non-cancellable requests like Log would not be cleared + */ +function clear() { + networkRequestQueue = networkRequestQueue.filter((request) => !request.data?.canCancel); +} + +function getAll(): AnyRequest[] { + return networkRequestQueue; +} + +function replaceAll(requests: AnyRequest[]) { + networkRequestQueue = requests; +} + +function enqueue(command: string, data: Record = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise> { + return new Promise((resolve, reject) => { + const request: Request = { + command, + data, + type, + shouldUseSecure, + }; + + // By default, request are retry-able and cancellable + // (e.g. any requests currently happening when the user logs out are cancelled) + request.data = { + ...data, + shouldRetry: data?.shouldRetry ?? true, + canCancel: data?.canCancel ?? true, + appversion: pkg.version, + }; + + // Add promise handlers to any request that we are not persisting + request.resolve = resolve; + request.reject = reject; + + // Add the request to a queue of actions to perform + push(request); + }); +} + +const MainQueueStore = {clear, push, getAll, replaceAll, enqueue}; + +export default MainQueueStore; diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 898f9046c4dd..c3761146d978 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -19,7 +19,7 @@ import Log from '@libs/Log'; import {getIsOffline as isOfflineNetwork, subscribe as subscribeToNetworkState} from '@libs/NetworkState'; import {processWithMiddleware} from '@libs/Request'; import RequestThrottle from '@libs/RequestThrottle'; -import {logReceiptEnqueued, RECEIPT_BEARING_COMMANDS} from '@libs/telemetry/ReceiptObservability'; +import {logReceiptEnqueued, logReceiptGaveUp, RECEIPT_BEARING_COMMANDS} from '@libs/telemetry/ReceiptObservability'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -74,6 +74,70 @@ function setIsReadyPromisePending() { isReadyPromisePending = true; } +// A deferred write (API.writeWhenReady) isn't on the queue yet, so nothing else holds the gate for it. +let deferredWriteClaims = 0; +let deferredWritesLanded: Promise = Promise.resolve(); +let resolveDeferredWritesLanded: (() => void) | undefined; +// Bumped by resetQueue() so claims it wiped can't decrement a later one's count. +let deferredWriteGeneration = 0; +let pendingNetworkStateChange: Promise | undefined; +let unsubscribePendingNetworkStateChange: (() => void) | undefined; + +/** One shared wake-up for every READ parked on the gate, rather than a subscription per caller. */ +function whenNetworkStateChanges(): Promise { + pendingNetworkStateChange ??= new Promise((resolve) => { + const unsubscribe = subscribeToNetworkState(() => { + unsubscribe(); + unsubscribePendingNetworkStateChange = undefined; + pendingNetworkStateChange = undefined; + resolve(); + }); + unsubscribePendingNetworkStateChange = unsubscribe; + }); + + return pendingNetworkStateChange; +} + +/** + * Sole writer of the count, so deferredWritesLanded is pending exactly while claims are outstanding - + * resolving it early would spin waitForIdle(). + */ +function setDeferredWriteClaims(count: number) { + if (count > 0 && deferredWriteClaims === 0) { + deferredWritesLanded = new Promise((resolve) => { + resolveDeferredWritesLanded = resolve; + }); + } + + deferredWriteClaims = Math.max(0, count); + + if (deferredWriteClaims === 0) { + resolveDeferredWritesLanded?.(); + resolveDeferredWritesLanded = undefined; + deferredWritesLanded = Promise.resolve(); + } +} + +/** + * Holds the read gate for a write that hasn't been pushed yet. Returns a callback to run once the write + * has reached the queue - or definitely won't - after which the queue's own gate takes over. + */ +function claimReadGateForDeferredWrite(): () => void { + const generation = deferredWriteGeneration; + setDeferredWriteClaims(deferredWriteClaims + 1); + Log.info('[SequentialQueue] Deferred write claimed the read gate', false, {claims: deferredWriteClaims}); + + let hasSettled = false; + return () => { + if (hasSettled || generation !== deferredWriteGeneration) { + return; + } + hasSettled = true; + setDeferredWriteClaims(deferredWriteClaims - 1); + Log.info('[SequentialQueue] Deferred write released the read gate', false, {claims: deferredWriteClaims}); + }; +} + let isSequentialQueueRunning = false; let currentRequestPromise: Promise | null = null; let isQueuePaused = false; @@ -480,6 +544,16 @@ function process(): Promise { command: requestToProcess.command, errorMessage: error.message, }); + const receiptData = (requestToProcess.data ?? {}) as {transactionID?: string; receipt?: {receiptTraceId?: string}}; + if (RECEIPT_BEARING_COMMANDS.has(requestToProcess.command) && receiptData.receipt) { + logReceiptGaveUp({ + receiptTraceId: receiptData.receipt.receiptTraceId, + transactionID: receiptData.transactionID, + command: requestToProcess.command, + errorMessage: error.message, + errorName: error.name, + }); + } Onyx.update(requestToProcess.failureData ?? []); endPersistedRequestAndRemoveFromQueue(requestToProcess); sequentialQueueRequestThrottle.clear(); @@ -756,9 +830,10 @@ async function push(newRequest: OnyxRequest): Promis if (RECEIPT_BEARING_COMMANDS.has(newRequest.command)) { const data = (newRequest.data ?? {}) as { transactionID?: string; - receipt?: {receiptTraceId?: string}; + receipt?: {receiptTraceId?: string; receiptEnqueuedAt?: number}; }; if (data.receipt) { + data.receipt.receiptEnqueuedAt = Date.now(); logReceiptEnqueued({ receiptTraceId: data.receipt.receiptTraceId, transactionID: data.transactionID, @@ -842,9 +917,19 @@ function getCurrentRequest(): Promise { } /** - * Returns a promise that resolves when the sequential queue is done processing all persisted write requests. + * Resolves when the queue is done processing all persisted write requests, deferred writes included. + * Skipped while offline, where the queue can't run and push()/flush() open the gate for the same reason. */ -function waitForIdle(): Promise { +async function waitForIdle(): Promise { + while (deferredWriteClaims > 0 && !isOfflineNetwork()) { + Log.info('[SequentialQueue] READ is waiting on a deferred write', false, {claims: deferredWriteClaims}); + // The waits are deliberately sequential, each one re-checks the claim count and the + // network, so going offline releases READs and coming back re-parks them. + // eslint-disable-next-line no-await-in-loop + await Promise.race([deferredWritesLanded, whenNetworkStateChanges()]); + } + + // Read after the wait, the deferred write's push() has re-closed the gate by now. return isReadyPromise; } @@ -861,9 +946,15 @@ function resetQueue(): void { isReadyPromise = Promise.resolve(); isReadyPromisePending = false; resolveIsReadyPromise = undefined; + deferredWriteGeneration += 1; + setDeferredWriteClaims(0); + unsubscribePendingNetworkStateChange?.(); + unsubscribePendingNetworkStateChange = undefined; + pendingNetworkStateChange = undefined; } export { + claimReadGateForDeferredWrite, flush, getCurrentRequest, isPaused, diff --git a/src/libs/Network/index.ts b/src/libs/Network/index.ts index 6f9f7f0eb90d..0744271918eb 100644 --- a/src/libs/Network/index.ts +++ b/src/libs/Network/index.ts @@ -1,25 +1,26 @@ import * as ActiveClientManager from '@libs/ActiveClientManager'; import CONST from '@src/CONST'; -import type {Request} from '@src/types/onyx'; import type Response from '@src/types/onyx/Response'; import type {OnyxKey} from 'react-native-onyx'; -import pkg from '../../../package.json'; -import {process as processMainQueue, push as pushToMainQueue} from './MainQueue'; +import {process as processMainQueue} from './MainQueue'; +import MainQueueStore from './MainQueueStore'; import {flush as flushSequentialQueue} from './SequentialQueue'; // React Native uses a number for the timer id, but Web/NodeJS uses a Timeout object let processQueueInterval: NodeJS.Timeout | number; -// We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests -ActiveClientManager.isReady().then(() => { - flushSequentialQueue(); +function startMainQueue() { + // We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests + ActiveClientManager.isReady().then(() => { + flushSequentialQueue(); - // Start main queue and process once every n ms delay - processQueueInterval = setInterval(processMainQueue, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); -}); + // Start main queue and process once every n ms delay + processQueueInterval = setInterval(processMainQueue, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); + }); +} /** * Clear any existing intervals during test runs @@ -36,41 +37,18 @@ function clearProcessQueueInterval() { * Perform a queued post request */ function post(command: string, data: Record = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise> { - return new Promise((resolve, reject) => { - const request: Request = { - command, - data, - type, - shouldUseSecure, - }; - - // By default, request are retry-able and cancellable - // (e.g. any requests currently happening when the user logs out are cancelled) - request.data = { - ...data, - shouldRetry: data?.shouldRetry ?? true, - canCancel: data?.canCancel ?? true, - appversion: pkg.version, - }; - - // Add promise handlers to any request that we are not persisting - request.resolve = resolve; - request.reject = reject; - - // Add the request to a queue of actions to perform - pushToMainQueue(request); - - // This check is mainly used to prevent API commands from triggering calls to MainQueue.process() from inside the context of a previous - // call to MainQueue.process() e.g. calling a Log command without this would cause the requests in mainQueue to double process - // since we call Log inside MainQueue.process(). - const shouldProcessImmediately = request?.data?.shouldProcessImmediately ?? true; - if (!shouldProcessImmediately) { - return; - } + const promise = MainQueueStore.enqueue(command, data, type, shouldUseSecure); + // This check is mainly used to prevent API commands from triggering calls to MainQueue.process() from inside the context of a previous + // call to MainQueue.process() e.g. calling a Log command without this would cause the requests in mainQueue to double process + // since we call Log inside MainQueue.process(). + const shouldProcessImmediately = data?.shouldProcessImmediately ?? true; + if (shouldProcessImmediately) { // Try to fire off the request as soon as it's queued so we don't add a delay to every queued command processMainQueue(); - }); + } + + return promise; } -export {post, clearProcessQueueInterval}; +export {post, startMainQueue, clearProcessQueueInterval}; diff --git a/src/libs/NetworkState.ts b/src/libs/NetworkState.ts index a382a6e0cf75..f4d5cf628da9 100644 --- a/src/libs/NetworkState.ts +++ b/src/libs/NetworkState.ts @@ -6,7 +6,7 @@ import NetInfo from '@react-native-community/netinfo'; import {toDate} from 'date-fns-tz'; import Onyx from 'react-native-onyx'; -import {getCommandURL} from './ApiUtils'; +import {getCommandURL, isQAServerActive} from './ApiUtils'; import getEnvironment from './Environment/getEnvironment'; import {onSuccess as onRequestSuccess, onSustainedFailureChange, reset as resetFailureCounters} from './FailureTracker'; import Log from './Log'; @@ -340,7 +340,10 @@ function configureAndSubscribe() { configuredReachabilityUrl = buildReachabilityUrl(); - if (!CONFIG.IS_USING_LOCAL_WEB) { + // QA: the Ping URL sits behind Cloudflare Access and NetInfo issues that request itself, so it cannot + // carry the bearer and would read as a permanent outage. NetInfo's `reachabilityHeaders` is not a way + // out. That config is static and the access token rotates on Cloudflare's schedule. + if (!CONFIG.IS_USING_LOCAL_WEB && !isQAServerActive()) { NetInfo.configure({ reachabilityUrl: configuredReachabilityUrl, reachabilityMethod: 'GET', @@ -392,11 +395,9 @@ function configureAndSubscribe() { }); } -// Subscribe to NetInfo once getEnvironment() resolves so the first ping uses the correct root. -// queueMicrotask defers configureAndSubscribe past the current tick so ApiUtils' own -// SHOULD_USE_STAGING_SERVER Onyx callback — which is the source of truth for getApiRoot() — has -// already updated its cached flag. Without this defer, configureAndSubscribe samples ApiUtils' -// stale module-level flag and bakes the wrong reachabilityUrl into NetInfo. +// Subscribe to NetInfo once getEnvironment() resolves so the first ping uses the correct root, and defer +// configureAndSubscribe so ApiUtils' ACTIVE_SERVER callback, the source of truth for getApiRoot(), has +// updated its cached value first. Sampling it early bakes the wrong reachabilityUrl into NetInfo. getEnvironment().then(() => { queueMicrotask(configureAndSubscribe); }); @@ -415,12 +416,10 @@ Onyx.connectWithoutView({ }, }); -// Re-target the reachability ping when the staging-server toggle flips at runtime. -// queueMicrotask waits for ApiUtils' callback on the same key, which owns the flag behind -// getApiRoot(). Skip the rebuild when the URL is unchanged: rebuilding tears down NetInfo -// state and fires extra Pings, and the raw toggle can flip without changing the URL. +// Rebuilding tears down NetInfo state and fires extra Pings, and the switch +// can flip without changing the URL. Onyx.connectWithoutView({ - key: ONYXKEYS.SHOULD_USE_STAGING_SERVER, + key: ONYXKEYS.ACTIVE_SERVER, callback: () => { queueMicrotask(() => { if (buildReachabilityUrl() === configuredReachabilityUrl) { diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 96be8d5638a4..fe8a6023c844 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -1,7 +1,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; import type {ReportNextStep} from '@src/types/onyx/Report'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -45,6 +45,7 @@ type BuildNextStepNewParams = { bypassNextApproverID?: number; isTrackIntentUser: boolean | undefined; translate?: LocaleContextProps['translate']; + rules: OnyxCollection; }; type GetReportNextStepParams = { @@ -55,6 +56,7 @@ type GetReportNextStepParams = { transactionViolations: OnyxCollection; currentUserEmail: string; currentUserAccountID: number; + rules: OnyxCollection; }; function buildNextStepMessage( @@ -116,6 +118,7 @@ function buildOptimisticNextStep(params: BuildNextStepNewParams): ReportNextStep isRejectedReport: isRejectedReportParam, bypassNextApproverID, isTrackIntentUser, + rules, } = params; if (!isExpenseReport(report)) { @@ -127,7 +130,7 @@ function buildOptimisticNextStep(params: BuildNextStepNewParams): ReportNextStep const isInstantSubmitEnabled = autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; const shouldShowFixMessage = hasViolations && isInstantSubmitEnabled && !isASAPSubmitBetaEnabled; const hasTransactions = doesReportContainTransactions(report); - const approverAccountID = bypassNextApproverID ?? getNextApproverAccountID(report, isUnapprove); + const approverAccountID = bypassNextApproverID ?? getNextApproverAccountID(report, rules, isUnapprove); const reimburserAccountID = getReimburserAccountID(policy); const {reimbursableSpend} = getMoneyRequestSpendBreakdown(report); @@ -166,7 +169,7 @@ function buildOptimisticNextStep(params: BuildNextStepNewParams): ReportNextStep } if (isReopen) { nextStep = { - messageKey: shouldShowMarkAsDone({isTrackIntentUser, report, policy}) + messageKey: shouldShowMarkAsDone({isTrackIntentUser, report, policy, rules}) ? CONST.NEXT_STEP.MESSAGE_KEY.WAITING_TO_MARK_AS_DONE : CONST.NEXT_STEP.MESSAGE_KEY.WAITING_TO_SUBMIT, icon: CONST.NEXT_STEP.ICONS.HOURGLASS, @@ -233,6 +236,7 @@ function buildOptimisticNextStep(params: BuildNextStepNewParams): ReportNextStep isTrackIntentUser, report, policy, + rules, }) ? CONST.NEXT_STEP.MESSAGE_KEY.WAITING_TO_MARK_AS_DONE : CONST.NEXT_STEP.MESSAGE_KEY.WAITING_TO_SUBMIT, @@ -316,7 +320,16 @@ function buildOptimisticFixIssueNextStep(ownerAccountID: number): ReportNextStep }; } -function getReportNextStep({moneyRequestReport, moneyRequestReportOwnerLogin, transactions, policy, transactionViolations, currentUserEmail, currentUserAccountID}: GetReportNextStepParams) { +function getReportNextStep({ + moneyRequestReport, + moneyRequestReportOwnerLogin, + transactions, + policy, + transactionViolations, + currentUserEmail, + currentUserAccountID, + rules, +}: GetReportNextStepParams) { const {reimbursableSpend} = getMoneyRequestSpendBreakdown(moneyRequestReport); const shouldShowNoFurtherAction = reimbursableSpend === 0 && @@ -339,7 +352,7 @@ function getReportNextStep({moneyRequestReport, moneyRequestReportOwnerLogin, tr // When prevent self-approval is enabled & the current user is submitter AND they're submitting to themselves, we need to show the optimistic next step // We should always show this optimistic message for policies with preventSelfApproval // to avoid any flicker during transitions between online/offline states - if (shouldBlockSubmitDueToPreventSelfApproval(moneyRequestReport, policy)) { + if (shouldBlockSubmitDueToPreventSelfApproval(moneyRequestReport, policy, rules)) { return buildOptimisticNextStepForPreventSelfApprovalsEnabled(); } diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 03edc036cfdb..4547e3fa7b41 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -1,27 +1,60 @@ import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png'; +import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider/types'; + import * as AppUpdate from '@libs/actions/AppUpdate'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; +import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import {getForReportAction} from '@libs/ModifiedExpenseMessage'; import NotificationPermission from '@libs/Notification/notificationPermission'; +import {format} from '@libs/NumberFormatUtils'; import {getTextFromHtml} from '@libs/ReportActionsUtils'; import {deprecatedGetReportName} from '@libs/ReportNameUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; -import type {Report, ReportAction, ReportAttributesDerivedValue} from '@src/types/onyx'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CurrencyList, Report, ReportAction, ReportAttributesDerivedValue} from '@src/types/onyx'; import type {ImageSourcePropType} from 'react-native'; // Web implementation only. Do not import for direct use. Use LocalNotification. import {SafeString, Str} from 'expensify-common'; +import Onyx from 'react-native-onyx'; import type {LocalNotificationClickHandler, LocalNotificationData, LocalNotificationModifiedExpensePushParams} from './types'; const notificationCache: Record = {}; +// The browser-notification pipeline is driven by Pusher events outside React, so there is no component to inject the +// currency formatter from CurrencyListContextProvider. Subscribe to the currency list here and mirror the provider's +// implementation instead of relying on CurrencyUtils' module-scope fallback. +let currencyList: CurrencyList = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.CURRENCY_LIST, + callback: (value) => { + currencyList = value ?? {}; + }, +}); +const convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'] = (amountInCents, currencyCode) => { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = currencyList?.[sanitizedCurrency]?.decimals ?? CONST.DEFAULT_CURRENCY_DECIMALS; + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals); + return format(IntlStore.getCurrentLocale(), convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies + // See: https://github.com/Expensify/PHP-Libs/pull/834 + minimumFractionDigits: decimals, + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, + }); +}; + /** * Checks if the user has granted permission to show browser notifications, prompting them * if they have not yet decided. @@ -159,19 +192,20 @@ export default { usesIcon = false, policyTags, policy, + currentUserAccountID, currentUserLogin, reportAttributes, }: LocalNotificationModifiedExpensePushParams) { const title = reportAction.person?.map((f) => f.text).join(', ') ?? ''; const bodyWithHTML = getForReportAction({ translate: translateLocal, - // Non-React call path (pusher/notification pipeline): pass the standalone util, which falls back to the module-scope currency list. convertToDisplayString, reportAction, policy, movedFromReport, movedToReport, policyTags, + currentUserAccountID, currentUserLogin, reportAttributes, }); diff --git a/src/libs/Notification/LocalNotification/index.ts b/src/libs/Notification/LocalNotification/index.ts index f52489560980..493f2bbfcc7a 100644 --- a/src/libs/Notification/LocalNotification/index.ts +++ b/src/libs/Notification/LocalNotification/index.ts @@ -37,7 +37,16 @@ function showUpdateAvailableNotification() { BrowserNotifications.pushUpdateAvailableNotification(); } -function showModifiedExpenseNotification({report, reportAction, movedFromReport, movedToReport, onClick, currentUserLogin, reportAttributes}: LocalNotificationModifiedExpenseParams) { +function showModifiedExpenseNotification({ + report, + reportAction, + movedFromReport, + movedToReport, + onClick, + currentUserAccountID, + currentUserLogin, + reportAttributes, +}: LocalNotificationModifiedExpenseParams) { const policyID = report.policyID; const policyTags = policyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] : undefined; const policy = policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; @@ -50,6 +59,7 @@ function showModifiedExpenseNotification({report, reportAction, movedFromReport, usesIcon: true, policyTags, policy, + currentUserAccountID, currentUserLogin, reportAttributes, }); diff --git a/src/libs/Notification/LocalNotification/types.ts b/src/libs/Notification/LocalNotification/types.ts index 182a0abc6076..17ddab26a917 100644 --- a/src/libs/Notification/LocalNotification/types.ts +++ b/src/libs/Notification/LocalNotification/types.ts @@ -23,6 +23,7 @@ type LocalNotificationModifiedExpenseParams = { onClick: LocalNotificationClickHandler; movedFromReport?: OnyxEntry; movedToReport?: OnyxEntry; + currentUserAccountID: number | undefined; currentUserLogin: string; reportAttributes?: ReportAttributesDerivedValue['reports']; }; diff --git a/src/libs/OptionsListUtils/getChatPreviewParts.ts b/src/libs/OptionsListUtils/getChatPreviewParts.ts deleted file mode 100644 index 116062b0c2c5..000000000000 --- a/src/libs/OptionsListUtils/getChatPreviewParts.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; - -import { - getLastVisibleActionIncludingTransactionThread, - getOriginalMessage, - getRenamedAction, - getReportActionActorAccountID, - getReportActionMessageText, - isActionOfType, - isCardIssuedAction, - isInviteOrRemovedAction, - isMovedTransactionAction, - isOldDotReportAction, - isPolicyCopyReportAction, - isRenamedAction, - isReportActionVisibleAsLastAction, - isTaskAction, -} from '@libs/ReportActionsUtils'; -import {getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, shouldShowLastActorDisplayName} from '@libs/ReportAlternateTextUtils'; -import {getReportName} from '@libs/ReportNameUtils'; -import { - canUserPerformWriteAction, - formatReportLastMessageText, - getReportOrDraftReport, - isChatThread, - isDeprecatedGroupDM, - isExpenseReport, - isChatRoom as reportUtilsIsChatRoom, - isGroupChat as reportUtilsIsGroupChat, - isPolicyExpenseChat as reportUtilsIsPolicyExpenseChat, - isTaskReport as reportUtilsIsTaskReport, - isThread as reportUtilsIsThread, -} from '@libs/ReportUtils'; - -import CONST from '@src/CONST'; -import type {PersonalDetails, PersonalDetailsList, Report, ReportAction, ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; - -// These POLICY_CHANGE_LOG actions have no custom alternate text branch in SidebarUtils.getOptionData, -// so the LHN renders them with the generic `Name: message` prefix and search must keep the prefix too. -const POLICY_CHANGE_LOG_ACTIONS_WITHOUT_CUSTOM_TEXT = new Set([ - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORIES, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REPLACE_CATEGORIES, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_REIMBURSEMENT, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DISABLED_FIELDS, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MULTIPLE_TAGS_APPROVER_RULES, - CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS, -]); -const POLICY_CHANGE_LOG_ACTION_NAMES = new Set( - Object.values(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG).filter((actionName) => !POLICY_CHANGE_LOG_ACTIONS_WITHOUT_CUSTOM_TEXT.has(actionName)), -); -const ROOM_CHANGE_LOG_ACTION_NAMES = new Set(Object.values(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG)); -const CUSTOM_ALTERNATE_TEXT_ACTION_NAMES = new Set([ - CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED, - CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN, - CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE, - CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION, - CONST.REPORT.ACTIONS.TYPE.RETRACTED, - CONST.REPORT.ACTIONS.TYPE.REOPENED, - CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE, - CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL, - CONST.REPORT.ACTIONS.TYPE.REROUTE, - CONST.REPORT.ACTIONS.TYPE.REASSIGN_APPROVER, - CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED, -]); - -function isActionWithCustomAlternateText(lastAction: OnyxEntry): boolean { - const actionName = lastAction?.actionName; - if (!lastAction || !actionName) { - return false; - } - return ( - isRenamedAction(lastAction) || - isTaskAction(lastAction) || - isInviteOrRemovedAction(lastAction) || - isCardIssuedAction(lastAction) || - isOldDotReportAction(lastAction) || - isPolicyCopyReportAction(lastAction) || - isMovedTransactionAction(lastAction) || - (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT) && !!getOriginalMessage(lastAction)?.resolution) || - POLICY_CHANGE_LOG_ACTION_NAMES.has(actionName) || - ROOM_CHANGE_LOG_ACTION_NAMES.has(actionName) || - CUSTOM_ALTERNATE_TEXT_ACTION_NAMES.has(actionName) - ); -} - -/** - * Pieces of the chat preview line that the LHN renders for the last message. - */ -type ChatPreviewParts = { - /** The `Name: ` prefix identifying the author of the last message, or an empty string when no prefix should be shown */ - actorPrefix: string; - - /** Replacement preview text for actions whose LHN alternate text embeds the actor (e.g. rename, leave room, invite/remove) */ - customAlternateText?: string; -}; - -/** - * Returns the chat preview pieces that the LHN (SidebarUtils.getOptionData) renders for the last message: - * the `Name: ` actor prefix, plus a replacement text for the actions whose LHN alternate text embeds the actor - * (rename, leave room, invite/remove) — excluding those from the generic prefix alone would drop the actor. - */ -function getChatPreviewParts({ - report, - personalDetails, - isReportArchived, - translate, - visibleReportActionsData, - currentUserAccountID, - sortedActions, - reportAttributesDerived, - oneTransactionThreadReportID, -}: { - report: OnyxEntry; - personalDetails: OnyxEntry; - isReportArchived: boolean | undefined; - translate: LocalizedTranslate; - visibleReportActionsData?: VisibleReportActionsDerivedValue; - currentUserAccountID: number | undefined; - sortedActions?: Record; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; - oneTransactionThreadReportID?: string; -}): ChatPreviewParts { - if (!report || isReportArchived || currentUserAccountID === undefined) { - return {actorPrefix: ''}; - } - const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); - const sortedActionsForReport = sortedActions?.[report.reportID]; - - const lastAction = sortedActionsForReport - ? sortedActionsForReport.find((action) => isReportActionVisibleAsLastAction(action, canUserPerformWrite, visibleReportActionsData, report.reportID, currentUserAccountID)) - : getLastVisibleActionIncludingTransactionThread(report.reportID, canUserPerformWrite, undefined, visibleReportActionsData, oneTransactionThreadReportID, currentUserAccountID); - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const lastActorAccountID = getReportActionActorAccountID(lastAction, undefined, undefined) || report.lastActorAccountID; - let resolvedLastActorDetails: Partial | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null; - if (!resolvedLastActorDetails && lastAction?.person?.at(0)?.text) { - resolvedLastActorDetails = { - displayName: lastAction.person.at(0)?.text, - accountID: report.lastActorAccountID, - }; - } - const lastActorDisplayName = getLastActorDisplayName(resolvedLastActorDetails, currentUserAccountID, translate); - - const isThreadMessage = - reportUtilsIsThread(report) && lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && lastAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const usesChatPrefixRules = - reportUtilsIsChatRoom(report) || - reportUtilsIsPolicyExpenseChat(report) || - isChatThread(report) || - reportUtilsIsTaskReport(report) || - isThreadMessage || - reportUtilsIsGroupChat(report) || - isDeprecatedGroupDM(report, isReportArchived); - - let customAlternateText: string | undefined; - if (usesChatPrefixRules) { - if (isRenamedAction(lastAction)) { - customAlternateText = getRenamedAction(translate, lastAction, isExpenseReport(report), lastActorDisplayName); - } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM) { - const actionMessage = getReportActionMessageText(lastAction); - customAlternateText = actionMessage ? `${lastActorDisplayName}: ${actionMessage}` : ''; - } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM) { - customAlternateText = translate('report.actions.type.leftTheChatWithName', lastActorDisplayName); - } else if (isInviteOrRemovedAction(lastAction)) { - let actorDetails: Partial | undefined; - if (lastAction.actorAccountID) { - actorDetails = personalDetails?.[lastAction.actorAccountID] ?? undefined; - } - let actorDisplayName = lastAction.person?.[0]?.text; - if (!actorDetails && actorDisplayName && lastAction.actorAccountID) { - actorDetails = { - displayName: actorDisplayName, - accountID: lastAction.actorAccountID, - }; - } - actorDisplayName = actorDetails ? getLastActorDisplayName(actorDetails, currentUserAccountID, translate) : undefined; - const lastActionOriginalMessage = getOriginalMessage(lastAction); - const targetAccountIDs = lastActionOriginalMessage?.targetAccountIDs ?? []; - const targetAccountIDsLength = targetAccountIDs.length !== 0 ? targetAccountIDs.length : (report.lastMessageHtml?.match(/]*><\/mention-user>/g)?.length ?? 0); - const isInvite = - lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM || lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM; - const verb = isInvite ? translate('workspace.invite.invited') : translate('workspace.invite.removed'); - const users = translate(targetAccountIDsLength > 1 ? 'common.members' : 'common.member')?.toLocaleLowerCase(); - customAlternateText = formatReportLastMessageText(`${actorDisplayName ?? lastActorDisplayName}: ${verb} ${targetAccountIDsLength} ${users}`); - const lastActionReport = lastActionOriginalMessage?.reportID ? getReportOrDraftReport(String(lastActionOriginalMessage.reportID)) : undefined; - const derivedReportName = lastActionReport?.reportID ? reportAttributesDerived?.[lastActionReport.reportID]?.reportName : undefined; - const roomName = getReportName(lastActionReport, derivedReportName) || lastActionOriginalMessage?.roomName; - if (roomName) { - const preposition = isInvite ? ` ${translate('workspace.invite.to')}` : ` ${translate('workspace.invite.from')}`; - customAlternateText += `${preposition} ${roomName}`; - } - } - } - - const shouldShowActorPrefix = usesChatPrefixRules - ? lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && !!lastActorDisplayName && !isActionWithCustomAlternateText(lastAction) - : shouldShowLastActorDisplayName(report, resolvedLastActorDetails, lastAction, currentUserAccountID, translate); - if (!shouldShowActorPrefix) { - return {actorPrefix: '', customAlternateText}; - } - const displayName = - getLastActorDisplayNameFromLastVisibleActions( - report, - resolvedLastActorDetails, - currentUserAccountID, - personalDetails, - isReportArchived, - translate, - visibleReportActionsData, - lastAction, - ) || lastActorDisplayName; - return {actorPrefix: displayName ? `${displayName}: ` : '', customAlternateText}; -} - -export default getChatPreviewParts; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 9f42fee2d6d5..8ce76c33705f 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2,9 +2,12 @@ import FallbackAvatar from '@assets/images/avatars/fallback-avatar.svg'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; +import getCollator from '@libs/CollatorUtils'; +import {convertToDisplayStringWithoutCurrencyForLocale, getCurrencyDecimals} from '@libs/CurrencyUtils'; import filterArrayByMatch from '@libs/filterArrayByMatch'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from '@libs/LocalePhoneNumber'; @@ -26,8 +29,8 @@ import { getSubmitToAccountID, isTimeTrackingEnabled, } from '@libs/PolicyUtils'; -import {getIOUReportIDFromReportActionPreview, getOneTransactionThreadReportID, isActionOfType} from '@libs/ReportActionsUtils'; -import {deprecatedCachedOneTransactionThreadReportIDs, getLastMessageTextForReport} from '@libs/ReportAlternateTextUtils'; +import {getIOUReportIDFromReportActionPreview, getOneTransactionThreadReportID, isActionOfType, isCardIssuedAction} from '@libs/ReportActionsUtils'; +import {getExpensifyCardFromReportAction, getLastMessageTextForReport, getReportAlternateText, resolveLastActionContext} from '@libs/ReportAlternateTextUtils'; import {deprecatedGetReportName} from '@libs/ReportNameUtils'; import type {OptionData} from '@libs/ReportUtils'; import { @@ -41,7 +44,6 @@ import { getPolicyName, getReportNotificationPreference, getReportOrDraftReport, - getReportSubtitlePrefix, getViolatingReportIDForRBRInLHN, hasExpensifyGuidesEmails, hasIOUWaitingOnCurrentUserBankAccount, @@ -74,6 +76,7 @@ import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type { Beta, + CardList, Locale, Login, OnyxInputOrEntry, @@ -87,7 +90,9 @@ import type { Report, ReportAction, ReportAttributesDerivedValue, + Rule, VisibleReportActionsDerivedValue, + WorkspaceCardsList, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -125,7 +130,6 @@ import type { SectionForSearchTerm, } from './types'; -import getChatPreviewParts from './getChatPreviewParts'; import {doesPersonalDetailMatchSearchTerm, getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from './searchMatchUtils'; /** @@ -174,9 +178,19 @@ function getParticipantsOption(participant: OptionData | Participant, personalDe if (participant?.displayName) { displayName = participant.displayName; } else if (detail?.login) { - displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: detail, defaultValue: formattedLogin, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + displayName = temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: detail, + defaultValue: formattedLogin, + translate, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + }); } else { - const detailDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: detail, defaultValue: formattedLogin, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + 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 displayName = participant?.text || detailDisplayName; } @@ -233,7 +247,7 @@ type GetAlternateTextConfig = { personalDetails: OnyxEntry; // We'll make it required in the next PR. Ref: https://github.com/Expensify/App/issues/66415 policy?: OnyxEntry; - lastActorDetails?: Partial | null; + invoiceReceiverPolicy?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; translate?: LocalizedTranslate; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; @@ -241,11 +255,36 @@ type GetAlternateTextConfig = { conciergeReportID: string | undefined; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; + transactionThreadIDs?: Record; + lastActions?: Record; isTrackIntentUser?: boolean; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number; + currentUserLogin?: string; + cardList?: OnyxEntry; + workspaceCardList?: OnyxCollection; + localeCompare?: LocaleContextProps['localeCompare']; + formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; + rules: OnyxCollection; }; +/** + * Fallback for non-React callers that cannot provide the hook-bound localeCompare. + */ +function fallbackLocaleCompare(a: string, b: string): number { + return getCollator(IntlStore.getCurrentLocale()).compare(a, b); +} + +/** + * Fallback for the non-React option builders that cannot provide the hook-bound convertToDisplayStringWithoutCurrency. Those builders never + * render a chat preview; every preview caller threads the context implementation from useCurrencyListActions(). + */ +function fallbackConvertToDisplayStringWithoutCurrency(amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string { + return convertToDisplayStringWithoutCurrencyForLocale(IntlStore.getCurrentLocale(), amountInCents, currencyCode, getCurrencyDecimals); +} + /** * Update alternate text for the option when applicable */ @@ -256,16 +295,26 @@ function getAlternateText( isReportArchived, personalDetails, policy, - lastActorDetails = {}, + invoiceReceiverPolicy, visibleReportActionsData = {}, translate, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, dateFnsLocale, reportAttributesDerived, policyTags, conciergeReportID, sortedActions, + transactionThreadIDs, + lastActions, isTrackIntentUser, currentUserAccountID, + currentUserLogin, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, + rules, }: GetAlternateTextConfig, ) { const report = getReportOrDraftReport(option.reportID); @@ -274,73 +323,85 @@ function getAlternateText( const isGroupChat = reportUtilsIsGroupChat(report); const isExpenseThread = isMoneyRequest(report); const translateFn = translate ?? translateLocal; - // Keep Plain comments as they're typed (example: `test` stays `test`). - // Parser.htmlToText would strip it to `test`. Ref: https://github.com/Expensify/App/issues/82036 - const isLastActionAddComment = report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; - const formattedLastMessageText = - formatReportLastMessageText(isLastActionAddComment ? (option.lastMessageText ?? '') : Parser.htmlToText(option.lastMessageText ?? '')) || - getLastMessageTextForReport({ - translate: translateFn, - dateFnsLocale, - report, - personalDetails, - lastActorDetails, - policy, - isReportArchived, - visibleReportActionsDataParam: visibleReportActionsData, - reportAttributesDerived, - policyTags, - conciergeReportID, - sortedActions, - isTrackIntentUser, - currentUserAccountID, - }); - const reportPrefix = getReportSubtitlePrefix(report); - const {actorPrefix, customAlternateText} = - showChatPreviewLine && formattedLastMessageText - ? getChatPreviewParts({ - report, - personalDetails, - isReportArchived, - translate: translateFn, - visibleReportActionsData, - currentUserAccountID, - sortedActions, - reportAttributesDerived, - // eslint-disable-next-line @typescript-eslint/no-deprecated - oneTransactionThreadReportID: report?.reportID ? deprecatedCachedOneTransactionThreadReportIDs[report.reportID] : undefined, - }) - : {actorPrefix: '', customAlternateText: undefined}; - const formattedLastMessageTextWithPrefix = reportPrefix + actorPrefix + (customAlternateText ?? formattedLastMessageText); + const forcePolicyName = forcePolicyNamePreview && ((option.isPolicyExpenseChat ?? false) || isAdminRoom || isAnnounceRoom); + if (showChatPreviewLine && !forcePolicyName) { + if (report) { + const resolvedCurrentUserAccountID = currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID; + const oneTransactionThreadReportID = transactionThreadIDs?.[report.reportID]; + const {lastAction, lastActionReport, movedFromReport, movedToReport} = resolveLastActionContext(report, isReportArchived, visibleReportActionsData, oneTransactionThreadReportID); + const card = isCardIssuedAction(lastAction) + ? getExpensifyCardFromReportAction({ + reportAction: lastAction, + policy, + cardList, + workspaceCardList, + }) + : undefined; + return getReportAlternateText({ + report, + lastAction, + lastActionReport, + movedFromReport, + movedToReport, + card, + lastMessageTextFromReport: option.lastMessageText, + personalDetails, + policy, + invoiceReceiverPolicy, + policyTags, + isReportArchived, + // The Search path has no separate report NVP source here. The archived flag and private_isArchived + // both derive from the same NVP, so the option's archived flag is the equivalent input. + privateIsArchived: !!isReportArchived, + conciergeReportID, + reportAttributesDerived, + visibleReportActionsData, + sortedActions, + oneTransactionThreadReportID, + lastOriginalAction: lastActions?.[report.reportID], + currentUserAccountID: resolvedCurrentUserAccountID, + currentUserLogin: currentUserLogin ?? '', + isTrackIntentUser, + translate: translateFn, + localeCompare: localeCompare ?? fallbackLocaleCompare, + formatPhoneNumber: formatPhoneNumber ?? formatPhoneNumberPhoneUtils, + dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency: convertToDisplayStringWithoutCurrency ?? fallbackConvertToDisplayStringWithoutCurrency, + rules, + }); + } + if (option.lastMessageText) { + return formatReportLastMessageText(Parser.htmlToText(option.lastMessageText)); + } + } if (isExpenseThread || option.isMoneyRequestReport) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('iou.expense'); + return translateFn('iou.expense'); } if (option.isThread) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('threads.thread'); + return translateFn('threads.thread'); } if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : option.subtitle; + return option.subtitle; } if ((option.isPolicyExpenseChat ?? false) || isAdminRoom || isAnnounceRoom) { - return showChatPreviewLine && !forcePolicyNamePreview && formattedLastMessageText ? formattedLastMessageTextWithPrefix : option.subtitle; + return option.subtitle; } if (option.isTaskReport) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('task.task'); + return translateFn('task.task'); } if (isGroupChat) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('common.group'); + return translateFn('common.group'); } - return showChatPreviewLine && formattedLastMessageText - ? formattedLastMessageTextWithPrefix - : formatPhoneNumberPhoneUtils(option.participantsList && option.participantsList.length > 0 ? (option.participantsList.at(0)?.login ?? '') : ''); + return formatPhoneNumberPhoneUtils(option.participantsList && option.participantsList.length > 0 ? (option.participantsList.at(0)?.login ?? '') : ''); } /** @@ -364,18 +425,24 @@ type CreateOptionParams = { personalDetails: OnyxEntry; report: OnyxInputOrEntry; privateIsArchived: boolean | undefined; + rules: OnyxCollection; policy?: OnyxEntry; config?: PreviewConfig; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; translate?: LocalizedTranslate; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; isTrackIntentUser?: boolean; conciergeReportID: string | undefined; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; + transactionThreadIDs?: Record; + lastActions?: Record; // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number; + currentUserLogin?: string; }; /** Shared by createOption and shells so filtering uses the final display text. */ @@ -406,17 +473,23 @@ function createOption({ personalDetails, report, privateIsArchived, + rules, policy, config, reportAttributesDerived, policyTags, visibleReportActionsData = {}, translate, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, dateFnsLocale, isTrackIntentUser, conciergeReportID, sortedActions, + transactionThreadIDs, + lastActions, currentUserAccountID, + currentUserLogin, }: CreateOptionParams): SearchOptionData { const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false, selected, isSelected, isDisabled} = config ?? {}; const translateFn = translate ?? translateLocal; @@ -486,16 +559,21 @@ function createOption({ // 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, true, result.private_isArchived); + subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translateFn, rules, true, result.private_isArchived); // If displaying chat preview line is needed, let's overwrite the default alternate text const lastActorDetails = personalDetails?.[report?.lastActorAccountID ?? String(CONST.DEFAULT_NUMBER_ID)] ?? {}; + const {lastAction, movedFromReport, movedToReport} = resolveLastActionContext(report, result.private_isArchived, visibleReportActionsData, transactionThreadIDs?.[report.reportID]); result.lastMessageText = getLastMessageTextForReport({ translate: translateFn, + convertToDisplayString, dateFnsLocale, report, personalDetails, lastActorDetails, + lastAction, + movedFromReport, + movedToReport, policy, isReportArchived: result.private_isArchived, visibleReportActionsDataParam: visibleReportActionsData, @@ -505,6 +583,10 @@ function createOption({ sortedActions, isTrackIntentUser, currentUserAccountID, + currentUserLogin, + oneTransactionThreadReportID: transactionThreadIDs?.[report.reportID], + lastOriginalAction: lastActions?.[report.reportID], + rules, }); result.alternateText = showPersonalDetails && personalDetail?.login @@ -517,13 +599,20 @@ function createOption({ isReportArchived: !!result.private_isArchived, personalDetails, policy, - lastActorDetails, visibleReportActionsData, translate: translateFn, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, reportAttributesDerived, policyTags, conciergeReportID, + sortedActions, + transactionThreadIDs, + lastActions, + isTrackIntentUser, currentUserAccountID, + currentUserLogin, + rules, }, ); @@ -540,10 +629,22 @@ function createOption({ : ''); reportName = showPersonalDetails - ? getPersonalDetailOptionText({accountID: accountIDs.at(0), hasReport: true, personalDetails, login: personalDetail?.login, translate: translateFn}) + ? getPersonalDetailOptionText({ + accountID: accountIDs.at(0), + hasReport: true, + personalDetails, + login: personalDetail?.login, + translate: translateFn, + }) : computedReportName; } else { - reportName = getPersonalDetailOptionText({accountID: accountIDs.at(0), hasReport: false, personalDetails, login: personalDetail?.login, translate: translateFn}); + reportName = getPersonalDetailOptionText({ + accountID: accountIDs.at(0), + hasReport: false, + personalDetails, + login: personalDetail?.login, + translate: translateFn, + }); result.keyForList = String(accountIDs.at(0)); result.alternateText = formatPhoneNumberPhoneUtils(personalDetails?.[accountIDs[0]]?.login ?? ''); @@ -578,6 +679,8 @@ function createOption({ /** * Get the option for a given report. */ +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function getReportOption( participant: Participant, privateIsArchived: boolean | undefined, @@ -587,21 +690,28 @@ function getReportOption( reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined, reportDraft: OnyxEntry, currentUserAccountID: number, - localize: {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined}, + localize: { + translate: LocalizedTranslate; + dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + }, + rules: OnyxCollection, policyTags?: OnyxCollection, ): OptionData { - const {translate, dateFnsLocale} = localize; + const {translate, dateFnsLocale, convertToDisplayString} = localize; const report = getReportOrDraftReport(participant.reportID, undefined, undefined, reportDraft); const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; const option = createOption({ dateFnsLocale, + convertToDisplayString, accountIDs: visibleParticipantAccountIDs, personalDetails: personalDetails ?? {}, report: !isEmptyObject(report) ? report : undefined, policy, privateIsArchived, + rules, config: { showChatPreviewLine: false, forcePolicyNamePreview: false, @@ -620,11 +730,15 @@ function getReportOption( option.text = deprecatedGetReportName(report, reportAttributesDerived); option.alternateText = translate('workspace.common.invoices'); } else { - option.text = getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}); + option.text = getPolicyName({ + report, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), + }); option.alternateText = translate('workspace.common.workspace'); if (report?.policyID) { - const submitToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report?.ownerAccountID, personalDetails)); + const submitToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report?.ownerAccountID, personalDetails), rules); const submitsToAccountDetails = personalDetails?.[submitToAccountID]; const subtitle = submitsToAccountDetails?.displayName ?? submitsToAccountDetails?.login; @@ -646,6 +760,7 @@ type GetReportDisplayOptionParams = { unknownUserDetails: OnyxEntry; personalDetails: OnyxEntry; privateIsArchived: boolean | undefined; + rules: OnyxCollection; policy: OnyxEntry; conciergeReportID: string | undefined; translate: LocalizedTranslate; @@ -653,6 +768,7 @@ type GetReportDisplayOptionParams = { reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; /** @@ -663,9 +779,11 @@ function getReportDisplayOption({ unknownUserDetails, personalDetails, privateIsArchived, + rules, policy, conciergeReportID, translate, + convertToDisplayString, dateFnsLocale, currentUserAccountID, reportAttributesDerived, @@ -676,11 +794,13 @@ function getReportDisplayOption({ const option = createOption({ dateFnsLocale, + convertToDisplayString, accountIDs: visibleParticipantAccountIDs, personalDetails: personalDetails ?? {}, report: !isEmptyObject(report) ? report : undefined, policy, privateIsArchived, + rules, conciergeReportID, config: { showChatPreviewLine: false, @@ -701,9 +821,19 @@ function getReportDisplayOption({ } else if (unknownUserDetails) { option.text = unknownUserDetails.text ?? unknownUserDetails.login; option.alternateText = unknownUserDetails.login; - option.participantsList = [{...unknownUserDetails, displayName: unknownUserDetails.login, accountID: unknownUserDetails.accountID ?? CONST.DEFAULT_NUMBER_ID}]; + option.participantsList = [ + { + ...unknownUserDetails, + displayName: unknownUserDetails.login, + accountID: unknownUserDetails.accountID ?? CONST.DEFAULT_NUMBER_ID, + }, + ]; } else if (report?.ownerAccountID !== 0 || !option.text) { - option.text = getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}); + option.text = getPolicyName({ + report, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), + }); option.alternateText = translate('workspace.common.workspace'); } option.isDisabled = true; @@ -715,30 +845,39 @@ function getReportDisplayOption({ /** * Get the option for a policy expense report. */ +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function getPolicyExpenseReportOption( participant: Participant | SearchOptionData, privateIsArchived: boolean | undefined, personalDetails: OnyxEntry, expenseReport: OnyxEntry, policy: OnyxEntry, - localize: {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined}, + localize: { + translate: LocalizedTranslate; + dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + }, currentUserAccountID: number, + rules: OnyxCollection, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], policyTags?: OnyxEntry, visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): SearchOptionData { - const {translate, dateFnsLocale} = localize; + const {translate, dateFnsLocale, convertToDisplayString} = localize; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) .filter(([, reportParticipant]) => reportParticipant && !isHiddenForCurrentUser(reportParticipant.notificationPreference)) .map(([accountID]) => Number(accountID)); const option = createOption({ dateFnsLocale, + convertToDisplayString, accountIDs: visibleParticipantAccountIDs, personalDetails: personalDetails ?? {}, report: !isEmptyObject(expenseReport) ? expenseReport : null, policy, privateIsArchived, + rules, // Passing conciergeReportID as undefined is intentional, a policy expense chat is never the Concierge chat. conciergeReportID: undefined, config: { @@ -752,7 +891,11 @@ function getPolicyExpenseReportOption( }); // Update text & alternateText because createOption returns workspace name only if report is owned by the user - option.text = getPolicyName({report: expenseReport, policy, unavailableTranslation: translate('workspace.common.unavailable')}); + option.text = getPolicyName({ + report: expenseReport, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), + }); option.alternateText = translate('workspace.common.workspace'); option.isSelected = participant.selected; option.selected = participant.selected; // Keep for backwards compatibility @@ -867,16 +1010,25 @@ function processReport( visibleReportActionsData = {}, isTrackIntentUser, sortedActions, + transactionThreadIDs, + lastActions, currentUserAccountID, + currentUserLogin, + convertToDisplayString, }: { currentUserAccountID: number; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; isTrackIntentUser?: boolean; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; + transactionThreadIDs?: Record; + lastActions?: Record; + currentUserLogin?: string; }, + rules: OnyxCollection, ): { reportMapEntry?: [number, Report]; // The entry to add to reportMapForAccountIDs if applicable reportOption: SearchOption | null; // The report option to add to allReportOptions if applicable @@ -902,10 +1054,12 @@ function processReport( item: report, ...createOption({ dateFnsLocale, + convertToDisplayString, accountIDs, personalDetails, report, privateIsArchived, + rules, policy, conciergeReportID, reportAttributesDerived, @@ -913,7 +1067,10 @@ function processReport( visibleReportActionsData, isTrackIntentUser, sortedActions, + transactionThreadIDs, + lastActions, currentUserAccountID, + currentUserLogin, }), }, }; @@ -949,7 +1106,9 @@ function buildFilteredOptionListCacheKey(args: Array) function cloneOptionList(optionList: OptionList): OptionList { return { reports: optionList.reports.map((option) => ({...option})), - personalDetails: optionList.personalDetails.map((option) => ({...option})), + personalDetails: optionList.personalDetails.map((option) => ({ + ...option, + })), }; } @@ -983,7 +1142,13 @@ function clearFilteredOptionListCache() { registerSessionCleanupCallback(() => filteredOptionListCache.clear()); /** Builds the display option from the shell's captured inputs. */ -function buildFullOption(accountID: number, item: PersonalDetails | null, report: Report | undefined, context: LazyHydrationContext): HydratedPersonalDetailOption { +function buildFullOption( + accountID: number, + item: PersonalDetails | null, + report: Report | undefined, + context: LazyHydrationContext, + rules: OnyxCollection, +): HydratedPersonalDetailOption { const { personalDetails, policiesCollection, @@ -995,6 +1160,7 @@ function buildFullOption(accountID: number, item: PersonalDetails | null, report currentUserAccountID, dateFnsLocale, translate, + convertToDisplayString, } = context; const privateIsArchived = report ? privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`] : undefined; const policy = policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; @@ -1004,11 +1170,13 @@ function buildFullOption(accountID: number, item: PersonalDetails | null, report item, ...createOption({ dateFnsLocale, + convertToDisplayString, accountIDs: [accountID], personalDetails, report, policy, privateIsArchived, + rules, conciergeReportID, config: {showPersonalDetails: true}, reportAttributesDerived, @@ -1032,7 +1200,7 @@ function buildFullOption(accountID: number, item: PersonalDetails | null, report * Step 5 of createFilteredOptionList: one lightweight shell per personal detail. * Only filter/rank fields are computed here. getValidOptions hydrates survivors via hydrateContactOption. */ -function buildPersonalDetailsOptions(reportMapForAccountIDs: Record, context: LazyHydrationContext): PersonalDetailShell[] { +function buildPersonalDetailsOptions(reportMapForAccountIDs: Record, context: LazyHydrationContext, rules: OnyxCollection): PersonalDetailShell[] { const {personalDetails, translate} = context; return Object.values(personalDetails ?? {}).map((personalDetail) => { const accountID = personalDetail?.accountID ?? CONST.DEFAULT_NUMBER_ID; @@ -1040,11 +1208,17 @@ function buildPersonalDetailsOptions(reportMapForAccountIDs: Record (built ??= buildFullOption(accountID, personalDetail, report, context)); + const hydrate = () => (built ??= buildFullOption(accountID, personalDetail, report, context, rules)); return { item: personalDetail, @@ -1073,6 +1247,8 @@ function hydrateContactOption(option: PersonalDetailOptionOrShell): HydratedPers return {...option.hydrate(), isHydrated: true}; } +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function createFilteredOptionList( personalDetails: OnyxEntry, reports: OnyxCollection, @@ -1081,7 +1257,11 @@ function createFilteredOptionList( policiesCollection: OnyxCollection, options: { currentUserAccountID: number; + currentUserLogin?: string; + transactionThreadIDs?: Record; + lastActions?: Record; dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; conciergeReportID: string | undefined; maxRecentReports?: number; /** Whether to build contact shells. */ @@ -1096,13 +1276,25 @@ function createFilteredOptionList( deferContactsUntilSearch?: boolean; locale?: Locale; }, + rules: OnyxCollection, policyTags?: OnyxCollection, visibleReportActionsData: VisibleReportActionsDerivedValue = EMPTY_VISIBLE_REPORT_ACTIONS, isTrackIntentUser?: boolean, // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record, ): OptionList { - const {currentUserAccountID, conciergeReportID, maxRecentReports = 500, includeP2P = true, isSearching = false, deferContactsUntilSearch = false, locale} = options; + const { + currentUserAccountID, + currentUserLogin, + transactionThreadIDs, + lastActions, + conciergeReportID, + maxRecentReports = 500, + includeP2P = true, + isSearching = false, + deferContactsUntilSearch = false, + locale, + } = options; // Use the cache-key locale for translated contact fields. const activeLocale = locale ?? IntlStore.getCurrentLocale(); @@ -1128,14 +1320,21 @@ function createFilteredOptionList( visibleReportActionsData, isTrackIntentUser, conciergeReportID, + rules, // Option building translates strings and formats dates, so both the active locale and the // date-fns locale are part of the output. activeLocale, options.dateFnsLocale, + // Money-request previews format amounts with this function; the provider memoizes it on the + // currency list and locale, so a new reference signals the formatting inputs changed. + options.convertToDisplayString, // The RAM_ONLY_SORTED_REPORT_ACTIONS derived value produces a new object on every recompute, // so its reference signals that the underlying report actions changed. sortedActions, + transactionThreadIDs, + lastActions, currentUserAccountID, + currentUserLogin, ]; const cachedEntry = shouldUseCache ? filteredOptionListCache.get(cacheEntryKey) : undefined; if (cachedEntry && cacheInputs.every((value, index) => value === cachedEntry.inputs.at(index))) { @@ -1189,14 +1388,27 @@ function createFilteredOptionList( const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; const policy = policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; - const {reportMapEntry, reportOption} = processReport(report, personalDetails, privateIsArchived, policy, conciergeReportID, options.dateFnsLocale, { - reportAttributesDerived, - policyTags: reportPolicyTags, - visibleReportActionsData, - isTrackIntentUser, - sortedActions, - currentUserAccountID, - }); + const {reportMapEntry, reportOption} = processReport( + report, + personalDetails, + privateIsArchived, + policy, + conciergeReportID, + options.dateFnsLocale, + { + convertToDisplayString: options.convertToDisplayString, + reportAttributesDerived, + policyTags: reportPolicyTags, + visibleReportActionsData, + isTrackIntentUser, + sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserLogin, + }, + rules, + ); if (reportMapEntry) { const [accountID, reportValue] = reportMapEntry; @@ -1220,18 +1432,23 @@ function createFilteredOptionList( // Build contact shells; display fields are added after filtering. const personalDetailsOptions = shouldBuildContacts - ? buildPersonalDetailsOptions(reportMapForAccountIDs, { - personalDetails, - policiesCollection, - reportAttributesDerived, - policyTags, - visibleReportActionsData, - privateIsArchivedMap, - conciergeReportID, - currentUserAccountID, - dateFnsLocale: options.dateFnsLocale, - translate: translateInActiveLocale, - }) + ? buildPersonalDetailsOptions( + reportMapForAccountIDs, + { + personalDetails, + policiesCollection, + reportAttributesDerived, + policyTags, + visibleReportActionsData, + privateIsArchivedMap, + conciergeReportID, + currentUserAccountID, + dateFnsLocale: options.dateFnsLocale, + convertToDisplayString: options.convertToDisplayString, + translate: translateInActiveLocale, + }, + rules, + ) : []; const result: OptionList = { @@ -1265,14 +1482,21 @@ type CreateOptionFromReportParams = { report: Report; personalDetails: OnyxEntry; privateIsArchived: boolean | undefined; + rules: OnyxCollection; policy: OnyxEntry; sortedActions: Record | undefined; + transactionThreadIDs?: Record; + lastActions?: Record; + currentUserAccountID?: number; + currentUserLogin?: string; conciergeReportID: string | undefined; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; config?: PreviewConfig; policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; isTrackIntentUser?: boolean; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; }; function createOptionFromReport({ @@ -1280,14 +1504,21 @@ function createOptionFromReport({ report, personalDetails, privateIsArchived, + rules, policy, sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserLogin, conciergeReportID, reportAttributesDerived, config, policyTags, visibleReportActionsData = {}, isTrackIntentUser, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, }: CreateOptionFromReportParams) { const accountIDs = getParticipantsAccountIDsForDisplay(report); @@ -1295,10 +1526,13 @@ function createOptionFromReport({ item: report, ...createOption({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, accountIDs, personalDetails, report, privateIsArchived, + rules, policy, conciergeReportID, config, @@ -1306,6 +1540,10 @@ function createOptionFromReport({ policyTags, visibleReportActionsData, sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserLogin, isTrackIntentUser, }), }; @@ -1431,7 +1669,10 @@ function optionsOrderAndGroupBy( // If limit is 0 or negative, return N+1 empty arrays if (limit !== undefined && limit <= 0) { - return {options: Array.from({length: separators.length + 1}, () => []), hasMore}; + return { + options: Array.from({length: separators.length + 1}, () => []), + hasMore, + }; } const heaps = Array.from({length: separators.length}, () => createDecoratedOptionHeap(reversed, limit)); @@ -1612,6 +1853,7 @@ function canCreateOptimisticPersonalDetailOption({ */ function getUserToInviteOption({ dateFnsLocale, + convertToDisplayString, searchValue, personalDetails, searchInputValue, @@ -1623,7 +1865,12 @@ function getUserToInviteOption({ loginList = {}, currentUserEmail, visibleReportActionsData = {}, -}: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue; dateFnsLocale: DateFnsLocale | undefined}): SearchOptionData | null { + rules, +}: GetUserToInviteConfig & { + visibleReportActionsData?: VisibleReportActionsDerivedValue; + dateFnsLocale: DateFnsLocale | undefined; + rules: OnyxCollection; +}): SearchOptionData | null { if (!searchValue) { return null; } @@ -1657,10 +1904,12 @@ function getUserToInviteOption({ }; const userToInvite = createOption({ dateFnsLocale, + convertToDisplayString, accountIDs: [optimisticAccountID], personalDetails: personalDetailsExtended, report: null, privateIsArchived: undefined, + rules, // Passing conciergeReportID as undefined is intentional, the invite option is built without a report, so it can never be the Concierge chat. conciergeReportID: undefined, config: {showChatPreviewLine}, @@ -1844,6 +2093,57 @@ function isValidReport( return true; } +/** + * Cache for the chat preview text that `getAlternateText` resolves. + * + * `createFilteredOptionList` reuses its whole result while its Onyx inputs are unchanged, but + * `prepareReportOptionsForDisplay` runs on every call - once per keystroke in Search - and resolves the full preview + * (`getReportAlternateText`) for every option it keeps. The preview depends only on the report and on the Onyx + * snapshots guarded below, so it is kept per report and the whole cache is dropped as soon as any of those inputs + * changes identity. + */ +const alternateTextCache = new Map(); +let alternateTextCacheInputs: unknown[] | undefined; + +// Bound the LRU cache so a wide result cap cannot grow it without limit. +const ALTERNATE_TEXT_CACHE_MAX_ENTRIES = 500; + +// The cached previews belong to the signed-in account, so drop them on sign-out instead of holding them until the +// next call. +registerSessionCleanupCallback(() => { + alternateTextCache.clear(); + alternateTextCacheInputs = undefined; +}); + +/** Drops every cached preview as soon as any input that feeds `getAlternateText` changes identity. */ +function syncAlternateTextCache(inputs: unknown[]) { + if (alternateTextCacheInputs?.length === inputs.length && inputs.every((input, index) => input === alternateTextCacheInputs?.at(index))) { + return; + } + + alternateTextCache.clear(); + alternateTextCacheInputs = inputs; +} + +/** Returns the cached preview for the key, resolving and storing it on a miss. */ +function getCachedAlternateText(cacheKey: string, resolveAlternateText: () => string | undefined): string | undefined { + if (alternateTextCache.has(cacheKey)) { + return alternateTextCache.get(cacheKey); + } + + const alternateText = resolveAlternateText(); + + if (alternateTextCache.size >= ALTERNATE_TEXT_CACHE_MAX_ENTRIES) { + const oldestEntry = alternateTextCache.keys().next(); + if (!oldestEntry.done) { + alternateTextCache.delete(oldestEntry.value); + } + } + alternateTextCache.set(cacheKey, alternateText); + + return alternateText; +} + /** * Prepares report options for display by enriching them with UI-specific properties and filtering out invalid options. * @@ -1854,14 +2154,30 @@ function isValidReport( * @param config - Configuration object specifying display preferences and filtering criteria * @returns Array of enriched and filtered report options ready for UI display */ +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function prepareReportOptionsForDisplay( options: Array>, policiesCollection: OnyxCollection, isOffline: boolean, - config: GetValidReportsConfig & {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined; currentUserAccountID?: number}, + config: GetValidReportsConfig & { + translate: LocalizedTranslate; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; + dateFnsLocale: DateFnsLocale | undefined; + currentUserAccountID?: number; + currentUserLogin?: string; + cardList?: OnyxEntry; + workspaceCardList?: OnyxCollection; + localeCompare?: LocaleContextProps['localeCompare']; + formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; + transactionThreadIDs?: Record; + lastActions?: Record; + }, conciergeReportID: string | undefined, sortedActions: Record | undefined, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, + rules: OnyxCollection, + visibleReportActionsData: VisibleReportActionsDerivedValue = EMPTY_VISIBLE_REPORT_ACTIONS, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], policyTags?: OnyxCollection, isTrackIntentUser?: boolean, @@ -1880,13 +2196,46 @@ function prepareReportOptionsForDisplay( shouldUnreadBeBold = false, personalDetails, translate, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, currentUserAccountID, + currentUserLogin, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, + transactionThreadIDs, + lastActions, } = config; const validOptions: Array> = []; const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; + syncAlternateTextCache([ + allReports, + policiesCollection, + personalDetails, + visibleReportActionsData, + reportAttributesDerived, + policyTags, + sortedActions, + transactionThreadIDs, + lastActions, + cardList, + workspaceCardList, + conciergeReportID, + isTrackIntentUser, + currentUserAccountID, + currentUserLogin, + config.dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + localeCompare, + formatPhoneNumber, + translate, + ]); + for (let i = 0; i < options.length; i++) { const option = options.at(i); if (!option) { @@ -1895,29 +2244,49 @@ function prepareReportOptionsForDisplay( const report = option.item; const policy = policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; + const parentReport = report.parentReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`] : undefined; + let invoiceReceiverPolicyID: string | undefined; + if (report.invoiceReceiver && 'policyID' in report.invoiceReceiver) { + invoiceReceiverPolicyID = report.invoiceReceiver.policyID; + } + if (parentReport?.invoiceReceiver && 'policyID' in parentReport.invoiceReceiver) { + invoiceReceiverPolicyID = parentReport.invoiceReceiver.policyID; + } + const invoiceReceiverPolicy = invoiceReceiverPolicyID ? policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`] : undefined; /** * By default, generated options does not have the chat preview line enabled. * If showChatPreviewLine or forcePolicyNamePreview are true, let's generate and overwrite the alternate text. */ - const lastActorDetails = personalDetails?.[report.lastActorAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? null; - - const alternateText = getAlternateText( - option, - {showChatPreviewLine, forcePolicyNamePreview}, - { - dateFnsLocale: config.dateFnsLocale, - isReportArchived: !!option.private_isArchived, - personalDetails, - policy, - lastActorDetails, - visibleReportActionsData, - reportAttributesDerived, - policyTags: reportPolicyTags, - conciergeReportID, - sortedActions, - isTrackIntentUser, - currentUserAccountID, - }, + const alternateText = getCachedAlternateText(`${report.reportID}_${showChatPreviewLine ? 1 : 0}_${forcePolicyNamePreview ? 1 : 0}_${option.private_isArchived ? 1 : 0}`, () => + getAlternateText( + option, + {showChatPreviewLine, forcePolicyNamePreview}, + { + dateFnsLocale: config.dateFnsLocale, + isReportArchived: !!option.private_isArchived, + personalDetails, + policy, + invoiceReceiverPolicy, + visibleReportActionsData, + translate, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + reportAttributesDerived, + policyTags: reportPolicyTags, + conciergeReportID, + sortedActions, + transactionThreadIDs, + lastActions, + isTrackIntentUser, + currentUserAccountID, + currentUserLogin, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, + rules, + }, + ), ); const isSelected = isReportSelected(option, selectedOptions); @@ -1964,11 +2333,15 @@ function prepareReportOptionsForDisplay( } if (shouldSeparateWorkspaceChat && newReportOption.isPolicyExpenseChat && !newReportOption.private_isArchived) { - newReportOption.text = getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}); + newReportOption.text = getPolicyName({ + report, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), + }); newReportOption.alternateText = translate('workspace.common.workspace'); if (report?.policyID) { - const submitToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report?.ownerAccountID, personalDetails)); + const submitToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report?.ownerAccountID, personalDetails), rules); const submitsToAccountDetails = personalDetails?.[submitToAccountID]; const subtitle = submitsToAccountDetails?.displayName ?? submitsToAccountDetails?.login; @@ -2020,14 +2393,22 @@ function getValidOptions( personalDetails, allPolicyTags, countryCode = CONST.DEFAULT_COUNTRY_CODE, - visibleReportActionsData = {}, + visibleReportActionsData = EMPTY_VISIBLE_REPORT_ACTIONS, reportAttributesDerived, sortedActions, + transactionThreadIDs, + lastActions, + currentUserLogin, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, isTrackIntentUser, isOffline, ...config }: GetOptionsConfig, translate: LocalizedTranslate, + rules: OnyxCollection, ): OptionsResult { // Gather shared configs: // Hard exclusions: cannot be selected at all @@ -2169,9 +2550,17 @@ function getValidOptions( personalDetails, translate, currentUserAccountID, + currentUserLogin: currentUserLogin ?? currentUserEmail, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, + transactionThreadIDs, + lastActions, }, conciergeReportID, sortedActions, + rules, visibleReportActionsData, reportAttributesDerived, allPolicyTags, @@ -2197,9 +2586,17 @@ function getValidOptions( personalDetails, translate, currentUserAccountID, + currentUserLogin: currentUserLogin ?? currentUserEmail, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, + transactionThreadIDs, + lastActions, }, conciergeReportID, sortedActions, + rules, visibleReportActionsData, reportAttributesDerived, allPolicyTags, @@ -2221,9 +2618,17 @@ function getValidOptions( personalDetails, translate, currentUserAccountID, + currentUserLogin: currentUserLogin ?? currentUserEmail, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, + transactionThreadIDs, + lastActions, }, conciergeReportID, sortedActions, + rules, visibleReportActionsData, reportAttributesDerived, allPolicyTags, @@ -2336,7 +2741,11 @@ function getValidOptions( let userToInvite: SearchOptionData | null = null; if (includeUserToInvite) { userToInvite = filterUserToInvite( - {currentUserOption: currentUserRef.current, recentReports: recentReportOptions, personalDetails: personalDetailsOptions}, + { + currentUserOption: currentUserRef.current, + recentReports: recentReportOptions, + personalDetails: personalDetailsOptions, + }, searchString ?? '', loginList, currentUserEmail, @@ -2344,10 +2753,12 @@ function getValidOptions( countryCode, { dateFnsLocale, + convertToDisplayString: config.convertToDisplayString, excludeLogins: loginsToExclude, shouldAcceptName, searchInputValue, }, + rules, ); } @@ -2366,6 +2777,8 @@ function getValidOptions( type SearchOptionsConfig = { dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; options: OptionList; draftComments: OnyxCollection; betas?: Beta[]; @@ -2388,10 +2801,18 @@ type SearchOptionsConfig = { reportAttributesDerived?: ReportAttributesDerivedValue['reports']; allPolicyTags?: OnyxCollection; sortedActions: Record | undefined; + transactionThreadIDs?: Record; + lastActions?: Record; + currentUserLogin?: string; + cardList?: OnyxEntry; + workspaceCardList?: OnyxCollection; + localeCompare?: LocaleContextProps['localeCompare']; + formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; conciergeReportID: string | undefined; excludeFromSuggestionsOnly?: Record; isTrackIntentUser?: boolean; translate: LocalizedTranslate; + rules: OnyxCollection; }; /** @@ -2421,10 +2842,20 @@ function getSearchOptions({ personalDetails, allPolicyTags, sortedActions, + transactionThreadIDs, + lastActions, + currentUserLogin, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, conciergeReportID, excludeFromSuggestionsOnly = {}, isTrackIntentUser, translate, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, }: SearchOptionsConfig): OptionsResult { const optionList = getValidOptions( options, @@ -2436,6 +2867,8 @@ function getSearchOptions({ conciergeReportID, { dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, betas, includeRecentReports, includeMultipleParticipantReports: true, @@ -2461,10 +2894,18 @@ function getSearchOptions({ reportAttributesDerived, allPolicyTags, sortedActions, + transactionThreadIDs, + lastActions, + currentUserLogin: currentUserLogin ?? currentUserEmail, + cardList, + workspaceCardList, + localeCompare, + formatPhoneNumber, excludeFromSuggestionsOnly, isTrackIntentUser, }, translate, + rules, ); return optionList; @@ -2481,10 +2922,21 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail( ): PayeePersonalDetails { const login = personalDetail?.login ?? ''; return { - text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetail, defaultValue: formatPhoneNumber(login), translate, formatPhoneNumber}), + text: temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: personalDetail, + defaultValue: formatPhoneNumber(login), + translate, + formatPhoneNumber, + }), alternateText: formatPhoneNumber(login) || - temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetail, defaultValue: '', shouldFallbackToHidden: false, translate, formatPhoneNumber}), + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: personalDetail, + defaultValue: '', + shouldFallbackToHidden: false, + translate, + formatPhoneNumber, + }), icons: [ { source: personalDetail?.avatar ?? FallbackAvatar, @@ -2597,7 +3049,9 @@ function formatSectionsFromSearchTerm( currentUserAccountID: number, allPolicies: OnyxCollection, translate: LocalizedTranslate, + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], dateFnsLocale: DateFnsLocale | undefined, + rules: OnyxCollection, personalDetails: OnyxEntry = {}, shouldGetOptionDetails = false, filteredWorkspaceChats: SearchOptionData[] = [], @@ -2627,8 +3081,9 @@ function formatSectionsFromSearchTerm( personalDetails, expenseReport, expenseReportPolicy, - {translate, dateFnsLocale}, + {translate, dateFnsLocale, convertToDisplayString}, currentUserAccountID, + rules, reportAttributesDerived, ); } @@ -2668,8 +3123,9 @@ function formatSectionsFromSearchTerm( personalDetails, expenseReport, expenseReportPolicy, - {translate, dateFnsLocale}, + {translate, dateFnsLocale, convertToDisplayString}, currentUserAccountID, + rules, reportAttributesDerived, ); } @@ -2774,10 +3230,11 @@ function filterUserToInvite( loginList: OnyxEntry, currentUserEmail: string, personalDetails: OnyxEntry, - countryCode: number = CONST.DEFAULT_COUNTRY_CODE, - config?: FilterUserToInviteConfig, + countryCode: number, + config: FilterUserToInviteConfig, + rules: OnyxCollection, ): SearchOptionData | null { - const {canInviteUser = true, excludeLogins = {}, dateFnsLocale} = config ?? {}; + const {canInviteUser = true, excludeLogins = {}} = config; if (!canInviteUser) { return null; } @@ -2798,13 +3255,13 @@ function filterUserToInvite( ...excludeLogins, }; return getUserToInviteOption({ - dateFnsLocale, searchValue, personalDetails, loginsToExclude, countryCode, loginList, currentUserEmail, + rules, ...config, }); } @@ -2821,7 +3278,8 @@ function filterOptions( currentUserEmail: string, currentUserAccountID: number, personalDetailsCollection: OnyxEntry, - config?: FilterUserToInviteConfig, + config: FilterUserToInviteConfig, + rules: OnyxCollection, ): Options { const trimmedSearchInput = searchInputValue.trim(); const searchInputValueForInvite = config?.searchInputValue ?? trimmedSearchInput; @@ -2846,10 +3304,9 @@ function filterOptions( countryCode, { ...config, - // `config` is optional, so the required locale has to be set explicitly rather than relying on the spread. - dateFnsLocale: config?.dateFnsLocale, searchInputValue: searchInputValueForInvite, }, + rules, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); @@ -2911,11 +3368,12 @@ function filterAndOrderOptions( currentUserEmail: string, currentUserAccountID: number, personalDetails: OnyxEntry, - config?: FilterAndOrderConfig, + config: FilterAndOrderConfig, + rules: OnyxCollection, ): Options { let filterResult = options; if (searchInputValue.trim().length > 0) { - filterResult = filterOptions(options, searchInputValue, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, config); + filterResult = filterOptions(options, searchInputValue, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, config, rules); } const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filterResult, searchInputValue, config); diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index bb3a6d6f6702..d0240cbe3221 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -1,6 +1,7 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type {Section as SelectionListSection} from '@components/SelectionList/SelectionListWithSections/types'; +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; import type {OptionData} from '@libs/ReportUtils'; @@ -9,6 +10,7 @@ import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {IOUAction} from '@src/CONST'; import type { Beta, + CardList, Login, PersonalDetails, PersonalDetailsList, @@ -20,6 +22,7 @@ import type { ReportAttributesDerivedValue, TransactionViolation, VisibleReportActionsDerivedValue, + WorkspaceCardsList, } from '@src/types/onyx'; import type {Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -118,6 +121,9 @@ type LazyHydrationContext = { /** Locale used when the option list was built. */ translate: LocalizedTranslate; + + /** Currency formatter used when the option list was built. */ + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; type SearchOption = SearchOptionData & { @@ -264,6 +270,8 @@ type IsValidReportsConfig = Pick< type GetOptionsConfig = { dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; excludeLogins?: Record; excludeFromSuggestionsOnly?: Record; includeCurrentUser?: boolean; @@ -281,6 +289,13 @@ type GetOptionsConfig = { visibleReportActionsData?: VisibleReportActionsDerivedValue; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; sortedActions?: Record; + transactionThreadIDs?: Record; + lastActions?: Record; + currentUserLogin?: string; + cardList?: OnyxEntry; + workspaceCardList?: OnyxCollection; + localeCompare?: LocaleContextProps['localeCompare']; + formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; isTrackIntentUser?: boolean; /** TODO: Should be required field in the future. Refactor issue: https://github.com/Expensify/App/issues/66407 */ isOffline?: boolean; @@ -303,6 +318,7 @@ type GetUserToInviteConfig = { countryCode?: number; loginList: OnyxEntry; currentUserEmail: string; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; } & Pick; type MemberForList = { @@ -345,6 +361,7 @@ type PreviewConfig = { type FilterUserToInviteConfig = Pick & { dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; canInviteUser?: boolean; excludeLogins?: Record; }; diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index 955e66f68bf1..e39aa51b5adb 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -12,7 +12,7 @@ import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {AccountData, Beta, BillingGraceEndPeriod, Policy, Report} from '@src/types/onyx'; +import type {AccountData, Beta, BillingGraceEndPeriod, Policy, Report, Rule} from '@src/types/onyx'; import type BankAccount from '@src/types/onyx/BankAccount'; import type Fund from '@src/types/onyx/Fund'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -49,6 +49,7 @@ type SelectPaymentTypeParams = { isASAPSubmitBetaEnabled: boolean; confirmApproval?: () => void; iouReport?: OnyxEntry; + rules: OnyxCollection; betas: OnyxEntry; userBillingGracePeriodEnds: OnyxCollection; amountOwed: OnyxEntry; @@ -225,6 +226,7 @@ const selectPaymentType = (params: SelectPaymentTypeParams) => { isASAPSubmitBetaEnabled, confirmApproval, iouReport, + rules, betas, userBillingGracePeriodEnds, amountOwed, @@ -257,6 +259,7 @@ const selectPaymentType = (params: SelectPaymentTypeParams) => { approveMoneyRequest({ expenseReport: iouReport, expenseReportPolicy, + rules, currentUserAccountIDParam: currentAccountID, currentUserEmailParam: currentEmail, hasViolations, diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 66cc2e2cd991..4dc08babb22f 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -1,9 +1,22 @@ +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; import type BetaConfiguration from '@src/types/onyx/BetaConfiguration'; +import type BetaOverrides from '@src/types/onyx/BetaOverrides'; import type {OnyxEntry} from 'react-native-onyx'; +import {isProduction} from './Environment/Environment'; + +// Start from the synchronous config so overrides never apply in production, then refine with the resolved +// environment, which downgrades TestFlight builds to staging +let isProductionEnvironment = CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.PRODUCTION; +isProduction() + .then((value) => { + isProductionEnvironment = value; + }) + .catch(() => {}); + // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.ALL); @@ -16,7 +29,14 @@ function canUseLinkPreviews(): boolean { return false; } -function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration?: OnyxEntry): boolean { +function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration?: OnyxEntry, betaOverrides?: OnyxEntry): boolean { + if (!isProductionEnvironment) { + const override = betaOverrides?.[beta]; + if (override !== undefined) { + return override; + } + } + const hasAllBetasEnabled = canUseAllBetas(betas); const isFeatureEnabled = !!betas?.includes(beta); diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 6320b19a541e..7908e509ffa8 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -185,13 +185,36 @@ function getRateStatus(rate: Rate): string { return CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE; } +function getGovernmentRateAmountForUnit(governmentRateAmount: number, sourceRateID: string | undefined, currentUnit: Unit | undefined): number { + if (!sourceRateID || !currentUnit) { + return governmentRateAmount; + } + + const snapshotCountry = sourceRateID.split('_').at(0); + const countryToUnit: Partial> = CONST.CUSTOM_UNITS.GOVERNMENT_RATE_COUNTRY_TO_UNIT; + const snapshotUnit = snapshotCountry ? countryToUnit[snapshotCountry] : undefined; + + if (!snapshotUnit || snapshotUnit === currentUnit) { + return governmentRateAmount; + } + + // If a rate is expressed in cents / km, converting to cents / mi means multiplying by a factor that cancels the kilometers: + // cents / km * km / mi = cents / mi. Do the opposite for a rate expressed in cents / mi. + const convertedAmount = + snapshotUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS + ? governmentRateAmount * CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS + : governmentRateAmount / CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS; + + return Math.round(convertedAmount * 100) / 100; +} + /** * Whether a government-managed rate still matches the government-published snapshot it was copied from. * Returns true only when the rate amount, start date, and end date each match the snapshot in attributes.governmentRate. * The amount is compared within a small tolerance to absorb floating-point noise from the stored cents value. * A date omitted on both sides counts as a match; a date omitted on only one side does not. */ -function isGovernmentRateUnmodified(rate: Rate): boolean { +function isGovernmentRateUnmodified(rate: Rate, currentUnit?: Unit): boolean { const governmentRate = rate.attributes?.governmentRate; // A snapshot without a rate amount (e.g. malformed data) can never be matched, otherwise `undefined === undefined` would // incorrectly report an unset rate as unmodified. @@ -201,7 +224,8 @@ function isGovernmentRateUnmodified(rate: Rate): boolean { // The submit path stores the amount as `Number(value) * 100`, which can introduce tiny floating-point errors (e.g. restoring // 0.29 yields 28.999999999999996), so compare amounts within a tolerance rather than requiring strict equality. - const isRateAmountMatching = Math.abs(rate.rate - governmentRate.rate) < CONST.CUSTOM_UNITS.GOVERNMENT_RATE_MATCH_TOLERANCE; + const governmentRateAmount = getGovernmentRateAmountForUnit(governmentRate.rate, governmentRate.sourceRateID, currentUnit); + const isRateAmountMatching = Math.abs(rate.rate - governmentRateAmount) < CONST.CUSTOM_UNITS.GOVERNMENT_RATE_MATCH_TOLERANCE; return isRateAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate; } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 31a5a88361ad..e55940290221 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -19,6 +19,7 @@ import type { Transaction, TravelSettings, } from '@src/types/onyx'; +import type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { ApprovalRule, @@ -38,6 +39,7 @@ import type { Vendor, } from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; +import type Rule from '@src/types/onyx/Rule'; import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -46,6 +48,7 @@ import type {TupleToUnion, ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; +import {getQuickbooksOnlineIntegrationName} from './AccountingUtils'; import {getBankAccountFromID} from './actions/BankAccounts'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from './actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from './actions/connections/QuickbooksOnline'; @@ -55,6 +58,7 @@ import {getCategoryApproverRule, hasAnyCategoryRules} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; import isTeachersUnitePolicyID from './isTeachersUnitePolicyID'; import {getHRAdvancedModeFinalApprover, isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from './merge/HRUtils'; +import {isAnyRecruitingConnected} from './merge/RecruitingUtils'; import Navigation from './Navigation/Navigation'; import {getIsOffline} from './NetworkState'; import {getAccountIDsByLogins, getKnownAccountIDByLogin, getPersonalDetailByEmail} from './PersonalDetailsUtils'; @@ -81,10 +85,27 @@ type WorkspaceDetails = { }; type ConnectionWithLastSyncData = { - /** State of the last synchronization */ lastSync?: ConnectionLastSync; }; +/** 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. */ @@ -818,6 +839,12 @@ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: strin const isPolicyAuditor = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.AUDITOR; +/** + * Checks if the current user is a workspace or card admin of the policy and the policy has a card product enabled. + */ +const isAdminOfCardEnabledPolicy = (policy: OnyxInputOrEntry, login?: string): boolean => + (isPolicyAdmin(policy, login) || getPolicyRole(policy, login) === CONST.POLICY.ROLE.CARD_ADMIN) && (!!policy?.areCompanyCardsEnabled || !!policy?.areExpensifyCardsEnabled); + const isPolicyEmployee = (policyID: string | undefined, policy: OnyxEntry): boolean => { return !!policyID && policyID === policy?.id; }; @@ -1102,6 +1129,53 @@ function hasTags(policyTagList: OnyxEntry): boolean { return tagLists.some((tagList) => Object.keys(tagList.tags ?? {}).length > 0); } +/** + * Checks whether a policy tag is selectable under a given parent tag path. + * Tags of a dependent list only apply below the parents their parentTagsFilter matches, + * while tags without a filter apply everywhere. + */ +function matchesParentTagPath(policyTag: ValueOf, parentTagPath: string): boolean { + const filterRegex = policyTag.rules?.parentTagsFilter ?? policyTag.parentTagsFilter; + return !filterRegex || new RegExp(filterRegex).test(parentTagPath); +} + +/** + * Finds the policy tag at a single tag list level that matches a tag name. + * Dependent tag lists can hold same-named child tags under different parents (stored under unique + * record keys), so a tag only matches by name when its parent filter also matches the parent tag path. + */ +function findPolicyTagAtLevel(levelTags: PolicyTags, tagName: string, parentTagPath: string): ValueOf | undefined { + const matchesTagAtLevel = (levelTag: ValueOf | undefined): levelTag is ValueOf => { + if (!levelTag || levelTag.name !== tagName) { + return false; + } + return matchesParentTagPath(levelTag, parentTagPath); + }; + + const directMatch = levelTags[tagName]; + return matchesTagAtLevel(directMatch) ? directMatch : Object.values(levelTags).find(matchesTagAtLevel); +} + +function isTagInPolicy(tagValue: string, policyTags: OnyxEntry): boolean { + if (!policyTags) { + return false; + } + const tagComponents = getTagArrayFromName(tagValue); + const sortedTagLists = getTagLists(policyTags); + + return tagComponents.every((component, index) => { + if (!component) { + return true; + } + const levelTags = sortedTagLists.at(index)?.tags; + if (!levelTags) { + return false; + } + const tag = findPolicyTagAtLevel(levelTags, component, tagComponents.slice(0, index).join(':')); + return !!tag && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); +} + /** * Checks if a policy has any custom categories (categories not in the default list) */ @@ -1283,20 +1357,7 @@ function getTagGLCode(policyTagLists: OnyxEntry, transactionTag: return ''; } - // Dependent tag lists can hold same-named child tags under different parents (stored under unique - // record keys), so a tag only matches by name when its parent filter also matches the parent tag path. - const parentTagPath = tagParts.slice(0, index).join(':'); - const matchesTagAtLevel = (levelTag: ValueOf | undefined): levelTag is ValueOf => { - if (!levelTag || levelTag.name !== tagName) { - return false; - } - const filterRegex = levelTag.rules?.parentTagsFilter ?? levelTag.parentTagsFilter; - return !filterRegex || new RegExp(filterRegex).test(parentTagPath); - }; - - const directMatch = levelTags[tagName]; - const matchingTag = matchesTagAtLevel(directMatch) ? directMatch : Object.values(levelTags).find(matchesTagAtLevel); - return getGLCodeFromPolicyTag(matchingTag); + return getGLCodeFromPolicyTag(findPolicyTagAtLevel(levelTags, tagName, tagParts.slice(0, index).join(':'))); }) .filter(Boolean) .join(', '); @@ -1466,16 +1527,21 @@ function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { /** * Whether the policy can access a feature based on plan level. * Corporate-only features are restricted to control (Corporate) policies. - * Rules are available on Control always, and on Collect only when the rulesRevamp beta is enabled. + * Rules are available on both Control and Collect. */ -function canPolicyAccessFeature(policy: OnyxEntry, featureName: PolicyFeatureName, isRulesRevampEnabled = false): boolean { +function canPolicyAccessFeature(policy: OnyxEntry, featureName: PolicyFeatureName): boolean { if (!isPaidGroupPolicy(policy)) { return false; } if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { - return isControlPolicy(policy) || (isCollectPolicy(policy) && isRulesRevampEnabled); - } - const corporateOnlyFeatures = new Set([CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED]); + return isControlPolicy(policy) || isCollectPolicy(policy); + } + const corporateOnlyFeatures = new Set([ + CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED, + CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED, + CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, + CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, + ]); if (corporateOnlyFeatures.has(featureName)) { return isControlPolicy(policy); } @@ -1566,7 +1632,7 @@ function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; } -function arePaymentsEnabled(policy: OnyxEntry): boolean { +function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { return policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; } @@ -1608,6 +1674,16 @@ 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; +} + /** * Whether the policy has active accounting integration connections. * `getCurrentConnectionName` only returns connections supported in NewDot. @@ -1684,17 +1760,10 @@ function canDisableOrDeleteTaxRate(policy: Policy, taxID: string): boolean { return policy.taxRates?.defaultExternalID !== taxID && policy.taxRates?.foreignTaxDefault !== taxID; } -/** - * @param isRulesRevampEnabled - Prefer `isBetaEnabled(CONST.BETAS.RULES_REVAMP)` from `usePermissions()`, not raw betas from Onyx. - * Collect workspaces can only access Rules when this beta is enabled. - */ -function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null, isRulesRevampEnabled = false): boolean { +function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { if (!isPaidGroupPolicy(policy)) { return false; } - if (isCollectPolicy(policy) && !isRulesRevampEnabled) { - return false; - } if (policy?.areRulesEnabled === true) { return true; } @@ -1708,9 +1777,9 @@ function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: Pol return hasAnyCategoryRules(policyCategories ?? undefined); } -function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null, isRulesRevampEnabled = false): boolean { +function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { - return arePolicyRulesEnabled(policy, policyCategories, isRulesRevampEnabled); + return arePolicyRulesEnabled(policy, policyCategories); } if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { return !!policy?.tax?.trackingEnabled; @@ -1718,9 +1787,15 @@ function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFe 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; } @@ -1868,7 +1943,109 @@ function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: Onyx return firstCategoryApprover || firstTagApprover; } -function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined): string { +/** + * True when this node is a single comparison instead of a combination of two children. + */ +function isApprovalWorkflowComparison(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): node is ApprovalWorkflowFilterComparison { + return typeof node.left === 'string'; +} + +function matchesApprovalWorkflowEmailComparison(node: ApprovalWorkflowFilterComparison, 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: ApprovalWorkflowFilterComparison, 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: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison, context: ApprovalWorkflowContext): boolean { + if (!isApprovalWorkflowComparison(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.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE : CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.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 (!Object.values(rule.triggers ?? {}).includes(trigger)) { + continue; + } + if (!evaluateApprovalWorkflowRule(rule, context)) { + continue; + } + + return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.APPROVAL_WORKFLOW.ACTION.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. @@ -1884,8 +2061,8 @@ function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | return employee?.submitsTo ?? defaultApprover ?? ''; } -function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined) { - const managerEmail = getManagerAccountEmail(policy, ownerLogin); +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; } @@ -1893,7 +2070,13 @@ function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | und * 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, shouldFallBackWhenManagerIsNotMember = false): string { +function getSubmitToEmail( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): string { const approvalRules = policy?.rules?.approvalRules; if (!isSubmitAndClose(policy) && approvalRules?.length) { @@ -1903,7 +2086,12 @@ function getSubmitToEmail(policy: OnyxEntry, expenseReport: OnyxEntry, expenseReport: OnyxEntry, expenseReport: OnyxEntry, ownerLogin: string | undefined, shouldFallBackWhenManagerIsNotMember = false): number { - const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, shouldFallBackWhenManagerIsNotMember); +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): number | undefined { +function getSubmitReportManagerAccountID(policy: OnyxEntry, expenseReport: OnyxEntry, submitterLogin: string | undefined, rules: OnyxCollection): number | undefined { const approvalRules = policy?.rules?.approvalRules; const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; const hasReliablePolicyRoute = @@ -1933,7 +2127,7 @@ function getSubmitReportManagerAccountID(policy: OnyxEntry, expenseRepor return undefined; } - const submitToAccountID = getKnownAccountIDByLogin(getSubmitToEmail(policy, expenseReport, submitterLogin, true)); + const submitToAccountID = getKnownAccountIDByLogin(getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true)); if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { return undefined; } @@ -1944,8 +2138,17 @@ function getSubmitReportManagerAccountID(policy: OnyxEntry, expenseRepor /** * 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): string { +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 ''; } @@ -2449,16 +2652,23 @@ function isXeroVendorMatchingActive(policy: OnyxEntry): boolean { return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config?.isConfigured; } +/** + * True when Rillet is connected AND configured. Mirrors `Rillet::hasVendorFeature` on the PHP side. + */ +function isRilletVendorMatchingActive(policy: OnyxEntry): boolean { + return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config?.isConfigured; +} + /** * 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 `getMatchingVendors` (QBO → Intacct → Xero) 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 + * in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet) 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 isXeroVendorMatchingActive(policy) && !isQBOVendorMatchingActive(policy) && !isIntacctVendorMatchingActive(policy); + return getActiveVendorMatchingIntegration(policy) === CONST.POLICY.CONNECTIONS.NAME.XERO; } /** @@ -2468,10 +2678,11 @@ function isXeroActiveMatchingSource(policy: OnyxEntry): boolean { * the field. * * The `vendorMatching` beta only gates the integrations that haven't reached GA yet, so - * `isVendorMatchingBetaEnabled` is consulted on the Intacct and Xero branches but not on QBO: + * `isVendorMatchingBetaEnabled` is consulted on the Intacct, Xero, and Rillet branches but not on QBO: * - QBO (R1) with non-reimbursable export = Credit Card or Debit Card. GA, so no beta required * - Sage Intacct (R2) with non-reimbursable export = Credit Card Charge. Beta required - * - Xero (R3) has no export destination enum, so a present connection is enough. Beta required + * - Xero (R3) has no export destination enum, so a configured connection is enough. Beta required + * - Rillet (R4) configured connection. Beta required */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!policy) { @@ -2480,12 +2691,12 @@ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled if (isQBOVendorMatchingActive(policy)) { return true; } - return isVendorMatchingBetaEnabled && (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy)); + return isVendorMatchingBetaEnabled && (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy)); } /** * Single source of truth for which connected integration scopes the vendor field for this workspace - * (QBO, Sage Intacct, or Xero) and what its vendor list looks like. Returns `undefined` when no + * (QBO, Sage Intacct, Xero, or Rillet) and what its vendor list looks like. Returns `undefined` when no * vendor-matching integration is active OR when the active integration's list hasn't synced yet — * distinct from `[]` (loaded-empty). Lets callers tell "no vendors" from "not loaded". * @@ -2517,6 +2728,12 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry): Connecti if (isIntacctVendorMatchingActive(policy)) { return CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; } + if (isXeroVendorMatchingActive(policy)) { + return CONST.POLICY.CONNECTIONS.NAME.XERO; + } + if (isRilletVendorMatchingActive(policy)) { + return CONST.POLICY.CONNECTIONS.NAME.RILLET; + } return undefined; } @@ -2546,6 +2763,18 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry): Vendor[] | u } return Object.values(xeroContacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); } + if (isRilletVendorMatchingActive(policy)) { + const rilletVendors = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.data?.vendors; + if (rilletVendors === undefined) { + return undefined; + } + return rilletVendors.map((vendor) => ({ + id: vendor.id, + name: vendor.name, + currency: '', + email: vendor.email ?? '', + })); + } return undefined; } @@ -2617,6 +2846,15 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) if (xeroContact) { return {id: xeroContact.id, name: xeroContact.name, currency: '', email: xeroContact.email}; } + const rilletVendor = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.data?.vendors?.find((vendor) => vendor.id === vendorID); + if (rilletVendor) { + return { + id: rilletVendor.id, + name: rilletVendor.name, + currency: '', + email: rilletVendor.email ?? '', + }; + } return undefined; } @@ -2637,10 +2875,42 @@ function getVendorRuleDisplayValue(policy: OnyxEntry, vendorID: string, } const historicalVendorName = findVendorByID(policy, vendorID)?.name; - const hasActiveVendorMatchingSource = getActiveVendorMatchingIntegration(policy) !== undefined || isXeroActiveMatchingSource(policy); + const hasActiveVendorMatchingSource = getActiveVendorMatchingIntegration(policy) !== undefined; return historicalVendorName ?? (hasActiveVendorMatchingSource ? vendorID : unavailableLabel); } +/** + * Source-specific empty state copy for the vendor selector when the active integration has zero vendors. + */ +function getVendorEmptyState(policy: OnyxEntry, translate: LocaleContextProps['translate']): {title: string; subtitle: string} { + const activeIntegration = getActiveVendorMatchingIntegration(policy); + switch (activeIntegration) { + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return { + title: translate('workspace.sageIntacct.noAccountsFound'), + subtitle: translate('workspace.sageIntacct.noAccountsFoundDescription'), + }; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return { + title: translate('workspace.xero.noSuppliersFound'), + subtitle: translate('workspace.xero.noSuppliersFoundDescription'), + }; + case CONST.POLICY.CONNECTIONS.NAME.RILLET: + return { + title: translate('workspace.rillet.noVendorsFound'), + subtitle: translate('workspace.rillet.noVendorsFoundDescription'), + }; + case CONST.POLICY.CONNECTIONS.NAME.QBO: + default: { + const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); + return { + title: translate('workspace.qbo.noAccountsFound'), + subtitle: translate('workspace.qbo.noAccountsFoundDescription', integrationName), + }; + } + } +} + /** * Xero-scoped supplier list, normalized to the shared `Vendor` shape. Use this from Xero-specific * UI (the default-supplier picker, the Xero export config row) so the data source stays bound to @@ -3061,6 +3331,11 @@ 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. */ @@ -3137,9 +3412,11 @@ export { getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, + getVendorEmptyState, getVendorRuleDisplayValue, getXeroSupplierByID, getXeroSuppliers, + isRilletVendorMatchingActive, isXeroActiveMatchingSource, isXeroVendorMatchingActive, hasVendorFeature, @@ -3162,6 +3439,9 @@ export { getTagListName, getTagLists, hasTags, + isTagInPolicy, + findPolicyTagAtLevel, + matchesParentTagPath, hasCustomCategories, hasConfiguredRules, isMaxExpenseAmountSet, @@ -3201,6 +3481,7 @@ export { isPolicyAdmin, isPolicyUser, isPolicyAuditor, + isAdminOfCardEnabledPolicy, hasEligibleBankAccountShareRecipient, isPolicyEmployee, arePolicyRulesEnabled, @@ -3284,6 +3565,8 @@ export { getDefaultChatEnabledPolicy, getDefaultChatEnabledPolicySelection, getForwardsToAccount, + getForwardsToFromRules, + evaluateApprovalWorkflowRule, getSubmitToAccountID, getSubmitReportManagerAccountID, getAllTaxRatesNamesAndKeys as getAllTaxRates, @@ -3320,6 +3603,7 @@ export { getGLCodeFromPolicyTag, isPolicyMemberWithoutPendingDelete, hasDynamicExternalWorkflow, + shouldHideDynamicExternalWorkflowPeople, getActivePoliciesWithExpenseChatAndPerDiemEnabled, isPerDiemEnabled, isPerDiemEligiblePolicy, @@ -3329,6 +3613,7 @@ export { isNonUSDPolicy, isDefaultTagName, isTimeTrackingEnabled, + isMCPEnabled, getDefaultTimeTrackingRate, getActivePoliciesWithExpenseChatAndTimeEnabled, isPolicyTaxEnabled, diff --git a/src/libs/Pusher/EventType.ts b/src/libs/Pusher/EventType.ts index 1eba93ecbadb..50dad3dffcd0 100644 --- a/src/libs/Pusher/EventType.ts +++ b/src/libs/Pusher/EventType.ts @@ -16,8 +16,6 @@ export default { CONCIERGE_DRAFT_FAILED: 'conciergeDraftFailed', CONCIERGE_DRAFT_CLEARED: 'conciergeDraftCleared', - // An event that the server sends back to the client in response to a "ping" API command - PONG: 'pong', MULTIPLE_EVENT_TYPE: { ONYX_API_UPDATE: 'onyxApiUpdate', RECONNECT_APP: 'reconnectApp', diff --git a/src/libs/Pusher/index.native.ts b/src/libs/Pusher/index.native.ts index 4f0282f8fb96..0cedc42834eb 100644 --- a/src/libs/Pusher/index.native.ts +++ b/src/libs/Pusher/index.native.ts @@ -71,6 +71,8 @@ function init(args: Args): Promise { socket.init({ apiKey: args.appKey, cluster: args.cluster, + activityTimeout: CONST.PUSHER.ACTIVITY_TIMEOUT_MS, + pongTimeout: CONST.PUSHER.PONG_TIMEOUT_MS, onConnectionStateChange: (currentState: string, previousState: string) => { if (currentState === CONST.PUSHER.STATE.CONNECTED) { socket?.getSocketId().then((id: string) => { diff --git a/src/libs/Pusher/index.ts b/src/libs/Pusher/index.ts index 557f3aa7fe26..b9f627fe5805 100644 --- a/src/libs/Pusher/index.ts +++ b/src/libs/Pusher/index.ts @@ -1,6 +1,7 @@ import Log from '@libs/Log'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption'; @@ -86,7 +87,8 @@ function init(args: Args): Promise { const options: Options = { cluster: args.cluster, - authEndpoint: args.authEndpoint, + activityTimeout: CONST.PUSHER.ACTIVITY_TIMEOUT_MS, + pongTimeout: CONST.PUSHER.PONG_TIMEOUT_MS, }; if (customAuthorizer) { diff --git a/src/libs/Pusher/types.ts b/src/libs/Pusher/types.ts index 33043a1c14af..028fbe3b4d9a 100644 --- a/src/libs/Pusher/types.ts +++ b/src/libs/Pusher/types.ts @@ -19,7 +19,6 @@ type States = { type Args = { appKey: string; cluster: string; - authEndpoint: string; }; type UserIsTypingEvent = ReportUserIsTyping & { @@ -30,11 +29,6 @@ type UserIsLeavingRoomEvent = Record & { userLogin?: string; }; -type PingPongEvent = Record & { - pingID: string; - pingTimestamp: number; -}; - type ConciergeReasoningEvent = { reasoning: string; agentZeroRequestID: string; @@ -74,7 +68,6 @@ type ConciergeDraftEventsEvent = { type PusherEventMap = { [TYPE.USER_IS_TYPING]: UserIsTypingEvent; [TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent; - [TYPE.PONG]: PingPongEvent; [TYPE.CONCIERGE_REASONING]: ConciergeReasoningEvent; [TYPE.CONCIERGE_DRAFT_EVENTS]: ConciergeDraftEventsEvent; [TYPE.CONCIERGE_DRAFT_STARTED]: ConciergeDraftEvent; @@ -136,7 +129,6 @@ export type { Args, UserIsTypingEvent, UserIsLeavingRoomEvent, - PingPongEvent, ConciergeDraftEvent, ConciergeDraftEventsEvent, EventData, diff --git a/src/libs/PusherUtils.ts b/src/libs/PusherUtils.ts index 055c205f7e4f..d311abf03649 100644 --- a/src/libs/PusherUtils.ts +++ b/src/libs/PusherUtils.ts @@ -4,8 +4,6 @@ import type {AnyOnyxUpdatesFromServer, OnyxServerUpdate} from '@src/types/onyx/O import type {OnyxKey} from 'react-native-onyx'; -import type {PingPongEvent} from './Pusher/types'; - import {reconnect} from './actions/Reconnect'; import Log from './Log'; import Pusher from './Pusher'; @@ -37,14 +35,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 | PingPongEvent) => void) { +function subscribeToPrivateUserChannelEvent(eventName: string, accountID: string, onEvent: (pushJSON: AnyOnyxUpdatesFromServer) => void) { const pusherChannelName = getUserChannelName(accountID); - function logPusherEvent(pushJSON: AnyOnyxUpdatesFromServer | PingPongEvent) { + function logPusherEvent(pushJSON: AnyOnyxUpdatesFromServer) { Log.info(`[Report] Handled ${eventName} event sent by Pusher`, false, pushJSON); } - function onEventPush(pushJSON: AnyOnyxUpdatesFromServer | PingPongEvent) { + function onEventPush(pushJSON: AnyOnyxUpdatesFromServer) { logPusherEvent(pushJSON); onEvent(pushJSON); } diff --git a/src/libs/QuickActionUtils.ts b/src/libs/QuickActionUtils.ts index cf5339f1e367..3b606bd9a20d 100644 --- a/src/libs/QuickActionUtils.ts +++ b/src/libs/QuickActionUtils.ts @@ -1,12 +1,12 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {Beta, Policy, Report} from '@src/types/onyx'; +import type {Beta, Policy, Report, Rule} from '@src/types/onyx'; import type {QuickActionName} from '@src/types/onyx/QuickAction'; import type QuickAction from '@src/types/onyx/QuickAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import getIconForAction from './getIconForAction'; import {getPerDiemCustomUnit, isControlPolicy, isPerDiemEnabled, isTimeTrackingEnabled} from './PolicyUtils'; @@ -106,6 +106,7 @@ const isQuickActionAllowed = ( quickActionPolicy: Policy | undefined, isReportArchived: boolean | undefined, betas: OnyxEntry, + rules: OnyxCollection, isRestrictedToPreferredPolicy = false, ) => { if (quickAction?.action === CONST.QUICK_ACTIONS.PER_DIEM || quickAction?.action === CONST.QUICK_ACTIONS.TRACK_PER_DIEM) { @@ -125,7 +126,7 @@ const isQuickActionAllowed = ( const iouType = getIOUType(quickAction?.action); if (iouType) { - return canCreateRequest(quickActionReport, quickActionPolicy, iouType, isReportArchived, betas, isRestrictedToPreferredPolicy); + return canCreateRequest(quickActionReport, quickActionPolicy, iouType, isReportArchived, betas, rules, isRestrictedToPreferredPolicy); } return true; }; diff --git a/src/libs/Reauthentication.ts b/src/libs/Reauthentication.ts index 86deedf89156..10cb7aa2910d 100644 --- a/src/libs/Reauthentication.ts +++ b/src/libs/Reauthentication.ts @@ -39,6 +39,11 @@ let isAuthenticatingWithShortLivedToken = false; let isSupportAuthTokenUsed = false; let isSupportSession = false; +type ReauthenticationResult = { + wasSuccessful: boolean; + didRestoreOriginalSession?: boolean; +}; + // A SAML-required account cannot silently reauthenticate (there is no stored password), so an expired // session sends the user back through their IdP. When the token expires, the app fires several requests // at once (ReconnectApp, OpenApp, AuthenticatePusher, ...) and they all get a 407 in the same tick, so @@ -122,7 +127,7 @@ function Authenticate(parameters: Parameters): Promise { /** * Reauthenticate using the stored credentials and redirect to the sign in page if unable to do so. * @param [command] command name for logging purposes - * @return returns true if reauthentication was successful, false otherwise. + * @return returns an object indicating if reauthentication was successful or restored the original session. */ -function reauthenticate(command = ''): Promise { +function reauthenticate(command = ''): Promise { Log.hmmm('[Reauthenticate] Attempting re-authentication', { command, }); @@ -186,7 +191,7 @@ function reauthenticate(command = ''): Promise { isSupportAuthTokenUsed, accountIsLoading: account?.isLoading, }); - return Promise.resolve(false); + return Promise.resolve({wasSuccessful: false}); } Log.alert('[Reauthenticate] Found stale shortLivedToken authentication state. Clearing it before re-authenticating.', { @@ -223,21 +228,21 @@ function reauthenticate(command = ''): Promise { // sign-in page is not torn down and re-mounted (and SAML re-initiated) once per concurrent 407. if (hasQueuedSAMLReauthRedirect) { Log.info('[Reauthenticate] SAML sign-in redirect already queued, skipping duplicate redirect'); - return false; + return {wasSuccessful: false}; } hasQueuedSAMLReauthRedirect = true; Log.info(`[Reauthenticate] Redirecting to Sign In because SAML is required`); - redirectToSignIn(undefined, true); - return false; + redirectToSignIn(CONST.SIGN_OUT_REASON.SAML_REQUIRED, undefined, true); + return {wasSuccessful: false}; } // Prevent reauthentication if credentials are missing (e.g. after sign out) if (!credentials?.autoGeneratedLogin || !credentials?.autoGeneratedPassword) { Log.info('[Reauthenticate] No credentials available, redirecting to sign in'); setIsAuthenticating(false); - redirectToSignIn('No credentials available'); - return false; + redirectToSignIn(CONST.SIGN_OUT_REASON.NO_CREDENTIALS, 'No credentials available'); + return {wasSuccessful: false}; } Log.info(`[Reauthenticate] Re-authenticating with ${checkIfShouldUseNewPartnerName(credentials?.autoGeneratedLogin) ? 'new' : 'old'} partner name`); @@ -255,7 +260,7 @@ function reauthenticate(command = ''): Promise { }) .then((response) => { if (!response) { - return false; + return {wasSuccessful: false}; } Log.hmmm('[Reauthenticate] Processing authentication result', { @@ -291,8 +296,8 @@ function reauthenticate(command = ''): Promise { command, error: errorMessage, }); - redirectToSignIn(errorMessage); - return false; + redirectToSignIn(CONST.SIGN_OUT_REASON.REAUTH_FAILED, errorMessage); + return {wasSuccessful: false}; } // If we reauthenticate due to an expired delegate token, restore the delegate's original account. @@ -306,8 +311,16 @@ function reauthenticate(command = ''): Promise { email: response.email, stashedCredentials, stashedSession, + }).catch((error: unknown) => { + // If the restore chain is interrupted before it unpauses the network, the queue would stay + // paused forever and the app would be stuck loading indefinitely. + setIsAuthenticating(false); + Log.hmmm('[Reauthenticate] Failed to restore the delegate session', { + command, + error: getErrorMessage(error), + }); }); - return false; + return {wasSuccessful: false, didRestoreOriginalSession: true}; } // Update authToken in Onyx and in our local variables so that API requests will use the new authToken @@ -325,7 +338,7 @@ function reauthenticate(command = ''): Promise { command, }); - return true; + return {wasSuccessful: true}; }) .catch((error) => { if (error instanceof HttpsError && !shouldRetryAuthenticateError(error)) { @@ -347,8 +360,8 @@ function reauthenticate(command = ''): Promise { error: error.message, status: error.status, }); - redirectToSignIn(errorMessage); - return false; + redirectToSignIn(CONST.SIGN_OUT_REASON.REAUTH_HTTP_ERROR, errorMessage); + return {wasSuccessful: false}; } // Caught values can be non-Error objects, but telemetry expects an Error instance. @@ -368,3 +381,4 @@ function reauthenticate(command = ''): Promise { } export default reauthenticate; +export type {ReauthenticationResult}; diff --git a/src/libs/ReceiptStorage/durableFolder.ts b/src/libs/ReceiptStorage/durableFolder.ts new file mode 100644 index 000000000000..dd98633ae295 --- /dev/null +++ b/src/libs/ReceiptStorage/durableFolder.ts @@ -0,0 +1,36 @@ +import fileURIToPath from '@libs/fileURIToPath'; +import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; + +/** + * Matches on the folder name and ignores the container prefix. The app reads this directory through two filesystem + * libraries whose absolute forms can differ (/private/var and /var), but the trailing segments stay stable. + * + * Kept in its own leaf module rather than in ReceiptStorage: the telemetry snapshot needs the same check, and + * importing ReceiptStorage there would pull in fileDownload/FileUtils and, through it, Localize. + */ +function toDurableName(storedPath: string): string | undefined { + const dirName = getReceiptsUploadFolderPath().split('/').pop(); + const path = fileURIToPath(storedPath); + if (!dirName || !path.includes(`/${dirName}/`)) { + return undefined; + } + return path.split('/').pop(); +} + +/** + * Whether a stored path sits in the receipts folder. A check on the path, not on the bytes: it says where the receipt + * was rooted, not that the file is still there. False on platforms with no receipts folder. + */ +function isInDurableFolder(storedPath: unknown): boolean { + if (typeof storedPath !== 'string') { + return false; + } + try { + return toDurableName(storedPath) !== undefined; + } catch { + // getReceiptsUploadFolderPath reaches the native filesystem module, which is not always available. + return false; + } +} + +export {toDurableName, isInDurableFolder}; diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 5d283b6f902c..da95c83a91c8 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -7,6 +7,8 @@ import RNFS from 'react-native-fs'; import type ReceiptStorage from './types'; +import {toDurableName} from './durableFolder'; + // A durable name is the bare filename inside the receipts folder. Never store a full path: iOS moves // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. @@ -50,20 +52,6 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; -/** - * Matches on the folder name and ignores the container prefix. The app reads this directory through - * two filesystem libraries whose absolute forms can differ (/private/var and /var), but the trailing - * segments stay stable. - */ -function toDurableName(storedPath: string): string | undefined { - const dirName = getReceiptsUploadFolderPath().split('/').pop(); - const path = fileURIToPath(storedPath); - if (!dirName || !path.includes(`/${dirName}/`)) { - return undefined; - } - return path.split('/').pop(); -} - const resolve: ReceiptStorage['resolve'] = (source) => { if (typeof source !== 'string') { return undefined; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6b6fe5005d38..3524c3704748 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -10,6 +10,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type { Card, @@ -56,7 +57,7 @@ import type {OptimisticIOUReportAction, PartialReportAction} from './ReportUtils import {getBankName, isCardPendingActivate} from './CardUtils'; import {getDecodedCategoryName} from './CategoryUtils'; -import {convertAmountToDisplayString, convertToBackendAmount, convertToDisplayStringWithExplicitCurrency, convertToShortDisplayString} from './CurrencyUtils'; +import {convertAmountToDisplayString, convertToBackendAmount, convertToShortDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getFormattedDistanceInUnits} from './DistanceDisplayUtils'; import {getEnvironmentURL, getOldDotEnvironmentURL} from './Environment/Environment'; @@ -66,11 +67,10 @@ import {toLocaleOrdinal} from './LocaleDigitUtils'; import {formatPhoneNumber} from './LocalePhoneNumber'; import {formatMessageElementList} from './Localize'; import Log from './Log'; -import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import getReportURLForCurrentContext from './Navigation/helpers/getReportURLForCurrentContext'; import {getIsOffline, subscribe as subscribeNetworkState} from './NetworkState'; import Parser from './Parser'; -import {arePersonalDetailsMissing, getEffectiveDisplayName, getPersonalDetailByEmail} from './PersonalDetailsUtils'; +import {arePersonalDetailsMissing, getEffectiveDisplayName} from './PersonalDetailsUtils'; import stripFollowupListFromHtml from './ReportActionFollowupUtils/stripFollowupListFromHtml'; import StringUtils from './StringUtils'; import {getReportFieldTypeTranslationKey} from './WorkspaceReportFieldUtils'; @@ -301,6 +301,10 @@ function isSubmittedAndClosedAction(reportAction: OnyxInputOrEntry return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED); } +function isAddExpenseOnSubmittedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_EXPENSE_ON_SUBMITTED); +} + function isDynamicExternalWorkflowSubmitAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) && getOriginalMessage(reportAction)?.workflow === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; } @@ -445,8 +449,13 @@ function isActionOfType(action: OnyxInputOrEntry): reportAction is ReportAction { - return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN); +function isCardBrokenConnectionAction( + reportAction: OnyxInputOrEntry, +): reportAction is ReportAction { + return ( + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN) || + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN_30_DAYS) + ); } function getOriginalMessage(reportAction: OnyxInputOrEntry>): OriginalMessage | undefined { @@ -462,9 +471,21 @@ function getOriginalMessage(reportAction: OnyxInputO return candidate as OriginalMessage; } -function getCardConnectionBrokenMessage(card: Card | undefined, originalCardName: string | undefined, translate: LocaleContextProps['translate'], connectionLink?: string) { - const personalCardName = originalCardName ?? card?.cardName ?? getBankName(card?.bank as CompanyCardFeed); - return translate('personalCard.conciergeBrokenConnection', personalCardName, connectionLink); +function getPersonalCardName(card: Card | undefined, originalCardName: string | undefined): string { + return originalCardName ?? card?.cardName ?? getBankName(card?.bank as CompanyCardFeed); +} + +function getCardConnectionBrokenMessage( + card: Card | undefined, + originalCardName: string | undefined, + translate: LocaleContextProps['translate'], + is30DaysReminder: boolean, + connectionLink?: string, +) { + const cardName = getPersonalCardName(card, originalCardName); + return is30DaysReminder + ? translate('personalCard.conciergeBrokenConnection30Days', cardName, connectionLink) + : translate('personalCard.conciergeBrokenConnection', cardName, connectionLink); } function getElsewherePaymentReportActionMessage(translate: LocalizedTranslate, originalMessage: OriginalMessageIOU | undefined, payer?: string): string { @@ -2860,6 +2881,11 @@ function getExportIntegrationActionFragments(translate: LocalizedTranslate, repo case CONST.EXPORT_LABELS.DUALENTRY: url = nonReimbursableUrls.at(0)?.substring(0, nonReimbursableUrls.at(0)?.lastIndexOf('/')) ?? ''; break; + case CONST.EXPORT_LABELS.CAMPFIRE: + // s77rt Test in R2 + // https://github.com/Expensify/App/issues/100181 + url = nonReimbursableUrls.at(0)?.substring(0, nonReimbursableUrls.at(0)?.lastIndexOf('/')) ?? ''; + break; default: url = QBO_EXPENSES_URL; } @@ -4692,11 +4718,16 @@ function shouldShowActivateCard(actionName?: ReportActionName, card?: Card, priv return (actionName === CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED || actionName === CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED) && isCardPendingActivate(card) && !missingDetails; } -function getJoinRequestMessage(translate: LocalizedTranslate, policy: OnyxEntry, reportAction: ReportAction) { - const userDetail = getPersonalDetailByEmail(getOriginalMessage(reportAction)?.email ?? ''); +function getJoinRequestMessage( + translate: LocalizedTranslate, + policyName: string, + reportAction: ReportAction, + userDetail: OnyxEntry, +) { const userName = userDetail?.firstName ? `${userDetail.displayName} (${userDetail.login})` : (userDetail?.login ?? getOriginalMessage(reportAction)?.email); - return translate('workspace.inviteMessage.joinRequest', {user: userName ?? '', workspaceName: policy?.name ?? ''}); + return translate('workspace.inviteMessage.joinRequest', {user: userName ?? '', workspaceName: policyName}); } + function isCardActive(card?: Card): boolean { if (!card) { return false; @@ -4710,6 +4741,7 @@ function getCardIssuedMessage({ shouldRenderHTML = false, shouldNavigateToCardDetails = false, policyID = '-1', + buildDynamicRoute, expensifyCard, companyCard, translate, @@ -4719,6 +4751,7 @@ function getCardIssuedMessage({ shouldRenderHTML?: boolean; shouldNavigateToCardDetails?: boolean; policyID?: string; + buildDynamicRoute: (dynamicRouteSuffixWithParams: string) => Route; expensifyCard?: Card; companyCard?: Card; translate: LocaleContextProps['translate']; @@ -4731,7 +4764,7 @@ function getCardIssuedMessage({ const assignee = shouldRenderHTML ? `` : Parser.htmlToText(``); const navigateRoute = shouldNavigateToCardDetails - ? createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_DETAILS.getRoute(String(cardID), policyID)) + ? buildDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_DETAILS.getRoute(String(cardID), policyID)) : ROUTES.SETTINGS_DOMAIN_CARD_DETAIL.getRoute(String(cardID)); const isExpensifyCardActive = isCardActive(expensifyCard); const expensifyCardLink = (expensifyCardLinkText: string) => @@ -4781,13 +4814,19 @@ function getRoomChangeLogMessage(translate: LocalizedTranslate, reportAction: Re function getActionableCard3DSTransactionApprovalMessage( translate: LocalizedTranslate, reportAction: ReportAction, + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], ) { const originalMessage = getOriginalMessage(reportAction); if (!originalMessage) { return undefined; } const {amount, currency, merchant} = originalMessage; - const formattedAmount = amount ? convertToDisplayStringWithExplicitCurrency(amount, currency) : ''; + let formattedAmount = ''; + if (amount) { + // Show the amount without any currency symbol when the message carries no currency, rather than defaulting to USD. + formattedAmount = currency ? convertToDisplayString(amount, currency) : convertToDisplayStringWithoutCurrency(amount); + } return translate('report.actions.type.actionableCard3DSTransactionApproval', formattedAmount, merchant); } @@ -4885,6 +4924,18 @@ function getCompanyCardConnectionBrokenMessage(translate: LocalizedTranslate, ac }); } +function getCompanyCardConnectionBroken30DaysMessage(translate: LocalizedTranslate, action: OnyxEntry): string { + const originalMessage = isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN_30_DAYS) ? getOriginalMessage(action) : undefined; + const {feedName, policyID} = originalMessage ?? {feedName: '', policyID: ''}; + const workspaceCompanyCardRoute = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)}`; + const workspaceCompanyCardSettingsRoute = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)}`; + return translate('report.actions.type.companyCardConnectionBroken30Days', { + feedName, + workspaceCompanyCardRoute, + workspaceCompanyCardSettingsRoute, + }); +} + function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: OnyxEntry): string { const {maskedAccountNumber} = getOriginalMessage(action as ReportAction) ?? {maskedAccountNumber: ''}; const walletRoute = `${environmentURL}/${ROUTES.SETTINGS_WALLET}`; @@ -5083,10 +5134,12 @@ export { isTripPreview, isHoldAction, isWhisperAction, + isAddExpenseOnSubmittedAction, isSubmittedAction, isSubmittedAndClosedAction, isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, + isApprovedAction, isForwardedAction, isDynamicExternalWorkflowForwardedAction, isUnapprovedAction, @@ -5105,7 +5158,6 @@ export { shouldHideNewMarker, shouldReportActionBeVisible, isReportActionVisible, - isReportActionVisibleAsLastAction, wasActionTakenByCurrentUser, isInviteOrRemovedAction, isActionableAddPaymentCard, @@ -5210,6 +5262,7 @@ export { getIntegrationSyncFailedMessage, getCommuterExclusionMessage, getCompanyCardConnectionBrokenMessage, + getCompanyCardConnectionBroken30DaysMessage, getPlaidBalanceFailureMessage, getPolicyChangeLogDefaultReimbursableMessage, getManagerOnVacation, diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index cef1f4e24147..b514dc395d5d 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -6,6 +6,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { Card, + CardList, PersonalDetails, PersonalDetailsList, Policy, @@ -15,8 +16,10 @@ import type { ReportActions, ReportAttributesDerivedValue, ReportMetadata, + Rule, Transaction, VisibleReportActionsDerivedValue, + WorkspaceCardsList, } from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -27,15 +30,15 @@ import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from './AgentRuleChangeLogUtils'; -import {convertToDisplayString as convertToDisplayStringUtil} from './CurrencyUtils'; import {isReportMessageAttachment} from './isReportMessageAttachment'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {formatList} from './Localize'; -import {getForReportAction} from './ModifiedExpenseMessage'; +import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage'; +import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {getIsOffline} from './NetworkState'; import Parser from './Parser'; import {getLoginByAccountID, getPersonalDetailsByID, getPersonalDetailsForAccountIDs, getPersonalDetailsListByIDs, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; -import {getCleanedTagName, hasDynamicExternalWorkflow} from './PolicyUtils'; +import {getCleanedTagName, hasDynamicExternalWorkflow, isPolicyAdmin} from './PolicyUtils'; import { getActionableCard3DSTransactionApprovalMessage, getActionableCardFraudAlertResolutionMessage, @@ -47,11 +50,13 @@ import { getAssignedCompanyCardMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, + getCardConnectionBrokenMessage, getCardIssuedMessage, getCategoryTaxRateMessage, getChangedApproverActionMessage, getCombinedReportActions, getCompanyAddressUpdateMessage, + getCompanyCardConnectionBroken30DaysMessage, getCompanyCardConnectionBrokenMessage, getCurrencyConversionFeeMessage, getCurrencyDefaultTaxUpdateMessage, @@ -242,6 +247,7 @@ import {getAddExpensifyCardRuleMessage, getRemoveExpensifyCardRuleMessage, getUp import StringUtils from './StringUtils'; import {getTaskCreatedMessage, getTaskReportActionMessage} from './TaskUtils'; import {getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, getDescription, isScanning} from './TransactionUtils'; +import {getTravelBillingFeedID} from './TravelBillingUtils'; let allReports: OnyxCollection; // connectWithoutView is justified: this is module-level, non-render preview computation shared by LHN and Search; @@ -324,7 +330,12 @@ 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'); } @@ -398,6 +409,34 @@ function getExpenseReportPreviewText( return formatReportLastMessageText(translate('iou.expenseAmount', formattedAmount, comment || undefined)); } +function resolveLastActionContext( + report: Report, + isReportArchived: boolean | undefined, + visibleReportActionsData: VisibleReportActionsDerivedValue | undefined, + oneTransactionThreadReportID?: string, +): { + lastAction: OnyxEntry; + lastActionReport: OnyxEntry; + movedFromReport: OnyxEntry; + movedToReport: OnyxEntry; +} { + const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const resolvedOneTransactionThreadReportID = oneTransactionThreadReportID ?? deprecatedCachedOneTransactionThreadReportIDs[report.reportID]; + const lastAction = getLastVisibleActionIncludingTransactionThread(report.reportID, canUserPerformWrite, undefined, visibleReportActionsData, resolvedOneTransactionThreadReportID); + let lastActionReport: OnyxEntry; + if (isInviteOrRemovedAction(lastAction)) { + const lastActionOriginalMessage = getOriginalMessage(lastAction); + lastActionReport = lastActionOriginalMessage?.reportID ? getReportOrDraftReport(String(lastActionOriginalMessage.reportID)) : undefined; + } + return { + lastAction, + lastActionReport, + movedFromReport: getReportOrDraftReport(getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)), + movedToReport: getReportOrDraftReport(getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)), + }; +} + function getLastActorDisplayNameFromLastVisibleActions( report: OnyxEntry, lastActorDetails: Partial | null, @@ -407,10 +446,11 @@ function getLastActorDisplayNameFromLastVisibleActions( translate: LocalizedTranslate, visibleReportActionsData?: VisibleReportActionsDerivedValue, lastAction?: OnyxEntry, + oneTransactionThreadReportID?: string, ): string { const reportID = report?.reportID; const canUserPerformWrite = canUserPerformWriteAction(report, privateIsArchived); - const lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsData); + const lastReportAction = lastAction ?? getLastVisibleActionIncludingTransactionThread(reportID, canUserPerformWrite, undefined, visibleReportActionsData, oneTransactionThreadReportID); if (lastReportAction) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -437,6 +477,7 @@ function getLastActorDisplayNameFromLastVisibleActions( */ function getLastMessageTextForReport({ translate, + convertToDisplayString, dateFnsLocale, report, personalDetails, @@ -455,8 +496,12 @@ function getLastMessageTextForReport({ // eslint-disable-next-line @typescript-eslint/no-deprecated sortedActions = deprecatedAllSortedReportActions, currentUserAccountID, + oneTransactionThreadReportID, + lastOriginalAction, + rules, }: { translate: LocalizedTranslate; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; dateFnsLocale: DateFnsLocale | undefined; report: OnyxEntry; personalDetails: OnyxEntry; @@ -478,13 +523,16 @@ function getLastMessageTextForReport({ sortedActions?: Record; // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number; + oneTransactionThreadReportID?: string; + lastOriginalAction?: OnyxEntry; + rules: OnyxCollection; }): string { const reportID = report?.reportID; const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); let lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsDataParam); // eslint-disable-next-line @typescript-eslint/no-deprecated - const transactionThreadReportID = reportID ? deprecatedCachedOneTransactionThreadReportIDs[reportID] : undefined; + const transactionThreadReportID = oneTransactionThreadReportID ?? (reportID ? deprecatedCachedOneTransactionThreadReportIDs[reportID] : undefined); if (reportID && !lastAction && transactionThreadReportID) { lastReportAction = @@ -515,7 +563,7 @@ function getLastMessageTextForReport({ // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action // eslint-disable-next-line @typescript-eslint/no-deprecated - const lastOriginalReportAction = reportID ? deprecatedLastReportActions[reportID] : undefined; + const lastOriginalReportAction = lastOriginalAction ?? (reportID ? deprecatedLastReportActions[reportID] : undefined); let lastMessageTextFromReport = ''; if (isArchivedNonExpenseReport(report, isReportArchived)) { @@ -527,8 +575,16 @@ function getLastMessageTextForReport({ case CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY: case CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED: { lastMessageTextFromReport = translate(`reportArchiveReasons.${archiveReason}`, { - displayName: temporaryGetDisplayNameOrDefault({passedPersonalDetails: lastActorDetails, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}), - policyName: getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}), + displayName: temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: lastActorDetails, + translate, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + }), + policyName: getPolicyName({ + report, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), + }), }); break; } @@ -541,8 +597,7 @@ function getLastMessageTextForReport({ } } } else if (isMoneyRequestAction(lastReportAction)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - const properSchemaForMoneyRequestMessage = getReportPreviewMessage(translate, convertToDisplayStringUtil, { + const properSchemaForMoneyRequestMessage = getReportPreviewMessage(translate, convertToDisplayString, { reportOrID: report, iouReportAction: lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -565,8 +620,7 @@ function getLastMessageTextForReport({ const reportName = reportAttributesDerived?.[iouReport.reportID]?.reportName ?? ''; lastMessageTextFromReport = formatReportLastMessageText(reportName); } else { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - const reportPreviewMessage = getReportPreviewMessage(translate, convertToDisplayStringUtil, { + const reportPreviewMessage = getReportPreviewMessage(translate, convertToDisplayString, { reportOrID: !isEmptyObject(iouReport) ? iouReport : null, iouReportAction: lastIOUMoneyReportAction ?? lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -581,10 +635,14 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = formatReportLastMessageText(Parser.htmlToText(reportPreviewMessage)); } } else if (isReimbursementQueuedAction(lastReportAction)) { - lastMessageTextFromReport = getReimbursementQueuedActionMessage({reportAction: lastReportAction, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils, report}); + lastMessageTextFromReport = getReimbursementQueuedActionMessage({ + reportAction: lastReportAction, + translate, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + report, + }); } else if (isReimbursementDeQueuedOrCanceledAction(lastReportAction)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - lastMessageTextFromReport = getReimbursementDeQueuedOrCanceledActionMessage(translate, lastReportAction, report?.ownerAccountID, convertToDisplayStringUtil); + lastMessageTextFromReport = getReimbursementDeQueuedOrCanceledActionMessage(translate, lastReportAction, report?.ownerAccountID, convertToDisplayString); } else if (isDeletedParentAction(lastReportAction) && reportUtilsIsChatReport(report)) { lastMessageTextFromReport = getDeletedParentActionMessageForChatReport(lastReportAction); } else if (isPendingRemove(lastReportAction) && report?.reportID && isThreadParentMessage(lastReportAction, report.reportID)) { @@ -599,22 +657,27 @@ function getLastMessageTextForReport({ report?.ownerAccountID, getLoginByAccountID(report?.ownerAccountID, personalDetails), getLoginByAccountID(lastReportAction.actorAccountID, personalDetails), - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - convertToDisplayStringUtil, + convertToDisplayString, currentUserAccountID, ); - } else if (isReportMessageAttachment({text: report?.lastMessageText ?? '', html: report?.lastMessageHtml, type: ''})) { + } else if ( + isReportMessageAttachment({ + text: report?.lastMessageText ?? '', + html: report?.lastMessageHtml, + type: '', + }) + ) { lastMessageTextFromReport = `[${translate('common.attachment')}]`; } else if (isModifiedExpenseAction(lastReportAction)) { const properSchemaForModifiedExpenseMessageWithHTML = getForReportAction({ translate, - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - convertToDisplayString: convertToDisplayStringUtil, + convertToDisplayString, reportAction: lastReportAction, policy, movedFromReport, movedToReport, policyTags, + currentUserAccountID, currentUserLogin: currentUserLogin ?? '', }); // Strip HTML tags for plain text display in options list @@ -652,6 +715,7 @@ function getLastMessageTextForReport({ report, isTrackIntentUser, policy, + rules, }) ? translate('iou.markedAsDone', getOriginalMessage(lastReportAction)?.message) : translate('iou.submitted', getOriginalMessage(lastReportAction)?.message); @@ -702,11 +766,19 @@ function getLastMessageTextForReport({ // RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission const iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID); const missingFields = getOriginalMessage(lastReportAction)?.missingFields; - lastMessageTextFromReport = translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction), missingFields}); + lastMessageTextFromReport = translate('violations.smartscanFailed', { + canEdit: wasActionTakenByCurrentUser(iouAction), + missingFields, + }); } else if (lastReportAction?.actionName && isOldDotReportAction(lastReportAction)) { lastMessageTextFromReport = getMessageOfOldDotReportAction(translate, lastReportAction, false); } else if (isActionableJoinRequest(lastReportAction)) { - lastMessageTextFromReport = getJoinRequestMessage(translate, policy, lastReportAction); + lastMessageTextFromReport = getJoinRequestMessage( + translate, + policy?.name ?? '', + lastReportAction, + getPersonalDetailsByID(getOriginalMessage(lastReportAction)?.accountID, personalDetails), + ); } else if ( lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM || lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM @@ -731,8 +803,7 @@ function getLastMessageTextForReport({ } else if (isRenamedAction(lastReportAction)) { lastMessageTextFromReport = getRenamedAction(translate, lastReportAction, isExpenseReport(report)); } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - lastMessageTextFromReport = getDeletedTransactionMessage(translate, lastReportAction, convertToDisplayStringUtil); + lastMessageTextFromReport = getDeletedTransactionMessage(translate, lastReportAction, convertToDisplayString); } else if ( isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL) || isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.REROUTE) || @@ -757,8 +828,7 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = getDynamicExternalWorkflowRoutedMessage(lastReportAction, translate); } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - lastMessageTextFromReport = getPolicyChangeLogMaxExpenseAmountMessage(translate, lastReportAction, convertToDisplayStringUtil); + lastMessageTextFromReport = getPolicyChangeLogMaxExpenseAmountMessage(translate, lastReportAction, convertToDisplayString); } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE)) { lastMessageTextFromReport = getPolicyChangeLogMaxExpenseAgeMessage(translate, lastReportAction); @@ -791,8 +861,7 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = getUpdatedAutoHarvestingMessage(translate, lastReportAction); } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction, convertToDisplayStringUtil); + lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction, convertToDisplayString); } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE)) { lastMessageTextFromReport = getCategoryTaxRateMessage(translate, lastReportAction); @@ -884,20 +953,20 @@ function getLastMessageTextForReport({ const scanningTransactions = transactions.filter((transaction) => isScanning(transaction)); if (scanningTransactions.length > 0) { - lastMessageTextFromReport = translate('iou.receiptScanning', {count: scanningTransactions.length}); + lastMessageTextFromReport = translate('iou.receiptScanning', { + count: scanningTransactions.length, + }); } else if (report?.transactionCount && report?.transactionCount > 0 && report?.currency) { const latestVisibleMoneyRequestAction = getLatestVisibleMoneyRequestAction(reportID, canUserPerformWrite, sortedActions?.[reportID], visibleReportActionsDataParam); if (isExpenseReport(report) && latestVisibleMoneyRequestAction) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - lastMessageTextFromReport = getExpenseReportPreviewText(report, latestVisibleMoneyRequestAction, translate, transactions, convertToDisplayStringUtil); + lastMessageTextFromReport = getExpenseReportPreviewText(report, latestVisibleMoneyRequestAction, translate, transactions, convertToDisplayString); } else if (!isExpenseReport(report)) { lastMessageTextFromReport = lastVisibleMessage?.lastMessageText; } else if (!isCreatedAction(lastReportAction)) { lastMessageTextFromReport = formatReportLastMessageText( Parser.htmlToText( - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. - getReportPreviewMessage(translate, convertToDisplayStringUtil, { + getReportPreviewMessage(translate, convertToDisplayString, { reportOrID: report, iouReportAction: lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -932,7 +1001,11 @@ function getLastMessageTextForReport({ return lastMessageTextFromReport || (report?.lastMessageText ?? ''); } -type WelcomeMessage = {phrase1?: string; messageText?: string; messageHtml?: string}; +type WelcomeMessage = { + phrase1?: string; + messageText?: string; + messageHtml?: string; +}; type WelcomeMessageParams = { report: OnyxEntry; @@ -977,7 +1050,15 @@ function getWelcomeMessage(params: WelcomeMessageParams): WelcomeMessage { } if (isChatRoom(report)) { - return getRoomWelcomeMessage({translate, report, invoiceReceiverPolicy, derivedReportName, isReportArchived, reportDetailsLink, formatPhoneNumber}); + return getRoomWelcomeMessage({ + translate, + report, + invoiceReceiverPolicy, + derivedReportName, + isReportArchived, + reportDetailsLink, + formatPhoneNumber, + }); } if (isPolicyExpenseChat(report)) { @@ -990,8 +1071,16 @@ function getWelcomeMessage(params: WelcomeMessageParams): WelcomeMessage { } else { welcomeMessage.messageHtml = translate( 'reportActionsView.beginningOfChatHistoryPolicyExpenseChat', - getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}), - getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber, translate}), + getPolicyName({ + report, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), + }), + getDisplayNameForParticipant({ + accountID: report?.ownerAccountID, + formatPhoneNumber, + translate, + }), ); welcomeMessage.messageText = Parser.htmlToText(welcomeMessage.messageHtml); } @@ -1055,7 +1144,10 @@ function getRoomWelcomeMessage({ formatPhoneNumber, }: GetRoomWelcomeMessageParams): WelcomeMessage { const welcomeMessage: WelcomeMessage = {}; - const workspaceName = getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); + const workspaceName = getPolicyName({ + report, + unavailableTranslation: translate('workspace.common.unavailable'), + }); const reportName = getReportName(report ?? undefined, derivedReportName); if (report?.description) { @@ -1075,9 +1167,16 @@ function getRoomWelcomeMessage({ } else if (isInvoiceRoom(report)) { const payer = report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL - ? getDisplayNameForParticipant({accountID: report?.invoiceReceiver?.accountID, formatPhoneNumber, translate}) + ? getDisplayNameForParticipant({ + accountID: report?.invoiceReceiver?.accountID, + formatPhoneNumber, + translate, + }) : invoiceReceiverPolicy?.name; - const receiver = getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); + const receiver = getPolicyName({ + report, + unavailableTranslation: translate('workspace.common.unavailable'), + }); welcomeMessage.messageHtml = translate('reportActionsView.beginningOfChatHistoryInvoiceRoom', payer ?? '', receiver); } else { // Message for user created rooms or other room types. @@ -1099,12 +1198,17 @@ type GetReportAlternateTextParams = { personalDetails: OnyxEntry; policy: OnyxEntry; invoiceReceiverPolicy: OnyxEntry; + reportMetadata?: OnyxEntry; + participantPersonalDetailListExcludeCurrentUser?: PersonalDetails[]; policyTags?: OnyxEntry; isReportArchived: boolean | undefined; privateIsArchived: boolean; conciergeReportID: string | undefined; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; visibleReportActionsData?: VisibleReportActionsDerivedValue; + sortedActions?: Record; + oneTransactionThreadReportID?: string; + lastOriginalAction?: OnyxEntry; currentUserAccountID: number; currentUserLogin: string; isTrackIntentUser?: boolean; @@ -1113,6 +1217,8 @@ type GetReportAlternateTextParams = { formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; dateFnsLocale: DateFnsLocale | undefined; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; + rules: OnyxCollection; }; /** @@ -1130,12 +1236,17 @@ function getReportAlternateText({ personalDetails, policy, invoiceReceiverPolicy, + reportMetadata: reportMetadataParam, + participantPersonalDetailListExcludeCurrentUser: participantPersonalDetailListExcludeCurrentUserParam, policyTags, isReportArchived, privateIsArchived, conciergeReportID, reportAttributesDerived, visibleReportActionsData, + sortedActions, + oneTransactionThreadReportID, + lastOriginalAction, currentUserAccountID, currentUserLogin, isTrackIntentUser, @@ -1144,6 +1255,8 @@ function getReportAlternateText({ formatPhoneNumber, dateFnsLocale, convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, }: GetReportAlternateTextParams): string | undefined { let alternateText: string | undefined; const isChatRoomReport = isChatRoom(report); @@ -1152,8 +1265,13 @@ function getReportAlternateText({ const isTaskReportFlag = isTaskReport(report); const isAllowedToComment = canUserPerformWriteAction(report, isReportArchived); const isExpense = isExpenseReport(report); - const reportMetadata = getReportMetadata(report.reportID); + const reportMetadata = reportMetadataParam ?? getReportMetadata(report.reportID); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const resolvedOneTransactionThreadReportID = oneTransactionThreadReportID ?? deprecatedCachedOneTransactionThreadReportIDs[report.reportID]; const getParticipantPersonalDetailListExcludeCurrentUser = () => { + if (participantPersonalDetailListExcludeCurrentUserParam) { + return participantPersonalDetailListExcludeCurrentUserParam; + } const participantAccountIDs = getParticipantsAccountIDsForDisplay(report); const participantAccountIDsExcludeCurrentUser = excludeParticipantsForDisplay(participantAccountIDs, report.participants ?? {}, reportMetadata, {shouldExcludeCurrentUser: true}); return Object.values(getPersonalDetailsForAccountIDs(participantAccountIDsExcludeCurrentUser, personalDetails)); @@ -1176,10 +1294,13 @@ function getReportAlternateText({ } const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, currentUserAccountID, translate); - let lastMessageTextFromReport = lastMessageTextFromReportProp; - if (!lastMessageTextFromReport) { - lastMessageTextFromReport = getLastMessageTextForReport({ + // 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, + convertToDisplayString, dateFnsLocale, report, personalDetails, @@ -1196,15 +1317,33 @@ function getReportAlternateText({ lastAction, isTrackIntentUser, currentUserAccountID, + sortedActions, + oneTransactionThreadReportID: resolvedOneTransactionThreadReportID, + lastOriginalAction, + rules, }); - } + + const getLastActorDisplayNamePrefix = () => + (lastMessageTextFromReport.length > 0 && + getLastActorDisplayNameFromLastVisibleActions( + report, + lastActorDetails, + currentUserAccountID, + personalDetails, + privateIsArchived, + translate, + visibleReportActionsData, + lastAction, + resolvedOneTransactionThreadReportID, + )) || + lastActorDisplayName; // We need to remove sms domain in case the last message text has a phone number mention with sms domain. let lastMessageText = Str.removeSMSDomain(lastMessageTextFromReport); // Specifically for concierge chats and expense reports, which don't meet any of the conditions in the if statement below if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { - lastMessageText = getActionableCard3DSTransactionApprovalMessage(translate, lastAction) ?? lastMessageText; + lastMessageText = getActionableCard3DSTransactionApprovalMessage(translate, lastAction, convertToDisplayString, convertToDisplayStringWithoutCurrency) ?? lastMessageText; } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT) && !!getDelegateSubmitMessage(translate, lastAction, currentUserLogin)) { lastMessageText = Parser.htmlToText(getDelegateSubmitMessage(translate, lastAction, currentUserLogin)); } @@ -1276,6 +1415,12 @@ function getReportAlternateText({ alternateText = Parser.htmlToText(getIntegrationSyncFailedMessage(translate, lastAction, report?.policyID)); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN)) { alternateText = Parser.htmlToText(getCompanyCardConnectionBrokenMessage(translate, lastAction)); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN_30_DAYS)) { + alternateText = Parser.htmlToText(getCompanyCardConnectionBroken30DaysMessage(translate, lastAction)); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN)) { + alternateText = Parser.htmlToText(getCardConnectionBrokenMessage(undefined, getOriginalMessage(lastAction)?.cardName, translate, false)); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN_30_DAYS)) { + alternateText = Parser.htmlToText(getCardConnectionBrokenMessage(undefined, getOriginalMessage(lastAction)?.cardName, translate, true)); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) { alternateText = Parser.htmlToText(getPlaidBalanceFailureMessage(translate, lastAction)); } else if (lastAction?.actionName && isCategoryModificationAction(lastAction.actionName)) { @@ -1389,7 +1534,13 @@ function getReportAlternateText({ } else if (isLeavePolicyAction(lastAction)) { alternateText = getPolicyChangeLogEmployeeLeftMessage(translate, lastAction, getPersonalDetailsByID(lastAction.actorAccountID, personalDetails), true); } else if (isCardIssuedAction(lastAction)) { - alternateText = getCardIssuedMessage({reportAction: lastAction, expensifyCard: card, translate, currentUserAccountID}); + alternateText = getCardIssuedMessage({ + reportAction: lastAction, + expensifyCard: card, + translate, + currentUserAccountID, + buildDynamicRoute: createDynamicRoute, + }); } else if (lastAction && isOldDotReportAction(lastAction)) { alternateText = getMessageOfOldDotReportAction(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { @@ -1506,20 +1657,7 @@ function getReportAlternateText({ } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { alternateText = Parser.htmlToText(getSettlementAccountLockedMessage(translate, lastAction)); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { - const displayName = - (lastMessageTextFromReport.length > 0 && - getLastActorDisplayNameFromLastVisibleActions( - report, - lastActorDetails, - currentUserAccountID, - personalDetails, - privateIsArchived, - translate, - visibleReportActionsData, - lastAction, - )) || - lastActorDisplayName; - alternateText = formatReportLastMessageText(`${displayName}: ${lastMessageText}`); + alternateText = formatReportLastMessageText(`${getLastActorDisplayNamePrefix()}: ${lastMessageText}`); } else { alternateText = lastMessageTextFromReport.length > 0 @@ -1565,20 +1703,7 @@ function getReportAlternateText({ ); } if (shouldShowLastActorDisplayName(report, lastActorDetails, lastAction, currentUserAccountID, translate) && !isReportArchived) { - const displayName = - (lastMessageTextFromReport.length > 0 && - getLastActorDisplayNameFromLastVisibleActions( - report, - lastActorDetails, - currentUserAccountID, - personalDetails, - privateIsArchived, - translate, - visibleReportActionsData, - lastAction, - )) || - lastActorDisplayName; - alternateText = `${displayName}: ${formatReportLastMessageText(lastMessageText)}`; + alternateText = `${getLastActorDisplayNamePrefix()}: ${formatReportLastMessageText(lastMessageText)}`; } else { alternateText = formatReportLastMessageText(lastMessageText); } @@ -1587,13 +1712,41 @@ function getReportAlternateText({ return alternateText; } +function getExpensifyCardFromReportAction({ + reportAction, + policy, + cardList, + workspaceCardList, +}: { + reportAction: OnyxEntry; + policy: OnyxEntry; + cardList: OnyxEntry; + workspaceCardList: OnyxCollection; +}): Card | undefined { + const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; + + const cardIssuedActionOriginalMessage = isCardIssuedAction(reportAction) ? getOriginalMessage(reportAction) : undefined; + const cardID = cardIssuedActionOriginalMessage?.cardID ?? CONST.DEFAULT_NUMBER_ID; + if (!isPolicyAdmin(policy)) { + return cardList?.[cardID]; + } + + // Issued Expensify Cards live on one of two Onyx keys: regular cards on the 2-segment key, + // Travel Billing cards on the `_TRAVEL_US` variant. Check both. + return ( + workspaceCardList?.[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`]?.[cardID] ?? + workspaceCardList?.[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${getTravelBillingFeedID(workspaceAccountID)}`]?.[cardID] ?? + cardList?.[cardID] + ); +} + export { - // eslint-disable-next-line @typescript-eslint/no-deprecated - deprecatedCachedOneTransactionThreadReportIDs, + getExpensifyCardFromReportAction, getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, getReportAlternateText, getWelcomeMessage, + resolveLastActionContext, shouldShowLastActorDisplayName, }; diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index dd78fe109573..1b47de3b76a9 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -1,5 +1,7 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { @@ -12,6 +14,7 @@ import type { ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, + Rule, Transaction, } from '@src/types/onyx'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; @@ -26,14 +29,15 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from './AgentRuleChangeLogUtils'; -import {convertToDisplayString, getCurrencySymbol} from './CurrencyUtils'; +import getCollator from './CollatorUtils'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {translateLocal} from './Localize'; // eslint-disable-next-line import/no-cycle import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage'; +import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {getCurrentUserEmail} from './Network/NetworkStore'; import Parser from './Parser'; -import {temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {getPersonalDetailsByID, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; import {getCleanedTagName, isPolicyAdmin, isPolicyFieldListEmpty} from './PolicyUtils'; import { getActionableCard3DSTransactionApprovalMessage, @@ -42,10 +46,12 @@ import { getAssignedCompanyCardMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, + getCardConnectionBrokenMessage, getCardIssuedMessage, getCategoryTaxRateMessage, getChangedApproverActionMessage, getCompanyAddressUpdateMessage, + getCompanyCardConnectionBroken30DaysMessage, getCompanyCardConnectionBrokenMessage, getCreatedReportForUnapprovedTransactionsMessage, getCrossBorderReimbursedMessage, @@ -204,7 +210,11 @@ type ComputeReportName = { reportAttributes?: ReportAttributesDerivedValue['reports']; reportTransactions: Record; isTrackIntentUser: boolean | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; + getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; pendingDeleteMemberAccountIDs?: string[]; + rules: OnyxCollection; }; function generateArchivedReportName(reportName: string): string { @@ -261,7 +271,7 @@ const buildReportNameFromParticipantNames = ({ * The reason for this is that the computation of default group name should not depend on the locale. * This is used to ensure that group name stays consistent across locales. */ -const customCollator = new Intl.Collator('en', {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'}); +const customCollator = getCollator(CONST.LOCALES.EN); /** * Returns the report name if the report is a group chat @@ -378,6 +388,7 @@ function getInvoiceReportName( report: OnyxEntry, linkedTransactions: Transaction[], translate: LocalizedTranslate, + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], personalDetailsList: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry, @@ -389,6 +400,7 @@ function getInvoiceReportName( linkedTransactions, personalDetailsList, translate, + convertToDisplayString, }); const oldDotInvoiceName = report?.reportName ?? moneyRequestReportName; return isNewDotInvoice(report?.chatReportID) ? moneyRequestReportName : oldDotInvoiceName; @@ -425,6 +437,7 @@ function getMoneyRequestReportName({ linkedTransactions, personalDetailsList, translate, + convertToDisplayString, }: { report: OnyxEntry; policy?: OnyxEntry; @@ -432,6 +445,7 @@ function getMoneyRequestReportName({ linkedTransactions: Transaction[]; personalDetailsList: OnyxEntry; translate: LocalizedTranslate; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }): string { // For expense reports with empty fieldList and empty reportName, return "New Report" (matches OldDot behavior) if (isExpenseReport(report)) { @@ -492,6 +506,9 @@ function computeReportNameBasedOnReportAction({ reportAttributes, isTrackIntentUser, currentUserAccountID, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, }: { translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined; @@ -504,6 +521,9 @@ function computeReportNameBasedOnReportAction({ reportAttributes: ReportAttributesDerivedValue['reports'] | undefined; isTrackIntentUser: boolean | undefined; currentUserAccountID: number; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; + rules: OnyxCollection; }): string | undefined { if (!parentReportAction) { return undefined; @@ -522,6 +542,7 @@ function computeReportNameBasedOnReportAction({ isTrackIntentUser, policy: reportPolicy, report: parentReport, + rules, }) ) { return translate('iou.markedAsDone', getOriginalMessage(parentReportAction)?.message); @@ -573,7 +594,6 @@ function computeReportNameBasedOnReportAction({ } if (isReimbursementDeQueuedOrCanceledAction(parentReportAction)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. return getReimbursementDeQueuedOrCanceledActionMessage(translate, parentReportAction, parentReport?.ownerAccountID, convertToDisplayString); } if (isRejectedAction(parentReportAction)) { @@ -651,7 +671,6 @@ function computeReportNameBasedOnReportAction({ } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. return getPolicyChangeLogMaxExpenseAmountMessage(translate, parentReportAction, convertToDisplayString); } @@ -660,12 +679,10 @@ function computeReportNameBasedOnReportAction({ } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. return getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, parentReportAction, convertToDisplayString); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. return getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage(translate, parentReportAction, convertToDisplayString); } @@ -695,7 +712,6 @@ function computeReportNameBasedOnReportAction({ return getAutoPayApprovedReportsEnabledMessage(translate, parentReportAction); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. return getAutoReimbursementMessage(translate, parentReportAction, convertToDisplayString); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE)) { @@ -752,7 +768,7 @@ function computeReportNameBasedOnReportAction({ } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { - return getActionableCard3DSTransactionApprovalMessage(translate, parentReportAction); + return getActionableCard3DSTransactionApprovalMessage(translate, parentReportAction, convertToDisplayString, convertToDisplayStringWithoutCurrency); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ADDRESS)) { @@ -811,7 +827,6 @@ function computeReportNameBasedOnReportAction({ return getElsewherePaymentReportActionMessage(translate, originalMessage); } if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. const crossBorderMessage = getCrossBorderReimbursedMessage(translate, originalMessage, convertToDisplayString, last4Digits); if (crossBorderMessage) { return crossBorderMessage; @@ -843,7 +858,7 @@ function computeReportNameBasedOnReportAction({ } if (isActionableJoinRequest(parentReportAction)) { - return getJoinRequestMessage(translate, reportPolicy, parentReportAction); + return getJoinRequestMessage(translate, reportPolicy?.name ?? '', parentReportAction, getPersonalDetailsByID(getOriginalMessage(parentReportAction)?.accountID, personalDetailsList)); } if (isTaskReport(report) && isCanceledTaskReport(report, parentReportAction)) { @@ -858,6 +873,18 @@ function computeReportNameBasedOnReportAction({ return Parser.htmlToText(getCompanyCardConnectionBrokenMessage(translate, parentReportAction)); } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN_30_DAYS)) { + return Parser.htmlToText(getCompanyCardConnectionBroken30DaysMessage(translate, parentReportAction)); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN)) { + return Parser.htmlToText(getCardConnectionBrokenMessage(undefined, getOriginalMessage(parentReportAction)?.cardName, translate, false)); + } + + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN_30_DAYS)) { + return Parser.htmlToText(getCardConnectionBrokenMessage(undefined, getOriginalMessage(parentReportAction)?.cardName, translate, true)); + } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) { return Parser.htmlToText(getPlaidBalanceFailureMessage(translate, parentReportAction)); } @@ -893,7 +920,7 @@ function computeReportNameBasedOnReportAction({ } if (isCardIssuedAction(parentReportAction)) { - return getCardIssuedMessage({reportAction: parentReportAction, translate, currentUserAccountID}); + return getCardIssuedMessage({reportAction: parentReportAction, translate, currentUserAccountID, buildDynamicRoute: createDynamicRoute}); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED)) { return getAddedCardFeedMessage(translate, parentReportAction); @@ -961,17 +988,33 @@ function computeReportNameBasedOnReportAction({ return undefined; } -function computeChatThreadReportName( - translate: LocalizedTranslate, - isArchived: boolean, - report: Report, - reports: OnyxCollection, - currentUserLogin: string, - transactions: OnyxCollection, - parentReportAction?: ReportAction, - policyTags?: OnyxEntry, - policy?: OnyxEntry, -): string | undefined { +function computeChatThreadReportName({ + translate, + convertToDisplayString, + getCurrencySymbol, + isArchived, + report, + reports, + currentUserAccountID, + currentUserLogin, + transactions, + parentReportAction, + policyTags, + policy, +}: { + translate: LocalizedTranslate; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + isArchived: boolean; + report: Report; + reports: OnyxCollection; + currentUserAccountID: number | undefined; + currentUserLogin: string; + transactions: OnyxCollection; + parentReportAction?: ReportAction; + policyTags?: OnyxEntry; + policy?: OnyxEntry; +}): string | undefined { if (!isChatThread(report)) { return undefined; } @@ -988,7 +1031,6 @@ function computeChatThreadReportName( const linkedTransactionReport = linkedTransaction?.reportID ? reports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedTransaction.reportID}`] : undefined; let formattedName = getTransactionReportName({ translate, - // Non-React call path: pass the standalone utils until this file's own currency-context threading PR. convertToDisplayString, getCurrencySymbol, reportAction: parentReportAction, @@ -1030,13 +1072,13 @@ function computeChatThreadReportName( const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(parentReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; const modifiedMessageWithHTML = getForReportAction({ translate, - // Non-React call path: pass the standalone util until this file's own convertToDisplayString threading PR. convertToDisplayString, reportAction: parentReportAction, movedFromReport, movedToReport, policyTags, policy, + currentUserAccountID, currentUserLogin, }); // Strip HTML tags for plain text display in report previews @@ -1070,7 +1112,11 @@ function computeReportName({ reportAttributes, reportTransactions, isTrackIntentUser, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol, pendingDeleteMemberAccountIDs, + rules, }: ComputeReportName): string { if (!report?.reportID) { return ''; @@ -1092,6 +1138,9 @@ function computeReportName({ reportAttributes, isTrackIntentUser, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, }); if (parentReportActionBasedName) { @@ -1121,8 +1170,12 @@ function computeReportName({ reportAttributes, reportTransactions, isTrackIntentUser, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol, // TODO: pass the true data in the next PR, issue https://github.com/Expensify/App/issues/66421 pendingDeleteMemberAccountIDs: undefined, + rules, }); return getCreatedReportForUnapprovedTransactionsMessage(originalID, reportName, isOriginalReportDeleted(parentReportAction, originalReport), translate); } @@ -1136,17 +1189,20 @@ function computeReportName({ const privateIsArchivedValue = !!allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]?.private_isArchived; const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`]; - const chatThreadReportName = computeChatThreadReportName( + const chatThreadReportName = computeChatThreadReportName({ translate, - privateIsArchivedValue, + convertToDisplayString, + getCurrencySymbol, + isArchived: privateIsArchivedValue, report, - reports ?? {}, - currentUserLogin ?? '', + reports: reports ?? {}, + currentUserAccountID, + currentUserLogin: currentUserLogin ?? '', transactions, parentReportAction, policyTags, - reportPolicy, - ); + policy: reportPolicy, + }); if (chatThreadReportName) { return chatThreadReportName; } @@ -1177,6 +1233,7 @@ function computeReportName({ linkedTransactions: reportTransactions[report.reportID] ?? [], personalDetailsList, translate, + convertToDisplayString, }); } @@ -1188,7 +1245,7 @@ function computeReportName({ chatReceiverPolicyID = (chatReceiver as {policyID: string}).policyID; } const invoiceReceiverPolicy = chatReceiverPolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReceiverPolicyID}`] : undefined; - formattedName = getInvoiceReportName(report, reportTransactions[report.reportID] ?? [], translate, personalDetailsList, policy, invoiceReceiverPolicy); + formattedName = getInvoiceReportName(report, reportTransactions[report.reportID] ?? [], translate, convertToDisplayString, personalDetailsList, policy, invoiceReceiverPolicy); } if (isInvoiceRoom(report)) { diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 8ddea54d16a7..b2d9f6304a3c 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -1,5 +1,5 @@ import CONST from '@src/CONST'; -import type {BankAccountList, Policy, Report, ReportMetadata, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {BankAccountList, Policy, Report, ReportMetadata, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -43,6 +43,7 @@ function canSubmit( currentUserAccountID: number, currentUserEmail: string, ownerLogin: string | undefined, + rules: OnyxCollection, violations?: OnyxCollection, policy?: Policy, transactions?: Transaction[], @@ -71,7 +72,7 @@ function canSubmit( return false; } - const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin); + const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin, rules); if (submitToAccountID === report.ownerAccountID && policy?.preventSelfApproval) { return false; @@ -238,6 +239,7 @@ function getReportPreviewAction({ violationsData, reportMetadata, ownerLogin, + rules, }: { isReportArchived: boolean; currentUserAccountID: number; @@ -254,6 +256,7 @@ function getReportPreviewAction({ violationsData?: OnyxCollection; reportMetadata: OnyxEntry; ownerLogin: string | undefined; + rules: OnyxCollection; }): ValueOf { if (!report) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; @@ -268,7 +271,7 @@ function getReportPreviewAction({ if (isSubmittingAnimationRunning) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } - if (isAddExpenseAction(report, transactions ?? [], isReportArchived)) { + if (isAddExpenseAction(report, transactions ?? [], isReportArchived, rules)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } @@ -276,7 +279,7 @@ function getReportPreviewAction({ return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; } - if (canSubmit(report, currentUserAccountID, currentUserLogin, ownerLogin, violationsData, policy, transactions)) { + if (canSubmit(report, currentUserAccountID, currentUserLogin, ownerLogin, rules, violationsData, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } if (canApprove(report, currentUserAccountID, reportMetadata, policy, transactions)) { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 64776f21d470..1c65c718ffdf 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -84,6 +84,7 @@ type GetReportPrimaryActionParams = { isChatReportArchived: boolean; invoiceReceiverPolicy?: Policy; ownerLogin: string | undefined; + rules: OnyxCollection; /** TODO: Should be a required field in the future. Refactor issue: https://github.com/Expensify/App/issues/66407 */ isOffline?: boolean; }; @@ -103,13 +104,13 @@ type IsPrimaryPayActionParams = { canNonPayerAdminPay?: boolean; }; -function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean) { +function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean, rules: OnyxCollection) { if (isChatReportArchived) { return false; } const isExpenseReport = isExpenseReportUtils(report); - const canAddTransaction = canAddTransactionUtil(report); + const canAddTransaction = canAddTransactionUtil(report, rules); return isExpenseReport && canAddTransaction && reportTransactions.length === 0; } @@ -119,6 +120,7 @@ function isSubmitAction( reportTransactions: Transaction[], reportMetadata: OnyxEntry, ownerLogin: string | undefined, + rules: OnyxCollection, policy?: Policy, violations?: OnyxCollection, currentUserEmail?: string, @@ -150,7 +152,7 @@ function isSubmitAction( return false; } - const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin); + const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin, rules); if (submitToAccountID === report.ownerAccountID && policy?.preventSelfApproval && !isReportSubmitter) { return false; @@ -158,7 +160,11 @@ function isSubmitAction( // Workflow approver (direct submitsTo, not rule approvers). Fail closed on unresolved ownerLogin — else falls back to policy.approver. const isWorkflowApprover = - !isReportSubmitter && currentUserAccountID !== undefined && !!ownerLogin && !isSubmitAndClose(policy) && currentUserAccountID === getManagerAccountID(policy, ownerLogin); + !isReportSubmitter && + currentUserAccountID !== undefined && + !!ownerLogin && + !isSubmitAndClose(policy) && + currentUserAccountID === getManagerAccountID(policy, ownerLogin, rules, report.total ?? 0); const canBeSubmitter = isReportSubmitter || isWorkflowApprover; return isExpenseReport && canBeSubmitter && isOpenReport && reportTransactions.length !== 0; } @@ -464,6 +470,7 @@ function getAllExpensesToHoldIfApplicable( reportTransactions: Transaction[], policy: OnyxEntry, currentUserAccountID: number | undefined, + rules: OnyxCollection, ) { if (!report || !reportActions || !hasOnlyHeldExpenses(reportTransactions)) { return []; @@ -477,7 +484,7 @@ function getAllExpensesToHoldIfApplicable( const transactionID = getOriginalMessage(action)?.IOUTransactionID; const transaction = reportTransactions.find((reportTransaction) => reportTransaction.transactionID === transactionID); const holdReportAction = getReportAction(action?.childReportID, `${transaction?.comment?.hold ?? ''}`); - return canHoldUnholdReportAction(report, action, holdReportAction, transaction, policy, currentUserAccountID).canUnholdRequest; + return canHoldUnholdReportAction(report, action, holdReportAction, transaction, policy, currentUserAccountID, rules).canUnholdRequest; }); } @@ -497,6 +504,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf, isReportArchived = false) { const isReportSubmitter = isCurrentUserSubmitter(report); if (!isReportSubmitter) { return false; } - return canAddTransaction(report, isReportArchived); + return canAddTransaction(report, rules, isReportArchived); } function isSplitAction( @@ -119,6 +120,7 @@ function isSplitAction( originalTransaction: OnyxEntry, currentUserLogin: string, currentUserAccountID: number, + rules: OnyxCollection, policy?: OnyxEntry, parentReport?: OnyxEntry, ): boolean { @@ -185,7 +187,7 @@ function isSplitAction( } // Hide split option for the submitter if the report is forwarded - return (isSubmitter && isAwaitingFirstLevelApproval(report)) || isAdmin || isManager; + return (isSubmitter && isAwaitingFirstLevelApproval(report, rules)) || isAdmin || isManager; } function isSubmitAction({ @@ -199,6 +201,7 @@ function isSubmitAction({ currentUserLogin, currentUserAccountID, ownerLogin, + rules, }: { report: Report; reportTransactions: Transaction[]; @@ -210,6 +213,7 @@ function isSubmitAction({ currentUserLogin?: string; currentUserAccountID: number; ownerLogin: string | undefined; + rules: OnyxCollection; }): boolean { // State transitions are blocked only on archived or pending-delete policies. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still move through the workflow. @@ -255,9 +259,13 @@ function isSubmitAction({ const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; // Workflow approver (direct submitsTo, not rule approvers). Fail closed on unresolved ownerLogin — else falls back to policy.approver. - const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin); + const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin, rules); const isWorkflowApprover = - !isReportSubmitter && currentUserAccountID !== undefined && !!ownerLogin && !isSubmitAndClose(policy) && currentUserAccountID === getManagerAccountID(policy, ownerLogin); + !isReportSubmitter && + currentUserAccountID !== undefined && + !!ownerLogin && + !isSubmitAndClose(policy) && + currentUserAccountID === getManagerAccountID(policy, ownerLogin, rules, report.total ?? 0); if (!isReportSubmitter && !isAdmin && !isWorkflowApprover) { return false; @@ -640,6 +648,7 @@ function isHoldAction( reportActions: ReportAction[] | undefined, policy: OnyxEntry, currentUserAccountID: number | undefined, + rules: OnyxCollection, /** TODO: Should be a required field in the future. Refactor issue: https://github.com/Expensify/App/issues/66407 */ isOffline?: boolean, ): boolean { @@ -652,7 +661,7 @@ function isHoldAction( } const action = !!reportActions && getIOUActionForTransactionID(reportActions, transaction.transactionID); - return !!action && isHoldActionForTransaction(report, transaction, action, policy, currentUserAccountID); + return !!action && isHoldActionForTransaction(report, transaction, action, policy, currentUserAccountID, rules); } function isHoldActionForTransaction( @@ -661,12 +670,13 @@ function isHoldActionForTransaction( reportAction: ReportAction, policy: OnyxEntry, currentUserAccountID: number | undefined, + rules: OnyxCollection, ): boolean { const isExpenseReport = isExpenseReportUtils(report); const isIOUReport = isIOUReportUtils(report); const iouOrExpenseReport = isExpenseReport || isIOUReport; const holdReportAction = getReportAction(reportAction?.childReportID, `${reportTransaction?.comment?.hold ?? ''}`); - const {canHoldRequest} = canHoldUnholdReportAction(report, reportAction, holdReportAction, reportTransaction, policy, currentUserAccountID); + const {canHoldRequest} = canHoldUnholdReportAction(report, reportAction, holdReportAction, reportTransaction, policy, currentUserAccountID, rules); const isActionOwner = isActionCreator(reportAction); if (isOpenExpenseReport(report)) { @@ -733,8 +743,8 @@ function isChangeWorkspaceAction(report: Report, policies: OnyxCollection, reportActions?: ReportAction[]): boolean { + return canDeleteMoneyRequestReport(report, reportTransactions, reportActions ?? [], currentUserAccountID, rules); } function shouldShowEditSplitInDeleteAction( @@ -743,6 +753,7 @@ function shouldShowEditSplitInDeleteAction( reportActions: ReportAction[] | undefined, originalTransaction: OnyxEntry, currentUserAccountID: number, + rules: OnyxCollection, ): boolean { if (reportTransactions.length !== 1) { return false; @@ -754,7 +765,10 @@ function shouldShowEditSplitInDeleteAction( } const isSelfDMSplit = isSelfDMReportUtils(report); - return shouldRedirectDeleteToSplitExpenseEdit(reportTransaction, originalTransaction, isSelfDMSplit) && isDeleteAction(report, reportTransactions, currentUserAccountID, reportActions); + return ( + shouldRedirectDeleteToSplitExpenseEdit(reportTransaction, originalTransaction, isSelfDMSplit) && + isDeleteAction(report, reportTransactions, currentUserAccountID, rules, reportActions) + ); } function isRetractAction(report: Report, policy?: Policy): boolean { @@ -810,7 +824,7 @@ function isReopenAction(report: Report, policy?: Policy): boolean { /** * Checks whether the supplied report supports merging transactions from it. */ -function isMergeAction(parentReport: Report, reportTransactions: Transaction[], policy?: Policy): boolean { +function isMergeAction(parentReport: Report, reportTransactions: Transaction[], rules: OnyxCollection, policy?: Policy): boolean { // Do not show merge action if there are more than 2 transactions if (reportTransactions.length > 2) { return false; @@ -837,10 +851,10 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin); + return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin, rules); } -function isMergeActionForSelectedTransactions(transactions: Transaction[], reports: Report[], policies: Policy[], currentUserAccountID?: number) { +function isMergeActionForSelectedTransactions(transactions: Transaction[], reports: Report[], policies: Policy[], rules: OnyxCollection, currentUserAccountID?: number) { if ([transactions, reports, policies].some((collection) => collection?.length > 2)) { return false; } @@ -886,7 +900,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); + return isMoneyRequestReportEligibleForMerge(report, policy?.role === CONST.POLICY.ROLE.ADMIN, rules); }); return allReportsEligible && (transactions.length === 1 || areTransactionsEligibleForMerge(transactions.at(0), transactions.at(1))); @@ -993,6 +1007,7 @@ function getSecondaryReportActions({ isChatReportArchived = false, parentReport, isOffline, + rules, }: { currentUserLogin: string; currentUserAccountID: number; @@ -1014,6 +1029,7 @@ function getSecondaryReportActions({ parentReport?: OnyxEntry; /** TODO: Should be a required field in the future. Refactor issue: https://github.com/Expensify/App/issues/66407 */ isOffline?: boolean; + rules: OnyxCollection; }): Array> { const options: Array> = []; const reportNameValuePairs = moveExpenseReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; @@ -1041,7 +1057,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.PAY); } - if (isAddExpenseAction(report, reportTransactions, isChatReportArchived || isArchivedReport(reportNameValuePairs))) { + if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs))) { options.push(CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE); } @@ -1059,6 +1075,7 @@ function getSecondaryReportActions({ reportMetadata, isChatReportArchived, ownerLogin: submitterLogin, + rules, isOffline, }); @@ -1074,6 +1091,7 @@ function getSecondaryReportActions({ currentUserLogin, currentUserAccountID, ownerLogin: submitterLogin, + rules, }) ) { options.push(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); @@ -1103,7 +1121,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.REOPEN); } - if (isHoldAction(report, chatReport, reportTransactions, reportActions, policy, currentUserAccountID, isOffline)) { + if (isHoldAction(report, chatReport, reportTransactions, reportActions, policy, currentUserAccountID, rules, isOffline)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.HOLD); } @@ -1116,13 +1134,13 @@ function getSecondaryReportActions({ } if ( - isSplitAction(report, reportTransactions, originalTransaction, currentUserLogin, currentUserAccountID, policy, parentReport) && - !shouldShowEditSplitInDeleteAction(report, reportTransactions, reportActions, originalTransaction, currentUserAccountID) + isSplitAction(report, reportTransactions, originalTransaction, currentUserLogin, currentUserAccountID, rules, policy, parentReport) && + !shouldShowEditSplitInDeleteAction(report, reportTransactions, reportActions, originalTransaction, currentUserAccountID, rules) ) { options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT); } - if (reportTransactions?.length === 1 && isMergeAction(report, reportTransactions, policy)) { + if (reportTransactions?.length === 1 && isMergeAction(report, reportTransactions, rules, policy)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.MERGE); } @@ -1161,6 +1179,7 @@ function getSecondaryReportActions({ outstandingReportsByPolicyID, reportNameValuePairs: moveExpenseReportNameValuePairs, transaction, + rules, }); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isChatReportArchived); @@ -1177,7 +1196,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS); - if (isDeleteAction(report, reportTransactions, currentUserAccountID, reportActions ?? [])) { + if (isDeleteAction(report, reportTransactions, currentUserAccountID, rules, reportActions ?? [])) { options.push(CONST.REPORT.SECONDARY_ACTIONS.DELETE); } @@ -1221,6 +1240,7 @@ function getSecondaryTransactionThreadActions({ isChatReportArchived, grandParentReport, hasWorkspaceToSubmitTo = false, + rules, }: { currentUserLogin: string; currentUserAccountID: number; @@ -1236,10 +1256,11 @@ function getSecondaryTransactionThreadActions({ grandParentReport?: OnyxEntry; /** Whether the user belongs to a workspace they can submit an expense to (self-DM split expenses can only be submitted to a workspace). */ hasWorkspaceToSubmitTo?: boolean; + rules: OnyxCollection; }): Array> { const options: Array> = []; - if (!!reportAction && isHoldActionForTransaction(parentReport, reportTransaction, reportAction, policy, currentUserAccountID)) { + if (!!reportAction && isHoldActionForTransaction(parentReport, reportTransaction, reportAction, policy, currentUserAccountID, rules)) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); } @@ -1252,13 +1273,13 @@ function getSecondaryTransactionThreadActions({ } if ( - isSplitAction(parentReport, [reportTransaction], originalTransaction, currentUserLogin, currentUserAccountID, policy, grandParentReport) && - !shouldShowEditSplitInDeleteAction(parentReport, [reportTransaction], reportAction ? [reportAction] : [], originalTransaction, currentUserAccountID) + isSplitAction(parentReport, [reportTransaction], originalTransaction, currentUserLogin, currentUserAccountID, rules, policy, grandParentReport) && + !shouldShowEditSplitInDeleteAction(parentReport, [reportTransaction], reportAction ? [reportAction] : [], originalTransaction, currentUserAccountID, rules) ) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SPLIT); } - if (isMergeAction(parentReport, [reportTransaction], policy)) { + if (isMergeAction(parentReport, [reportTransaction], rules, policy)) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE); } @@ -1276,6 +1297,7 @@ function getSecondaryTransactionThreadActions({ outstandingReportsByPolicyID, reportNameValuePairs, transaction: reportTransaction, + rules, }) && canUserPerformWriteActionReportUtils(parentReport, isChatReportArchived) ) { @@ -1303,7 +1325,7 @@ function getSecondaryTransactionThreadActions({ options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS); - if (isDeleteAction(parentReport, [reportTransaction], currentUserAccountID, reportAction ? [reportAction] : [])) { + if (isDeleteAction(parentReport, [reportTransaction], currentUserAccountID, rules, reportAction ? [reportAction] : [])) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DELETE); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 863176d29431..8f70f6bf3cd0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1,6 +1,5 @@ import FallbackAvatar from '@assets/images/avatars/fallback-avatar.svg'; -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; @@ -15,7 +14,7 @@ import type {PolicyTagList} from '@pages/workspace/tags/types'; import type {ThemeColors} from '@styles/theme/types'; -import type {IOUAction, IOURequestType, IOUType, OnboardingAccounting} from '@src/CONST'; +import type {IOUAction, IOUType, OnboardingAccounting} from '@src/CONST'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; @@ -27,7 +26,6 @@ import SCREENS from '@src/SCREENS'; import type { BankAccountList, Beta, - BillingGraceEndPeriod, GuideAccountIDsDerivedValue, IntroSelected, OnyxInputOrEntry, @@ -46,11 +44,13 @@ import type { ReportMetadata, ReportNameValuePairs, ReportViolationName, + Rule, SearchResults, Task, Transaction, TransactionViolation, TransactionViolations, + ViolationName, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {ReportTransactionsAndViolations} from '@src/types/onyx/DerivedValues'; @@ -66,7 +66,7 @@ import type { PaymentMethodType, } from '@src/types/onyx/OriginalMessage'; import type {Status, Timezone} from '@src/types/onyx/PersonalDetails'; -import type {AllConnectionName, ConnectionName, CreatableWorkspaceType} from '@src/types/onyx/Policy'; +import type {AllConnectionName, ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, Participant as ReportParticipant} from '@src/types/onyx/Report'; import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction'; import type {PendingChatMember} from '@src/types/onyx/ReportMetadata'; @@ -106,20 +106,9 @@ import type {AvatarSource} from './UserAvatarUtils'; import {isIntuitEnterpriseSuiteConnection} from './AccountingUtils'; import {getBankAccountFromID} from './actions/BankAccounts'; import {unholdRequest} from './actions/IOU/Hold'; -import { - createDraftTransaction, - setMoneyRequestParticipants, - setMoneyRequestParticipantsFromReport, - setMoneyRequestReportID, - startDistanceRequest, - startMoneyRequest, -} from './actions/IOU/MoneyRequest'; import {canApproveIOU, canIOUBePaid, canSubmitReport, getBadgeFromIOUReport, getIOUReportActionWithBadge} from './actions/IOU/ReportWorkflow'; -import {createDraftWorkspace} from './actions/Policy/Policy'; import hasCreditBankAccount from './actions/ReimbursementAccount/hasCreditBankAccount'; -import {openUnreportedExpense} from './actions/Report'; import {isAnonymousUser as isAnonymousUserSession} from './actions/Session'; -import {removeDraftTransactionsByIDs} from './actions/TransactionEdit'; import {getOnboardingMessages} from './actions/Welcome/OnboardingFlow'; import {convertAttendeesToArray, normalizeAttendees} from './AttendeeUtils'; import {getCategoryGLCode} from './CategoryUtils'; @@ -137,7 +126,6 @@ import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDy import getReportURLForCurrentContext from './Navigation/helpers/getReportURLForCurrentContext'; import getStateFromPath from './Navigation/helpers/getStateFromPath'; import {isFullScreenName} from './Navigation/helpers/isNavigatorName'; -import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './Navigation/helpers/isSearchTopmostFullScreenRoute'; import {linkingConfig} from './Navigation/linkingConfig'; import Navigation, {navigationRef} from './Navigation/Navigation'; @@ -191,6 +179,7 @@ import { getLastVisibleAction as getLastVisibleActionReportActionsUtils, getLastVisibleMessage as getLastVisibleMessageActionUtils, getLastVisibleMessage as getLastVisibleMessageReportActionsUtils, + getMessageOfOldDotReportAction, getMostRecentActiveDEWApproveFailedAction, getMostRecentActiveDEWSubmitFailedAction, getNumberOfMoneyRequests, @@ -242,7 +231,6 @@ import { // eslint-disable-next-line import/no-cycle import {deprecatedGetReportName, getGroupChatName, getInvoicePayerName, getInvoiceReportName, getReportName} from './ReportNameUtils'; import {getAllPersonalDetailLogins} from './ShortMentionLogins'; -import {shouldRestrictUserBillableActions} from './SubscriptionUtils'; import {isTaskCompleted} from './TaskUtils'; import { getAttendees, @@ -284,6 +272,7 @@ import { hasViolation, hasWarningTypeViolation, isManagedCardTransaction as isCardTransactionTransactionUtils, + isDeletedTransaction, isDemoTransaction, isDistanceRequest, isFetchingWaypointsFromServer, @@ -302,7 +291,6 @@ import { import addTrailingForwardSlash from './UrlUtils'; import {getDefaultAvatarURL} from './UserAvatarUtils'; import {generateAccountID} from './UserUtils'; -import {isInvalidMerchantValue} from './ValidationUtils'; import ViolationsUtils from './Violations/ViolationsUtils'; type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18; @@ -1037,6 +1025,7 @@ type BuildOptimisticExpenseReportParams = { reportTransactions?: Record; createdTimestamp?: string; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type ReportByPolicyMap = Record>; @@ -1067,14 +1056,6 @@ const FALLBACK_TITLE_FIELD: PolicyReportField = { isTax: false, }; -let conciergeReportIDOnyxConnect: OnyxEntry; -Onyx.connect({ - key: ONYXKEYS.CONCIERGE_REPORT_ID, - callback: (value) => { - conciergeReportIDOnyxConnect = value; - }, -}); - const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon'; Onyx.connect({ key: ONYXKEYS.SESSION, @@ -1308,7 +1289,7 @@ function getReportOrDraftReport( } /** - * This function is being migrated to remove `deprecatedReportsTransactions`. + * @deprecated This function is being migrated and will be removed later. * If you need to use this function, you must explicitly pass the `allReportsTransactions` parameter * instead of relying on the default value. In React components, prefer using the `useReportTransactions` hook. */ @@ -1328,15 +1309,6 @@ function isReportTotalPending(report: OnyxInputOrEntry): boolean { return !!report?.pendingFields?.total; } -/** - * Check if a report is a draft report - */ -function isDraftReport(reportID: string | undefined): boolean { - const draftReport = deprecatedAllReportsDraft?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]; - - return !!draftReport; -} - /** * @private */ @@ -1483,8 +1455,7 @@ function isChatReport(report: OnyxEntry): boolean { return report?.type === CONST.REPORT.TYPE.CHAT; } -function isInvoiceReport(reportOrID: OnyxInputOrEntry | string): boolean { - const report = typeof reportOrID === 'string' ? (getReport(reportOrID, deprecatedAllReports) ?? null) : reportOrID; +function isInvoiceReport(report: OnyxInputOrEntry): boolean { return report?.type === CONST.REPORT.TYPE.INVOICE; } @@ -2032,9 +2003,11 @@ function isSelfDMOrSelfDMThread(report: OnyxEntry, currentUserAccountID? } /** - * Returns true if the report is an expense report, a group policy, a self-DM, or the iouType is create, and the iouType is not split or invoice. + * Returns true if negative amounts are supported: the report is an expense report, the policy is a group policy, the report is a self-DM, + * the iouType is create with no P2P recipient selected, or it is the first time creating a report (submit with no report, policy or P2P + * recipient). Split and invoice never support negative amounts. */ -function shouldEnableNegative(report: OnyxEntry, policy?: OnyxEntry, iouType?: string, participants?: Participant[], isNewManualExpenseFlow = false) { +function shouldEnableNegative(report: OnyxEntry, policy?: OnyxEntry, iouType?: string, participants?: Participant[]) { const isSelfDMReport = isSelfDMOrSelfDMThread(report); const isUserInRecipients = participants?.some((participant) => !participant.isSender && !participant.isPolicyExpenseChat && participant.accountID); @@ -2042,7 +2015,7 @@ function shouldEnableNegative(report: OnyxEntry, policy?: OnyxEntry, report return reportActionsArray.some((action) => isForwardedAction(action) && action.created > lastSubmittedAt); } -function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { +function isAwaitingFirstLevelApproval(report: OnyxEntry, rules: OnyxCollection): boolean { if (!report) { return false; } @@ -2109,7 +2082,7 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { return false; } - const submitsToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report.ownerAccountID, allPersonalDetails)); + const submitsToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report.ownerAccountID, allPersonalDetails), rules); return isProcessingReport(report) && submitsToAccountID === report.managerID && !hasReportBeenForwardedSinceLastSubmit(report); } @@ -2941,19 +2914,22 @@ function isOneOnOneChat(report: OnyxEntry, currentUserAccountID?: number * Returns the other participant of a cached 1:1 DM as OpenReport participant info, so the server can * resolve a stale/optimistic reportID to the real chat (returned as preexistingReportID) instead of * failing with "Report not found". Returns an empty list for any report that is not a 1:1 DM. - */ -function getOneOnOneChatParticipants( - report: OnyxEntry, - personalDetails: OnyxEntry, - currentUserAccountID: number | undefined, -): Array<{login: string; accountID: number}> { + * + * Only the login is returned, never the accountID. The other participant may be an invited user whose + * accountID was generated locally (see generateAccountID) and does not exist on the server yet, and sending + * that ID in accountIDList makes OpenReport fail with "Email not found". This cannot be gated on + * isOptimisticPersonalDetail because not every flow sets it (sendMoney writes the recipient's optimistic + * detail without it). The login alone is enough for the server to resolve a 1:1 DM, the same way + * navigateToAndOpenReport creates one, so a real accountID adds nothing here. + */ +function getOneOnOneChatParticipants(report: OnyxEntry, personalDetails: OnyxEntry, currentUserAccountID: number | undefined): Array<{login: string}> { if (!currentUserAccountID || !isOneOnOneChat(report, currentUserAccountID)) { return []; } return Object.keys(report?.participants ?? {}) .map(Number) .filter((accountID) => accountID !== currentUserAccountID) - .map((accountID) => ({login: personalDetails?.[accountID]?.login ?? '', accountID})) + .map((accountID) => ({login: personalDetails?.[accountID]?.login ?? ''})) .filter((participant) => !!participant.login); } @@ -3047,7 +3023,7 @@ function getChildReportNotificationPreference(reportAction: OnyxInputOrEntry, isReportArchived = false): boolean { +function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry, rules: OnyxCollection, isReportArchived = false): boolean { if (!(isMoneyRequestReport(moneyRequestReport) || isInvoiceReport(moneyRequestReport)) || isReportArchived) { return false; } @@ -3058,7 +3034,7 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry, isRep } if (isProcessingReport(moneyRequestReport) && isExpenseReport(moneyRequestReport)) { - return isAwaitingFirstLevelApproval(moneyRequestReport); + return isAwaitingFirstLevelApproval(moneyRequestReport, rules); } if (isReportApproved({report: moneyRequestReport}) || isClosedReport(moneyRequestReport) || isSettled(moneyRequestReport?.reportID)) { @@ -3076,7 +3052,7 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry, isRep * Returns false if: * - if current user is not the submitter of an expense report */ -function canAddTransaction(moneyRequestReport: OnyxEntry, isReportArchived = false, isMovingTransaction = false): boolean { +function canAddTransaction(moneyRequestReport: OnyxEntry, rules: OnyxCollection, isReportArchived = false, isMovingTransaction = false): boolean { if (!isMoneyRequestReport(moneyRequestReport)) { return false; } @@ -3097,7 +3073,7 @@ function canAddTransaction(moneyRequestReport: OnyxEntry, isReportArchiv return false; } - return canAddOrDeleteTransactions(moneyRequestReport, isReportArchived); + return canAddOrDeleteTransactions(moneyRequestReport, rules, isReportArchived); } /** @@ -3106,8 +3082,8 @@ function canAddTransaction(moneyRequestReport: OnyxEntry, isReportArchiv * - report is a non-settled IOU * - report is a non-approved IOU */ -function canDeleteTransaction(moneyRequestReport: OnyxEntry, isReportArchived = false): boolean { - return canAddOrDeleteTransactions(moneyRequestReport, isReportArchived); +function canDeleteTransaction(moneyRequestReport: OnyxEntry, rules: OnyxCollection, isReportArchived = false): boolean { + return canAddOrDeleteTransactions(moneyRequestReport, rules, isReportArchived); } /** @@ -3122,7 +3098,7 @@ function canDeleteTransaction(moneyRequestReport: OnyxEntry, isReportArc * * @returns True if the report is eligible for merging transactions, false otherwise */ -function isMoneyRequestReportEligibleForMerge(reportOrReportID: Report | string, isAdmin: boolean, currentUserAccountID?: number): boolean { +function isMoneyRequestReportEligibleForMerge(reportOrReportID: Report | string, isAdmin: boolean, rules: OnyxCollection, currentUserAccountID?: number): boolean { const report = typeof reportOrReportID === 'string' ? getReportOrDraftReport(reportOrReportID) : reportOrReportID; if (!isMoneyRequestReport(report) || isIOUReport(report)) { @@ -3137,7 +3113,7 @@ function isMoneyRequestReportEligibleForMerge(reportOrReportID: Report | string, } if (isSubmitter) { - return isOpenReport(report) || isAwaitingFirstLevelApproval(report); + return isOpenReport(report) || isAwaitingFirstLevelApproval(report, rules); } return isManager && isExpenseReport(report) && isProcessingReport(report); @@ -3248,107 +3224,6 @@ function shouldCurrentUserSubmitReport(iouReport: OnyxEntry, chatReport: return isOwnReportAndRetracted || isWaitingForSubmissionFromCurrentUser(chatReport, policy); } -/** - * Returns the dropdown options for the add expense button - * @param iouReport - The IOU report to add an expense to - * @param policy - The policy of the IOU report - * @param backToReport - The report to return to after adding an expense - * @returns The dropdown options for the add expense button - */ -type GetAddExpenseDropdownOptionsParams = { - translate: LocalizedTranslate; - icons: Record<'Location' | 'ReceiptPlus' | 'Plus', IconAsset>; - iouReportID: string | undefined; - policy: OnyxEntry; - userBillingGracePeriodEnds: OnyxCollection; - draftTransactionIDs: string[] | undefined; - amountOwed: OnyxEntry; - ownerBillingGracePeriodEnd: OnyxEntry; - iouRequestBackToReport?: string; - unreportedExpenseBackToReport?: string; - lastDistanceExpenseType?: IOURequestType; - currentUserAccountID: number; - blockDistanceRequestIfNeeded?: () => boolean; -}; - -function getAddExpenseDropdownOptions({ - translate, - icons, - iouReportID, - policy, - userBillingGracePeriodEnds, - draftTransactionIDs, - amountOwed, - ownerBillingGracePeriodEnd, - iouRequestBackToReport, - unreportedExpenseBackToReport, - lastDistanceExpenseType, - currentUserAccountID, - blockDistanceRequestIfNeeded, -}: GetAddExpenseDropdownOptionsParams): Array>> { - const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); - - return [ - // Teachers Unite only supports expenses via split expense - ...(isReportTeachersUnite - ? [] - : [ - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, - text: translate('iou.createExpense'), - icon: icons.Plus, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, - onSelected: () => { - if (!iouReportID) { - return; - } - if ( - policy && - policy.type !== CONST.POLICY.TYPE.PERSONAL && - shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) - ) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); - }, - }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: icons.Location, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, - onSelected: () => { - if (!iouReportID) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - if (blockDistanceRequestIfNeeded?.()) { - return; - } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); - }, - }, - ]), - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, - text: translate('iou.addExistingExpense'), - icon: icons.ReceiptPlus, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_EXISTING, - onSelected: () => { - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - openUnreportedExpense(iouReportID, unreportedExpenseBackToReport); - }, - }, - ]; -} - /** * Checks whether the card transaction support deleting based on liability type */ @@ -3360,7 +3235,13 @@ function canDeleteCardTransactionByLiabilityType(transaction: OnyxEntry, reportTransactions: Transaction[], reportActions: ReportAction[], currentUserAccountID: number): boolean { +function canDeleteMoneyRequestReport( + report: OnyxEntry, + reportTransactions: Transaction[], + reportActions: ReportAction[], + currentUserAccountID: number, + rules: OnyxCollection, +): boolean { const transaction = reportTransactions.at(0); const transactionID = transaction?.transactionID; const isOwner = transactionID ? getIOUActionForTransactionID(reportActions, transactionID)?.actorAccountID === currentUserAccountID : false; @@ -3393,7 +3274,7 @@ function canDeleteMoneyRequestReport(report: OnyxEntry, reportTransactio } const isReportSubmitter = isCurrentUserSubmitter(report, currentUserAccountID); - return isReportSubmitter && (isOpenReport(report) || (isProcessingReport(report) && isAwaitingFirstLevelApproval(report))); + return isReportSubmitter && (isOpenReport(report) || (isProcessingReport(report) && isAwaitingFirstLevelApproval(report, rules))); } return false; @@ -3410,6 +3291,7 @@ function canDeleteReportAction( transactions: OnyxCollection, childReportActions: OnyxCollection, currentUserAccountID: number, + rules: OnyxCollection, ): boolean { const report = getReportOrDraftReport(reportID); const isActionOwner = reportAction?.actorAccountID === currentUserAccountID; @@ -3430,7 +3312,7 @@ function canDeleteReportAction( if (isActionOwner) { if (!isEmptyObject(report) && (isMoneyRequestReport(report) || isInvoiceReport(report))) { - return canDeleteTransaction(report) && canCardTransactionBeDeleted; + return canDeleteTransaction(report, rules) && canCardTransactionBeDeleted; } if (isTrackExpenseAction(reportAction)) { return canCardTransactionBeDeleted; @@ -3445,6 +3327,7 @@ function canDeleteReportAction( Object.values(transactions ?? {}).filter((t): t is Transaction => !!t), Object.values(childReportActions ?? {}).filter((action): action is ReportAction => !!action), currentUserAccountID, + rules, ); } @@ -4904,6 +4787,18 @@ function isReportFieldOfTypeTitle(reportField: OnyxEntry): bo return reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; } +function isReportFieldTargetMatchingReport(report: OnyxEntry, reportField: PolicyReportField): boolean { + return reportField.target === report?.type || (report?.type === CONST.REPORT.TYPE.EXPENSE && !reportField.target); +} + +/** + * Invoice receivers only get a reduced copy of the sender's policy, which does not include the invoice fields feature flag. + * Invoice field visibility therefore depends on fields attached to the report, while expense fields still depend on the workspace setting. + */ +function shouldDisplayReportFields(report: OnyxEntry, policy: OnyxEntry): boolean { + return (isInvoiceReport(report) && policy?.areInvoiceFieldsEnabled !== false) || (isGroupPolicyExpenseReport(report, policy?.type) && !!policy?.areReportFieldsEnabled); +} + /** * Check if Report has any held expenses */ @@ -4934,16 +4829,13 @@ function didCurrentUserPlaceHoldOnReportExpense(reportActions: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry): boolean { - if (isInvoiceReport(report)) { - return true; - } +function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry, rules: OnyxCollection): boolean { const isReportSettled = isSettled(report?.reportID); const isReportClosed = isClosedReport(report); const isTitleField = isReportFieldOfTypeTitle(reportField); const isAdmin = isPolicyAdmin(policy); const isApproved = isReportApproved({report}); - const isForwardedForSubmitter = isReportOwner(report) && isExpenseReport(report) && isProcessingReport(report) && !isAwaitingFirstLevelApproval(report); + const isForwardedForSubmitter = isReportOwner(report) && isExpenseReport(report) && isProcessingReport(report) && !isAwaitingFirstLevelApproval(report, rules); if (!isAdmin && (isReportSettled || isReportClosed || isApproved || isForwardedForSubmitter)) { return true; } @@ -4960,8 +4852,14 @@ function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry * A field is considered disabled if it is disabled by the report configuration itself * or if the user is not an admin, owner, approver, or the report owner. */ -function isReportFieldDisabledForUser(report: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined): boolean { - return isReportFieldDisabled(report, reportField, policy) || !isAdminOwnerApproverOrReportOwner(report, policy, currentUserAccountID); +function isReportFieldDisabledForUser( + report: OnyxEntry, + reportField: OnyxEntry, + policy: OnyxEntry, + currentUserAccountID: number | undefined, + rules: OnyxCollection, +): boolean { + return isReportFieldDisabled(report, reportField, policy, rules) || !isAdminOwnerApproverOrReportOwner(report, policy, currentUserAccountID); } /** @@ -4974,10 +4872,10 @@ function isReportFieldDisabledForUser(report: OnyxEntry, reportField: On * - the report is an expense report, * - the report belongs to a paid group policy. */ -function canEditReportTitle(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined): boolean { +function canEditReportTitle(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined, rules: OnyxCollection): boolean { const titleField = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})).find((reportField) => isReportFieldOfTypeTitle(reportField)) ?? getTitleFieldWithFallback(policy); - const isFieldDisabled = isReportFieldDisabled(report, titleField, policy); + const isFieldDisabled = isReportFieldDisabled(report, titleField, policy, rules); return !isFieldDisabled && isAdminOwnerApproverOrReportOwner(report, policy, currentUserAccountID) && isExpenseReport(report) && isGroupPolicyPolicyUtils(policy); } @@ -5056,7 +4954,7 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)])); const fields = mergedFieldIds.map((id) => { - const field = report?.fieldList?.[getReportFieldKey(id)]; + const field = report?.fieldList?.[getReportFieldKey(id)] ?? report?.fieldList?.[id]; const policyReportField = policyReportFields.find(({fieldID}) => fieldID === id); if (field) { @@ -5081,6 +4979,20 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: return fields.filter(Boolean) as PolicyReportField[]; } +function isTransactionFromExpenseReport(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { + return isEmptyObject(report) ? isGroupPolicyPolicyUtils(policy) : isExpenseReport(report); +} + +/** + * Returns a transaction's amount with the sign it is displayed with. A transaction on an expense report, on a group + * policy with no report of its own, unreported, or deleted is stored with the opposite sign, so its stored amount is + * negated. Any other transaction returns its magnitude. + */ +function getTransactionDisplayAmount(transaction: OnyxInputOrEntry, report: OnyxInputOrEntry, policy: OnyxInputOrEntry): number { + const isFromTrackedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + return getTransactionAmount(transaction, isTransactionFromExpenseReport(report, policy), isFromTrackedExpense, !!transaction && isDeletedTransaction(transaction)); +} + /** * Gets transaction created, amount, currency, comment, and waypoints (for distance expense) * into a flat object. Used for displaying transactions and sending them in API commands @@ -5100,7 +5012,7 @@ function getTransactionDetails( } const report = getReportOrDraftReport(transaction?.reportID, undefined, 'report' in transaction ? transaction.report : undefined); - const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy); + const isFromExpenseReport = isTransactionFromExpenseReport(report, policy); return { created: getFormattedCreated(transaction, createdDateFormat, dateFnsLocale), @@ -5154,6 +5066,7 @@ function getTransactionCommentObject(transaction: OnyxEntry): Comme function canEditMoneyRequest( reportAction: OnyxInputOrEntry>, linkedTransaction: OnyxEntry, + rules: OnyxCollection, isChatReportArchived = false, report?: OnyxInputOrEntry, policy?: OnyxEntry, @@ -5224,7 +5137,7 @@ function canEditMoneyRequest( isExpenseReport(moneyRequestReport) && isOpenReport(moneyRequestReport) && !isSubmitAndClose(reportPolicy) && - deprecatedCurrentUserAccountID === getManagerAccountID(reportPolicy, ownerLogin); + deprecatedCurrentUserAccountID === getManagerAccountID(reportPolicy, ownerLogin, rules, moneyRequestReport?.total ?? 0); if (isWorkflowApproverEditing) { return true; } @@ -5236,7 +5149,7 @@ function canEditMoneyRequest( if (reportPolicy?.type === CONST.POLICY.TYPE.CORPORATE && moneyRequestReport && isSubmitted && isCurrentUserSubmitter(moneyRequestReport)) { const isForwarded = - getSubmitToAccountID(reportPolicy, moneyRequestReport, getLoginByAccountID(moneyRequestReport.ownerAccountID, allPersonalDetails)) !== moneyRequestReport.managerID || + getSubmitToAccountID(reportPolicy, moneyRequestReport, getLoginByAccountID(moneyRequestReport.ownerAccountID, allPersonalDetails), rules) !== moneyRequestReport.managerID || hasReportBeenForwardedSinceLastSubmit(moneyRequestReport, reportActions); return !isForwarded; } @@ -5245,7 +5158,7 @@ function canEditMoneyRequest( } // TODO: currentUserEmail will be required eventually so this becomes a pure function. Subscribe the data via useOnyx and pass it from the component. Refactor issue: https://github.com/Expensify/App/issues/66412 -function getNextApproverAccountID(report: OnyxEntry, isUnapproved = false, currentUserEmail?: string) { +function getNextApproverAccountID(report: OnyxEntry, rules: OnyxCollection, isUnapproved = false, currentUserEmail?: string) { // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 const policy = getPolicy(report?.policyID); const currentUserLogin = currentUserEmail ?? deprecatedCurrentUserEmail ?? ''; @@ -5258,8 +5171,8 @@ function getNextApproverAccountID(report: OnyxEntry, isUnapproved = fals } const ownerLogin = getLoginByAccountID(report?.ownerAccountID, allPersonalDetails); - const approvalChain = getApprovalChain(policy, report, ownerLogin); - const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin); + const approvalChain = getApprovalChain(policy, report, ownerLogin, rules); + const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin, rules); if (isUnapproved) { if (approvalChain.includes(currentUserLogin)) { @@ -5323,6 +5236,7 @@ function canEditMultipleTransactions( reportActions: OnyxCollection, reports: OnyxCollection, policies: OnyxCollection, + rules: OnyxCollection, areReportsSelected = false, searchSnapshotData?: SearchResults['data'], ): boolean { @@ -5366,7 +5280,7 @@ function canEditMultipleTransactions( CONST.EDIT_REQUEST_FIELD.TAX_RATE, ]; - const isTransactionEditable = fieldsToCheck.some((field) => canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report, policy})); + const isTransactionEditable = fieldsToCheck.some((field) => canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report, policy, rules})); if (!isTransactionEditable) { return false; @@ -5393,7 +5307,12 @@ function canUnreportedBeMoved(transaction: OnyxEntry, policies: Ony * approve the report it belongs to. This is only about who the person is - callers add their own rules for what may * be changed and when, so it stays usable wherever an expense-level permission is needed. */ -function canCurrentUserEditExpense(reportAction: OnyxInputOrEntry, moneyRequestReport: OnyxEntry, reportPolicy: OnyxEntry): boolean { +function canCurrentUserEditExpense( + reportAction: OnyxInputOrEntry, + moneyRequestReport: OnyxEntry, + reportPolicy: OnyxEntry, + rules: OnyxCollection, +): boolean { const isAdmin = isExpenseReport(moneyRequestReport) && reportPolicy?.role === CONST.POLICY.ROLE.ADMIN; const isManager = isExpenseReport(moneyRequestReport) && deprecatedCurrentUserAccountID === moneyRequestReport?.managerID; const isRequestor = deprecatedCurrentUserAccountID === reportAction?.actorAccountID; @@ -5404,7 +5323,7 @@ function canCurrentUserEditExpense(reportAction: OnyxInputOrEntry, isExpenseReport(moneyRequestReport) && isOpenReport(moneyRequestReport) && !isSubmitAndClose(reportPolicy) && - deprecatedCurrentUserAccountID === getManagerAccountID(reportPolicy, ownerLogin); + deprecatedCurrentUserAccountID === getManagerAccountID(reportPolicy, ownerLogin, rules, moneyRequestReport?.total ?? 0); return isAdmin || isManager || isRequestor || isApprover; } @@ -5423,6 +5342,7 @@ function canEditFieldOfMoneyRequest({ report, policy, reportNameValuePairs, + rules, }: { reportAction: OnyxInputOrEntry; fieldToEdit: ValueOf; @@ -5434,6 +5354,7 @@ function canEditFieldOfMoneyRequest({ policy?: OnyxEntry; // Temporarily optional while archived report checks are migrated in smaller PRs. Remove this fallback as part of https://github.com/Expensify/App/issues/66422. reportNameValuePairs?: OnyxCollection; + rules: OnyxCollection; }): boolean { // A list of fields that cannot be edited by anyone, once an expense has been settled const restrictedFields: string[] = [ @@ -5455,7 +5376,7 @@ function canEditFieldOfMoneyRequest({ return canUnreportedBeMoved(transaction, allPolicies); } - if (!isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, transaction, isChatReportArchived, report, policy)) { + if (!isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, transaction, rules, isChatReportArchived, report, policy)) { return false; } @@ -5474,7 +5395,16 @@ function canEditFieldOfMoneyRequest({ return false; } - if (isSettled(moneyRequestReport) || isReportApproved({report: moneyRequestReport})) { + // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 + const reportPolicy = policy ?? getPolicy(moneyRequestReport?.policyID); + // Moving an expense to another report weighs these two on their own rather than as part of the combined check. + const isAdmin = isExpenseReport(moneyRequestReport) && reportPolicy?.role === CONST.POLICY.ROLE.ADMIN; + const isManager = isExpenseReport(moneyRequestReport) && deprecatedCurrentUserAccountID === moneyRequestReport?.managerID; + + // Admins can add or replace a receipt on an approved expense so a missing receipt can still be provided after approval. + const isAdminChangingReceiptOnApprovedReport = isAdmin && fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT && !isDeleteAction && !isSettled(moneyRequestReport); + + if (!isAdminChangingReceiptOnApprovedReport && (isSettled(moneyRequestReport) || isReportApproved({report: moneyRequestReport}))) { return false; } @@ -5486,13 +5416,8 @@ function canEditFieldOfMoneyRequest({ return false; } - // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 - const reportPolicy = policy ?? getPolicy(moneyRequestReport?.policyID); - const canEditExpense = canCurrentUserEditExpense(reportAction, moneyRequestReport, reportPolicy); + const canEditExpense = canCurrentUserEditExpense(reportAction, moneyRequestReport, reportPolicy, rules); const isRequestor = deprecatedCurrentUserAccountID === reportAction?.actorAccountID; - // Moving an expense to another report weighs these two on their own rather than as part of the combined check. - const isAdmin = isExpenseReport(moneyRequestReport) && reportPolicy?.role === CONST.POLICY.ROLE.ADMIN; - const isManager = isExpenseReport(moneyRequestReport) && deprecatedCurrentUserAccountID === moneyRequestReport?.managerID; if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.REIMBURSABLE) { return canEditExpense; @@ -5535,7 +5460,7 @@ function canEditFieldOfMoneyRequest({ } const moneyRequestReportNameValuePair = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${moneyRequestReport.reportID}`]; - if (!isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID, moneyRequestReportNameValuePair)) { + if (!isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID, rules, moneyRequestReportNameValuePair)) { return false; } @@ -5550,6 +5475,7 @@ function canEditFieldOfMoneyRequest({ getOutstandingReportsForUser( moneyRequestReport?.policyID, moneyRequestReport?.ownerAccountID, + rules, reportNameValuePairs, outstandingReportsByPolicyID?.[moneyRequestReport?.policyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, ).length > 0 @@ -5564,14 +5490,20 @@ function canEditFieldOfMoneyRequest({ } // Check the cheaper condition first - if ((isOwner || isAdmin || isManager) && isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID, moneyRequestReportNameValuePair)) { + if ((isOwner || isAdmin || isManager) && isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID, rules, moneyRequestReportNameValuePair)) { return true; } // Check if there are multiple outstanding reports across policies let outstandingReportsCount = 0; for (const currentPolicy of policiesArray) { - const reports = getOutstandingReportsForUser(currentPolicy.id, moneyRequestReport?.ownerAccountID, reportNameValuePairs, outstandingReportsByPolicyID?.[currentPolicy?.id] ?? {}); + const reports = getOutstandingReportsForUser( + currentPolicy.id, + moneyRequestReport?.ownerAccountID, + rules, + reportNameValuePairs, + outstandingReportsByPolicyID?.[currentPolicy?.id] ?? {}, + ); outstandingReportsCount += reports.length; // Short-circuit once we find more than 1 @@ -5599,7 +5531,12 @@ function canEditFieldOfMoneyRequest({ * - It's an expense where conditions for modifications are defined in canEditMoneyRequest method * - It's not pending deletion */ -function canEditReportAction(reportAction: OnyxInputOrEntry, linkedTransaction: OnyxEntry, reportActions?: OnyxEntry): boolean { +function canEditReportAction( + reportAction: OnyxInputOrEntry, + linkedTransaction: OnyxEntry, + rules: OnyxCollection, + reportActions?: OnyxEntry, +): boolean { const isCommentOrIOU = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; // Only an attachment-only comment has nothing to edit while it uploads; text keeps it editable throughout. @@ -5624,7 +5561,7 @@ function canEditReportAction(reportAction: OnyxInputOrEntry, linke return !!( reportAction?.actorAccountID === deprecatedCurrentUserAccountID && isCommentOrIOU && - (!isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction, linkedTransaction, false, undefined, undefined, reportActions)) && + (!isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction, linkedTransaction, rules, false, undefined, undefined, reportActions)) && !isOptimisticAttachment && !isDeletedAction(reportAction) && !isCreatedTaskReportAction(reportAction) && @@ -5632,7 +5569,7 @@ function canEditReportAction(reportAction: OnyxInputOrEntry, linke ); } -function canModifyHoldStatus(report: Report, reportAction: ReportAction, currentUserAccountID: number | undefined, isAdmin: boolean): boolean { +function canModifyHoldStatus(report: Report, reportAction: ReportAction, currentUserAccountID: number | undefined, isAdmin: boolean, rules: OnyxCollection): boolean { if (!isMoneyRequestReport(report) || isTrackExpenseReport(report)) { return false; } @@ -5648,7 +5585,7 @@ function canModifyHoldStatus(report: Report, reportAction: ReportAction, current } if (isActionOwner && !isAdmin) { - return isAwaitingFirstLevelApproval(report); + return isAwaitingFirstLevelApproval(report, rules); } return (isAdmin || isManager) && isProcessingReport(report); @@ -5661,6 +5598,7 @@ function canHoldUnholdReportAction( transaction: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined, + rules: OnyxCollection, ): {canHoldRequest: boolean; canUnholdRequest: boolean} { if (!report || !reportAction || !isMoneyRequestAction(reportAction) || isInvoiceReport(report)) { return {canHoldRequest: false, canUnholdRequest: false}; @@ -5678,7 +5616,7 @@ function canHoldUnholdReportAction( const isOnHold = isOnHoldTransactionUtils(transaction); const isClosed = isClosedReport(report); - const canModifyStatus = canModifyHoldStatus(report, reportAction, currentUserAccountID, isAdmin); + const canModifyStatus = canModifyHoldStatus(report, reportAction, currentUserAccountID, isAdmin, rules); const canModifyUnholdStatus = !isTrackExpenseMoneyReport && (isAdmin || (isActionOwner && isHoldActionCreator) || isApprover); const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isClosed && !isDeletedParentAction(reportAction); @@ -5698,6 +5636,7 @@ const changeMoneyRequestHoldStatus = ( transactionViolations: OnyxEntry, isTrackIntentUser: boolean | undefined, delegateAccountID: number | undefined, + rules: OnyxCollection, ): void => { if (!isMoneyRequestAction(reportAction)) { return; @@ -5721,7 +5660,18 @@ const changeMoneyRequestHoldStatus = ( if (isOnHold) { if (reportAction.childReportID) { - unholdRequest(transactionID, reportAction.childReportID, policy, isOffline, currentUserLogin, currentUserAccountID, transactionViolations, isTrackIntentUser, delegateAccountID); + unholdRequest( + transactionID, + reportAction.childReportID, + policy, + isOffline, + currentUserLogin, + currentUserAccountID, + transactionViolations, + isTrackIntentUser, + delegateAccountID, + rules, + ); } else { Log.warn('Missing reportAction.childReportID during money request unhold'); } @@ -6551,6 +6501,7 @@ function getChatRoomSubtitle( policy: OnyxEntry, conciergeReportID: string | undefined, translate: LocalizedTranslate, + rules: OnyxCollection, isPolicyNamePreferred = false, isReportArchived = false, ): string | undefined { @@ -6574,7 +6525,7 @@ function getChatRoomSubtitle( return report?.reportName?.substring(1) ?? ''; } if ((isPolicyExpenseChat(report) && !!report?.isOwnPolicyExpenseChat) || isExpenseReport(report)) { - const submitToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report?.ownerAccountID, allPersonalDetails)); + const submitToAccountID = getSubmitToAccountID(policy, report, getLoginByAccountID(report?.ownerAccountID, allPersonalDetails), rules); const submitsToAccountDetails = allPersonalDetails?.[submitToAccountID]; const subtitle = submitsToAccountDetails?.displayName ?? submitsToAccountDetails?.login; @@ -6644,6 +6595,13 @@ function getParentNavigationSubtitle( return {}; } + const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD) { + return { + reportName: getMessageOfOldDotReportAction(translate, parentReportAction, false), + }; + } + if (isInvoiceReport(report) || isInvoiceRoom(parentReport)) { const senderWorkspaceName = getPolicyName({report: parentReport, policy, unavailableTranslation: translate('workspace.common.unavailable')}); const invoiceReceiverPolicyID = getInvoiceReceiverPolicyID(parentReport); @@ -7414,6 +7372,7 @@ function buildOptimisticExpenseReport({ reportTransactions, createdTimestamp, getCurrencyDecimals, + rules, }: BuildOptimisticExpenseReportParams): OptimisticExpenseReport { // The amount for Expense reports are stored as negative value in the database const storedTotal = total * -1; @@ -7459,7 +7418,7 @@ function buildOptimisticExpenseReport({ }; // Get the approver/manager for this report to properly display the optimistic data - const submitToAccountID = getSubmitToAccountID(policy, expenseReport, getLoginByAccountID(expenseReport.ownerAccountID, allPersonalDetails)); + const submitToAccountID = getSubmitToAccountID(policy, expenseReport, getLoginByAccountID(expenseReport.ownerAccountID, allPersonalDetails), rules); if (submitToAccountID) { expenseReport.managerID = submitToAccountID; } @@ -7486,6 +7445,7 @@ function buildOptimisticEmptyReport( timeOfCreation: string, betas: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, ) { const {stateNum, statusNum} = getExpenseReportStateAndStatus(policy, betas, true); const optimisticEmptyReport: OptimisticNewReport = { @@ -7508,7 +7468,7 @@ function buildOptimisticEmptyReport( parentReportID: parentReport?.reportID, parentReportActionID, chatReportID: parentReport?.reportID, - managerID: getManagerAccountID(policy, login), + managerID: getManagerAccountID(policy, login, rules), }; // Compute optimistic report name if applicable @@ -9339,8 +9299,12 @@ function buildOptimisticAnnounceChat(policyID: string, accountIDs: number[], cur } // TODO: currentUserEmail will be required eventually so this becomes a pure function. Subscribe the data via useOnyx and pass it from the component. Refactor issue: https://github.com/Expensify/App/issues/66412 -function shouldPinAdminRoomByDefault(currentUserEmail?: string) { - return !isExpensifyTeam(currentUserEmail ?? deprecatedCurrentUserEmail); +/** + * Returns true if the admin room should be pinned by default for the current user. + * Admin rooms are pinned by default for all users except for Expensify Team members and users who own a paid policy. + */ +function shouldPinAdminRoomByDefault(currentUserEmail?: string, hasOwnedPaidPolicy = false) { + return !isExpensifyTeam(currentUserEmail ?? deprecatedCurrentUserEmail) && !hasOwnedPaidPolicy; } function buildOptimisticWorkspaceChats( @@ -9349,6 +9313,7 @@ function buildOptimisticWorkspaceChats( currentUserAccountID: number | undefined, currentUserEmail: string | undefined, expenseReportId?: string, + hasOwnedPaidPolicy?: boolean, ): OptimisticWorkspaceChats { const pendingChatMembers = getPendingChatMembers(currentUserAccountID ? [currentUserAccountID] : [], [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const adminsChatData = { @@ -9359,7 +9324,7 @@ function buildOptimisticWorkspaceChats( policyID, ownerAccountID: CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, oldPolicyName: policyName, - isPinned: shouldPinAdminRoomByDefault(), + isPinned: shouldPinAdminRoomByDefault(currentUserEmail, hasOwnedPaidPolicy), currentUserAccountID, }), }; @@ -9884,7 +9849,10 @@ function getViolatingReportIDForRBRInLHN(report: OnyxEntry, transactionV // consistent with what the opened report renders, which also filters out DELETE-pending transactions. const transactions = getReportTransactions(potentialReport.reportID).filter((transaction) => !isTransactionPendingDelete(transaction)); - const excludedNoticeNamesForLHN = isProcessingReport(potentialReport) ? [CONST.VIOLATIONS.MODIFIED_AMOUNT] : []; + // 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] : []; return ( !isInvoiceReport(potentialReport) && @@ -9895,62 +9863,57 @@ function getViolatingReportIDForRBRInLHN(report: OnyxEntry, transactionV deprecatedCurrentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, policy, transactions, + excludedViolationNamesForLHN, ) && - (hasViolations( - potentialReport.reportID, + hasViolationOfAnyTypeForRBRInLHN( transactionViolations, deprecatedCurrentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserLogin, - true, transactions, potentialReport, - // This path only runs for reports the current user submitted, so the report owner is the current user. - currentUserLogin, policy, - ) || - hasWarningTypeViolations( - potentialReport.reportID, - transactionViolations, - deprecatedCurrentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, - currentUserLogin, - true, - transactions, - potentialReport, - // This path only runs for reports the current user submitted, so the report owner is the current user. - currentUserLogin, - policy, - ) || - hasNoticeTypeViolationsForRBRInLHN( - transactionViolations, - deprecatedCurrentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, - currentUserLogin, - transactions, - potentialReport, - policy, - excludedNoticeNamesForLHN, - )) + excludedViolationNamesForLHN, + excludedNoticeNamesForLHN, + ) ); }); return violatingReport ? violatingReport.reportID : null; } -function hasNoticeTypeViolationsForRBRInLHN( +/** + * Whether any transaction on the report carries a violation that should drive the LHN RBR, checking all three violation + * types. Names in `excludedViolationNames` are dropped from every type check, so the exclusion holds regardless of which + * bucket the back end assigns the violation to. Names in `excludedNoticeNames` are dropped from the notice check only. + * Apart from the name filtering this is the same decision the three separate `hasViolations` / + * `hasWarningTypeViolations` / `hasNoticeTypeViolationsForRBRInLHN` calls used to make, folded into a single pass over + * the report transactions. + */ +function hasViolationOfAnyTypeForRBRInLHN( transactionViolations: OnyxCollection, currentUserAccountIDParam: number, currentUserEmailParam: string, reportTransactions: Transaction[], report: OnyxEntry, policy: OnyxEntry, - excludedViolationNames: string[], + excludedViolationNames: ViolationName[], + excludedNoticeNames: ViolationName[], ): boolean { return reportTransactions.some((transaction) => { const rawViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction?.transactionID}`]; if (!rawViolations?.length) { return false; } - const filteredViolations = excludedViolationNames.length > 0 ? rawViolations.filter((violation) => !excludedViolationNames.includes(violation.name)) : rawViolations; + const violations = excludedViolationNames.length > 0 ? rawViolations.filter((violation) => !excludedViolationNames.includes(violation.name)) : rawViolations; + if (!violations.length) { + return false; + } + const noticeViolations = excludedNoticeNames.length > 0 ? violations.filter((violation) => !excludedNoticeNames.includes(violation.name)) : violations; // This path only runs for reports the current user submitted, so the report owner is the current user. - return hasNoticeTypeViolation(transaction, filteredViolations, currentUserEmailParam, currentUserAccountIDParam, report, currentUserEmailParam, policy, true); + return ( + hasViolation(transaction, violations, currentUserEmailParam, currentUserAccountIDParam, report, currentUserEmailParam, policy, true) || + hasWarningTypeViolation(transaction, violations, currentUserEmailParam, currentUserAccountIDParam, report, currentUserEmailParam, policy, true) || + hasNoticeTypeViolation(transaction, noticeViolations, currentUserEmailParam, currentUserAccountIDParam, report, currentUserEmailParam, policy, true) + ); }); } @@ -9974,8 +9937,9 @@ function hasViolations( ); } -function hasVisibleReportFieldViolations(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined): boolean { - if (!report || !policy?.fieldList || !policy?.areReportFieldsEnabled) { +function hasVisibleReportFieldViolations(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined, rules: OnyxCollection): boolean { + const areFieldsEnabledForReport = isInvoiceReport(report) ? policy?.areInvoiceFieldsEnabled : policy?.areReportFieldsEnabled; + if (!report || !policy?.fieldList || !areFieldsEnabledForReport) { return false; } @@ -9989,21 +9953,21 @@ function hasVisibleReportFieldViolations(report: OnyxEntry, policy: Onyx return false; } - // Allow both open and processing reports to show RBR for field violations (expense reports only) - if (isGroupPolicyReport && !isOpenOrProcessingReport(report)) { + // Only open and processing reports show RBR for field violations; a settled report never shows one + if (!isOpenOrProcessingReport(report)) { return false; } const {fieldsByName} = getReportFieldMaps(report, policy.fieldList); return Object.values(fieldsByName).some((field) => { - if (field.target !== report.type) { + if (!isReportFieldTargetMatchingReport(report, field)) { return false; } if (shouldHideSingleReportField(field)) { return false; } - if (isReportFieldDisabledForUser(report, field, policy, currentUserAccountID)) { + if (isReportFieldDisabledForUser(report, field, policy, currentUserAccountID, rules)) { return false; } return !!getFieldViolation(field); @@ -10094,12 +10058,12 @@ function shouldBlockSubmitDueToStrictPolicyRules( return hasAnyViolations(reportID, transactionViolations, currentUserAccountIDParam, currentUserEmailParam, reportTransactions); } -function shouldBlockSubmitDueToPreventSelfApproval(report: OnyxEntry, policy: OnyxEntry): boolean { +function shouldBlockSubmitDueToPreventSelfApproval(report: OnyxEntry, policy: OnyxEntry, rules: OnyxCollection): boolean { if (!policy?.preventSelfApproval) { return false; } - const nextApproverAccountID = getNextApproverAccountID(report); + const nextApproverAccountID = getNextApproverAccountID(report, rules); const isSubmitterSameAsNextApprover = isReportOwner(report) && nextApproverAccountID === report?.ownerAccountID; const isSubmitterSameAsApprover = isReportOwner(report) && (report?.managerID === report?.ownerAccountID || nextApproverAccountID === report?.ownerAccountID); return (isSubmitterSameAsNextApprover && isOpenExpenseReport(report)) || (isSubmitterSameAsApprover && isProcessingReport(report)); @@ -10697,7 +10661,7 @@ function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxInputOrEntry, policy: OnyxEntry, otherParticipants: number[], currentUserAccountID?: number): boolean { +function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, otherParticipants: number[], rules: OnyxCollection, currentUserAccountID?: number): boolean { // User cannot submit expenses in a chat thread, task report or in a chat room if (isChatThread(report) || isTaskReport(report) || isChatRoom(report) || isSelfDM(report) || isGroupChat(report)) { return false; @@ -10730,7 +10694,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o } if (isMoneyRequestReport(report)) { - return canAddTransaction(report); + return canAddTransaction(report, rules); } // In the case of policy expense chat, users can only submit expenses from their own policy expense chat @@ -10781,6 +10745,7 @@ function getMoneyRequestOptions( policy: OnyxEntry, reportParticipants: number[], betas: OnyxEntry, + rules: OnyxCollection, filterDeprecatedTypes = false, isReportArchived = false, isRestrictedToPreferredPolicy = false, @@ -10817,7 +10782,7 @@ function getMoneyRequestOptions( options = [CONST.IOU.TYPE.TRACK]; } - if (canRequestMoney(report, policy, otherParticipants, currentUserAccountID)) { + if (canRequestMoney(report, policy, otherParticipants, rules, currentUserAccountID)) { if (!isTeachersUniteReportValue) { options = [...options, CONST.IOU.TYPE.SUBMIT]; if (!filterDeprecatedTypes) { @@ -10885,11 +10850,12 @@ function temporary_getMoneyRequestOptions( policy: OnyxEntry, reportParticipants: number[], betas: OnyxEntry, + rules: OnyxCollection, isReportArchived = false, isRestrictedToPreferredPolicy = false, currentUserAccountID?: number, ): Array> { - return getMoneyRequestOptions(report, policy, reportParticipants, betas, true, isReportArchived, isRestrictedToPreferredPolicy, currentUserAccountID) as Array< + return getMoneyRequestOptions(report, policy, reportParticipants, betas, rules, true, isReportArchived, isRestrictedToPreferredPolicy, currentUserAccountID) as Array< Exclude >; } @@ -11140,6 +11106,7 @@ function canCreateRequest( iouType: ValueOf, isReportArchived: boolean | undefined, betas: OnyxEntry, + rules: OnyxCollection, isRestrictedToPreferredPolicy = false, ): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); @@ -11148,7 +11115,7 @@ function canCreateRequest( return false; } - const requestOptions = getMoneyRequestOptions(report, policy, participantAccountIDs, betas, false, isReportArchived, isRestrictedToPreferredPolicy); + const requestOptions = getMoneyRequestOptions(report, policy, participantAccountIDs, betas, rules, false, isReportArchived, isRestrictedToPreferredPolicy); requestOptions.push(CONST.IOU.TYPE.CREATE); return requestOptions.includes(iouType); @@ -11959,6 +11926,7 @@ function shouldCreateNewMoneyRequestReport( chatReport: OnyxInputOrEntry, isScanRequest: boolean, betas: OnyxEntry, + rules: OnyxCollection, action?: IOUAction, isFromExistingReport?: boolean, ): boolean { @@ -11971,15 +11939,11 @@ function shouldCreateNewMoneyRequestReport( !existingIOUReport || isReportPendingDelete(existingIOUReport) || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || - !canAddTransaction(existingIOUReport) || + !canAddTransaction(existingIOUReport, rules) || (!isFromExistingReport && action !== CONST.IOU.ACTION.SUBMIT && isScanRequest && isASAPSubmitBetaEnabled) ); } -function getTripIDFromTransactionParentReportID(transactionParentReportID: string | undefined): string | undefined { - return getReportOrDraftReport(transactionParentReportID)?.tripData?.tripID; -} - /** Precomputed report-action error state used to make per-transaction RBR checks O(1). */ type ActionErrorsByTransaction = { /** A non-money-request action (or money-request action without an IOUTransactionID) has errors, flagging every transaction. */ @@ -12141,314 +12105,17 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, curr ); } -function createDraftWorkspaceAndNavigateToConfirmationScreen( - introSelected: OnyxEntry, - transactionID: string, - actionName: IOUAction, - workspaceName: string, - currentUserAccountID: number, - currentUserEmail: string, - currentUserLocalCurrency: string, - policyType?: CreatableWorkspaceType, - backToReportID?: string, -): void { - const isCategorizing = actionName === CONST.IOU.ACTION.CATEGORIZE; - const {expenseChatReportID, policyID, policyName} = createDraftWorkspace({ - introSelected, - workspaceName, - currentUserAccountID, - currentUserEmail, - currency: currentUserLocalCurrency, - type: policyType, - }); - setMoneyRequestParticipants(transactionID, [ - { - selected: true, - accountID: 0, - isPolicyExpenseChat: true, - reportID: expenseChatReportID, - policyID, - searchText: policyName, - }, - ]); - 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 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 = { - reportID: string | undefined; - actionName: IOUAction; - reportActionID: string | undefined; - reportActions: OnyxEntry; - introSelected: OnyxEntry; - draftTransactionIDs: string[] | undefined; - activePolicy: OnyxEntry; - userBillingGracePeriodEnds: OnyxCollection; - amountOwed: OnyxEntry; - ownerBillingGracePeriodEnd?: OnyxEntry; - isRestrictedToPreferredPolicy?: boolean; - preferredPolicyID?: string; - transaction: OnyxEntry; - currentUserAccountID: number; - currentUserEmail: string; - currentUserLocalCurrency: string; - /** - * For the SUBMIT action, which destination the user picked from the track-expense whisper: - * - 'friend' (default): show the existing recipient picker (individuals + workspaces). - * - 'employer': route to a submit-enabled workspace, auto-selecting/creating one as needed. - */ - submitDestination?: ValueOf; - /** Localized default name for a workspace created on the fly (e.g. "Submit to my employer" with no existing workspace). */ - defaultWorkspaceName?: string; - filteredPoliciesCount: number; - firstPolicyID: string | undefined; -}; - -function createDraftTransactionAndNavigateToParticipantSelector({ - reportID, - actionName, - reportActionID, - reportActions, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - isRestrictedToPreferredPolicy = false, - preferredPolicyID, - transaction, - currentUserAccountID, - currentUserEmail, - currentUserLocalCurrency, - submitDestination = CONST.IOU.SUBMIT_DESTINATION.FRIEND, - defaultWorkspaceName = '', - filteredPoliciesCount, - firstPolicyID, -}: CreateDraftTransactionParams): void { - const transactionID = transaction?.transactionID; - if (!transactionID || !reportID) { - return; - } - - const linkedTrackedExpenseReportAction = Object.values(reportActions ?? {}) - .filter(Boolean) - .find((action) => isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === transactionID); - - const {created, amount, currency, merchant, mccGroup} = getTransactionDetails(transaction) ?? {}; - const isMerchantValid = !isInvalidMerchantValue(merchant); - const baseComment = getTransactionCommentObject(transaction); - // Use modifiedAttendees if present (for edited transactions), otherwise use the attendees from comment - const comment = { - ...baseComment, - attendees: transaction?.modifiedAttendees ?? baseComment.attendees, - }; - - removeDraftTransactionsByIDs(draftTransactionIDs); - - createDraftTransaction({ - ...transaction, - actionableWhisperReportActionID: reportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: reportID, - created, - modifiedCreated: undefined, - modifiedAmount: undefined, - modifiedCurrency: undefined, - amount, - isAmountSet: true, - currency, - comment, - merchant, - modifiedMerchant: '', - isMerchantSet: !!isMerchantValid, - modifiedAttendees: undefined, - mccGroup, - participants: [], - } as Transaction); - - if (actionName === CONST.IOU.ACTION.CATEGORIZE) { - if (activePolicy && shouldRestrictUserBillableActions(activePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(activePolicy.id)); - return; - } - - if (activePolicy && shouldShowPolicy(activePolicy, false, deprecatedCurrentUserEmail)) { - const policyExpenseReportID = getPolicyExpenseChat(deprecatedCurrentUserAccountID, activePolicy.id)?.reportID; - setMoneyRequestParticipants(transactionID, [ - { - selected: true, - accountID: 0, - isPolicyExpenseChat: true, - reportID: policyExpenseReportID, - policyID: activePolicy.id, - searchText: activePolicy?.name, - }, - ]); - if (policyExpenseReportID) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID: policyExpenseReportID}), - ROUTES.REPORT_WITH_ID.getRoute(reportID), - ), - ); - } else { - Log.warn('policyExpenseReportID is not valid during expense categorizing'); - } - return; - } - if (filteredPoliciesCount === 0 || filteredPoliciesCount > 1) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: actionName, - iouType: CONST.IOU.TYPE.SUBMIT, - transactionID, - reportID, - upgradePath: actionName === CONST.IOU.ACTION.CATEGORIZE ? CONST.UPGRADE_PATHS.CATEGORIES : '', - shouldSubmitExpense: true, - }), - ROUTES.REPORT_WITH_ID.getRoute(reportID), - ), - ); - return; - } - - const policyExpenseReportID = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicyID)?.reportID; - setMoneyRequestParticipants(transactionID, [ - { - selected: true, - accountID: 0, - isPolicyExpenseChat: true, - reportID: policyExpenseReportID, - policyID: firstPolicyID, - searchText: activePolicy?.name, - }, - ]); - if (policyExpenseReportID) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID: policyExpenseReportID}), - ROUTES.REPORT_WITH_ID.getRoute(reportID), - ), - ); - } else { - Log.warn('policyExpenseReportID is not valid during expense categorizing'); - } - return; - } - - if (actionName === CONST.IOU.ACTION.SHARE) { - Navigation.navigate( - createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ACCOUNTANT.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, reportID), ROUTES.REPORT_WITH_ID.getRoute(reportID)), - ); - return; - } - - // "Submit to my employer" routes the expense into a workspace the user can submit to, based on how many they belong to. - // Per issue #92704 the count spans every paid workspace the user is a member of (Collect/Control/Submit), so we reuse the - // shared shouldShowPolicy-based count (filteredPoliciesCount/firstPolicyID) that also backs the workspaces-only picker. - if (actionName === CONST.IOU.ACTION.SUBMIT && submitDestination === CONST.IOU.SUBMIT_DESTINATION.EMPLOYER) { - // No accessible workspace: spin up a new Submit (submit2026) workspace and drop the expense into its draft report. - if (filteredPoliciesCount === 0) { - // `reportID` is where the expense lives (the self DM), which is not necessarily what the user is looking at: - // they may have drilled into the expense thread first. Going back resolves the report route to a POP_TO on - // the tab navigator, which rebuilds the central pane from the route, so returning to the self DM here would - // swap the report out from under them. - const visibleReportID = isReportTopmostSplitNavigator() ? Navigation.getTopmostReportId() : undefined; - createDraftWorkspaceAndNavigateToConfirmationScreen( - introSelected, - transactionID, - actionName, - defaultWorkspaceName, - currentUserAccountID, - currentUserEmail, - currentUserLocalCurrency, - CONST.POLICY.TYPE.SUBMIT, - visibleReportID ?? reportID, - ); - return; - } - - // Exactly one accessible workspace: skip the destination picker and submit straight to that workspace. - if (filteredPoliciesCount === 1 && firstPolicyID) { - const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicyID); - if (policyExpenseReport) { - // The draft inherits the source expense's unreported ID from the self DM. The picker we skip here is what - // normally rebinds it to the destination chat, so without this the confirmation page still reads the draft - // as unreported: it renders "None" for the report and resolves the policy from the self DM instead of the - // destination workspace. - setMoneyRequestReportID(transactionID, policyExpenseReport.reportID); - setMoneyRequestParticipantsFromReport(transactionID, policyExpenseReport, deprecatedCurrentUserAccountID).then(() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.SUBMIT, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReport.reportID)); - }); - return; - } - } - - // Multiple accessible workspaces: show the destination picker limited to workspaces only (no individual recipients). - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({ - action: actionName, - iouType: CONST.IOU.TYPE.SUBMIT, - transactionID, - reportID, - isWorkspacesOnly: true, - }), - ROUTES.REPORT_WITH_ID.getRoute(reportID), - ), - ); - return; - } - - if (actionName === CONST.IOU.ACTION.SUBMIT || filteredPoliciesCount > 0) { - // Check if user is restricted to preferred workspace for submit tracked expenses - if (isRestrictedToPreferredPolicy && preferredPolicyID) { - const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, preferredPolicyID); - - if (policyExpenseReport) { - // Same picker-skip as the single-workspace branch above, so the draft needs the same rebinding. - setMoneyRequestReportID(transactionID, policyExpenseReport.reportID); - setMoneyRequestParticipantsFromReport(transactionID, policyExpenseReport, deprecatedCurrentUserAccountID).then(() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.SUBMIT, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReport.reportID)); - }); - return; - } - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID}), - ROUTES.REPORT_WITH_ID.getRoute(reportID), - ), - ); - return; - } - - return createDraftWorkspaceAndNavigateToConfirmationScreen(introSelected, transactionID, actionName, '', currentUserAccountID, currentUserEmail, currentUserLocalCurrency); -} - /** * Check if a report is forwarded or not */ -function isForwardedReport(report: OnyxEntry): boolean { - return isProcessingReport(report) && !isAwaitingFirstLevelApproval(report); +function isForwardedReport(report: OnyxEntry, rules: OnyxCollection): boolean { + return isProcessingReport(report) && !isAwaitingFirstLevelApproval(report, rules); } function isReportOutstanding( iouReport: OnyxInputOrEntry, policyID: string | undefined, + rules: OnyxCollection, // Temporarily optional while archived report checks are migrated in smaller PRs. Remove this fallback as part of https://github.com/Expensify/App/issues/66422. reportNameValuePair?: OnyxInputOrEntry, allowSubmitted = true, @@ -12460,7 +12127,7 @@ function isReportOutstanding( iouReport?.stateNum === undefined || iouReport?.statusNum === undefined || iouReport?.policyID !== policyID || - isForwardedReport(iouReport) + isForwardedReport(iouReport, rules) ) { return false; } @@ -12471,7 +12138,7 @@ function isReportOutstanding( const currentRoute = navigationRef.getCurrentRoute(); const params = currentRoute?.params as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION] | ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; const activeReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${params?.reportID}`]; - if (allowSubmitted || isProcessingReport(activeReport) || canAddTransaction(iouReport, false, false)) { + if (allowSubmitted || isProcessingReport(activeReport) || canAddTransaction(iouReport, rules, false, false)) { return iouReport.stateNum <= CONST.REPORT.STATE_NUM.SUBMITTED && iouReport.statusNum <= CONST.REPORT.STATE_NUM.SUBMITTED; } return iouReport.stateNum < CONST.REPORT.STATE_NUM.SUBMITTED && iouReport.statusNum < CONST.REPORT.STATE_NUM.SUBMITTED; @@ -12487,6 +12154,7 @@ function isReportOutstanding( function getOutstandingReportsForUser( policyID: string | undefined, reportOwnerAccountID: number | undefined, + rules: OnyxCollection, // Temporarily optional while archived report checks are migrated in smaller PRs. Remove this fallback as part of https://github.com/Expensify/App/issues/66422. reportNameValuePairs?: OnyxCollection, reports: OnyxCollection = deprecatedAllReports, @@ -12498,7 +12166,7 @@ function getOutstandingReportsForUser( return Object.values(reports).filter( (report) => report?.pendingFields?.preview !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - isReportOutstanding(report, policyID, reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`], allowSubmitted) && + isReportOutstanding(report, policyID, rules, reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`], allowSubmitted) && report?.ownerAccountID === reportOwnerAccountID, ); } @@ -12575,8 +12243,8 @@ type PrepareOnboardingOnyxDataParams = { isSelfTourViewed?: boolean; // TODO: should be required field. Refactor issue: https://github.com/Expensify/App/issues/66412 currentUserEmail?: string; - /** The concierge chat report, looked up by conciergeReportID. Falls back to getChatByParticipants using the deprecated module-level Onyx data while the refactor is in progress. */ - conciergeChat?: OnyxEntry; + /** The concierge chat report, looked up by conciergeReportID */ + conciergeChat: OnyxEntry; /** The admins chat report, looked up by adminsChatReportID. Falls back to the deprecated module-level Onyx data while the refactor is in progress. */ adminsChatReport?: OnyxEntry; /** The self-DM report, looked up by ONYXKEYS.SELF_DM_REPORT_ID. Falls back to the deprecated module-level Onyx data while the refactor is in progress. */ @@ -12600,7 +12268,7 @@ function prepareOnboardingOnyxData({ onboardingPurposeSelected, isSelfTourViewed, currentUserEmail, - conciergeChat: conciergeChatParam, + conciergeChat, adminsChatReport: adminsChatReportParam, selfDMReport: selfDMReportParam, currentUserAccountID, @@ -12620,10 +12288,6 @@ function prepareOnboardingOnyxData({ // Server picks the inboxAdminsBespoke variant at response time, so optimistic writes here would be stale. const shouldDeferOptimisticTasks = engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM; const adminsChatReport = adminsChatReportParam ?? deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`]; - const conciergeChat = - conciergeChatParam ?? - getChatByParticipants([CONST.ACCOUNT_ID.CONCIERGE, deprecatedCurrentUserAccountID ?? CONST.DEFAULT_NUMBER_ID], deprecatedAllReports, false) ?? - (conciergeReportIDOnyxConnect ? {reportID: conciergeReportIDOnyxConnect} : undefined); const targetChatReport = shouldPostTasksInAdminsRoom ? (adminsChatReport ?? {reportID: adminsChatReportID, policyID: onboardingPolicyID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS}) : conciergeChat; @@ -13393,7 +13057,17 @@ function getIntegrationIcon( expensifyIcons?: | Partial< Record< - 'XeroSquare' | 'QBOSquare' | 'NetSuiteSquare' | 'IntacctSquare' | 'QBDSquare' | 'CertiniaSquare' | 'RilletSquare' | 'DualEntrySquare' | 'GustoSquare' | 'IntuitSquare', + | 'XeroSquare' + | 'QBOSquare' + | 'NetSuiteSquare' + | 'IntacctSquare' + | 'QBDSquare' + | 'CertiniaSquare' + | 'RilletSquare' + | 'DualEntrySquare' + | 'CampfireSquare' + | 'GustoSquare' + | 'IntuitSquare', IconAsset > > @@ -13427,6 +13101,9 @@ function getIntegrationIcon( if (connectionName === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY) { return expensifyIcons?.DualEntrySquare; } + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { + return expensifyIcons?.CampfireSquare; + } if (connectionName === CONST.POLICY.CONNECTIONS.NAME.GUSTO) { return expensifyIcons?.GustoSquare; } @@ -13590,7 +13267,7 @@ function getBypassApproverAccountIDIfTakenControl(expenseReport: OnyxEntry, expenseReport: OnyxEntry, ownerLogin: string | undefined): string[] { +function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection): string[] { const approvalChain: string[] = []; const fullApprovalChain: string[] = []; const reportTotal = expenseReport?.total ?? 0; @@ -13611,11 +13288,11 @@ function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry, ): string { const reportForHeader = getReportForHeader(report, parentReport); @@ -13682,7 +13360,7 @@ function getChatListItemReportName( // Search snapshots of invoice reports may only carry `parentReportID` as the invoice room ID, so fall back to it // when `chatReportID` is missing (without mutating the Onyx report) so `getInvoiceReportName` resolves the NewDot title. const invoiceReport = reportForHeader.chatReportID ? reportForHeader : {...reportForHeader, chatReportID: reportForHeader.parentReportID}; - return getInvoiceReportName(invoiceReport, linkedTransactions, translate, personalDetailsList); + return getInvoiceReportName(invoiceReport, linkedTransactions, translate, convertToDisplayString, personalDetailsList); } if (action?.reportName) { @@ -14205,19 +13883,59 @@ function shouldHideSingleReportField(reportField: PolicyReportField) { return isReportFieldOfTypeTitle(reportField) || !hasEnableOption; } +function isReportNameValuePairField(value: unknown): value is PolicyReportField { + return typeof value === 'object' && value !== null && 'fieldID' in value && typeof value.fieldID === 'string' && 'name' in value && typeof value.name === 'string'; +} + +function getReportFieldFromReportNameValuePairs(reportNameValuePairs: OnyxEntry, reportFieldIDOrKey: string): PolicyReportField | undefined { + for (const [key, value] of Object.entries(reportNameValuePairs ?? {})) { + if (key === reportFieldIDOrKey && isReportNameValuePairField(value)) { + return value; + } + } + + return undefined; +} + +function getInvoiceReportFieldsFromReportNameValuePairs(reportNameValuePairs: OnyxEntry): PolicyReportField[] { + const fieldsByID = new Map(); + + for (const value of Object.values(reportNameValuePairs ?? {})) { + if (!isReportNameValuePairField(value) || value.target !== CONST.REPORT_FIELD_TARGETS.INVOICE) { + continue; + } + fieldsByID.set(value.fieldID, value); + } + + return [...fieldsByID.values()]; +} + /** * Get both field values map and fields-by-name map in a single pass */ -function getReportFieldMaps(report: OnyxEntry, fieldList: Record): {fieldValues: Record; fieldsByName: Record} { - const fields = getAvailableReportFields(report, Object.values(fieldList ?? {})); +function getReportFieldMaps( + report: OnyxEntry, + fieldList: Record, + reportNameValuePairsParam?: OnyxEntry, +): {fieldValues: Record; fieldsByName: Record} { + const reportNameValuePairs = reportNameValuePairsParam ?? allReportNameValuePair?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]; + const availableFields = getAvailableReportFields(report, Object.values(fieldList ?? {})); + const invoiceReportFields = report?.type === CONST.REPORT.TYPE.INVOICE ? getInvoiceReportFieldsFromReportNameValuePairs(reportNameValuePairs) : []; + const fieldsMatchingReport = [...availableFields, ...invoiceReportFields].filter((field) => isReportFieldTargetMatchingReport(report, field)); + const fields = [...new Map(fieldsMatchingReport.map((field) => [field.fieldID, field])).values()]; const fieldValues: Record = {}; const fieldsByName: Record = {}; for (const field of fields) { if (field.name) { + const fieldKey = getReportFieldKey(field.fieldID); + const shouldReadReportNameValuePair = report?.type === CONST.REPORT.TYPE.INVOICE && field.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const reportNameValuePairField = shouldReadReportNameValuePair + ? (getReportFieldFromReportNameValuePairs(reportNameValuePairs, fieldKey) ?? getReportFieldFromReportNameValuePairs(reportNameValuePairs, field.fieldID)) + : undefined; const key = field.name.toLowerCase(); - fieldValues[key] = field.value ?? field.defaultValue ?? ''; - fieldsByName[key] = field; + fieldValues[key] = reportNameValuePairField?.value ?? field.value ?? field.defaultValue ?? ''; + fieldsByName[key] = reportNameValuePairField ? {...field, value: reportNameValuePairField.value} : field; } } @@ -14361,17 +14079,27 @@ function getLinkedIOUTransaction(reportAction: OnyxEntry; policy: OnyxEntry}): boolean { +function shouldShowMarkAsDone({ + isTrackIntentUser, + report, + policy, + rules, +}: { + isTrackIntentUser?: boolean; + report: OnyxEntry; + policy: OnyxEntry; + rules: OnyxCollection; +}): boolean { if (!isTrackIntentUser || !isSubmitAndClose(policy) || !isReportOwner(report)) { return false; } - return getNextApproverAccountID(report) === report?.ownerAccountID; + return getNextApproverAccountID(report, rules) === report?.ownerAccountID; } /** * Determines whether the current user is eligible to initiate a merge of the selected expense reports. */ -function canMergeReports(selectedReports: Array>, currentUserAccountID: number | undefined): boolean { +function canMergeReports(selectedReports: Array>, currentUserAccountID: number | undefined, rules: OnyxCollection): boolean { // Need at least 2 reports and a valid caller identity. if (selectedReports.length < 2 || !currentUserAccountID) { return false; @@ -14417,7 +14145,7 @@ function canMergeReports(selectedReports: Array>, currentUserA const hasWriteAccess = canUserPerformWriteAction(report, isReportArchived); const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - const isReportEligibleForMerge = isMoneyRequestReportEligibleForMerge(report, isAdmin, currentUserAccountID); + const isReportEligibleForMerge = isMoneyRequestReportEligibleForMerge(report, isAdmin, rules, currentUserAccountID); if (!hasWriteAccess || !isReportEligibleForMerge) { return false; @@ -14512,7 +14240,6 @@ export { chatIncludesChronos, chatIncludesChronosWithID, chatIncludesConcierge, - createDraftTransactionAndNavigateToParticipantSelector, doesReportBelongToWorkspace, shouldEnableNegative, findLastAccessedReport, @@ -14588,10 +14315,11 @@ export { getRootParentReport, getRouteFromLink, canDeleteCardTransactionByLiabilityType, - getAddExpenseDropdownOptions, isTeachersUniteReport, getTaskAssigneeChatOnyxData, + getTransactionCommentObject, getTransactionDetails, + getTransactionDisplayAmount, getTransactionReportName, getDisplayedReportID, getTransactionsWithReceipts, @@ -14683,6 +14411,8 @@ export { isReportFieldDisabled, isReportFieldDisabledForUser, isReportFieldOfTypeTitle, + isReportFieldTargetMatchingReport, + shouldDisplayReportFields, isReportManager, isReportOwner, isReportParticipant, @@ -14728,15 +14458,12 @@ export { updateOptimisticParentReportAction, updateReportPreview, temporary_getMoneyRequestOptions, - getTripIDFromTransactionParentReportID, buildOptimisticInvoiceReport, isCurrentUserInvoiceReceiver, - isDraftReport, changeMoneyRequestHoldStatus, rejectMoneyRequestReason, isAdminOwnerApproverOrReportOwner, canEditReportTitle, - createDraftWorkspaceAndNavigateToConfirmationScreen, isChatUsedForOnboarding, buildOptimisticExportIntegrationAction, getChatUsedForOnboarding, @@ -14785,6 +14512,7 @@ export { buildOptimisticResolvedDuplicatesReportAction, getTitleReportField, getTitleFieldWithFallback, + getReportFieldFromReportNameValuePairs, getReportFieldsByPolicyID, getChatListItemReportName, buildOptimisticMovedTransactionAction, diff --git a/src/libs/RequireFieldsRulesUtils.ts b/src/libs/RequireFieldsRulesUtils.ts index f8e9dd5a1952..7cd263e01703 100644 --- a/src/libs/RequireFieldsRulesUtils.ts +++ b/src/libs/RequireFieldsRulesUtils.ts @@ -661,7 +661,7 @@ function createRequireFieldsTableItem({ const isPendingDelete = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const decodedCategoryName = getDecodedCategoryName(categoryName); const conditionText = translate('workspace.rules.requireFieldsTable.conditionCategoryIs', decodedCategoryName); - const typeLabel = translate('workspace.rules.requireFieldsRule.title'); + const typeLabel = translate('workspace.rules.requireFieldsTable.typeLabel'); const ruleDescriptions = getRequireFieldsRuleDescriptionsForCategory(category, translate, convertToDisplayString, policyCurrency, isPendingDelete); const ruleDescription = formatRequireFieldsRuleDescriptions(ruleDescriptions); @@ -672,7 +672,9 @@ function createRequireFieldsTableItem({ typeLabel, conditionText, ruleDescription, - searchTokens: [decodedCategoryName, ruleDescription, typeLabel, ...ruleDescriptions], + // The Type column reads "Require" now, but the rules are still called field requirements everywhere else, so + // the old name stays searchable rather than making admins learn the column's shorthand to find a row. + searchTokens: [decodedCategoryName, ruleDescription, typeLabel, translate('workspace.rules.requireFieldsRule.title'), ...ruleDescriptions], pendingAction, disabled: isPendingDelete, action: () => { diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 394b22e35bfe..049fe93d181b 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,7 +1,7 @@ import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; -import type {SearchAutocompleteQueryRange, SearchAutocompleteResult, SearchColumnType} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchAutocompleteResult, SearchColumnType, SearchFilterKey} from '@components/Search/types'; -import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST'; +import CONST from '@src/CONST'; import type {PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx'; import type {MarkdownRange} from '@expensify/react-native-live-markdown'; @@ -12,6 +12,16 @@ import {getTagNamesFromTagsLists} from './PolicyUtils'; import {parse} from './SearchParser/autocompleteParser'; import {getUserFriendlyKey, getUserFriendlyValue} from './SearchQueryUtils'; +const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS: SearchFilterKey[] = [ + CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, +]; + /** * Parses given query using the autocomplete parser. * This is a smaller and simpler version of search parser used for autocomplete displaying logic. @@ -344,6 +354,7 @@ function getTrimmedUserSearchQueryPreservingComma(textInputValue: string, fieldK } export { + CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, getAutocompleteCategories, getAutocompleteQueryWithComma, getAutocompleteRecentCategories, diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 925faf6c3590..67afc9ffb88e 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,12 +1,23 @@ import ROUTES from '@src/ROUTES'; +import type {SearchKey} from './SearchUIUtils'; + import {setSearchContext} from './actions/Search'; import Navigation from './Navigation/Navigation'; +import {getValidLastQuery} from './SearchQueryUtils'; -function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { +function navigateToCannedSpendSearch( + searchKey: SearchKey, + searchQuery: string, + lastSearchQuery: string | undefined, + clearSelectedTransactions: () => void, + setCurrentSearchKey: (key: SearchKey, pendingQuery?: string) => void, +) { clearSelectedTransactions(); setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + const query = getValidLastQuery(lastSearchQuery, searchQuery); + setCurrentSearchKey(searchKey, query); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); } export default navigateToCannedSpendSearch; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 2861fb35b26a..17e670525573 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -60,6 +60,7 @@ import {getStandardExportTemplateDisplayName} from './AccountingUtils'; import {getBankAccountSearchLabel, isBankAccountPartiallySetup} from './BankAccountUtils'; import {getCardFeedsForDisplay} from './CardFeedUtils'; import {getCardDescription} from './CardUtils'; +import getCollator from './CollatorUtils'; import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import DateUtils from './DateUtils'; import Log from './Log'; @@ -553,7 +554,7 @@ function getUpdatedFilterValue(filterName: SyntaxFilterKey, filterValue: string * The reason for this is that the computation of hashes should not depend on the locale. * This is used to ensure that hashes stay consistent. */ -const customCollator = new Intl.Collator('en', {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'}); +const customCollator = getCollator(CONST.LOCALES.EN); let defaultSearchQueryJSON: SearchQueryJSON | undefined; @@ -586,6 +587,30 @@ function wasViewExplicitlySet(queryJSON?: SearchQueryJSON | Readonly) { + let orderedQuery = ''; + const flatFilters = query.flatFilters + .map((filter) => { + const filterKey = filter.key; + const filters = cloneDeep(filter.filters); + filters.sort((a, b) => customCollator.compare(a.value.toString(), b.value.toString())); + return {filterString: buildFilterValuesString(filterKey, filters), filterKey}; + }) + .sort((a, b) => customCollator.compare(a.filterString, b.filterString)); + + for (const {filterString, filterKey} of flatFilters) { + if (exclude.has(filterKey)) { + continue; + } + + orderedQuery += ` ${filterString}`; + } + + const primaryHash = hashText(orderedQuery, 2 ** 32); + + return primaryHash; +} + /** * @private * Computes and returns a numerical hash for a given queryJSON. @@ -831,6 +856,23 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON | Readonly([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); + +function buildQueryStringWithResetFilters(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON | undefined) { + const resetFilters = (defaultQueryJSON?.flatFilters ?? []).filter((filter) => !NON_FILTER_CHIP_KEYS.has(filter.key)); + const keptFilters = currentQueryJSON.flatFilters.filter((filter) => NON_FILTER_CHIP_KEYS.has(filter.key)); + + return buildSearchQueryString({ + ...currentQueryJSON, + type: defaultQueryJSON?.type ?? currentQueryJSON.type, + flatFilters: [...resetFilters, ...keptFilters], + }); +} + +function hasFiltersChangedFromDefault(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON) { + return getQueryHashWithoutFilters(currentQueryJSON, NON_FILTER_CHIP_KEYS) !== getQueryHashWithoutFilters(defaultQueryJSON, NON_FILTER_CHIP_KEYS); +} + function getSanitizedRawFilters(queryJSON: SearchQueryJSON): RawQueryFilter[] | undefined { if (!queryJSON.rawFilterList || queryJSON.rawFilterList.length === 0) { return undefined; @@ -2526,30 +2568,6 @@ function getEmptyDateValues(): SearchDateValues { }; } -/** - * Returns an object containing the filter values needed to reset - * the currently applied advanced filters back to their initial state. - * - * - STATUS is reset to `ALL` - * - TYPE is reset to `EXPENSE` - * - COLUMNS is reset to undefined only if the current TYPE is not EXPENSE - * - Other filters are reset to `undefined` - */ -function getAdvancedFiltersToReset(searchAdvancedFiltersForm: Partial) { - const isTypeExpense = searchAdvancedFiltersForm.type === CONST.SEARCH.DATA_TYPES.EXPENSE; - return Object.keys(searchAdvancedFiltersForm).reduce((acc, filterKey) => { - if (filterKey === FILTER_KEYS.TYPE) { - if (!isTypeExpense) { - acc[filterKey] = CONST.SEARCH.DATA_TYPES.EXPENSE; - } - } else if (filterKey !== FILTER_KEYS.COLUMNS || !isTypeExpense) { - Object.assign(acc, {[filterKey]: undefined}); - } - - return acc; - }, {} as Partial); -} - /** * 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. @@ -2643,11 +2661,52 @@ function getFilterFormValues filter.key)); + const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key)); + + return [...defaultQueryFilterKeys].every((value) => queryFilterKeys.has(value)) && queryJSON.type === defaultQueryJSON.type; +} + +function getValidLastQuery(lastQuery: string | undefined, defaultQuery: string) { + if (!lastQuery) { + return defaultQuery; + } + + const lastQueryJSON = buildSearchQueryJSON(lastQuery); + + if (!lastQueryJSON) { + return defaultQuery; + } + + const defaultQueryJSON = buildSearchQueryJSON(defaultQuery); + + if (!defaultQueryJSON) { + return defaultQuery; + } + + if (!doesQueryMatchDefaultFilterKeysAndType(lastQueryJSON, defaultQueryJSON)) { + return defaultQuery; + } + + return lastQuery; +} + export { getDateRangeDisplayValueFromFormValue, getRangeBoundariesFromFormValue, getRangeQueryValue, + getQueryHashWithoutFilters, getQueryHashes, + hasFiltersChangedFromDefault, withExactMatchFilterKeys, isSearchDatePreset, getDateRangeForPreset, @@ -2656,6 +2715,7 @@ export { isFilterSupported, buildSearchQueryJSON, buildSearchQueryString, + buildQueryStringWithResetFilters, buildUserReadableQueryString, buildFilterValuesString, getDisplayQueryFiltersForKey, @@ -2685,7 +2745,6 @@ export { buildOptimisticSnapshotData, getDateFilterKeys, getEmptyDateValues, - getAdvancedFiltersToReset, getDateModifierTitle, applyContainsOperatorToTextFields, serializeQueryJSONForBackend, @@ -2698,6 +2757,8 @@ export { removeNegation, getFilterFormValues, getFilterFromQuery, + getValidLastQuery, + doesQueryMatchDefaultFilterKeysAndType, queryHasSubmittedViolationFilter, }; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 9ae32b6a0b14..389a7c276f93 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -30,6 +30,7 @@ import type { import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import type { GroupedItem, + QueryFilterKey, QueryFilters, ReportFieldKey, ReportFieldTextKey, @@ -59,6 +60,7 @@ import type {FeedKeysWithAssignedCards} from '@hooks/useFeedKeysWithAssignedCard import type {ThemeColors} from '@styles/theme/types'; import type {ButtonVariant} from '@styles/utils/types'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -69,6 +71,7 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import FILTER_KEYS, {AMOUNT_FILTER_KEYS, DATE_FILTER_KEYS, TEXT_FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm'; import type {HasFilterValues, SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ViolationsSnapshot} from '@src/types/onyx/OriginalMessage'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import type SearchResults from '@src/types/onyx/SearchResults'; @@ -99,6 +102,7 @@ import type {TupleToUnion, ValueOf} from 'type-fest'; /* eslint-disable max-lines */ // TODO: Remove this disable once SearchUIUtils is refactored (see dedicated refactor issue) import {addDays, format, parse, subDays} from 'date-fns'; +import {deepEqual} from 'fast-equals'; import type {TransactionPreviewData} from './actions/Search'; import type {CardFeedForDisplay} from './CardFeedUtils'; @@ -129,6 +133,7 @@ import { getSubmitToAccountID, getTagGLCode, isArchivedOrPendingDeletePolicy, + isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin, @@ -146,6 +151,9 @@ import { isMoneyRequestAction, isReportActionVisible, isResolvedActionableWhisper, + isAddExpenseOnSubmittedAction, + isApprovedAction, + isForwardedAction, isSubmittedAction, isSubmittedAndClosedAction, isWhisperActionTargetedToOthers, @@ -167,6 +175,7 @@ import { getReportOrDraftReport, getReportStatusTooltipTranslation, getReportStatusTranslation, + getTransactionDisplayAmount, hasHeldExpenses, hasInvoiceReports, hasOnlyNonReimbursableTransactions, @@ -220,7 +229,6 @@ import { getTag, getTaxAmount, getTaxName, - getAmount as getTransactionAmount, getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, getTransactionViolations, @@ -279,6 +287,7 @@ type GetReportSectionsParams = { isActionLoadingSet: ReadonlySet | undefined; isOffline: boolean | undefined; bankAccountList: OnyxEntry; + rules: OnyxCollection; reportActions?: Record; queryJSON?: SearchQueryJSON; onyxPersonalDetailsList?: OnyxTypes.PersonalDetailsList; @@ -293,6 +302,7 @@ type GetTransactionSectionsParams = { translate: LocalizedTranslate; isActionLoadingSet: ReadonlySet | undefined; bankAccountList: OnyxEntry; + rules: OnyxCollection; reportActions?: Record; queryJSON?: SearchQueryJSON; isAttendeesEnabledForMovingPolicy?: boolean; @@ -441,6 +451,13 @@ const expenseStatusActionMapping: Record = { [CONST.SEARCH.STATUS.EXPENSE.DELETED]: (_expenseReport, transactionReportID) => transactionReportID === CONST.REPORT.TRASH_REPORT_ID, }; +type TaskStatusPredicate = (taskReport?: OnyxTypes.Report | SearchTask) => boolean; + +const taskStatusActionMapping: Record = { + [CONST.SEARCH.STATUS.TASK.OUTSTANDING]: (taskReport) => taskReport?.stateNum === CONST.REPORT.STATE_NUM.OPEN && taskReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN, + [CONST.SEARCH.STATUS.TASK.COMPLETED]: (taskReport) => taskReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && taskReport.statusNum === CONST.REPORT.STATUS_NUM.APPROVED, +}; + const nonSortableColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.RECEIPT, CONST.SEARCH.TABLE_COLUMNS.TYPE, @@ -454,6 +471,10 @@ function isValidExpenseStatus(status: unknown): status is ValueOf { + return typeof status === 'string' && status in taskStatusActionMapping; +} + // Statuses a freshly created expense can never be in. The tracked optimistic item is kept visible // before its server snapshot arrives, but it must not be force-shown under these terminal status // filters (e.g. a brand-new "Draft" expense leaking into the "Deleted" tab). @@ -647,6 +668,7 @@ type GetSectionsParams = { formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; bankAccountList: OnyxEntry; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + rules: OnyxCollection; groupBy?: SearchGroupBy; reportActions?: Record; currentSearch?: SearchKey; @@ -765,9 +787,8 @@ function getSuggestedSearches( accountID: number = CONST.DEFAULT_NUMBER_ID, defaultFeedID?: string, shouldShowExpensifyCard?: boolean, - topSpendersPolicyIDs: string[] = [], activeExpensifyCardFeedID?: string, -): Record, SearchTypeMenuItem> { +): Record { // Card accruals (UNAPPROVED_CARD) defaults to the active workspace's Expensify Card when it has one, // falling back to the company/bank feed otherwise. Other feed-based searches keep using `defaultFeedID`. const unapprovedCardFeedID = activeExpensifyCardFeedID ?? defaultFeedID; @@ -1016,8 +1037,6 @@ function getSuggestedSearches( type: CONST.SEARCH.DATA_TYPES.EXPENSE, groupBy: CONST.SEARCH.GROUP_BY.FROM, dateOn: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, - // Scope Top Spenders to the eligible workspaces so individual-chat/personal expenses don't leak in. - ...(topSpendersPolicyIDs.length > 0 ? {policyID: topSpendersPolicyIDs} : {}), status: [ CONST.SEARCH.STATUS.EXPENSE.DRAFTS, CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, @@ -1156,7 +1175,7 @@ function getSuggestedSearchesVisibility( hasReportAwaitingApproval = false, isTrackIntentUser = false, policyCategories?: OnyxCollection, -): {visibility: Record, boolean>; hasEligibleGroupPolicies: boolean; shouldShowExpensifyCard: boolean; topSpendersPolicyIDs: string[]} { +): {visibility: Record, boolean>; hasEligibleGroupPolicies: boolean; shouldShowExpensifyCard: boolean} { let shouldShowSubmitSuggestion = false; let shouldShowPaySuggestion = false; let shouldShowApproveSuggestion = hasReportAwaitingApproval; @@ -1172,7 +1191,6 @@ function getSuggestedSearchesVisibility( let shouldShowViolationsBySubmitterSuggestion = false; let hasEligibleGroupPolicies = false; let shouldShowSpendOverTimeSuggestion = false; - const topSpendersPolicyIDs: string[] = []; const hasCardFeed = Object.values(cardFeedsByPolicy ?? {}).some((feeds) => feeds.length > 0); const hasAnyPolicyWithWorkflowsEnabled = Object.values(policies ?? {}).some((policy) => policy?.areWorkflowsEnabled); @@ -1218,7 +1236,7 @@ function getSuggestedSearchesVisibility( const isEligibleForTopCategoriesSuggestion = isGroupPolicyEligible && policy.areCategoriesEnabled === true; const isEligibleForTopMerchantsSuggestion = isGroupPolicyEligible; const isEligibleForViolationsBySubmitterSuggestion = - isGroupPolicyEligible && + isControlPolicy(policy) && (isAdmin || isAuditor) && arePolicyRulesEnabled(policy, policy.id ? policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`] : undefined) && memberCount >= 2; @@ -1233,9 +1251,6 @@ function getSuggestedSearchesVisibility( shouldShowExpensifyCardSuggestion ||= isEligibleForExpensifyCardSuggestion; shouldShowReimbursementsSuggestion ||= isEligibleForReimbursementsSuggestion; shouldShowTopSpendersSuggestion ||= isEligibleForTopSpendersSuggestion; - if (policy.id && isEligibleForTopSpendersSuggestion) { - topSpendersPolicyIDs.push(policy.id); - } shouldShowTopCategoriesSuggestion ||= isEligibleForTopCategoriesSuggestion; shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; shouldShowViolationsBySubmitterSuggestion ||= isEligibleForViolationsBySubmitterSuggestion; @@ -1267,7 +1282,6 @@ function getSuggestedSearchesVisibility( }, hasEligibleGroupPolicies, shouldShowExpensifyCard: shouldShowExpensifyCardSuggestion, - topSpendersPolicyIDs, }; } @@ -1285,8 +1299,6 @@ function getTransactionItemCommonFormattedProperties( report: OnyxTypes.Report | undefined, translate: LocalizedTranslate, ): Pick { - const isExpenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE; - const formattedFrom = temporaryGetDisplayNameOrDefault({passedPersonalDetails: from, translate, formatPhoneNumber}); // Sometimes the search data personal detail for the 'to' account might not hold neither the display name nor the login @@ -1296,8 +1308,8 @@ function getTransactionItemCommonFormattedProperties( formattedTo = temporaryGetDisplayNameOrDefault({passedPersonalDetails: getPersonalDetailsForAccountID(to?.accountID), translate, formatPhoneNumber}); } - const isDeleted = isDeletedTransaction(transactionItem); - const formattedTotal = getTransactionAmount(transactionItem, isExpenseReport, false, isDeleted); + // formattedTotal is the Amount column's sort key and holds the same signed value the row displays. + const formattedTotal = getTransactionDisplayAmount(transactionItem, report, policy); const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created; const merchant = getTransactionMerchant(transactionItem); const formattedMerchant = isInvalidMerchantValue(merchant) ? '' : merchant; @@ -1360,6 +1372,13 @@ function isTransactionReportGroupListItemType(item: ListItem): item is Transacti return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; } +/** + * Type guard that checks if something is a TransactionWithdrawalIDGroupListItemType + */ +function isTransactionWithdrawalIDGroupListItemType(item: ListItem): item is TransactionWithdrawalIDGroupListItemType { + return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID; +} + /** * Type guard that checks if something is a TransactionCategoryGroupListItemType */ @@ -2209,9 +2228,39 @@ function isEligibleForStatus(currentQueryJSON: SearchQueryJSON | undefined, repo }); } - return status.value.some((expenseStatus) => { - return isValidExpenseStatus(expenseStatus) ? expenseStatusActionMapping[expenseStatus](report, transactionItemReportID) : false; - }); + // Invalid statuses should be treated as if there were no status filter, mirroring backend behaviour. + const validStatuses = status.value.filter(isValidExpenseStatus); + if (validStatuses.length === 0) { + return true; + } + + return validStatuses.some((expenseStatus) => expenseStatusActionMapping[expenseStatus](report, transactionItemReportID)); +} + +/** + * Whether a task still belongs under the active `status:` filter, judged against the live report rather than the + * search snapshot. Completing or reopening a task does not patch the snapshot, so without this a completed task + * lingers under `status:outstanding` (and vice versa) until the next server fetch. Mirrors `isEligibleForStatus`. + */ +function isEligibleForTaskStatus(currentQueryJSON: SearchQueryJSON | undefined, report: OnyxEntry | SearchTask) { + const status = getFilterFromQuery(currentQueryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS); + if (!status.value) { + return true; + } + + if (status.isNegated) { + return Object.keys(taskStatusActionMapping).some((taskStatus) => { + const isExcluded = status.value?.includes(taskStatus); + return !isExcluded && taskStatusActionMapping[taskStatus](report); + }); + } + + // A status we don't model (e.g. a hand-typed `status:all`) must not silently empty the list, so leave the row visible. + if (!status.value.every(isValidTaskStatus)) { + return true; + } + + return status.value.some((taskStatus) => taskStatusActionMapping[taskStatus](report)); } /** @@ -2261,6 +2310,7 @@ function getTransactionsSections({ translate, isActionLoadingSet, bankAccountList, + rules, reportActions = {}, queryJSON, isAttendeesEnabledForMovingPolicy, @@ -2344,7 +2394,7 @@ function getTransactionsSections({ const submitted = report ? getSubmittedDate(report, actions) : undefined; const approved = report ? getApprovedDate(report, actions) : undefined; const reportMetadata = data[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, reportMetadata, actions); + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, reportMetadata, rules, actions); const transactionPendingAction = getTransactionPendingAction(transactionItem); const reportOwnerAccountIDAsAttendee = getReportOwnerAccountIDAsAttendee(transactionItem, currentAccountID); const reportOwnerAsAttendee = reportOwnerAccountIDAsAttendee ? getReportOwnerAsAttendee(personalDetailsMap.get(reportOwnerAccountIDAsAttendee.toString())) : undefined; @@ -2557,6 +2607,7 @@ function getViolationsFromSearchData(data: OnyxTypes.SearchResults['data']): Ony * * Do not use directly, use only via `getSections()` facade. */ +// eslint-disable-next-line @typescript-eslint/max-params function getActions( data: OnyxTypes.SearchResults['data'], allViolations: OnyxCollection, @@ -2566,6 +2617,7 @@ function getActions( currentUserAccountID: number, bankAccountList: OnyxEntry, reportMetadata: OnyxEntry, + rules: OnyxCollection, reportActions: OnyxTypes.ReportAction[] = [], precomputedTransactionsForReport?: OnyxTypes.Transaction[], ): SearchTransactionAction[] { @@ -2663,7 +2715,7 @@ function getActions( const hasOnlyPendingCardOrScanningTransactions = allReportTransactions.length > 0 && allReportTransactions.every((t) => isScanning(t) || isPending(t)); const ownerLogin = getLoginByAccountID(report.ownerAccountID, data.personalDetailsList); - const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin); + const submitToAccountID = getSubmitToAccountID(policy, report, ownerLogin, rules); const isAllowedToApproveExpenseReport = isAllowedToApproveExpenseReportUtils(report, submitToAccountID, policy); // We're not supporting approve partial amount on search page now @@ -2778,8 +2830,10 @@ function getTaskSections( conciergeReportID: string | undefined, reportNameValuePairs?: OnyxCollection, reportAttributesDerivedValue?: OnyxTypes.ReportAttributesDerivedValue['reports'], + queryJSON?: SearchQueryJSON, ): [TaskListItemType[], number] { const {shouldShowYearCreated} = shouldShowYear(data); + const currentQueryJSON = queryJSON ?? getCurrentSearchQueryJSON(); const tasks = Object.keys(data) .filter(isReportEntry) // Ensure that the reports that were passed are tasks, and not some other @@ -2810,6 +2864,10 @@ function getTaskSections( formattedCreatedBy, keyForList: taskItem.reportID, shouldShowYear: shouldShowYearCreated, + // The snapshot is not patched when a task is completed or reopened, so prefer the live report's status. + // Otherwise the row keeps rendering the Complete button instead of the Completed badge until a refetch. + statusNum: report.statusNum ?? taskItem.statusNum, + stateNum: report.stateNum ?? taskItem.stateNum, }; if (parentReport && personalDetails) { @@ -2844,39 +2902,32 @@ function getTaskSections( } return result; - }); + }) + // Drop tasks whose live status no longer matches the active `status:` filter — the snapshot still lists a + // just-completed task under `status:outstanding` because `completeTask` never writes to it. + .filter((task) => isEligibleForTaskStatus(currentQueryJSON, task)); return [tasks, tasks.length]; } type CreateAndOpenSearchTransactionThreadParams = { - /** The transaction list item being opened */ item: TransactionListItemType; - - /** The intro selected by the user */ introSelected: OnyxEntry; /** The route to go back to after navigation */ backTo: string; - /** The current user's login */ currentUserLogin: string; - - /** The current user's account ID */ currentUserAccountID: number; /** Beta features list */ betas: OnyxEntry; - /** The Concierge chat report */ conciergeChat: OnyxEntry; /** The personal details of the participants */ personalDetails: OnyxEntry; - /** Whether the user has seen the self tour */ isSelfTourViewed: boolean | undefined; - - /** Whether the user has completed the guided setup flow */ hasCompletedGuidedSetupFlow: boolean | undefined; /** Existing transaction thread report ID (childReportID), if any */ @@ -3169,6 +3220,7 @@ function getReportSections({ formatPhoneNumber, isActionLoadingSet, bankAccountList, + rules, reportActions = {}, queryJSON, onyxPersonalDetailsList, @@ -3226,7 +3278,19 @@ function getReportSections({ const shouldShowBlankTo = !reportItem || isOpenExpenseReport(reportItem); const reportMetadata = data[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`] ?? {}; const allReportTransactions = transactionsByReportID.get(reportItem.reportID) ?? []; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, reportMetadata, actions, allReportTransactions); + const allActions = getActions( + data, + allViolations, + key, + currentSearch, + currentUserEmail, + currentAccountID, + bankAccountList, + reportMetadata, + rules, + actions, + allReportTransactions, + ); const fromDetails = mergedPersonalDetails?.[reportItem.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? @@ -3359,7 +3423,7 @@ function getReportSections({ ); const transactionReportMetadata = data[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, transactionReportMetadata, actions); + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, transactionReportMetadata, rules, actions); const transactionPendingAction = getTransactionPendingAction(transactionItem); const transaction = { ...transactionItem, @@ -3410,7 +3474,7 @@ function getReportSections({ return [reportIDToTransactionsValues, reportIDToTransactionsValues.length, hasDeletedTransaction]; } -function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: SearchFilterKey; value: string | number} | undefined { +function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: QueryFilterKey; value: string | number} | undefined { switch (groupBy) { case CONST.SEARCH.GROUP_BY.FROM: return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, value: (groupData as SearchMemberGroup).accountID}; @@ -3435,7 +3499,7 @@ function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: } } -function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFilterKey, filterValue: string | number): SearchQueryJSON | undefined { +function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: QueryFilterKey, filterValue: string | number): SearchQueryJSON | undefined { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); newFlatFilters.push({key: filterKey, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: filterValue}]}); const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, sortOrder: CONST.SEARCH.SORT_ORDER.DESC, flatFilters: newFlatFilters}; @@ -4020,6 +4084,7 @@ function getSections({ translate, formatPhoneNumber, bankAccountList, + rules, groupBy, reportActions, currentSearch = CONST.SEARCH.SEARCH_KEYS.EXPENSES, @@ -4043,7 +4108,7 @@ function getSections({ return [...getReportActionsSections(data, reportAttributesDerivedValue, visibleReportActionsData), false]; } if (type === CONST.SEARCH.DATA_TYPES.TASK) { - return [...getTaskSections(data, formatPhoneNumber, translate, conciergeReportID, reportNameValuePairs, reportAttributesDerivedValue), false]; + return [...getTaskSections(data, formatPhoneNumber, translate, conciergeReportID, reportNameValuePairs, reportAttributesDerivedValue, queryJSON), false]; } if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { @@ -4057,6 +4122,7 @@ function getSections({ formatPhoneNumber, isActionLoadingSet, bankAccountList, + rules, reportActions, queryJSON, onyxPersonalDetailsList, @@ -4100,6 +4166,7 @@ function getSections({ translate, isActionLoadingSet, bankAccountList, + rules, reportActions, queryJSON, isAttendeesEnabledForMovingPolicy, @@ -4921,11 +4988,9 @@ type ShareProps = { */ function getOverflowMenu( icons: OverflowMenuIconsType, - itemName: string, - hash: number, - inputQuery: string, + savedSearchID: string, translate: LocalizedTranslate, - showDeleteModal: (hash: number) => void, + showDeleteModal: (savedSearchID: string) => void, isMobileMenu?: boolean, closeMenu?: () => void, shareProps?: ShareProps, @@ -4937,7 +5002,7 @@ function getOverflowMenu( if (isMobileMenu && closeMenu) { closeMenu(); } - Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute({name: encodeURIComponent(itemName), jsonQuery: inputQuery})); + Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute(savedSearchID)); }, icon: icons.Pencil, shouldShowRightIcon: false, @@ -4963,7 +5028,7 @@ function getOverflowMenu( if (isMobileMenu && closeMenu) { closeMenu(); } - showDeleteModal(hash); + showDeleteModal(savedSearchID); }, icon: icons.Trashcan, shouldShowRightIcon: false, @@ -4974,6 +5039,24 @@ function getOverflowMenu( ]; } +function savedSearchIDToSearchKey(id: string): SearchKey { + return `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${id}`; +} + +/** + * Returns the last query used for a search key. + * + * A filter can also be stored as a string, which is a legacy format, so it's treated as if there is no last query. + */ +function getLastSearchQuery(searchFilters: OnyxEntry, searchKey: SearchKey): string | undefined { + const searchFilter = searchFilters?.[searchKey]; + return typeof searchFilter === 'object' ? searchFilter.query : undefined; +} + +function searchKeyToSavedSearchID(key: SearchKey | undefined) { + return key?.startsWith(CONST.SEARCH.SAVED_SEARCH_PREFIX) ? key.replace(CONST.SEARCH.SAVED_SEARCH_PREFIX, '') : undefined; +} + /** * Checks if the passed username is a correct standard username, and not a placeholder */ @@ -5028,9 +5111,8 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS visibility: suggestedSearchesVisibility, hasEligibleGroupPolicies, shouldShowExpensifyCard, - topSpendersPolicyIDs, } = getSuggestedSearchesVisibility(currentUserEmail, cardFeedsByPolicy, policies, defaultExpensifyCard, hasReportAwaitingApproval, isTrackIntentUser, policyCategories); - const suggestedSearches = getSuggestedSearches(currentUserAccountID, defaultCardFeed?.id, shouldShowExpensifyCard, topSpendersPolicyIDs, activeExpensifyCardFeedID); + const suggestedSearches = getSuggestedSearches(currentUserAccountID, defaultCardFeed?.id, shouldShowExpensifyCard, activeExpensifyCardFeedID); const hasAnyPolicyWithWorkflowsEnabled = Object.values(policies ?? {}).some((policy) => policy?.areWorkflowsEnabled); const isTrackIntentWithWorkflowsDisabled = isTrackIntentUser && !hasAnyPolicyWithWorkflowsEnabled; @@ -5325,16 +5407,82 @@ function getStatusOptions(translate: LocalizedTranslate, type: SearchDataTypes) } } -function getHasOptions(translate: LocalizedTranslate, type: SearchDataTypes) { +type HasOptionAvailability = { + shouldShowTag: boolean; + shouldShowCategory: boolean; + shouldShowSubmittedViolation: boolean; + shouldShowApprovedViolation: boolean; +}; + +type GetHasOptionsConfig = { + policies?: OnyxCollection; + policyCategories?: OnyxCollection; + /** Skip workspace feature filtering so already-selected values still resolve to labels. */ + shouldShowAllOptions?: boolean; + /** + * Keep these values in the picker even when their workspace feature is off. + * Needed so a saved/query selection like has:tag is not cleared when toggling another option. + */ + selectedValues?: readonly string[]; +}; + +/** + * Which expense `has:` options can apply for the current user, based on accessible workspaces. + * Pass an empty collection when the user has no workspaces so Tag/Category/Submitted/Approved violation stay hidden. + */ +function getHasOptionAvailability(policies: OnyxCollection | undefined, policyCategories?: OnyxCollection): HasOptionAvailability { + let shouldShowTag = false; + let shouldShowCategory = false; + let shouldShowSubmittedViolation = false; + let shouldShowApprovedViolation = false; + + for (const policy of Object.values(policies ?? {})) { + if (!policy || policy.isJoinRequestPending || !isGroupPolicy(policy)) { + continue; + } + + shouldShowTag ||= policy.areTagsEnabled === true; + shouldShowCategory ||= policy.areCategoriesEnabled === true; + // Migrated Control workspaces leave areRulesEnabled undefined. Fall back to Classic category rules in that case. + const shouldShowRulesBasedViolation = arePolicyRulesEnabled(policy, policy.id ? policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`] : undefined); + shouldShowSubmittedViolation ||= shouldShowRulesBasedViolation; + shouldShowApprovedViolation ||= shouldShowRulesBasedViolation; + + if (shouldShowTag && shouldShowCategory && shouldShowSubmittedViolation && shouldShowApprovedViolation) { + break; + } + } + + return {shouldShowTag, shouldShowCategory, shouldShowSubmittedViolation, shouldShowApprovedViolation}; +} + +/** + * Options for the `has:` filter / autocomplete. Tag, Category, Submitted violation, and Approved violation are omitted when + * no accessible workspace has the matching feature enabled. Pass `policies` from the picker and autocomplete. + * Pass `shouldShowAllOptions` for display/validation so already-selected values still resolve to labels. + * Pass `selectedValues` in the picker so query/saved selections stay selectable until the user clears them. + */ +function getHasOptions(translate: LocalizedTranslate, type: SearchDataTypes, config: GetHasOptionsConfig = {}) { + const {policies, policyCategories, shouldShowAllOptions = false, selectedValues} = config; + switch (type) { - case CONST.SEARCH.DATA_TYPES.EXPENSE: + case CONST.SEARCH.DATA_TYPES.EXPENSE: { + const availability = shouldShowAllOptions + ? {shouldShowTag: true, shouldShowCategory: true, shouldShowSubmittedViolation: true, shouldShowApprovedViolation: true} + : getHasOptionAvailability(policies, policyCategories); + const shouldShowTag = availability.shouldShowTag || !!selectedValues?.includes(CONST.SEARCH.HAS_VALUES.TAG); + const shouldShowCategory = availability.shouldShowCategory || !!selectedValues?.includes(CONST.SEARCH.HAS_VALUES.CATEGORY); + const shouldShowSubmittedViolation = availability.shouldShowSubmittedViolation || !!selectedValues?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + const shouldShowApprovedViolation = availability.shouldShowApprovedViolation || !!selectedValues?.includes(CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION); return [ {text: translate('common.receipt'), value: CONST.SEARCH.HAS_VALUES.RECEIPT}, {text: translate('common.attachment'), value: CONST.SEARCH.HAS_VALUES.ATTACHMENT}, - {text: translate('common.tag'), value: CONST.SEARCH.HAS_VALUES.TAG}, - {text: translate('common.category'), value: CONST.SEARCH.HAS_VALUES.CATEGORY}, - {text: translate('search.filters.has.submittedViolation'), value: CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}, + ...(shouldShowTag ? [{text: translate('common.tag'), value: CONST.SEARCH.HAS_VALUES.TAG}] : []), + ...(shouldShowCategory ? [{text: translate('common.category'), value: CONST.SEARCH.HAS_VALUES.CATEGORY}] : []), + ...(shouldShowSubmittedViolation ? [{text: translate('search.filters.has.submittedViolation'), value: CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}] : []), + ...(shouldShowApprovedViolation ? [{text: translate('search.filters.has.approvedViolation'), value: CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION}] : []), ]; + } case CONST.SEARCH.DATA_TYPES.CHAT: return [ {text: translate('common.link'), value: CONST.SEARCH.HAS_VALUES.LINK}, @@ -5354,13 +5502,66 @@ function isSubmittedTransactionViolationShortName(name: string): name is Submitt } /** - * Returns a parameter-free display label for a submitted violation name. + * Returns a parameter-free display label for a violation name. * Falls back to the raw identifier when no short-name translation exists. */ -function getSubmittedViolationDisplayName(violationName: string, translate: LocalizedTranslate): string { +function getViolationDisplayName(violationName: string, translate: LocalizedTranslate): string { return isSubmittedTransactionViolationShortName(violationName) ? translate(`violations.shortName.${violationName}`) : violationName; } +function collectViolationNamesForTransaction(violationNames: Set, violations: ViolationsSnapshot['transactions'] | undefined, transactionID: string) { + const transactionViolations = violations?.[transactionID]; + if (!transactionViolations?.length) { + return; + } + + for (const violation of transactionViolations) { + if (violation.name) { + violationNames.add(violation.name); + } + } +} + +function formatTransactionViolationNames(violationNames: Set, translate?: LocalizedTranslate): string | undefined { + if (violationNames.size === 0) { + return undefined; + } + + // Match ViolationMessages / submitter surfaces: only one of these receipt rules can apply. + if (violationNames.has(CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED)) { + violationNames.delete(CONST.VIOLATIONS.RECEIPT_REQUIRED); + } + + const names = Array.from(violationNames); + if (!translate) { + return names.join(', '); + } + + return names.map((name) => getViolationDisplayName(name, translate)).join(', '); +} + +function collectSubmittedViolationNamesForTransaction(violationNames: Set, reportActions: OnyxTypes.ReportAction[], transactionID: string) { + for (const action of reportActions) { + // An expense added to a report that was already awaiting approval is not in that report's submit snapshot, + // so its violations live on their own add-expense-on-submitted action instead. + if (!isSubmittedAction(action) && !isSubmittedAndClosedAction(action) && !isAddExpenseOnSubmittedAction(action)) { + continue; + } + + collectViolationNamesForTransaction(violationNames, getOriginalMessage(action)?.violations?.transactions, transactionID); + } +} + +function collectApprovedViolationNamesForTransaction(violationNames: Set, reportActions: OnyxTypes.ReportAction[], transactionID: string) { + for (const action of reportActions) { + if (!isApprovedAction(action) && !isForwardedAction(action)) { + continue; + } + + collectViolationNamesForTransaction(violationNames, getOriginalMessage(action)?.violations?.transactions, transactionID); + } +} + /** * Collects a transaction's submitted violations from its report's submit actions. * A report can be submitted more than once, so this aggregates across every submit action, @@ -5376,39 +5577,59 @@ function getSubmittedViolationsForTransaction(reportActions: OnyxTypes.ReportAct } const violationNames = new Set(); - for (const action of reportActions) { - if (!isSubmittedAction(action) && !isSubmittedAndClosedAction(action)) { - continue; - } - - const originalMessage = getOriginalMessage(action); - const transactionViolations = originalMessage?.violations?.transactions?.[transactionID]; - if (!transactionViolations?.length) { - continue; - } + collectSubmittedViolationNamesForTransaction(violationNames, reportActions, transactionID); + return formatTransactionViolationNames(violationNames, translate); +} - for (const violation of transactionViolations) { - if (violation.name) { - violationNames.add(violation.name); - } - } +/** + * Collects a transaction's approved violations from its report's approved and forwarded actions. + * A report can be approved or forwarded more than once, so this aggregates across every matching action, + * dedupes by violation name, and returns a comma-separated display string. + * When `translate` is provided, violation identifiers are converted to localized short labels. + * + * Itemized receipt required supersedes receipt required (same rule as `filterReceiptViolations`), + * so both are never shown together for a single expense. + */ +function getApprovedViolationsForTransaction(reportActions: OnyxTypes.ReportAction[] | undefined, transactionID: string | undefined, translate?: LocalizedTranslate): string | undefined { + if (!reportActions?.length || !transactionID) { + return undefined; } - if (violationNames.size === 0) { + const violationNames = new Set(); + collectApprovedViolationNamesForTransaction(violationNames, reportActions, transactionID); + return formatTransactionViolationNames(violationNames, translate); +} + +/** + * Returns the violations to show for a transaction based on the current `has` filter. + * Submitted and approved snapshots are collected independently, then merged in a Set so + * a search that includes both filters does not list the same violation twice. + */ +function getViolationsForTransaction( + reportActions: OnyxTypes.ReportAction[] | undefined, + transactionID: string | undefined, + has: HasFilterValues | undefined, + translate?: LocalizedTranslate, +): string | undefined { + if (!reportActions?.length || !transactionID) { return undefined; } - // Match ViolationMessages / submitter surfaces: only one of these receipt rules can apply. - if (violationNames.has(CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED)) { - violationNames.delete(CONST.VIOLATIONS.RECEIPT_REQUIRED); + const shouldIncludeSubmittedViolations = !!has?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + const shouldIncludeApprovedViolations = !!has?.includes(CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION); + if (!shouldIncludeSubmittedViolations && !shouldIncludeApprovedViolations) { + return undefined; } - const names = Array.from(violationNames); - if (!translate) { - return names.join(', '); + const violationNames = new Set(); + if (shouldIncludeSubmittedViolations) { + collectSubmittedViolationNamesForTransaction(violationNames, reportActions, transactionID); + } + if (shouldIncludeApprovedViolations) { + collectApprovedViolationNamesForTransaction(violationNames, reportActions, transactionID); } - return names.map((name) => getSubmittedViolationDisplayName(name, translate)).join(', '); + return formatTransactionViolationNames(violationNames, translate); } function getTypeOptions(translate: LocalizedTranslate, policies: OnyxCollection, currentUserLogin?: string) { @@ -6061,7 +6282,7 @@ function getDisplayValue( if (!hasValues?.length) { return; } - const hasOptions = getHasOptions(translate, type); + const hasOptions = getHasOptions(translate, type, {shouldShowAllOptions: true}); return hasOptions .filter((option) => hasValues.includes(option.value)) .map((option) => option.text) @@ -6111,6 +6332,46 @@ function getFilterNegatableValue | undefined, + values: Partial | undefined, +): boolean { + if (previousValues === values) { + return false; + } + + if (isAmountFilterKey(baseFilterKey)) { + const modifiers = [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO, CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN, CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; + return modifiers.some((modifier) => previousValues?.[`${baseFilterKey}${modifier}`] !== values?.[`${baseFilterKey}${modifier}`]); + } + + if (isDateFilterKey(baseFilterKey)) { + const modifiers = [CONST.SEARCH.DATE_MODIFIERS.ON, CONST.SEARCH.DATE_MODIFIERS.AFTER, CONST.SEARCH.DATE_MODIFIERS.BEFORE, CONST.SEARCH.DATE_MODIFIERS.RANGE]; + return ( + !!previousValues?.feed !== !!values?.feed || modifiers.some((modifier) => !deepEqual(previousValues?.[`${baseFilterKey}${modifier}`], values?.[`${baseFilterKey}${modifier}`])) + ); + } + + // The report field content is handed the whole form, so anything in it counts. + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD) { + return !deepEqual(previousValues, values); + } + + if (!deepEqual(getFilterNegatableValue(baseFilterKey, previousValues), getFilterNegatableValue(baseFilterKey, values))) { + return true; + } + + if (isTextFilterKey(baseFilterKey)) { + return false; + } + + // A list content also reads the search type and the workspace filter, which decide the options it offers. + const policyIDKey = CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID; + return previousValues?.type !== values?.type || !deepEqual(getFilterNegatableValue(policyIDKey, previousValues), getFilterNegatableValue(policyIDKey, values)); +} + function getLabelValue(key: SearchAdvancedFiltersKey, labelKey: TranslationPaths | undefined, translate: LocalizedTranslate) { if (!labelKey) { return undefined; @@ -6140,6 +6401,16 @@ function isReportFieldKey(key: string): key is ReportFieldKey { return key.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); } +/** + * Normalizes any report field key (`reportFieldOn-`, `reportFieldNot-`, ...) to the plain `reportField-` form that + * search query filters hold. Every prefix ends in a hyphen, and the field name itself may contain hyphens, so only the + * first one is treated as the separator. + */ +function getReportFieldTextKey(key: ReportFieldKey): ReportFieldTextKey { + const reportFieldName = key.slice(key.indexOf('-') + 1); + return `${CONST.SEARCH.REPORT_FIELD.DEFAULT_PREFIX}${reportFieldName}`; +} + type SearchFilter = { key: keyof typeof FILTER_VIEW_MAP; label: string; @@ -6154,6 +6425,7 @@ function isMappedFilterKey(key: string): key is MappedFilterKey { function mapFiltersFormToLabelValueList( searchAdvancedFiltersForm: Partial, + defaultSearchQueryFilterKeys: Set, skipFilters: Set | undefined, translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, @@ -6162,23 +6434,26 @@ function mapFiltersFormToLabelValueList( ): SearchFilter[]; function mapFiltersFormToLabelValueList>( searchAdvancedFiltersForm: Partial, + defaultSearchQueryFilterKeys: Set, skipFilters: Set | undefined, translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, localeCompare: LocaleContextProps['localeCompare'], convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], - mapper: (filterKey: MappedFilterKey) => T, + mapper: (filterKey: MappedFilterKey, isDefault: boolean) => T, ): Array; function mapFiltersFormToLabelValueList( searchAdvancedFiltersForm: Partial, + defaultSearchQueryFilterKeys: Set, skipFilters: Set | undefined, translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, localeCompare: LocaleContextProps['localeCompare'], convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], - mapper?: (filterKey: MappedFilterKey) => Record, + mapper?: (filterKey: MappedFilterKey, isDefault: boolean) => Record, ): SearchFilter[] { - const filters: SearchFilter[] = []; + const defaultFilters: SearchFilter[] = []; + const nonDefaultFilters: SearchFilter[] = []; const addedGroups = new Set(); const type = searchAdvancedFiltersForm.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -6202,13 +6477,14 @@ function mapFiltersFormToLabelValueList( if (displayValue && label) { addedGroups.add(syntax); - filters.push({key: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax)}); + const isDefault = defaultSearchQueryFilterKeys.has(syntax); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax, isDefault)}); } continue; } // Handle report field filters - only add once - if (key.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX)) { + if (isReportFieldKey(key)) { if (addedGroups.has(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX)) { continue; } @@ -6216,8 +6492,9 @@ function mapFiltersFormToLabelValueList( const value = getReportFieldDisplayValue(searchAdvancedFiltersForm, translate, dateFnsLocale); if (value) { addedGroups.add(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); - const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD); - filters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, label: translate('workspace.common.reportField'), value, ...extra}); + const isDefault = defaultSearchQueryFilterKeys.has(getReportFieldTextKey(key)); + const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, isDefault); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, label: translate('workspace.common.reportField'), value, ...extra}); } continue; } @@ -6233,11 +6510,12 @@ function mapFiltersFormToLabelValueList( const label = getLabelValue(key, labelKey, translate); if (label && value && !(Array.isArray(value) && value.length === 0)) { - filters.push({key: baseKey, label, value, ...mapper?.(key)}); + const isDefault = defaultSearchQueryFilterKeys.has(baseKey); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: baseKey, label, value, ...mapper?.(key, isDefault)}); } } - return filters; + return [...defaultFilters, ...nonDefaultFilters]; } function getSingleSelectFilterOptions(filterKey: SearchAdvancedFiltersKey, translate: LocalizedTranslate) { @@ -6252,9 +6530,15 @@ function getSingleSelectFilterOptions(filterKey: SearchAdvancedFiltersKey, trans return []; } -function getMultiSelectFilterOptions(filterKey: SearchAdvancedFiltersKey, type: SearchDataTypes, translate: LocalizedTranslate) { +function getMultiSelectFilterOptions( + filterKey: SearchAdvancedFiltersKey, + type: SearchDataTypes, + translate: LocalizedTranslate, + policies?: OnyxCollection, + policyCategories?: OnyxCollection, +) { if (filterKey === FILTER_KEYS.HAS) { - return getHasOptions(translate, type); + return getHasOptions(translate, type, {policies, policyCategories}); } if (filterKey === FILTER_KEYS.IS) { @@ -6735,7 +7019,7 @@ function getColumnsToShow({ if (!isExpenseReportView && !Array.isArray(data)) { const reportActions = Object.values(data[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}); - if (getSubmittedViolationsForTransaction(reportActions, transaction.transactionID)) { + if (getSubmittedViolationsForTransaction(reportActions, transaction.transactionID) || getApprovedViolationsForTransaction(reportActions, transaction.transactionID)) { columns[CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS] = true; } } @@ -6975,12 +7259,50 @@ function getTransactionFromTransactionListItem(item: TransactionListItemType): O return transaction as OnyxTypes.Transaction; } -function getTableMinWidth(columns: SearchColumnType[], type?: SearchDataTypes, isActionColumnWide?: boolean) { - // Starts at 24px to account for the checkbox width - let minWidth = 24; +/** + * 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. + */ +const SEARCH_TABLE_ROW_ARROW_WIDTH = variables.iconSizeNormal; + +/** + * Everything a row spends on something that is not a column: its margin and padding, the leading checkbox, the trailing + * arrow, and a gap between every adjacent pair of children. + * + * The column sizing and the horizontal scroller both subtract this from the table, so they share one expression rather + * than each keeping a copy: the two disagreeing is what makes a table scroll while it still has room, or reserve a band + * of width at the end of the row that no column ever fills. + */ +function getSearchTableRowInsetWidth(columnCount: number): number { + return variables.searchTableRowCheckboxWidth + (columnCount + 1) * variables.searchTableColumnGap + SEARCH_TABLE_ROW_ARROW_WIDTH + variables.searchTableRowChromeWidth; +} + +function getTableMinWidth( + columns: SearchColumnType[], + type?: SearchDataTypes, + isActionColumnWide?: boolean, + columnMinWidths?: Partial>, + shouldIncludeRowChrome = false, +) { + // The row lays out the checkbox, then every column, then the trailing arrow, as flex children of one gapped row, so + // it spends a gap between each adjacent pair: one more than there are columns. Those gaps, the arrow, the checkbox, + // and the row's own margin and padding are all width the table needs on top of the columns themselves. + // + // Off unless a caller asks for it, because it is worth well over 200px on a wide table and so decides whether the + // table scrolls at all. Only a caller that also passes real column widths has the rest of the arithmetic right; + // adding it on top of the estimates below, which already run several columns over, makes a table reserve room twice + // and scroll while it still has space. + let minWidth = shouldIncludeRowChrome ? getSearchTableRowInsetWidth(columns.length) : variables.searchTableRowCheckboxWidth; for (const column of columns) { - if (column === CONST.SEARCH.TABLE_COLUMNS.COMMENTS) { + // A caller that knows a column's real minimum passes it in, so use that over the estimate below. The estimates + // are a second copy of widths that live in the column styles, and several of them are off by 70px or more, so + // the table scrolls well before it has actually run out of room. + const knownMinWidth = columnMinWidths?.[column]; + + if (knownMinWidth !== undefined) { + minWidth += knownMinWidth; + } else if (column === CONST.SEARCH.TABLE_COLUMNS.COMMENTS) { minWidth += 36; } else if (column === CONST.SEARCH.TABLE_COLUMNS.RECEIPT) { minWidth += 28; @@ -7057,7 +7379,7 @@ function filterValidHasValues(hasValues: HasFilterValues | undefined, type: Sear return undefined; } - const validHasOptions = getHasOptions(translate, type); + const validHasOptions = getHasOptions(translate, type, {shouldShowAllOptions: true}); const validHasValues = new Set(validHasOptions.map((option) => option.value)); const filteredHasValues = hasValues.filter((hasValue) => validHasValues.has(hasValue)); @@ -7076,6 +7398,7 @@ function shouldShowDeleteOption( selectedTransactions: Record, currentSearchResults: SearchResults['data'] | undefined, currentUserAccountID: number, + rules: OnyxCollection, selectedReports: SelectedReports[] = [], searchDataType?: SearchDataTypes, ) { @@ -7100,7 +7423,7 @@ function shouldShowDeleteOption( reportTransactions.push(item); } } - return canDeleteMoneyRequestReport(fullReport, reportTransactions, reportActionsArray, currentUserAccountID); + return canDeleteMoneyRequestReport(fullReport, reportTransactions, reportActionsArray, currentUserAccountID, rules); }) : selectedTransactionsKeys.every((id) => { const transaction = currentSearchResults?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] ?? selectedTransactions[id]?.transaction; @@ -7114,7 +7437,7 @@ function shouldShowDeleteOption( Object.values(reportActions ?? {}).find((action) => (isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined) === id) ?? selectedTransactions[id].reportAction; - return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : [], currentUserAccountID); + return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : [], currentUserAccountID, rules); }); } @@ -7216,6 +7539,7 @@ export { isTransactionMatchWithGroupItem, isTransactionGroupListItemType, isTransactionReportGroupListItemType, + isTransactionWithdrawalIDGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionMerchantGroupListItemType, isTransactionTagGroupListItemType, @@ -7230,6 +7554,9 @@ export { isReportActionListItemType, shouldShowYear, getOverflowMenu, + getLastSearchQuery, + savedSearchIDToSearchKey, + searchKeyToSavedSearchID, isCorrectSearchUserName, isReportActionEntry, isTaskListItemType, @@ -7266,9 +7593,12 @@ export { insertColumnBeforeTotalAmount, getHasOptions, getSubmittedViolationsForTransaction, + getApprovedViolationsForTransaction, + getViolationsForTransaction, getSettlementStatus, getSettlementStatusBadgeProps, getSearchColumnTranslationKey, + getSearchTableRowInsetWidth, getTableMinWidth, getCustomColumns, getCustomColumnDefault, @@ -7285,6 +7615,7 @@ export { getDateDisplayValue, getDisplayValue, getFilterNegatableValue, + hasFilterContentValuesChanged, shouldShowFilter, mapFiltersFormToLabelValueList, isTextFilterKey, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 6cf4f48ad082..c8c7fa8730eb 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -25,6 +25,7 @@ import type Policy from '@src/types/onyx/Policy'; import type PriorityMode from '@src/types/onyx/PriorityMode'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type Rule from '@src/types/onyx/Rule'; import type {Locale as DateFnsLocale} from 'date-fns'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -699,6 +700,7 @@ function getOptionData({ translate, dateFnsLocale, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, isReportArchived, lastActionReport, @@ -711,6 +713,7 @@ function getOptionData({ currentUserLogin, isTrackIntentUser, formatPhoneNumber, + rules, }: { report: OnyxEntry; oneTransactionThreadReport: OnyxEntry; @@ -727,6 +730,7 @@ function getOptionData({ translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; localeCompare: LocaleContextProps['localeCompare']; isReportArchived: boolean | undefined; lastActionReport: OnyxEntry; @@ -739,6 +743,7 @@ function getOptionData({ currentUserLogin: string; isTrackIntentUser?: boolean; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + rules: OnyxCollection; }): 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 @@ -842,7 +847,7 @@ function getOptionData({ const isExpense = isExpenseReport(report); const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || isExpense; - const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, false, isReportArchived); + const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived); const status = personalDetail?.status ?? ''; @@ -877,6 +882,8 @@ function getOptionData({ personalDetails, policy, invoiceReceiverPolicy, + reportMetadata, + participantPersonalDetailListExcludeCurrentUser, policyTags, isReportArchived, privateIsArchived: !!reportNameValuePairs?.private_isArchived, @@ -891,6 +898,8 @@ function getOptionData({ formatPhoneNumber, dateFnsLocale, convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules, }); result.isIOUReportOwner = isIOUOwnedByCurrentUser(result as Report); diff --git a/src/libs/TagsOptionsListUtils.ts b/src/libs/TagsOptionsListUtils.ts index 163775d5c54a..b8038d62b005 100644 --- a/src/libs/TagsOptionsListUtils.ts +++ b/src/libs/TagsOptionsListUtils.ts @@ -38,7 +38,6 @@ type TagOption = Option & { }; type TagVisibility = { - /** Flag indicating if the tag is required */ isTagRequired: boolean; /** Flag indicating if the tag should be shown */ diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index b551a41feca7..d84ce27e2ce7 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BankAccountList, PersonalDetailsList, Policy, Report, ReportActions, ReportMetadata, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {BankAccountList, PersonalDetailsList, Policy, Report, ReportActions, ReportMetadata, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -44,6 +44,9 @@ type CreateTodosReportsAndTransactionsParams = { /** Whether the transaction collection has hydrated - an empty `reportTransactions` doesn't mean zero expenses until this is true */ areTransactionsLoaded: boolean; + + /** Every approval workflow rule, keyed by rule Onyx key - the submit predicate uses them to resolve rule-based approvers */ + rules: OnyxCollection; }; /** @@ -99,6 +102,9 @@ type TodoBucketContext = { /** Whether the transaction collection has hydrated - an empty `reportTransactions` doesn't mean zero expenses until this is true */ areTransactionsLoaded: boolean; + + /** Every approval workflow rule, keyed by rule Onyx key - the submit predicate uses them to resolve rule-based approvers */ + rules: OnyxCollection; }; /** @@ -122,6 +128,7 @@ function reportMatchesTodoBucket( currentUserAccountID, login, areTransactionsLoaded, + rules, }: TodoBucketContext, ): boolean { switch (searchKey) { @@ -137,7 +144,7 @@ function reportMatchesTodoBucket( } // isSubmitAction also allows workflow approvers to submit on the owner's behalf; the to-do only nudges the owner. - return isSubmitAction(report, reportTransactions, reportMetadata, ownerLogin, policy, undefined, login, currentUserAccountID) && !allExpensesHeld; + return isSubmitAction(report, reportTransactions, reportMetadata, ownerLogin, rules, policy, undefined, login, currentUserAccountID) && !allExpensesHeld; case CONST.SEARCH.SEARCH_KEYS.APPROVE: return isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && (!allExpensesHeld || currentUserPlacedHold); case CONST.SEARCH.SEARCH_KEYS.PAY: @@ -179,6 +186,7 @@ function createTodosReportsAndTransactions({ currentUserAccountID, login, areTransactionsLoaded, + rules, }: CreateTodosReportsAndTransactionsParams) { const reportsToSubmit: Report[] = []; const reportsToApprove: Report[] = []; @@ -214,6 +222,7 @@ function createTodosReportsAndTransactions({ currentUserAccountID, login, areTransactionsLoaded, + rules, }; if (reportMatchesTodoBucket(CONST.SEARCH.SEARCH_KEYS.SUBMIT, report, context)) { reportsToSubmit.push(report); @@ -250,6 +259,7 @@ function getTodoReportsForSearchKey( currentUserAccountID, login, areTransactionsLoaded, + rules, }: CreateTodosReportsAndTransactionsParams, ) { const reports: Report[] = []; @@ -276,6 +286,7 @@ function getTodoReportsForSearchKey( currentUserAccountID, login, areTransactionsLoaded, + rules, }; if (reportMatchesTodoBucket(searchKey, report, context)) { diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 8933f4e71166..2f89d6bbfd3d 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -48,6 +48,7 @@ import { isOnHold, isPending, isScanning, + shouldShowViolation, } from './TransactionUtils'; import {isInvalidMerchantValue} from './ValidationUtils'; import {filterReceiptViolations, isHardViolationOrRateDateWarning} from './Violations/ViolationsUtils'; @@ -210,7 +211,6 @@ function getTransactionPreviewTextAndTranslationPaths({ transactionDetails, isBillSplit, shouldShowRBR, - shouldShowCanceledStatus, violationMessage, reportActions, originalTransaction, @@ -225,8 +225,6 @@ function getTransactionPreviewTextAndTranslationPaths({ transactionDetails: Partial; isBillSplit: boolean; shouldShowRBR: boolean; - /** Whether a cancelled payment has to be reported on this line, because the enclosing surface doesn't show it anywhere else */ - shouldShowCanceledStatus: boolean; violationMessage?: string; reportActions?: OnyxTypes.ReportActions; originalTransaction?: OnyxEntry; @@ -329,18 +327,14 @@ function getTransactionPreviewTextAndTranslationPaths({ previewDateText = {text: date}; } - // Paid, Approved, Review required and the hold message are intentionally omitted here because the report status badge and the - // RBR row already show them, so repeating them on this line is noise. Canceled is the exception: it can't be derived from - // stateNum/statusNum, so surfaces without their own report status badge have to report it here. + // Paid, Approved, Review required and the hold message are omitted here: the status badge and the RBR row already show them. const previewStatusText: TranslationPathOrText[] = []; if (isPending(transaction)) { previewStatusText.push({translationPath: 'iou.pending'}); } - if (shouldShowCanceledStatus && iouReport?.isCancelledIOU) { - previewStatusText.push({translationPath: 'iou.canceled'}); - } else if (hasPendingRTERViolation(violations)) { + if (hasPendingRTERViolation(violations)) { previewStatusText.push({translationPath: 'iou.pendingMatch'}); } @@ -365,6 +359,27 @@ function getTransactionPreviewTextAndTranslationPaths({ }; } +/** + * The over-auto-approval-limit notice ships without `showInReview`, so the notice-type check that gates the preview RBR + * skips it, even though it is worth an RBR for whoever has to approve the report. `shouldShowViolation` is the single + * rule for who that is, and it is applied here rather than trusting the array because some callers pass raw Onyx + * violations instead of the visible ones. + */ +function hasVisibleOverAutoApprovalLimitViolation( + transaction: OnyxEntry | undefined, + violations: OnyxTypes.TransactionViolations, + currentUserEmail: string, + currentUserAccountID: number, + report: OnyxEntry, + policy: OnyxEntry, +): boolean { + if (!violations?.some((violation) => violation.name === CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT)) { + return false; + } + + return shouldShowViolation(report, policy, CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, currentUserEmail, currentUserAccountID, true, transaction); +} + function createTransactionPreviewConditionals({ iouReport, iouReportOwnerLogin, @@ -394,7 +409,7 @@ function createTransactionPreviewConditionals({ currentUserAccountID: number; reportActions?: OnyxTypes.ReportActions; }) { - const {amount: requestAmount, comment: requestComment, merchant, tag, category} = transactionDetails; + const {amount: requestAmount, comment: requestComment, merchant, category} = transactionDetails; const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); const description = truncate(StringUtils.lineBreaksToSpaces(requestComment), {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); @@ -416,7 +431,6 @@ function createTransactionPreviewConditionals({ const isFullyApproved = isApproved && !isSettlementOrApprovalPartial; const shouldShowSkeleton = isEmptyObject(transaction) && !isMessageDeleted(action) && !isDeletedAction(action) && action?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const shouldShowTag = !!tag && isReportAPolicyExpenseChat; const categoryForDisplay = isCategoryMissing(category) ? '' : category; @@ -426,6 +440,7 @@ function createTransactionPreviewConditionals({ !!hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction, violations, currentUserEmail ?? '', currentUserAccountID, iouReport ?? undefined, iouReportOwnerLogin, policy) || hasViolation(transaction, violations, currentUserEmail ?? '', currentUserAccountID, iouReport ?? undefined, iouReportOwnerLogin, policy, true) || + hasVisibleOverAutoApprovalLimitViolation(transaction, violations, currentUserEmail ?? '', currentUserAccountID, iouReport ?? undefined, policy) || (isDistanceRequest(transaction) && violations?.some( (violation) => violation.name === CONST.VIOLATIONS.MODIFIED_AMOUNT && (violation.type === CONST.VIOLATION_TYPES.VIOLATION || violation.type === CONST.VIOLATION_TYPES.NOTICE), @@ -450,7 +465,6 @@ function createTransactionPreviewConditionals({ return { shouldShowSkeleton, - shouldShowTag, shouldShowRBR, shouldShowCategory, shouldShowKeepButton, @@ -498,6 +512,11 @@ function transactionHasRBR( return true; } + // Check for the over-auto-approval-limit notice, which reaches the approver without showInReview + if (hasVisibleOverAutoApprovalLimitViolation(transaction, violations, currentUserEmail, currentUserAccountID, iouReport, policy)) { + return true; + } + // Check for distance-request modified-amount violations (type VIOLATION or NOTICE) if ( isDistanceRequest(transaction) && diff --git a/src/libs/TransactionThreadNavigationUtils.ts b/src/libs/TransactionThreadNavigationUtils.ts index 2188198a6d5a..4e1d66b070d2 100644 --- a/src/libs/TransactionThreadNavigationUtils.ts +++ b/src/libs/TransactionThreadNavigationUtils.ts @@ -34,6 +34,8 @@ type ResolveReportContext = { currentUserAccountID: number; personalDetails: OnyxEntry; conciergeChat: OnyxEntry; + isSelfTourViewed: boolean | undefined; + hasCompletedGuidedSetupFlow: boolean | undefined; }; /** @@ -73,6 +75,8 @@ function getReportIDToOpenForExpense(expense: TransactionThreadNavigationDescrip const transactionThreadReport = createTransactionThreadReport({ introSelected: context.introSelected, conciergeChat: context.conciergeChat, + isSelfTourViewed: context.isSelfTourViewed, + hasCompletedGuidedSetupFlow: context.hasCompletedGuidedSetupFlow, currentUserLogin: context.currentUserEmail ?? '', currentUserAccountID: context.currentUserAccountID, betas: context.betas, @@ -126,6 +130,8 @@ function getOrCreateTransactionThreadReportID( const transactionThreadReport = createTransactionThreadReport({ introSelected: context.introSelected, conciergeChat: context.conciergeChat, + isSelfTourViewed: context.isSelfTourViewed, + hasCompletedGuidedSetupFlow: context.hasCompletedGuidedSetupFlow, currentUserLogin: context.currentUserEmail ?? '', currentUserAccountID: context.currentUserAccountID, betas: context.betas, diff --git a/src/libs/TransactionUtils/getDistanceInMeters.ts b/src/libs/TransactionUtils/getDistanceInMeters.ts index f8ddd0f39b65..e76a0722ebbd 100644 --- a/src/libs/TransactionUtils/getDistanceInMeters.ts +++ b/src/libs/TransactionUtils/getDistanceInMeters.ts @@ -25,6 +25,12 @@ function getDistanceInMeters(transaction: OnyxInputOrEntry, unit: U return selectedRouteDistance; } + // Routes are cleared once the request is saved, leaving `routeDistanceMeters` (already in meters) as the only distance until the backend writes `quantity` back. + const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters; + if (routeDistanceMeters) { + return routeDistanceMeters; + } + return 0; } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 35529422e300..0c05a47bca95 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -97,7 +97,7 @@ import type {Locale as DateFnsLocale} from 'date-fns'; import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {format, isValid, parse} from 'date-fns'; +import {differenceInCalendarDays, format, isValid, parse, parseISO} from 'date-fns'; import {SafeString, Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; import lodashDeepClone from 'lodash/cloneDeep'; @@ -920,6 +920,20 @@ function getUpdatedTransaction({ shouldStopSmartscan = true; const existingDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters; + const quantity = transaction?.comment?.customUnit?.quantity; + const hasCommuterExclusion = hasAppliedCommuterExclusion(transaction); + // For transactions with an applied commuter exclusion, `quantity` is the route distance rounded + // to 2dp, so it differs from the exact conversion by at most 0.005. A gap larger than this rounding + // tolerance means the user manually edited the distance, so we must convert their quantity instead. + const ROUNDING_TOLERANCE = 0.01; + const isDistanceManuallyEdited = + hasCommuterExclusion && + typeof routeDistanceMeters === 'number' && + typeof quantity === 'number' && + !!existingDistanceUnit && + Math.abs(quantity - DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, existingDistanceUnit)) > ROUNDING_TOLERANCE; + const shouldUseExactRouteDistance = hasCommuterExclusion && typeof routeDistanceMeters === 'number' && !isDistanceManuallyEdited; // Get the new distance unit from the rate's unit const newDistanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: updatedTransaction, policy}); @@ -929,7 +943,9 @@ function getUpdatedTransaction({ // Skip conversion for odometer transactions — odometer readings are physical car readings and should be retained as-is. if (existingDistanceUnit && newDistanceUnit !== existingDistanceUnit && !isOdometerDistanceRequest(transaction)) { const conversionFactor = existingDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES; - const distance = roundToTwoDecimalPlaces((transaction?.comment?.customUnit?.quantity ?? 0) * conversionFactor); + const distance = roundToTwoDecimalPlaces( + shouldUseExactRouteDistance ? DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, newDistanceUnit) : (quantity ?? 0) * conversionFactor, + ); lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance); } @@ -952,7 +968,10 @@ function getUpdatedTransaction({ const fallbackConversionFactor = newDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES; const currentQuantity = updatedTransaction?.comment?.customUnit?.quantity ?? 0; - lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(currentQuantity * fallbackConversionFactor)); + const distance = shouldUseExactRouteDistance + ? DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, rateFromAnyPolicy.unit) + : currentQuantity * fallbackConversionFactor; + lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(distance)); } } } @@ -1920,7 +1939,7 @@ function isReceiptBeingScanned(transaction: OnyxInputOrEntry): bool /** * Check if category is being analyzed (manual request creation or auto-categorization grace period) */ -function isCategoryBeingAnalyzed(transaction: OnyxEntry): boolean { +function isCategoryBeingAnalyzed(transaction: OnyxEntry, report: OnyxEntry): boolean { if (!transaction) { return false; } @@ -1941,7 +1960,7 @@ function isCategoryBeingAnalyzed(transaction: OnyxEntry): boolean { } // Invoice expense is not auto-categorized - if (isInvoiceReport(transaction.reportID)) { + if (isInvoiceReport(report)) { return false; } @@ -2255,7 +2274,7 @@ function shouldShowViolation( return isAttendeeTrackingEnabledForPolicy(policy); } - if (violationName === CONST.VIOLATIONS.MISSING_CATEGORY && isCategoryBeingAnalyzed(transaction)) { + if (violationName === CONST.VIOLATIONS.MISSING_CATEGORY && isCategoryBeingAnalyzed(transaction, iouReport)) { return false; } @@ -2319,7 +2338,8 @@ function hasPendingUI(transaction: OnyxEntry, transactionViolations } /** - * Check if the transaction has a defined route + * Check if the transaction has a defined route. + * Unlike getDistanceInMeters this ignores `routeDistanceMeters`: an earlier fetch's distance does not make the current route resolved. */ function hasRoute(transaction: OnyxEntry, isDistanceRequestType?: boolean): boolean { return !!transaction?.routes?.route0?.geometry?.coordinates || (!!isDistanceRequestType && transaction?.comment?.customUnit?.quantity !== undefined); @@ -2649,6 +2669,29 @@ function hasReservationList(transaction: Transaction | undefined | null): boolea return !!transaction?.receipt?.reservationList && transaction?.receipt?.reservationList.length > 0; } +/** + * Returns the number of nights covered by a SmartScanned reservation receipt, or 0 when the + * transaction has no usable reservation range. + */ +function getReservationNights(transaction: OnyxEntry): number { + const startDate = transaction?.receipt?.hotelReservationStartDate; + const endDate = transaction?.receipt?.hotelReservationEndDate; + if (!startDate || !endDate) { + return 0; + } + + // The dates are calendar days with no time component, so they are parsed as local dates and compared by calendar + // day. Anchoring them to UTC instead would let a DST shift within the stay swallow or invent a night. + const start = parseISO(startDate); + const end = parseISO(endDate); + if (!isValid(start) || !isValid(end)) { + return 0; + } + + const nights = differenceInCalendarDays(end, start); + return nights > 0 ? nights : 0; +} + /** * Whether an expense is going to be paid later, either at checkout for hotels or drop off for car rental */ @@ -3653,6 +3696,34 @@ function hasSmartScanFailedWithMissingFields(transactions: Transaction[], report ); } +/** + * Whether a scan-failed expense is one that the backend moves to its own report on payment. Auth only moves it when + * both the merchant and the amount are unset, so anything with an amount has to stay put to keep the payment total in + * sync with the server. + */ +function isScanFailedTransactionMovedOnPayment(transaction: Transaction, report: OnyxEntry): boolean { + if (!hasSmartScanFailedWithMissingFields([transaction], report)) { + return false; + } + return getMerchant(transaction) === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && getAmount(transaction, true) === 0; +} + +/** + * Whether the report has scan-failed expenses to move out and at least one other expense left behind to pay. + */ +function shouldSplitScanFailedTransactions(transactions: Transaction[], report: OnyxEntry): boolean { + let hasScanFailedTransaction = false; + let hasRemainingTransaction = false; + for (const transaction of transactions) { + if (isScanFailedTransactionMovedOnPayment(transaction, report)) { + hasScanFailedTransaction = true; + } else { + hasRemainingTransaction = true; + } + } + return hasScanFailedTransaction && hasRemainingTransaction; +} + function getDistanceRequestType(transaction: OnyxEntry): string | undefined { const requestType = getRequestType(transaction); return isDistanceExpenseType(requestType) ? requestType : undefined; @@ -3875,7 +3946,10 @@ export { isDistanceTypeRequest, recalculateUnreportedTransactionDetails, hasSmartScanFailedWithMissingFields, + isScanFailedTransactionMovedOnPayment, + shouldSplitScanFailedTransactions, isDeletedTransaction, getDistanceRequestType, isUnreportedManagedCardTransaction, + getReservationNights, }; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index deabdb1f9afb..efd982c62ca5 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,5 +1,6 @@ import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; +import type ReportNameValuePairs from '@src/types/onyx/ReportNameValuePairs'; import type {Reservation, ReservationTimeDetails, ReservationType} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; import type {AirPnr, CarPnr, HotelPnr, Pnr, PnrData, PnrTraveler, RailPnr, TripData} from '@src/types/onyx/TripData'; @@ -502,9 +503,9 @@ function getReservationsFromSpotnanaPayload(reportID: string, tripData?: TripDat return reservations.sort((a, b) => new Date(a.reservation.start.date).getTime() - new Date(b.reservation.start.date).getTime()); } -function getReservationsFromTripReport(tripReport?: Report, transactions?: Transaction[]): ReservationData[] { - if (tripReport?.tripData?.payload) { - return getReservationsFromSpotnanaPayload(tripReport.reportID, tripReport.tripData.payload); +function getReservationsFromTripReport(tripReport?: Report, reportNameValuePairs?: ReportNameValuePairs, transactions?: Transaction[]): ReservationData[] { + if (tripReport && reportNameValuePairs?.tripData?.payload) { + return getReservationsFromSpotnanaPayload(tripReport.reportID, reportNameValuePairs.tripData.payload); } if (transactions) { return getReservationsFromTripTransactions(transactions); @@ -521,8 +522,8 @@ function formatTransitLocationLabel(reservationTimeDetails: ReservationTimeDetai return longName ? `${longName} (${shortName})` : `(${shortName})`; } -function getPNRReservationDataFromTripReport(tripReport?: Report, transactions?: Transaction[]): ReservationPNRData[] { - const reservations = getReservationsFromTripReport(tripReport, transactions); +function getPNRReservationDataFromTripReport(tripReport?: Report, reportNameValuePairs?: ReportNameValuePairs, transactions?: Transaction[]): ReservationPNRData[] { + const reservations = getReservationsFromTripReport(tripReport, reportNameValuePairs, transactions); if (reservations.length === 0) { return []; } @@ -547,7 +548,7 @@ function getPNRReservationDataFromTripReport(tripReport?: Report, transactions?: } return Array.from(pnrMap.values()).map((pnrData) => { - const pnrPayloadData = tripReport?.tripData?.payload?.pnrs?.find((pnr) => pnrData.pnrID === pnr.pnrId); + const pnrPayloadData = reportNameValuePairs?.tripData?.payload?.pnrs?.find((pnr) => pnrData.pnrID === pnr.pnrId); return { ...pnrData, totalFareAmount: ((pnrPayloadData?.data?.totalFareAmount?.base?.amount ?? 0) + (pnrPayloadData?.data?.totalFareAmount?.tax?.amount ?? 0)) * 100, @@ -556,14 +557,17 @@ function getPNRReservationDataFromTripReport(tripReport?: Report, transactions?: }); } -function getTripTotal(tripReport: Report): { +function getTripTotal( + tripReport: Report, + reportNameValuePairs?: ReportNameValuePairs, +): { totalDisplaySpend: number; currency?: string; } { - if (tripReport?.tripData?.payload) { + if (reportNameValuePairs?.tripData?.payload) { return { - totalDisplaySpend: (tripReport.tripData.payload.tripPaymentInfo?.totalFare?.amount ?? 0) * 100, - currency: tripReport.tripData.payload.tripPaymentInfo?.totalFare?.currencyCode, + totalDisplaySpend: (reportNameValuePairs.tripData.payload.tripPaymentInfo?.totalFare?.amount ?? 0) * 100, + currency: reportNameValuePairs.tripData.payload.tripPaymentInfo?.totalFare?.currencyCode, }; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index b225319da18a..8406a12de1c6 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -689,9 +689,11 @@ const ViolationsUtils = { const hasReceiptRequiredViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.RECEIPT_REQUIRED && violation.data); const hasCategoryReceiptRequiredViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.RECEIPT_REQUIRED && !violation.data); const hasItemizedReceiptRequiredViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); - const hasOverLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_LIMIT); + const existingOverLimitViolation = transactionViolations.find((violation) => violation.name === CONST.VIOLATIONS.OVER_LIMIT); + const existingCategoryOverLimitViolation = transactionViolations.find((violation) => violation.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); + const hasOverLimitViolation = !!existingOverLimitViolation; const hasOverTripLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_TRIP_LIMIT); - const hasCategoryOverLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); + const hasCategoryOverLimitViolation = !!existingCategoryOverLimitViolation; const hasMissingCommentViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_COMMENT); const hasMissingAttendeesViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); const hasTaxOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAX_OUT_OF_POLICY); @@ -719,6 +721,14 @@ const ViolationsUtils = { // Amount is stored with opposite sign (negative for expenses), so we negate it to get the actual expense amount const expenseAmount = -amount; + // A SmartScanned multi-day reservation is measured against its average nightly rate rather than its total + const reservationNights = TransactionUtils.getReservationNights(updatedTransaction); + const amountForLimitCheck = reservationNights > 0 ? expenseAmount / reservationNights : expenseAmount; + // The night count decides whether the violation reads as a nightly rate or a total, so an existing violation + // carrying a different count has to be rebuilt rather than left in place. + const expectedNights = reservationNights > 0 ? reservationNights : undefined; + const hasStaleCategoryOverLimitNights = hasCategoryOverLimitViolation && existingCategoryOverLimitViolation?.data?.nights !== expectedNights; + // The category maxExpenseAmountNoReceipt and maxExpenseAmount settings override the respective policy settings. const shouldShowReceiptRequiredViolation = canCalculateAmountViolations && @@ -754,6 +764,9 @@ const ViolationsUtils = { const overLimitAmount = policy.maxExpenseAmount; const categoryOverLimit = policyCategories[categoryName ?? '']?.maxExpenseAmount; + const categoryExpenseLimitType = policyCategories[categoryName ?? '']?.expenseLimitType; + const isIndividualExpenseLimitType = categoryExpenseLimitType === CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE || categoryExpenseLimitType === undefined; + const amountForCategoryLimitCheck = isIndividualExpenseLimitType ? amountForLimitCheck : expenseAmount; const shouldShowOverLimitViolation = canCalculateAmountViolations && !isInvoiceTransaction && @@ -767,7 +780,7 @@ const ViolationsUtils = { const shouldShowOverTripLimitViolation = canCalculateAmountViolations && !isInvoiceTransaction && TransactionUtils.hasReservationList(updatedTransaction) && isSameCurrency && expenseAmount > -updatedTransaction.amount; const shouldCategoryShowOverLimitViolation = - canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && expenseAmount > categoryOverLimit && isControlPolicy; + canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && amountForCategoryLimitCheck > categoryOverLimit && isControlPolicy; const shouldShowMissingComment = !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && @@ -859,16 +872,20 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.OVER_LIMIT}); } - if (canCalculateAmountViolations && hasCategoryOverLimitViolation && !shouldCategoryShowOverLimitViolation) { + if (canCalculateAmountViolations && hasCategoryOverLimitViolation && (!shouldCategoryShowOverLimitViolation || hasStaleCategoryOverLimitNights)) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.OVER_CATEGORY_LIMIT}); } - if (canCalculateAmountViolations && ((!hasOverLimitViolation && !!shouldShowOverLimitViolation) || (!hasCategoryOverLimitViolation && shouldCategoryShowOverLimitViolation))) { + if ( + canCalculateAmountViolations && + ((!hasOverLimitViolation && !!shouldShowOverLimitViolation) || ((!hasCategoryOverLimitViolation || hasStaleCategoryOverLimitNights) && shouldCategoryShowOverLimitViolation)) + ) { newTransactionViolations.push({ name: shouldCategoryShowOverLimitViolation ? CONST.VIOLATIONS.OVER_CATEGORY_LIMIT : CONST.VIOLATIONS.OVER_LIMIT, data: { amount: shouldCategoryShowOverLimitViolation ? categoryOverLimit : policy.maxExpenseAmount, currency: policy.outputCurrency, + ...(shouldCategoryShowOverLimitViolation && isIndividualExpenseLimitType && reservationNights > 0 ? {nights: reservationNights} : {}), }, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true, @@ -1043,7 +1060,9 @@ const ViolationsUtils = { case 'overAutoApprovalLimit': return translate('violations.overAutoApprovalLimit', convertToDisplayString(amount, currency)); case 'overCategoryLimit': - return translate('violations.overCategoryLimit', convertToDisplayString(amount, currency)); + return violation.data?.nights + ? translate('violations.overCategoryLimitPerNight', convertToDisplayString(amount, currency)) + : translate('violations.overCategoryLimit', convertToDisplayString(amount, currency)); case 'overLimit': return translate('violations.overLimit', convertToDisplayString(amount, currency)); case 'overTripLimit': @@ -1181,6 +1200,10 @@ const ViolationsUtils = { * Checks if any transactions in the report have violations that should be visible to the current user. * Filters violations based on user role (submitter, admin, policy member) and report state. * Also filters out dismissed violations. + * + * `excludedViolationNames` lets a caller that is answering a narrower question than "is anything visible" drop + * violations by name first. Callers that pair this with their own name-filtered check must pass the same list here, + * otherwise an excluded-but-visible violation vouches for a violation that the caller has already filtered out. */ hasVisibleViolationsForUser( report: OnyxEntry, @@ -1189,6 +1212,7 @@ const ViolationsUtils = { currentUserAccountID: number, policy: OnyxEntry, transactions: Transaction[], + excludedViolationNames: ViolationName[] = [], ): boolean { if (!report || !violations || !transactions) { return false; @@ -1204,6 +1228,7 @@ const ViolationsUtils = { // Check if any violation is not dismissed and should be shown based on user role and violation type return transactionViolations.some((violation: TransactionViolation) => { return ( + !excludedViolationNames.includes(violation.name) && !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, report, currentUserEmail, policy) && shouldShowViolation(report, policy, violation.name, currentUserEmail, currentUserAccountID, true, transaction) ); diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 0b91b338c49c..98d411f468c7 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -687,19 +687,21 @@ type GetApprovalLimitDescriptionParams = { approver: Approver | undefined; currency: string; translate: LocaleContextProps['translate']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; /** * Get the approval limit description for an approver (e.g., "Reports above $1,000 forward to John Doe") */ -function getApprovalLimitDescription({approver, currency, translate, convertToDisplayString}: GetApprovalLimitDescriptionParams): string | undefined { +function getApprovalLimitDescription({approver, currency, translate, formatPhoneNumber, convertToDisplayString}: GetApprovalLimitDescriptionParams): string | undefined { if (approver?.approvalLimit == null || !approver?.overLimitForwardsTo) { return undefined; } const formattedAmount = convertToDisplayString(approver.approvalLimit, currency); - const approverDisplayName = Str.removeSMSDomain(approver.overLimitForwardsToDisplayName ?? approver.overLimitForwardsTo); + const approverName = approver.overLimitForwardsToDisplayName ?? approver.overLimitForwardsTo; + const approverDisplayName = Str.isSMSLogin(approverName) ? formatPhoneNumber(approverName) : approverName; return translate('workflowsApprovalLimitPage.forwardLimitDescription', { approvalLimit: formattedAmount, @@ -879,14 +881,19 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva // Different approvers may share an `overLimitForwardsTo`, which would emit identical terminal rules. const seen = new Set(); - return rules.filter((rule) => { - const fingerprint = JSON.stringify(sortObjectKeysDeep(rule)); - if (seen.has(fingerprint)) { + const dedupedRules = rules.filter((rule) => { + const serializedRule = JSON.stringify(sortObjectKeysDeep(rule)); + if (seen.has(serializedRule)) { return false; } - seen.add(fingerprint); + seen.add(serializedRule); return true; }); + + if (!approvalWorkflow.isDefault) { + return dedupedRules; + } + return dedupedRules.map((rule) => ({...rule, isDefaultApprovalWorkflow: true})); } /** @@ -949,7 +956,7 @@ function sortObjectKeysDeep(value: unknown): unknown { return value.map(sortObjectKeysDeep); } if (value !== null && typeof value === 'object') { - // Byte-order sort (not locale-aware): this is a structural fingerprint, not user-facing text. + // Byte-order sort (not locale-aware): this builds a comparison key, not user-facing text. const sortedEntries = Object.entries(value).sort(([a], [b]) => { if (a === b) { return 0; @@ -962,11 +969,11 @@ function sortObjectKeysDeep(value: unknown): unknown { } /** - * Return a structural fingerprint of a rule with every `from` leaf's `right` (the submitter list) - * stripped and object keys sorted. Two rules with the same fingerprint differ only in their submitters, + * Describe the shape of a rule: what it matches on and what it does, with every `from` leaf's `right` (the + * submitter list) stripped and object keys sorted. Two rules with the same shape differ only in their submitters, * which is what we look for when deciding whether to merge two workflows into a shared rule. */ -function structuralFingerprint(rule: ApprovalWorkflowRule): string { +function getRuleShape(rule: ApprovalWorkflowRule): string { const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { if (!node) { return node; @@ -985,6 +992,7 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { triggers: rule.triggers, filters: stripFromValues(rule.filters), actions: rule.actions, + isDefaultApprovalWorkflow: !!rule.isDefaultApprovalWorkflow, }), ); } @@ -1020,6 +1028,61 @@ type ReconcileContext = { existingRules: Record; }; +type BuildApprovalWorkflowRulesForSaveContext = { + /** The policy's existing approval-workflow rules (`ruleID -> rule body`). */ + existingRules: Record; + + /** The policy's employees, used to read the default chain when it isn't rule-backed yet. */ + employees: PolicyEmployeeList; + + /** The policy's default approver. */ + defaultApprover: string; +}; + +/** + * Build the rules for a workflow that is being saved. + * + * A workflow routing exactly the way the default workflow does *is* the default workflow, so rather than + * standing up a duplicate of it we hand back the default-workflow shape and let the reconcilers fold the + * members into the existing default rules. When the default workflow has no rules at all, that means writing + * nothing: the members already reach the default approver through `employeeList`. + */ +function buildApprovalWorkflowRulesForSave( + approvalWorkflow: ApprovalWorkflow, + {existingRules, employees, defaultApprover}: BuildApprovalWorkflowRulesForSaveContext, +): ApprovalWorkflowRule[] { + const hasRuleBasedDefault = hasRuleBasedDefaultWorkflow(existingRules); + const rulesAsDefault = buildApprovalWorkflowRules({...approvalWorkflow, isDefault: true}); + + // Compare against the default workflow's own rules, or - before it has any - against the shapes the + // `employeeList` chain would produce. A rule's shape ignores its `from` list, so the members these are built + // against don't affect the comparison. + const defaultWorkflowRules = hasRuleBasedDefault + ? Object.values(existingRules).filter((rule) => !!rule.isDefaultApprovalWorkflow) + : buildApprovalWorkflowRules({ + members: approvalWorkflow.members, + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail: {}}), + isDefault: true, + }); + const defaultWorkflowRuleShapes = new Set(defaultWorkflowRules.map(getRuleShape)); + const matchesDefaultWorkflow = rulesAsDefault.length > 0 && rulesAsDefault.every((rule) => defaultWorkflowRuleShapes.has(getRuleShape(rule))); + + if (!matchesDefaultWorkflow) { + // `rulesAsDefault` is already the answer when this workflow is itself the default one. + return approvalWorkflow.isDefault ? rulesAsDefault : buildApprovalWorkflowRules(approvalWorkflow); + } + if (hasRuleBasedDefault) { + return rulesAsDefault; + } + + // The default workflow has no rules to fold into, so writing none leaves these members on whatever + // `employeeList` says. That is the default chain only for members who already submit to the default + // approver. Anyone arriving from another workflow still carries that workflow's approver there, because + // rules-based saves never touch `employeeList`. Dropping their rules would silently route them to it. + const membersAlreadyRoutedToDefault = getWorkflowMemberEmails(approvalWorkflow.members).every((email) => employees[email]?.submitsTo === defaultApprover); + return membersAlreadyRoutedToDefault ? [] : rulesAsDefault; +} + /** * Reconcile a freshly created workflow against the existing rules. New rules that match an * existing rule's structure (ignoring `from`) are folded into that existing rule by appending @@ -1031,8 +1094,8 @@ function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[ const existingEntries = Object.entries(context.existingRules); for (const newRule of newRules) { - const fingerprint = structuralFingerprint(newRule); - const match = existingEntries.find(([, existing]) => structuralFingerprint(existing) === fingerprint); + const ruleShape = getRuleShape(newRule); + const match = existingEntries.find(([, existing]) => getRuleShape(existing) === ruleShape); if (match) { const [existingID, existingRule] = match; @@ -1053,7 +1116,7 @@ function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { const diff: ApprovalWorkflowRulesDiff = {}; const memberSet = new Set(memberEmails); - const newFingerprints = new Set(newRules.map(structuralFingerprint)); + const newRuleShapes = new Set(newRules.map(getRuleShape)); // Track which existing rules we've already turned into a no-op match so we don't drop them in pass 2. const matchedExistingIDs = new Set(); @@ -1065,8 +1128,8 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], continue; } - const fingerprint = structuralFingerprint(existingRule); - if (newFingerprints.has(fingerprint)) { + const ruleShape = getRuleShape(existingRule); + if (newRuleShapes.has(ruleShape)) { // Structurally identical to a new rule: leave it alone but remember it as "covered". matchedExistingIDs.add(ruleID); continue; @@ -1079,10 +1142,10 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], // Pass 2: create or extend rules for any new rule that wasn't already covered. const existingEntries = Object.entries(context.existingRules); for (const newRule of newRules) { - const fingerprint = structuralFingerprint(newRule); + const ruleShape = getRuleShape(newRule); // Already in place via a pass-1 match — nothing to do. - const alreadyCovered = existingEntries.some(([id, existing]) => matchedExistingIDs.has(id) && structuralFingerprint(existing) === fingerprint); + const alreadyCovered = existingEntries.some(([id, existing]) => matchedExistingIDs.has(id) && getRuleShape(existing) === ruleShape); if (alreadyCovered) { continue; } @@ -1092,7 +1155,7 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], if (matchedExistingIDs.has(id)) { return false; } - if (structuralFingerprint(existing) !== fingerprint) { + if (getRuleShape(existing) !== ruleShape) { return false; } // "Different workflow" => no overlap with our members. @@ -1359,7 +1422,7 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai } /** Structural identity of a chain — used to fold submitters with identical chains into one workflow. */ -function approverChainFingerprint(chain: Approver[]): string { +function getApproverChainKey(chain: Approver[]): string { return JSON.stringify( chain.map((approver) => ({ email: approver.email, @@ -1370,12 +1433,16 @@ function approverChainFingerprint(chain: Approver[]): string { ); } -/** The sorted set of ruleIDs whose `from` filter includes this submitter — their exact rule membership. */ -function getSubmitterRuleIDs(submitter: string, rules: Record): string[] { - return Object.entries(rules) - .filter(([, rule]) => extractSubmitterEmails(rule).includes(submitter)) - .map(([ruleID]) => ruleID) - .sort(); +/** + * True when the policy's default workflow has rules of its own, meaning at least one rule declares itself part + * of it through `isDefaultApprovalWorkflow`. + * + * Rules only say so once the default workflow has been saved through the rules backend. A policy that predates + * that field, or has never had its default workflow edited, has none. Callers use this to decide whether the + * rules can answer "which workflow is the default", or whether to fall back to matching the default approver. + */ +function hasRuleBasedDefaultWorkflow(rules: Record): boolean { + return Object.values(rules).some((rule) => !!rule.isDefaultApprovalWorkflow); } /** @@ -1414,34 +1481,53 @@ function getApprovalWorkflowRulesForPolicy(rulesCollection: OnyxCollection triggers: toIndexMap(Object.values(rule.triggers ?? {})), filters: rule.filters, actions: toIndexMap(Object.values(rule.actions ?? {})), + ...(rule.isDefaultApprovalWorkflow ? {isDefaultApprovalWorkflow: true} : {}), }; } return result; } -/** Map every submitter found in the rules to their workflow's first approver. */ -function getRulesSubmitterToFirstApprover(rules: Record, employees: PolicyEmployeeList = {}): Record { +/** + * Map every submitter found in the rules to their workflow's first approver. + */ +function getRulesSubmitterToFirstApprover(rules: Record, employees: PolicyEmployeeList = {}, defaultApprover?: string): Record { const submitters = new Set(); + const defaultWorkflowSubmitters = new Set(); for (const rule of Object.values(rules)) { for (const email of extractSubmitterEmails(rule)) { submitters.add(email); + if (rule.isDefaultApprovalWorkflow) { + defaultWorkflowSubmitters.add(email); + } } } + const shouldExcludeDefaultWorkflow = defaultApprover !== undefined; + const canReadDefaultWorkflowFromRules = shouldExcludeDefaultWorkflow && hasRuleBasedDefaultWorkflow(rules); + const result: Record = {}; for (const submitter of submitters) { + // Resolving the chain is the expensive part, so skip it for submitters the rules already exclude. + if (canReadDefaultWorkflowFromRules && defaultWorkflowSubmitters.has(submitter)) { + continue; + } + const firstApprover = resolveFirstApprover(submitter, rules, employees); - if (firstApprover) { - result[submitter] = firstApprover; + if (!firstApprover) { + continue; } + if (shouldExcludeDefaultWorkflow && !canReadDefaultWorkflowFromRules && firstApprover === defaultApprover) { + continue; + } + result[submitter] = firstApprover; } return result; } /** - * Map every submitter found in the rules to a stable identity of the workflow they belong to (a fingerprint - * of their full approver chain). Unlike `getRulesSubmitterToFirstApprover`, this distinguishes workflows that + * Map every submitter found in the rules to a stable key for the workflow they belong to, derived from their + * full approver chain. Unlike `getRulesSubmitterToFirstApprover`, this distinguishes workflows that * share a first approver but diverge later, so callers can detect a genuine cross-workflow move rather than * treating "same first approver" as "same workflow". */ @@ -1455,16 +1541,16 @@ function getRulesSubmitterToWorkflowKey(rules: Record = {}; for (const submitter of submitters) { - // The fingerprint ignores display/avatar fields, so personal details aren't needed here. + // The key ignores display/avatar fields, so personal details aren't needed here. const chain = buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail: {}}); if (chain.length > 0) { - result[submitter] = approverChainFingerprint(chain); + result[submitter] = getApproverChainKey(chain); } } return result; } -/** The submitters and approver chain of one workflow, accumulated while grouping employees by fingerprint. */ +/** The submitters and approver chain of one workflow, accumulated while grouping employees by workflow key. */ type WorkflowGroup = { chain: Approver[]; members: Member[]; @@ -1496,9 +1582,20 @@ function convertApprovalWorkflowRulesToWorkflows({ personalDetailsByEmail[value?.login ?? key] = value; } - // Keyed by a source-tagged fingerprint so a legacy chain and a rule-based chain with the same shape stay - // in separate workflows. Tag values: 'r' (any rule mentions the submitter) or 'l'. - const groupedByFingerprint = new Map(); + // Keyed by a source-tagged workflow key so a legacy chain and a rule-based chain with the same shape stay + // in separate workflows. + const ruleBackedSubmitters = new Set(); + const defaultWorkflowSubmitters = new Set(); + for (const rule of Object.values(rules)) { + for (const submitter of extractSubmitterEmails(rule)) { + ruleBackedSubmitters.add(submitter); + if (rule.isDefaultApprovalWorkflow) { + defaultWorkflowSubmitters.add(submitter); + } + } + } + + const groupedByWorkflowKey = new Map(); const usedApproverEmails = new Set(); const availableMembers: Member[] = []; @@ -1555,13 +1652,12 @@ function convertApprovalWorkflowRulesToWorkflows({ } } - // Group by resolved approver chain (not by ruleID set) so submitters routing through the same chain - // render as one card even when their rules are stored as separate pairs. The `r`/`l` tag keeps - // rule-based chains separate from legacy employeeList chains. - const hasRuleBasedChain = getSubmitterRuleIDs(email, rules).length > 0; - const chainKey = approverChainFingerprint(chain); - const fingerprint = hasRuleBasedChain ? `r|${chainKey}` : `l|${chainKey}`; - const existingGroup = groupedByFingerprint.get(fingerprint); + const hasRuleBasedChain = ruleBackedSubmitters.has(email); + const isDefaultWorkflowChain = hasRuleBasedChain ? defaultWorkflowSubmitters.has(email) : firstApproverEmail === defaultApprover; + + const chainKey = getApproverChainKey(chain); + const workflowKey = `${hasRuleBasedChain ? 'r' : 'l'}${isDefaultWorkflowChain ? 'd' : ''}|${chainKey}`; + const existingGroup = groupedByWorkflowKey.get(workflowKey); if (existingGroup) { if (pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { @@ -1574,15 +1670,24 @@ function convertApprovalWorkflowRulesToWorkflows({ } const workflowPendingAction = pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? pendingAction : undefined; - groupedByFingerprint.set(fingerprint, { + groupedByWorkflowKey.set(workflowKey, { chain, members: pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? [member] : [], - isDefault: firstApproverEmail === defaultApprover, + isDefault: isDefaultWorkflowChain, pendingAction: workflowPendingAction, }); } - const sortedApprovalWorkflows: ApprovalWorkflow[] = Array.from(groupedByFingerprint.values()) + const workflowGroups = Array.from(groupedByWorkflowKey.values()); + + if (!workflowGroups.some((group) => group.isDefault)) { + const groupStartingAtDefaultApprover = workflowGroups.find((group) => group.chain.at(0)?.email === defaultApprover); + if (groupStartingAtDefaultApprover) { + groupStartingAtDefaultApprover.isDefault = true; + } + } + + const sortedApprovalWorkflows: ApprovalWorkflow[] = workflowGroups .map(({chain, members, isDefault, pendingAction}) => ({ members, approvers: chain, @@ -1615,8 +1720,9 @@ function convertApprovalWorkflowRulesToWorkflows({ export { applyApprovalWorkflowRulesDiff, - approverChainFingerprint, + getApproverChainKey, buildApprovalWorkflowRules, + buildApprovalWorkflowRulesForSave, calculateApprovers, convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, @@ -1628,6 +1734,7 @@ export { getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, getWorkflowMemberEmails, + hasRuleBasedDefaultWorkflow, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, diff --git a/src/libs/WorkspaceReportFieldUtils.ts b/src/libs/WorkspaceReportFieldUtils.ts index fa92208ac518..2ff9ffd1d74f 100644 --- a/src/libs/WorkspaceReportFieldUtils.ts +++ b/src/libs/WorkspaceReportFieldUtils.ts @@ -5,8 +5,12 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type ONYXKEYS from '@src/ONYXKEYS'; import type {InputID} from '@src/types/form/WorkspaceReportFieldForm'; +import type {Policy} from '@src/types/onyx'; import type {PolicyReportField, PolicyReportFieldType} from '@src/types/onyx/Policy'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + import type {FormulaPart} from './Formula'; import {addErrorMessage} from './ErrorUtils'; @@ -66,8 +70,9 @@ function validateReportFieldListValueName( /** * Generates a field ID based on the field name. */ -function generateFieldID(name: string) { - return `field_id_${name.replaceAll(CONST.REGEX.ANY_SPACE, '_').toUpperCase()}`; +function generateFieldID(name: string, target?: ValueOf) { + const targetPrefix = target ? `${target.toUpperCase()}_` : ''; + return `field_id_${targetPrefix}${name.replaceAll(CONST.REGEX.ANY_SPACE, '_').toUpperCase()}`; } /** @@ -109,8 +114,58 @@ function hasFormulaPartsInInitialValue(initialValue?: string): boolean { /** * Checks if a report field name already exists in the policy's field list (case-insensitive). */ -function isReportFieldNameExisting(fieldList: Record | undefined, fieldName: string): boolean { - return Object.values(fieldList ?? {}).some((reportField) => reportField.name.toLowerCase() === fieldName.toLowerCase()); +function isReportFieldNameExisting(fieldList: Record | undefined, fieldName: string, expectedTarget?: ValueOf): boolean { + return Object.values(fieldList ?? {}).some((reportField) => { + if (!isReportFieldTargetValid(reportField, expectedTarget)) { + return false; + } + + return reportField.name.toLowerCase() === fieldName.toLowerCase(); + }); +} + +/** + * Determines whether a report field matches the expected target. + */ +function isReportFieldTargetValid(reportField: PolicyReportField | null, expectedTarget?: ValueOf): boolean { + if (!reportField) { + return false; + } + + if (expectedTarget === CONST.REPORT_FIELD_TARGETS.INVOICE) { + return reportField.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + } + + if (expectedTarget === CONST.REPORT_FIELD_TARGETS.EXPENSE) { + return !reportField.target || reportField.target === CONST.REPORT_FIELD_TARGETS.EXPENSE; + } + + return true; +} + +/** + * Returns report fields that match the expected target. + */ +function getReportFieldsForTarget(fieldList: Record | undefined, expectedTarget?: ValueOf): Record { + return Object.fromEntries(Object.entries(fieldList ?? {}).filter(([, reportField]) => isReportFieldTargetValid(reportField, expectedTarget))); +} + +/** + * Determines whether a report field was imported from the accounting integration the workspace is currently connected to. + * + * The `origin` an integration stamps on a report field sticks around after that integration is disconnected, so the + * origin on its own isn't enough. A field only counts as imported while the connection that created it is still active, + * which means leftover fields from a disconnected (or replaced) integration become regular fields that admins can + * disable and delete again. + */ +function isReportFieldImportedFromIntegration(reportField: PolicyReportField | undefined | null, policy: OnyxEntry): boolean { + if (!reportField?.origin) { + return false; + } + + return CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.some( + (connectionName) => CONST.POLICY.CONNECTIONS.REPORT_FIELD_ORIGIN[connectionName] === reportField.origin && !!policy?.connections?.[connectionName], + ); } /** @@ -221,4 +276,7 @@ export { getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue, isReportFieldNameExisting, + isReportFieldTargetValid, + getReportFieldsForTarget, + isReportFieldImportedFromIntegration, }; diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts index cbe9cba393de..5f59ffd24698 100644 --- a/src/libs/actions/Agent.ts +++ b/src/libs/actions/Agent.ts @@ -1,3 +1,4 @@ +import {resolveAgentAccountID} from '@libs/AgentAccountIDMapping'; import {read, write} from '@libs/API'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {buildAvatarCropResult} from '@libs/AvatarCropUtils'; @@ -108,17 +109,9 @@ function createAgent( }, ]; + // The optimistic personal detail and agent prompt are not cleared here: replaceOptimisticAgentWithActualAgent + // clears them after redirecting any open agent settings screen, so the cleanup can never beat the redirect. const successData: AnyOnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: {[optimisticAccountID]: null}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, - value: null, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, @@ -207,15 +200,17 @@ function clearAgentDeleteError(accountID: number) { } function updateAgentName(accountID: number, firstName: string, originalFirstName: string) { + // 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.PERSONAL_DETAILS_LIST, - value: {[accountID]: {displayName: firstName}}, + value: {[agentAccountID]: {displayName: firstName}}, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, nameErrors: null}, }, ]; @@ -223,7 +218,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName const successData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: null, nameErrors: null}, }, ]; @@ -232,20 +227,22 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: {[accountID]: {displayName: originalFirstName}}, + value: {[agentAccountID]: {displayName: originalFirstName}}, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: null, nameErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateName')}, }, ]; - write(WRITE_COMMANDS.UPDATE_AGENT_NAME, {agentAccountID: accountID, firstName}, {optimisticData, successData, failureData}); + write(WRITE_COMMANDS.UPDATE_AGENT_NAME, {agentAccountID, firstName}, {optimisticData, successData, failureData}); } function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: string) { - const onyxKey = `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`; + // 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 optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -270,7 +267,7 @@ function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: st }, ]; - write(WRITE_COMMANDS.UPDATE_AGENT_PROMPT, {agentAccountID: accountID, prompt}, {optimisticData, successData, failureData}); + write(WRITE_COMMANDS.UPDATE_AGENT_PROMPT, {agentAccountID, prompt}, {optimisticData, successData, failureData}); } function clearAgentAvatarUpdateError(accountID: number) { @@ -284,12 +281,14 @@ function updateAgentAvatar( ) { const isCustomExpensifyAvatar = 'customExpensifyAvatarID' in update; + // 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.PERSONAL_DETAILS_LIST, value: { - [accountID]: { + [agentAccountID]: { avatar: update.uri, avatarThumbnail: update.uri, pendingFields: {avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, @@ -299,7 +298,7 @@ function updateAgentAvatar( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, avatarErrors: null}, }, ]; @@ -309,7 +308,7 @@ function updateAgentAvatar( onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { - [accountID]: { + [agentAccountID]: { pendingFields: {avatar: null}, errorFields: {avatar: null}, }, @@ -317,7 +316,7 @@ function updateAgentAvatar( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: null, avatarErrors: null}, }, ]; @@ -327,7 +326,7 @@ function updateAgentAvatar( onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { - [accountID]: { + [agentAccountID]: { avatar: currentAvatar, avatarThumbnail: typeof currentAvatar === 'string' ? currentAvatar : undefined, pendingFields: {avatar: null}, @@ -337,21 +336,23 @@ function updateAgentAvatar( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: null, avatarErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateAvatar')}, }, ]; - const params = isCustomExpensifyAvatar ? {agentAccountID: accountID, customExpensifyAvatarID: update.customExpensifyAvatarID} : {agentAccountID: accountID, file: update.file}; + const params = isCustomExpensifyAvatar ? {agentAccountID, customExpensifyAvatarID: update.customExpensifyAvatarID} : {agentAccountID, file: update.file}; write(WRITE_COMMANDS.UPDATE_AGENT_AVATAR, params, {optimisticData, successData, failureData}); } function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxCollection, shouldNavigateBack = true) { + // 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}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, }, ]; @@ -359,20 +360,20 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC const successData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: null, }, { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: {[accountID]: null}, + value: {[agentAccountID]: null}, }, ]; const failureData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, value: { pendingAction: null, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), @@ -405,7 +406,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC } } - write(WRITE_COMMANDS.DELETE_AGENT, {agentAccountID: accountID}, {optimisticData, successData, failureData}); + write(WRITE_COMMANDS.DELETE_AGENT, {agentAccountID}, {optimisticData, successData, failureData}); // 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. diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 0be2b5185427..b73ab96f3a0b 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -37,6 +37,8 @@ import {Str} from 'expensify-common'; import {AppState} from 'react-native'; import Onyx from 'react-native-onyx'; +import type {PolicyOwner} from './Policy/Policy'; + import clearOnyxAndSeedFullReconnect from './clearOnyxAndSeedFullReconnect'; import {setShouldForceOffline} from './Network'; import {getAll, rollbackOngoingRequest, save} from './PersistedRequests'; @@ -48,9 +50,10 @@ type PolicyParamsForOpenOrReconnect = { // `currentSessionData` is only used in actions, not during render. So `Onyx.connectWithoutView` is appropriate. // If React components need this value in the future, use `useOnyx` instead. -let currentSessionData: {accountID?: number; email: string} = { +let currentSessionData: {accountID?: number; email: string; authToken?: string} = { accountID: undefined, email: '', + authToken: undefined, }; Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, @@ -58,6 +61,7 @@ Onyx.connectWithoutView({ currentSessionData = { accountID: val?.accountID, email: val?.email ?? '', + authToken: val?.authToken, }; }, }); @@ -153,8 +157,9 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.PRESERVED_USER_SESSION, ONYXKEYS.PRESERVED_ACCOUNT, ONYXKEYS.HYBRID_APP, - ONYXKEYS.SHOULD_USE_STAGING_SERVER, + ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.IS_DEBUG_MODE_ENABLED, + ONYXKEYS.BETA_OVERRIDES, ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, ONYXKEYS.STASHED_SESSION, @@ -231,16 +236,13 @@ function setSidebarLoaded() { Onyx.set(ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, true); } -function setAppLoading(isLoading: boolean) { - Onyx.set(ONYXKEYS.IS_LOADING_APP, isLoading); -} - /** * Saves the current navigation path to lastVisitedPath before app goes to background */ function saveCurrentPathBeforeBackground() { try { - if (!navigationRef.isReady()) { + // Signed out there is only the sign-in page to save, and on Android the SAML browser backgrounds the app. + if (!navigationRef.isReady() || !currentSessionData.authToken) { return; } @@ -594,7 +596,7 @@ type PolicyType = typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPO type CreateWorkspaceWithPolicyDraftParams = { isSelfTourViewed: boolean | undefined; introSelected: OnyxEntry; - policyOwnerEmail?: string; + policyOwner?: PolicyOwner; policyName: string; transitionFromOldDot?: boolean; makeMeAdmin?: boolean; @@ -612,6 +614,7 @@ type CreateWorkspaceWithPolicyDraftParams = { type?: PolicyType; betas: OnyxEntry; hasActiveAdminPolicies: boolean; + hasOwnedPaidPolicy: boolean; isAnnualSubscription?: boolean; }; @@ -621,7 +624,7 @@ type CreateWorkspaceWithPolicyDraftParams = { function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWithPolicyDraftParams) { const { introSelected, - policyOwnerEmail = '', + policyOwner, policyName, transitionFromOldDot = false, makeMeAdmin = false, @@ -640,6 +643,7 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription = false, } = params; @@ -664,7 +668,7 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi savePolicyDraftByNewWorkspace({ policyID: policyIDWithDefault, policyName, - policyOwnerEmail, + policyOwner, makeMeAdmin, currency, file, @@ -680,6 +684,7 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription, }); @@ -706,7 +711,6 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi function createWorkspaceWithPolicyDraft(params: CreateWorkspaceWithPolicyDraftParams) { const { introSelected, - policyOwnerEmail = '', policyName, makeMeAdmin = false, policyID = '', @@ -721,6 +725,7 @@ function createWorkspaceWithPolicyDraft(params: CreateWorkspaceWithPolicyDraftPa isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, } = params; createDraftInitialWorkspace({ @@ -736,7 +741,6 @@ function createWorkspaceWithPolicyDraft(params: CreateWorkspaceWithPolicyDraftPa savePolicyDraftByNewWorkspace({ policyID, policyName, - policyOwnerEmail, makeMeAdmin, currency, file, @@ -751,6 +755,7 @@ function createWorkspaceWithPolicyDraft(params: CreateWorkspaceWithPolicyDraftPa isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); } @@ -758,7 +763,7 @@ type SavePolicyDraftByNewWorkspaceParams = { isSelfTourViewed: boolean | undefined; policyID?: string; policyName: string; - policyOwnerEmail?: string; + policyOwner?: PolicyOwner; makeMeAdmin?: boolean; currency?: string; file?: File; @@ -773,6 +778,7 @@ type SavePolicyDraftByNewWorkspaceParams = { type?: PolicyType; betas: OnyxEntry; hasActiveAdminPolicies: boolean; + hasOwnedPaidPolicy: boolean; isAnnualSubscription?: boolean; }; @@ -782,7 +788,7 @@ type SavePolicyDraftByNewWorkspaceParams = { function savePolicyDraftByNewWorkspace({ policyID, policyName, - policyOwnerEmail = '', + policyOwner, makeMeAdmin = false, currency = '', file, @@ -798,10 +804,11 @@ function savePolicyDraftByNewWorkspace({ isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription = false, }: SavePolicyDraftByNewWorkspaceParams) { createWorkspace({ - policyOwnerEmail, + policyOwner, makeMeAdmin, policyName, policyID, @@ -820,6 +827,7 @@ function savePolicyDraftByNewWorkspace({ isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription, }); } @@ -839,18 +847,37 @@ function savePolicyDraftByNewWorkspace({ * When the exitTo route is 'workspace/new', we create a new * workspace and navigate to it */ -function setUpPoliciesAndNavigate( - session: OnyxEntry, - introSelected: OnyxEntry, - currency: string, - activePolicy: OnyxEntry, - isSelfTourViewed: boolean | undefined, - betas: OnyxEntry, - hasActiveAdminPolicies: boolean, - lastWorkspaceNumber: number | undefined, - translate: LocalizedTranslate, - conciergeChat: OnyxEntry, -) { +type SetUpPoliciesAndNavigateParams = { + session: OnyxEntry; + introSelected: OnyxEntry; + currency: string; + activePolicy: OnyxEntry; + isSelfTourViewed: boolean | undefined; + betas: OnyxEntry; + hasActiveAdminPolicies: boolean; + lastWorkspaceNumber: number | undefined; + translate: LocalizedTranslate; + conciergeChat: OnyxEntry; + policyOwnerAccountID: number | undefined; + policyOwnerDisplayName: string | undefined; + hasOwnedPaidPolicy: boolean; +}; + +function setUpPoliciesAndNavigate({ + session, + introSelected, + currency, + activePolicy, + isSelfTourViewed, + betas, + hasActiveAdminPolicies, + hasOwnedPaidPolicy, + lastWorkspaceNumber, + translate, + conciergeChat, + policyOwnerAccountID, + policyOwnerDisplayName, +}: SetUpPoliciesAndNavigateParams) { const currentUrl = getCurrentUrl(); if (!session || !currentUrl?.includes('exitTo')) { return; @@ -874,8 +901,8 @@ function setUpPoliciesAndNavigate( createWorkspaceWithPolicyDraftAndNavigateToIt({ introSelected, currency, - policyOwnerEmail, - policyName: policyName || generateDefaultWorkspaceName(policyOwnerEmail, lastWorkspaceNumber, translate), + policyOwner: {email: policyOwnerEmail, accountID: policyOwnerAccountID}, + policyName: policyName || generateDefaultWorkspaceName(policyOwnerEmail, lastWorkspaceNumber, translate, policyOwnerDisplayName), transitionFromOldDot: true, makeMeAdmin, activePolicy, @@ -885,6 +912,7 @@ function setUpPoliciesAndNavigate( isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); return; } @@ -993,12 +1021,19 @@ function showSupportalPermissionDenied(payload: OnyxTypes.SupportalPermissionDen Onyx.set(ONYXKEYS.SUPPORTAL_PERMISSION_DENIED, payload); } +/** + * Clears the Corpay pay modal signal for the current session. + */ +function clearCorpayPayModal() { + Onyx.set(ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, null); +} + export { setLocale, setSidebarLoaded, + saveCurrentPathBeforeBackground, setUpPoliciesAndNavigate, openApp, - setAppLoading, reconnectApp, loadPostDataForOpenOrReconnect, triggerFullReconnect, @@ -1014,6 +1049,7 @@ export { clearOnyxAndResetApp, clearSupportalPermissionDenied, showSupportalPermissionDenied, + clearCorpayPayModal, setPreservedUserSession, getNonOptimisticPolicyIDs, setPreservedAccount, diff --git a/src/libs/actions/AppUpdate/index.ts b/src/libs/actions/AppUpdate/index.ts index 90b657ea46da..c45b0bff9384 100644 --- a/src/libs/actions/AppUpdate/index.ts +++ b/src/libs/actions/AppUpdate/index.ts @@ -8,8 +8,4 @@ function triggerUpdateAvailable() { Onyx.set(ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, true); } -function setIsAppInBeta(isBeta: boolean) { - Onyx.set(ONYXKEYS.IS_BETA, isBeta); -} - -export {triggerUpdateAvailable, setIsAppInBeta, updateApp}; +export {triggerUpdateAvailable, updateApp}; diff --git a/src/libs/actions/Attachment/types.ts b/src/libs/actions/Attachment/types.ts index 93f88d287669..19f66ebee52f 100644 --- a/src/libs/actions/Attachment/types.ts +++ b/src/libs/actions/Attachment/types.ts @@ -17,10 +17,7 @@ type GetCachedAttachmentProps = { /** Attachment ID based on the data-attachment-id attribute */ attachmentID: string; - /** Attachment data from Onyx */ attachment: OnyxEntry; - - /** Current source of the attachment */ currentSource: string; }; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index dfe88bb8d4de..8632c150be68 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -82,13 +82,8 @@ type OpenPersonalBankAccountSetupViewProps = { /** The policyID of the policy to set the bank account on */ policyID?: string; - /** The source of the bank account */ source?: string; - - /** Whether to set up a US bank account */ shouldSetUpUSBankAccount?: boolean; - - /** Whether the user is validated */ isUserValidated?: boolean; /** Route to navigate to after adding a bank account when the KYC flow should continue */ @@ -448,7 +443,7 @@ function getOnyxDataForConnectingVBBAAndLastPaymentMethod(policyID?: string, las /** * Submit Bank Account step with Plaid data so php can perform some checks. */ -function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string) { +function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string | undefined) { const isChaseBank = selectedPlaidBankAccount.bankName?.toLowerCase() === CONST.BANK_NAMES.CHASE; if (bankAccountID === CONST.DEFAULT_NUMBER_ID && isChaseBank) { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { @@ -1378,7 +1373,7 @@ function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContr /** * Create the bank account with manually entered data. */ -function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBankAccount, policyID: string) { +function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBankAccount, policyID: string | undefined) { const parameters: ConnectBankAccountParams = { bankAccountID: !Number.isNaN(bankAccountID) ? bankAccountID : CONST.DEFAULT_NUMBER_ID, routingNumber: bankAccount.routingNumber, diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index dec9ea4b7cde..7e2d0df7daab 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -3,6 +3,7 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import * as API from '@libs/API'; import type { ActivatePhysicalExpensifyCardParams, + ApproveDigitalWalletCardAdditionParams, CardDeactivateParams, CreateExpensifyCardParams, DeletePersonalCardParams, @@ -63,7 +64,6 @@ type IssueNewCardFlowData = { /** Data required to be sent to issue a new card */ data?: Partial; - /** ID of the policy */ policyID: string | undefined; /** Whether the changing assignee is disabled. E.g., The assignee is auto selected from workspace members page */ @@ -75,6 +75,26 @@ type CardListUpdateData = Omit, 'errors'> & { errors?: Card['errors'] | null; }; +/** + * Shared isLoading updates so both card writes show and hide the same spinner. The generic failure error is a + * fallback for responses that carry no message of their own; a backend error keyed by a later timestamp wins. + */ +function buildCardLoadingOnyxData(cardID: number) { + const mergeCard = (value: CardListUpdateData): Array> => [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: {[cardID]: value}, + }, + ]; + + return { + optimisticData: mergeCard({errors: null, isLoading: true}), + successData: mergeCard({isLoading: false}), + failureData: mergeCard({errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), isLoading: false}), + }; +} + function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) { const cardID = card?.cardID ?? CONST.DEFAULT_NUMBER_ID; const optimisticData: Array> = [ @@ -200,53 +220,25 @@ function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReas * Activates the physical Expensify card based on the last four digits of the card number */ function activatePhysicalExpensifyCard(cardLastFourDigits: string, cardID: number) { - const optimisticData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - errors: null, - isLoading: true, - }, - }, - }, - ]; - - const successData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - isLoading: false, - }, - }, - }, - ]; - - const failureData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - isLoading: false, - }, - }, - }, - ]; - const parameters: ActivatePhysicalExpensifyCardParams = { cardLastFourDigits, cardID, }; - API.write(WRITE_COMMANDS.ACTIVATE_PHYSICAL_EXPENSIFY_CARD, parameters, { - optimisticData, - successData, - failureData, - }); + API.write(WRITE_COMMANDS.ACTIVATE_PHYSICAL_EXPENSIFY_CARD, parameters, buildCardLoadingOnyxData(cardID)); +} + +/** + * Confirms or denies adding the card to a digital wallet. Confirming needs a magic code. Denying does not. + */ +function approveDigitalWalletCardAddition(cardID: number, isApproved: boolean, validateCode?: string) { + const parameters: ApproveDigitalWalletCardAdditionParams = { + cardID, + isApproved, + validateCode, + }; + + API.write(WRITE_COMMANDS.APPROVE_DIGITAL_WALLET_CARD_ADDITION, parameters, buildCardLoadingOnyxData(cardID)); } /** @@ -1560,6 +1552,21 @@ function issueExpensifyCard( ); } +/** + * Asks if any Expensify Card has a wallet addition waiting to be confirmed. + */ +function getExpensifyCardPendingWalletApproval() { + const setIsChecking = (value: boolean): Array> => [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL, + value, + }, + ]; + + API.read(READ_COMMANDS.GET_EXPENSIFY_CARD_PENDING_WALLET_APPROVAL, null, {optimisticData: setIsChecking(true), finallyData: setIsChecking(false)}); +} + function openCardDetailsPage(cardID: number) { const parameters: OpenCardDetailsPageParams = { cardID, @@ -2018,6 +2025,8 @@ export { configureExpensifyCardsForPolicy, issueExpensifyCard, openCardDetailsPage, + getExpensifyCardPendingWalletApproval, + approveDigitalWalletCardAddition, clearCardErrorField, clearCardNameValuePairsErrorField, setPersonalCardReimbursable, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 2c8926bb4bf5..ec080061efa1 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -49,8 +49,9 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ // This allows the report screen to load correctly when the delegate token expires and the delegate is returned to their original account. ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, ONYXKEYS.NETWORK, - ONYXKEYS.SHOULD_USE_STAGING_SERVER, + ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.IS_DEBUG_MODE_ENABLED, + ONYXKEYS.BETA_OVERRIDES, ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, @@ -152,6 +153,11 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, return; } + if (isConnectedAsDelegate({delegatedAccess})) { + Log.info('[Delegate] Already connected as a delegate, skipping connect'); + return; + } + Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, credentials ?? {}); Onyx.set(ONYXKEYS.STASHED_SESSION, session ?? {}); @@ -224,7 +230,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, return SequentialQueue.waitForIdle() .then(() => { // Update authToken in Onyx so it persists if the further flow does not complete for any reason. - return updateSessionAuthTokens(response?.restrictedToken, response?.encryptedAuthToken); + return updateSessionAuthTokens(response?.restrictedToken, response?.encryptedAuthToken, CONST.AUTH_TOKEN_TYPES.DELEGATE); }) .then(() => { NetworkStore.setAuthToken(response?.restrictedToken ?? null); @@ -877,6 +883,7 @@ function openSecuritySettingsPage() { } export { + KEYS_TO_PRESERVE_DELEGATE_ACCESS, connect, disconnect, clearDelegatorErrors, diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 7cfdfa6e424c..53cdc46dd4e9 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -6,6 +6,7 @@ import type { AddMemberToDomainParams, ChangeDomainSecurityGroupParams, CreateDomainSecurityGroupParams, + DeclineDomainAdminshipRequestParams, DeleteDomainMemberParams, DeleteDomainParams, DeleteDomainSecurityGroupParams, @@ -574,7 +575,7 @@ function requestDomainAdminship(domainAccountID: number, currentUserAccountID: n { onyxMethod: Onyx.METHOD.MERGE, key: domainErrorsKey, - value: {requestAdminshipError: getMicroSecondOnyxErrorWithTranslationKey('domain.domainAlreadyExists.requestAccessError')}, + value: {requestAdminshipError: getMicroSecondOnyxErrorWithTranslationKey('domain.requestAccessError')}, }, ]; @@ -680,6 +681,21 @@ function clearToggleConsolidatedDomainBillingErrors(domainAccountID: number) { }); } +/** + * Drops the optimistic adminship grant, which is keyed by accountID while the backend keys its own entry by index. + * It has to go once the request settles: left behind, it outlives a later revoke - which only removes the backend's + * key - and keeps reporting the member as an admin. + */ +function clearOptimisticAdminPermission(domainAccountID: number, accountID: number): OnyxUpdate { + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`]: null, + }, + }; +} + function addAdminToDomain(domainAccountID: number, accountID: number, targetEmail: string, domainName: string, isOptimisticAccount: boolean) { const PERMISSION_KEY = `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; @@ -748,18 +764,35 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai > = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, value: { - [PERMISSION_KEY]: null, + adminErrors: { + [accountID]: { + errors: null, + }, + }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: null, + }, + }, + }, + clearOptimisticAdminPermission(domainAccountID, accountID), + ]; + + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, value: { adminErrors: { [accountID]: { - errors: null, + errors: getMicroSecondOnyxErrorWithTranslationKey('domain.admins.addAdminError'), }, }, }, @@ -773,23 +806,102 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS}${domainAccountID}`, + value: {type: null, id: null}, + }, ]; - const failureData: Array< + if (isOptimisticAccount) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.PERSONAL_DETAILS_LIST}`, + value: { + [accountID]: null, + }, + }); + } + + const params: AddAdminToDomainParams = { + domainName, + targetEmail, + }; + + API.write(WRITE_COMMANDS.ADD_DOMAIN_ADMIN, params, {optimisticData, successData, failureData}); +} + +/** + * Approves a pending domain adminship request, granting the requester admin access. + */ +function approveDomainAdminshipRequest(domainAccountID: number, accountID: number, targetEmail: string, domainName: string) { + const PERMISSION_KEY = `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; + + const optimisticData: Array< OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.DOMAIN | typeof ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS | typeof ONYXKEYS.COLLECTION.DOMAIN_ERRORS - | typeof ONYXKEYS.PERSONAL_DETAILS_LIST | typeof ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS > > = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [PERMISSION_KEY]: accountID, + } as PrefixedRecord, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[accountID]: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, value: { adminErrors: { [accountID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('domain.admins.addAdminError'), + errors: null, + }, + }, + adminshipRequesterErrors: { + [accountID]: { + errors: null, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS}${domainAccountID}`, + value: {type: 'admins', id: String(accountID)}, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + adminErrors: { + [accountID]: { + errors: null, }, }, }, @@ -803,28 +915,57 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai }, }, }, + clearOptimisticAdminPermission(domainAccountID, accountID), + ]; + + const failureData: Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.DOMAIN + | typeof ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS + | typeof ONYXKEYS.COLLECTION.DOMAIN_ERRORS + | typeof ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS + > + > = [ + clearOptimisticAdminPermission(domainAccountID, accountID), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS}${domainAccountID}`, - value: {type: null, id: null}, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[accountID]: 'read'}, + }, }, - ]; - - if (isOptimisticAccount) { - const clearOptimisticPersonalDetails: OnyxUpdate = { + { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.PERSONAL_DETAILS_LIST}`, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, value: { - [accountID]: null, + admin: { + [accountID]: null, + }, }, - }; - successData.push(clearOptimisticPersonalDetails); - failureData.push(clearOptimisticPersonalDetails); - } + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + adminshipRequesterErrors: { + [accountID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('domain.admins.approveRequestError'), + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS}${domainAccountID}`, + value: {type: null, id: null}, + }, + ]; const params: AddAdminToDomainParams = { domainName, targetEmail, + domainAccountID, }; API.write(WRITE_COMMANDS.ADD_DOMAIN_ADMIN, params, {optimisticData, successData, failureData}); @@ -833,13 +974,20 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai /** * Removes an error and pending actions after trying to add admin */ -function clearAdminError(domainAccountID: number, accountID: number) { +function clearAdminError(domainAccountID: number, accountID: number, isOptimisticAccount = false) { const PERMISSION_KEY = `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { [PERMISSION_KEY]: null, }); + // The account only ever existed to carry the failed add, so dismissing it takes the placeholder details with it. + if (isOptimisticAccount) { + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [accountID]: null, + }); + } + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { adminErrors: { [accountID]: null, @@ -913,6 +1061,122 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { API.write(WRITE_COMMANDS.REMOVE_DOMAIN_ADMIN, parameters, {optimisticData, successData, failureData}); } +/** + * Denies a pending domain adminship request. + */ +function declineDomainAdminshipRequest(domainAccountID: number, accountID: number) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + adminshipRequester: { + [accountID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + adminshipRequesterErrors: { + [accountID]: { + errors: null, + }, + }, + }, + }, + ]; + // The requester is dropped only once the decline lands, not optimistically: while offline the entry has to survive so + // the row keeps rendering with its `DELETE` pending action, and on failure it has to survive so the row can show the error. + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[accountID]: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + adminshipRequester: { + [accountID]: null, + }, + }, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + adminshipRequester: { + [accountID]: { + pendingAction: null, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + adminshipRequesterErrors: { + [accountID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('domain.admins.declineRequestError'), + }, + }, + }, + }, + ]; + + const parameters: DeclineDomainAdminshipRequestParams = { + domainAccountID, + targetAccountID: accountID, + }; + + API.write(WRITE_COMMANDS.DECLINE_DOMAIN_ADMINSHIP_REQUEST, parameters, {optimisticData, successData, failureData}); +} + +/** + * Clears errors and pending actions after trying to approve or deny a domain adminship request + */ +function clearAdminshipRequesterError(domainAccountID: number, accountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { + adminshipRequesterErrors: { + [accountID]: null, + }, + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { + adminshipRequester: { + [accountID]: null, + }, + }); +} + +/** + * Drops adminship request errors whose request is gone, so they cannot land on a row belonging to a later request from the same account. + */ +function clearStaleAdminshipRequesterErrors(domainAccountID: number, accountIDs: number[]) { + if (accountIDs.length === 0) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { + adminshipRequesterErrors: accountIDs.reduce>((acc, accountID) => { + acc[accountID] = null; + + return acc; + }, {}), + }); +} + /** * Removes the domain */ @@ -1150,18 +1414,25 @@ function addMemberToDomain(domainAccountID: number, email: string, defaultSecuri /** * Removes an error and pending actions after trying to add member. It clears errors for both email and accountID + * @param securityGroupID ID of the group the member sits in, needed to drop an optimistic membership */ -function clearDomainMemberError(domainAccountID: number, accountID: number, email: string, defaultSecurityGroupID: string, pendingAction?: PendingAction) { - const DOMAIN_SECURITY_GROUP = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${defaultSecurityGroupID}`; - - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - [DOMAIN_SECURITY_GROUP]: { - shared: { - [accountID]: null, +function clearDomainMemberError(domainAccountID: number, accountID: number, email: string, securityGroupID?: string, isOptimisticAccount = false) { + // The account only ever existed to carry the failed add, so dismissing it takes the group membership and the placeholder + // details with it. Its pending action cannot gate this: failureData clears that before the error is ever shown. + if (isOptimisticAccount) { + if (securityGroupID) { + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${securityGroupID}`]: { + shared: { + [accountID]: null, + }, }, - }, - } as PrefixedRecord>); + } as PrefixedRecord>); + } + + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [accountID]: null, + }); } Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { @@ -2507,8 +2778,12 @@ export { toggleConsolidatedDomainBilling, clearToggleConsolidatedDomainBillingErrors, addAdminToDomain, + approveDomainAdminshipRequest, clearAdminError, revokeDomainAdminAccess, + declineDomainAdminshipRequest, + clearAdminshipRequesterError, + clearStaleAdminshipRequesterErrors, resetDomain, clearDomainErrors, addMemberToDomain, diff --git a/src/libs/actions/GPSDraftDetails.ts b/src/libs/actions/GPSDraftDetails.ts index 43591d4ee888..66a19eaf1e4b 100644 --- a/src/libs/actions/GPSDraftDetails.ts +++ b/src/libs/actions/GPSDraftDetails.ts @@ -1,4 +1,4 @@ -import {addressFromGpsPoint, calculateTrimmedEndPoint, coordinatesToString} from '@libs/GPSDraftDetailsUtils'; +import {addressFromGpsPoint, calculateTrimmedEndPoint, coordinatesToString} from '@libs/GPSPointUtils'; import {GPS_DISTANCE_INTERVAL_METERS} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; import {updateGpsTripNotificationDistance} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; diff --git a/src/libs/actions/HybridApp/types.ts b/src/libs/actions/HybridApp/types.ts index 0236e8b7cc85..7b518a999308 100644 --- a/src/libs/actions/HybridApp/types.ts +++ b/src/libs/actions/HybridApp/types.ts @@ -5,6 +5,8 @@ import type HybridApp from '@src/types/onyx/HybridApp'; type HybridAppSettings = { [ONYXKEYS.HYBRID_APP]: HybridApp; [ONYXKEYS.NVP_TRY_NEW_DOT]?: TryNewDot; + /** OldDot sends its own boolean staging flag on handoff; it knows nothing about ACTIVE_SERVER */ + // eslint-disable-next-line @typescript-eslint/no-deprecated [ONYXKEYS.SHOULD_USE_STAGING_SERVER]?: boolean; }; diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index 8361e72e4615..a09301057a13 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -24,6 +24,7 @@ import { isSelfDM, shouldEnableNegative, } from '@libs/ReportUtils'; +import {getUpdatedTransactionTag} from '@libs/TagsOptionsListUtils'; import { calculateTaxAmount, getAmount, @@ -142,6 +143,8 @@ function removeUnchangedBulkEditFields( type UpdateMultipleMoneyRequestsParams = { transactionIDs: string[]; changes: TransactionChanges; + /** Per-level tag edits from the bulk-edit draft, keyed by tag list index. */ + bulkEditTagChanges?: Record; policy: OnyxEntry; reports: OnyxCollection; transactions: OnyxCollection; @@ -158,6 +161,7 @@ type UpdateMultipleMoneyRequestsParams = { personalDetailsList: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; function writeBulkEditMoneyRequest( @@ -186,6 +190,7 @@ function writeBulkEditMoneyRequestAttendees( function updateMultipleMoneyRequests({ transactionIDs, changes, + bulkEditTagChanges, policy, reports, transactions, @@ -202,6 +207,7 @@ function updateMultipleMoneyRequests({ personalDetailsList, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateMultipleMoneyRequestsParams) { // Per-report running state so iterations in the same report see earlier edits (totals, transactions, snapshot). const optimisticReportsByID: Record = {}; @@ -279,7 +285,7 @@ function updateMultipleMoneyRequests({ return true; } - return canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report: iouReport, policy: transactionPolicy, reportNameValuePairs}); + return canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report: iouReport, policy: transactionPolicy, reportNameValuePairs, rules}); }; let transactionChanges: TransactionChanges = {}; @@ -312,8 +318,33 @@ function updateMultipleMoneyRequests({ if (changes.category !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) { transactionChanges.category = changes.category; } - if (changes.tag && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) { - transactionChanges.tag = changes.tag; + const editedTagIndexes = bulkEditTagChanges ? Object.keys(bulkEditTagChanges) : []; + if ((changes.tag || editedTagIndexes.length > 0) && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) { + if (editedTagIndexes.length > 0) { + // Rebuild the tag from THIS transaction's own tag so levels the user didn't touch are + // preserved, instead of overwriting every level with one shared common-prefix string. + // Apply each edited level in ascending order because editing a parent may clear its + // dependent children, and pass an empty currentTag so the selected value is always a + // fresh selection at that level rather than a per-transaction deselect. + const transactionPolicyTagList = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${transactionPolicy?.id}`]; + const transactionHasDependentTags = hasDependentTags(transactionPolicy, transactionPolicyTagList); + const transactionHasMultipleTagLists = transactionPolicy?.hasMultipleTagLists ?? false; + let reconstructedTag = transaction.tag ?? ''; + for (const editedIndex of editedTagIndexes.map(Number).sort((first, second) => first - second)) { + reconstructedTag = getUpdatedTransactionTag({ + transactionTag: reconstructedTag, + selectedTagName: bulkEditTagChanges?.[editedIndex] ?? '', + currentTag: '', + tagListIndex: editedIndex, + policyTags: transactionPolicyTagList, + hasDependentTags: transactionHasDependentTags, + hasMultipleTagLists: transactionHasMultipleTagLists, + }); + } + transactionChanges.tag = reconstructedTag; + } else { + transactionChanges.tag = changes.tag; + } } if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) { transactionChanges.comment = getParsedComment(changes.comment); diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index 0278ac5c3827..357260b99431 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -75,6 +75,7 @@ type DeleteMoneyRequestFunctionParams = { currentUserAccountID: number; currentUserEmail: string; transactionThreadReport: OnyxEntry; + transactionThreadReportActions: OnyxEntry; policy?: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; }; @@ -628,7 +629,7 @@ function getCleanUpTransactionThreadReportOnyxData({ updatedReportPreviewAction?: ReportAction; shouldAddUpdatedReportPreviewActionToOnyxData?: boolean; currentUserAccountID: number; - transactionThreadReportActionsParam?: OnyxEntry; + transactionThreadReportActionsParam: OnyxEntry; }) { const allReports = getAllReports(); const allReportNameValuePairs = getAllReportNameValuePairs(); @@ -642,7 +643,7 @@ function getCleanUpTransactionThreadReportOnyxData({ let transactionThreadReportActions = null; if (transactionThreadID) { transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`] ?? null; - transactionThreadReportActions = transactionThreadReportActionsParam ?? getAllReportActionsFromIOU()?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`] ?? null; + transactionThreadReportActions = transactionThreadReportActionsParam ?? null; } optimisticData.push( @@ -786,6 +787,7 @@ function deleteMoneyRequest({ allTransactionViolationsParam, currentUserAccountID, currentUserEmail, + transactionThreadReportActions, policy, getCurrencyDecimals, }: DeleteMoneyRequestFunctionParams) { @@ -941,6 +943,7 @@ function deleteMoneyRequest({ reportAction, isChatIOUReportArchived, currentUserAccountID, + transactionThreadReportActionsParam: transactionThreadReportActions, }); optimisticData.push(...cleanUpTransactionThreadReportOnyxData.optimisticData); diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index eb886b0f9977..2e1e1f72fee3 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -27,7 +27,9 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import { - getDistanceRequestType, + getAmount, + getConvertedAmount, + getCurrency, getReimbursable, getRequestType, getTransactionType, @@ -35,6 +37,7 @@ import { isDistanceRequest, isExpenseSplit, isFromCreditCardImport, + isGPSDistanceRequest, isOdometerDistanceRequest, isPartialTransaction, isPerDiemRequest, @@ -603,13 +606,19 @@ function resolveDuplicates({ API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData}); } +function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction, shouldDuplicateSelfDMExpense: boolean) { + return shouldDuplicateSelfDMExpense && isGPSDistanceRequest(transaction); +} + /** * Builds the transactionParams object and computes waypoints used when duplicating a transaction. * Shared between duplicateExpenseTransaction and duplicateReport. */ -function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType) { +function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType, shouldDuplicateSelfDMExpense = false) { const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + const isManualDistanceDuplicate = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); + const shouldKeepWaypoints = !isExpenseSplit(transaction) && !isManualDistanceDuplicate; + const waypoints = shouldKeepWaypoints ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; const transactionParams = { ...transactionWithoutLinkedAction, @@ -641,7 +650,8 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra unit: transaction.comment?.units?.unit, }; - if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction))) { + if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || isManualDistanceDuplicate)) { + // Completed GPS expenses store their measured distance in customUnit.quantity; duplicating as manual uses that saved value because the raw GPS track is no longer available. transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; } @@ -651,10 +661,18 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra /** * Returns the request type the duplicate should be created with. SCAN sources become MANUAL because * `buildDuplicateTransactionParams` strips the receipt — without one, the duplicate cannot be a scan request. + * GPS distance sources become manual distance requests for self-DM duplicates because the saved transaction + * does not contain the raw GPS track needed to create another GPS request. */ -function getDuplicateRequestType(transaction: OnyxTypes.Transaction) { +function getDuplicateRequestType(transaction: OnyxTypes.Transaction, shouldDuplicateSelfDMExpense = false) { const sourceRequestType = getRequestType(transaction); - return sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN ? CONST.IOU.REQUEST_TYPE.MANUAL : sourceRequestType; + if (sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { + return CONST.IOU.REQUEST_TYPE.MANUAL; + } + if (shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense)) { + return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; + } + return sourceRequestType; } /** @@ -678,6 +696,7 @@ function createExpenseByType({ dateFnsLocale, participantsPolicyTags, policyTags, + rules, }: { transactionType: string; params: RequestMoneyInformation; @@ -695,16 +714,18 @@ function createExpenseByType({ dateFnsLocale: DateFnsLocale | undefined; participantsPolicyTags: OnyxTypes.ParticipantsPolicyTags; policyTags: OnyxTypes.PolicyTagLists; + rules: OnyxCollection; }) { switch (transactionType) { case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { + const duplicateRequestType = getDuplicateRequestType(transaction); const distanceParams: CreateDistanceRequestInformation = { ...params, participants, currentUserLogin: params.currentUserEmailParam, currentUserAccountID: params.currentUserAccountIDParam, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -724,7 +745,7 @@ function createExpenseByType({ comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), validWaypoints: waypoints, modifiedAmount: transactionDetails?.amount, - distanceRequestType: getDistanceRequestType(transaction), + distanceRequestType: duplicateRequestType, }, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, @@ -733,6 +754,7 @@ function createExpenseByType({ recentWaypoints, formatPhoneNumber, participantsPolicyTags, + rules, }; return createDistanceRequest(distanceParams); } @@ -750,6 +772,7 @@ function createExpenseByType({ isTrackIntentUser, formatPhoneNumber, policyTags, + rules, }; return submitPerDiemExpense(perDiemParams); } @@ -792,6 +815,7 @@ type DuplicateExpenseTransactionParams = { getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; participantsPolicyTags: OnyxTypes.ParticipantsPolicyTags; conciergeChat: OnyxEntry; + rules: OnyxCollection; }; function duplicateExpenseTransaction({ @@ -826,6 +850,7 @@ function duplicateExpenseTransaction({ participantsPolicyTags, conciergeChat, targetPolicyTags, + rules, }: DuplicateExpenseTransactionParams) { if (!transaction) { return; @@ -834,7 +859,11 @@ function duplicateExpenseTransaction({ const participants = getMoneyRequestParticipantsFromReport(targetReport, currentUserAccountID); const transactionDetails = getTransactionDetails(transaction); - const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); + // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available + const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const shouldDuplicateSelfDMExpense = !targetPolicy || isSourceUnreported; + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails, shouldDuplicateSelfDMExpense); + const duplicateRequestType = getDuplicateRequestType(transaction, shouldDuplicateSelfDMExpense); const params: RequestMoneyInformation = { report: targetReport, @@ -860,8 +889,9 @@ function duplicateExpenseTransaction({ policyRecentlyUsedCurrencies, quickAction, existingTransactionDraft, + rules, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -880,8 +910,6 @@ function duplicateExpenseTransaction({ getCurrencyDecimals, }; - // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available - const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; if (!targetPolicy || isSourceUnreported) { const trackExpenseParams: CreateTrackExpenseParams = { ...params, @@ -890,7 +918,7 @@ function duplicateExpenseTransaction({ participant: {accountID: currentUserAccountID, selected: true}, }, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -908,9 +936,11 @@ function duplicateExpenseTransaction({ transactionParams: { ...(params.transactionParams ?? {}), validWaypoints: waypoints, + ...(isGPSDistanceRequest(transaction) && {distanceRequestType: duplicateRequestType}), }, report: undefined, isDraftPolicy: false, + isDraftChatReport: false, currentUser: {accountID: currentUserAccountID, email: currentUserLogin}, introSelected, conciergeChat, @@ -921,6 +951,7 @@ function duplicateExpenseTransaction({ currentUserLocalCurrency, delegateAccountID, reportActionsList: undefined, + rules, }; return trackExpense(trackExpenseParams); } @@ -948,6 +979,7 @@ function duplicateExpenseTransaction({ formatPhoneNumber, participantsPolicyTags, policyTags: targetPolicyTags ?? {}, + rules, }); } @@ -979,8 +1011,65 @@ type DuplicateReportParams = { getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; participantsPolicyTags: OnyxTypes.ParticipantsPolicyTags; conciergeChat: OnyxEntry; + rules: OnyxCollection; }; +/** + * The optimistic report totals a duplicated report should carry. These are exactly the fields + * `buildOptimisticEmptyReport` pins to `0`, all expressed in the copy's currency using the negative sign expense + * report totals are stored with. + */ +type DuplicateReportTotals = { + total: number; + reimbursableTotal: number; + nonReimbursableTotal: number; + unheldReimbursableTotal: number; +}; + +/** + * Returns each eligible transaction's amount expressed in the duplicated report's currency, or `undefined` when that + * can't be derived on the client. + * + * The copy is created as an empty report in the target policy's output currency, so every expense whose own currency + * differs from it is skipped by the currency-equality guard in `getMoneyRequestInformation` and never reaches the + * report total. Each source transaction already stores `convertedAmount`, its amount in the *source report's* + * currency, so as long as the copy lands in that same currency we can express every expense in the copy's currency + * without an FX table. This is the rule `calculateGroupTotal` already applies when totalling a mixed-currency group. + * + * `undefined` means "don't guess". That covers three cases: the copy's currency differs from the source report's (a + * cross-workspace duplicate into another output currency, where the stored conversion is for the wrong currency), an + * expense needs a conversion it doesn't carry, or an expense has an unsynced amount or currency edit. Only the server + * recomputes `convertedAmount`, so while such an edit is still pending the stored conversion describes the old amount. + * The caller then leaves the totals as the server will send them rather than writing a fabricated number. + */ +function buildDuplicateReportCurrencyAmounts(eligibleTransactions: OnyxTypes.Transaction[], sourceReport: OnyxEntry, targetPolicy: OnyxTypes.Policy): number[] | undefined { + const reportCurrency = sourceReport?.currency; + if (!reportCurrency || reportCurrency !== targetPolicy.outputCurrency) { + return undefined; + } + + const amounts: number[] = []; + for (const transaction of eligibleTransactions) { + // getCurrency/getAmount read the modified values first, so an expense whose currency was edited after it was + // created is measured by what it is now, not by what it was created as. + if (getCurrency(transaction) === reportCurrency) { + amounts.push(getAmount(transaction, true)); + continue; + } + + // An amount or currency edit that hasn't reached the server yet leaves `convertedAmount` describing the value + // before the edit, so it can't stand in for the expense here. + const hasUnsyncedAmountEdit = !!transaction.pendingFields?.amount || !!transaction.pendingFields?.currency; + if (transaction.convertedAmount === undefined || hasUnsyncedAmountEdit) { + return undefined; + } + + amounts.push(getConvertedAmount(transaction, true)); + } + + return amounts; +} + function duplicateReport({ dateFnsLocale, sourceReport, @@ -1009,6 +1098,7 @@ function duplicateReport({ getCurrencyDecimals, participantsPolicyTags, conciergeChat, + rules, }: DuplicateReportParams) { if (!targetPolicy || !parentChatReport) { return; @@ -1023,6 +1113,7 @@ function duplicateReport({ betas, isTrackIntentUser, getCurrencyDecimals, + rules, false, undefined, { @@ -1064,6 +1155,11 @@ function duplicateReport({ let currentIOUReport = newReport as OnyxEntry; + // The copy starts out as an empty report with every total pinned to 0. Accumulate them ourselves in the copy's + // currency so the running total survives expenses the money-request builder's currency-equality guard skips. + const reportCurrencyAmounts = buildDuplicateReportCurrencyAmounts(eligibleTransactions, sourceReport, targetPolicy); + const runningTotals: DuplicateReportTotals = {total: 0, reimbursableTotal: 0, nonReimbursableTotal: 0, unheldReimbursableTotal: 0}; + for (let i = 0; i < eligibleTransactions.length; i++) { const transaction = eligibleTransactions.at(i); if (!transaction) { @@ -1074,6 +1170,19 @@ function duplicateReport({ continue; } + // Accumulated here rather than up front so an expense skipped above is left out of the running total too. + const reportCurrencyAmount = reportCurrencyAmounts?.at(i); + if (reportCurrencyAmount !== undefined) { + runningTotals.total -= reportCurrencyAmount; + if (getReimbursable(transaction)) { + runningTotals.reimbursableTotal -= reportCurrencyAmount; + // A copy is never on hold, so its unheld reimbursable total always tracks its reimbursable total. + runningTotals.unheldReimbursableTotal -= reportCurrencyAmount; + } else { + runningTotals.nonReimbursableTotal -= reportCurrencyAmount; + } + } + const isLastExpense = i === eligibleTransactions.length - 1; const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); @@ -1090,6 +1199,14 @@ function duplicateReport({ gpsPoint: undefined, action: CONST.IOU.ACTION.CREATE, transactionParams, + ...(reportCurrencyAmount !== undefined + ? { + newReportTotal: runningTotals.total, + newReimbursableTotal: runningTotals.reimbursableTotal, + newNonReimbursableTotal: runningTotals.nonReimbursableTotal, + newUnheldReimbursableTotal: runningTotals.unheldReimbursableTotal, + } + : {}), shouldPlaySound: false, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled, @@ -1099,6 +1216,7 @@ function duplicateReport({ quickAction, policyRecentlyUsedCurrencies, existingTransactionDraft: undefined, + rules, existingTransaction: { iouRequestType: getDuplicateRequestType(transaction), amount: 0, @@ -1136,6 +1254,7 @@ function duplicateReport({ formatPhoneNumber, participantsPolicyTags, policyTags: targetPolicyTags ?? {}, + rules, }); if (result?.iouReport) { @@ -1175,6 +1294,7 @@ type BulkDuplicateExpensesParams = { getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; participantsPolicyTags: OnyxTypes.ParticipantsPolicyTags; conciergeChat: OnyxEntry; + rules: OnyxCollection; }; function bulkDuplicateExpenses({ @@ -1204,6 +1324,7 @@ function bulkDuplicateExpenses({ getCurrencyDecimals, participantsPolicyTags, conciergeChat, + rules, }: BulkDuplicateExpensesParams) { const transactionsToDuplicate = transactionIDs.map((id) => allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((t): t is OnyxTypes.Transaction => !!t); @@ -1260,7 +1381,7 @@ function bulkDuplicateExpenses({ // reads transactions from Onyx, which hasn't been updated yet for // optimistic reports (callbacks are deferred). let reportWasSplit = false; - if (optimisticIOUReport && (policyWillSplitReport || !canAddTransaction(optimisticIOUReport))) { + if (optimisticIOUReport && (policyWillSplitReport || !canAddTransaction(optimisticIOUReport, rules))) { optimisticIOUReport = undefined; currentOptimisticIOUReportID = generateReportID(); currentReportPreviewActionID = NumberUtils.rand64(); @@ -1309,6 +1430,7 @@ function bulkDuplicateExpenses({ getCurrencyDecimals, participantsPolicyTags, conciergeChat, + rules, }); if (result?.iouReport) { @@ -1350,6 +1472,7 @@ type BulkDuplicateReportsParams = { formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; conciergeChat: OnyxEntry; + rules: OnyxCollection; }; async function bulkDuplicateReports({ @@ -1379,6 +1502,7 @@ async function bulkDuplicateReports({ formatPhoneNumber, getCurrencyDecimals, conciergeChat, + rules, }: BulkDuplicateReportsParams) { const allTransactionsMap = getAllTransactions(); const transactionsByReportID = new Map(); @@ -1481,6 +1605,7 @@ async function bulkDuplicateReports({ getCurrencyDecimals, participantsPolicyTags, conciergeChat, + rules, }); hasDuplicatedReport = true; diff --git a/src/libs/actions/IOU/Hold.ts b/src/libs/actions/IOU/Hold.ts index 8fa1e5e63e4e..6bc1415026e2 100644 --- a/src/libs/actions/IOU/Hold.ts +++ b/src/libs/actions/IOU/Hold.ts @@ -30,7 +30,7 @@ import { isPolicyExpenseChat as isPolicyExpenseChatReportUtil, isProcessingReport, } from '@libs/ReportUtils'; -import {getAmount} from '@libs/TransactionUtils'; +import {getAmount, isScanFailedTransactionMovedOnPayment} from '@libs/TransactionUtils'; import {notifyNewAction} from '@userActions/Report'; @@ -46,6 +46,11 @@ import Onyx from 'react-native-onyx'; import {getAllReports, getAllTransactions} from '.'; +type PutOnHoldOptions = { + rules: OnyxCollection; + ancestors?: Ancestor[]; +}; + /** * Put expense on HOLD */ @@ -59,7 +64,7 @@ function putOnHold( transactionViolations: OnyxEntry, isTrackIntentUser: boolean | undefined, delegateAccountID: number | undefined, - ancestors: Ancestor[] = [], + {rules, ancestors = []}: PutOnHoldOptions, ) { const allTransactions = getAllTransactions(); const allReports = getAllReports(); @@ -288,6 +293,7 @@ function putOnHold( currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserLogin, isTrackIntentUser, + rules, }); optimisticData.push({ @@ -351,12 +357,12 @@ function putTransactionsOnHold( allTransactionViolations: OnyxCollection, isTrackIntentUser: boolean | undefined, delegateAccountID: number | undefined, - ancestors: Ancestor[] = [], + {rules, ancestors = []}: PutOnHoldOptions, ) { for (const transactionID of transactionsID) { const {childReportID} = getIOUActionForReportID(reportID, transactionID) ?? {}; const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; - putOnHold(transactionID, comment, childReportID, isOffline, currentUserLogin, currentUserAccountID, transactionViolations, isTrackIntentUser, delegateAccountID, ancestors); + putOnHold(transactionID, comment, childReportID, isOffline, currentUserLogin, currentUserAccountID, transactionViolations, isTrackIntentUser, delegateAccountID, {rules, ancestors}); } } @@ -373,6 +379,7 @@ function unholdRequest( transactionViolations: OnyxEntry, isTrackIntentUser: boolean | undefined, delegateAccountID: number | undefined, + rules: OnyxCollection, ) { const allTransactions = getAllTransactions(); const allReports = getAllReports(); @@ -496,6 +503,7 @@ function unholdRequest( currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserLogin, isTrackIntentUser, + rules, }); optimisticData.push({ @@ -549,7 +557,12 @@ type OptimisticHoldReportExpenseActionID = { oldReportActionID: string; }; -function getHoldReportActionsAndTransactions(reportID: string | undefined) { +function getHoldReportActionsAndTransactions( + reportID: string | undefined, + iouReport?: OnyxEntry, + shouldMoveHeldTransactions = true, + shouldMoveScanFailedTransactions = false, +) { const allTransactions = getAllTransactions(); const iouReportActions = getAllReportActions(reportID); const holdReportActions: Array> = []; @@ -559,7 +572,14 @@ function getHoldReportActionsAndTransactions(reportID: string | undefined) { const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (transaction?.comment?.hold) { + if (!transaction) { + continue; + } + + const isHeld = shouldMoveHeldTransactions && !!transaction.comment?.hold; + const isScanFailed = shouldMoveScanFailedTransactions && isScanFailedTransactionMovedOnPayment(transaction, iouReport); + + if (isHeld || isScanFailed) { holdReportActions.push(action as OnyxTypes.ReportAction); holdTransactions.push(transaction); } @@ -668,6 +688,9 @@ function getReportFromHoldRequestsOnyxData({ isApprovalFlow = false, delegateAccountID, getCurrencyDecimals, + shouldMoveHeldTransactions = true, + shouldMoveScanFailedTransactions = false, + rules, }: { chatReport: OnyxTypes.Report; iouReport: OnyxEntry; @@ -678,6 +701,9 @@ function getReportFromHoldRequestsOnyxData({ isApprovalFlow?: boolean; delegateAccountID: number | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + shouldMoveHeldTransactions?: boolean; + shouldMoveScanFailedTransactions?: boolean; + rules: OnyxCollection; }): { optimisticHoldReportID: string; optimisticHoldActionID: string; @@ -688,7 +714,7 @@ function getReportFromHoldRequestsOnyxData({ successData: Array>; failureData: Array>; } { - const {holdReportActions, holdTransactions} = getHoldReportActionsAndTransactions(iouReport?.reportID); + const {holdReportActions, holdTransactions} = getHoldReportActionsAndTransactions(iouReport?.reportID, iouReport, shouldMoveHeldTransactions, shouldMoveScanFailedTransactions); const firstHoldTransaction = holdTransactions.at(0); const newParentReportActionID = NumberUtils.rand64(); @@ -696,8 +722,11 @@ function getReportFromHoldRequestsOnyxData({ const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(chatReport); const holdReimbursable = getReimbursableTotal(iouReport) - getUnheldReimbursableTotal(iouReport); const holdNonReimbursable = (iouReport?.nonReimbursableTotal ?? 0) - (iouReport?.unheldNonReimbursableTotal ?? 0); - const holdAmount = (holdReimbursable + holdNonReimbursable) * coefficient; - const holdNonReimbursableAmount = holdNonReimbursable * coefficient; + + // Scan-failed expenses only move out when they have no amount, so they carry nothing over to the new report and + // leave the totals of the report they came from untouched. + const holdAmount = shouldMoveScanFailedTransactions ? 0 : (holdReimbursable + holdNonReimbursable) * coefficient; + const holdNonReimbursableAmount = shouldMoveScanFailedTransactions ? 0 : holdNonReimbursable * coefficient; // Pass held transactions for formula computation (e.g., {report:startdate}) const reportTransactions: Record = {}; @@ -720,6 +749,7 @@ function getReportFromHoldRequestsOnyxData({ reportTransactions, createdTimestamp, getCurrencyDecimals, + rules, }) : buildOptimisticIOUReport( iouReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, @@ -751,6 +781,7 @@ function getReportFromHoldRequestsOnyxData({ } const updateHeldReports: Record> = {}; + const revertHeldReports: Record> = {}; const addHoldReportActions: OnyxTypes.ReportActions = {}; const addHoldReportActionsSuccess: OnyxCollection> = {}; const deleteHoldReportActions: Record> = {}; @@ -791,6 +822,11 @@ function getReportFromHoldRequestsOnyxData({ parentReportID: optimisticExpenseReport.reportID, chatReportID: optimisticExpenseReport.reportID, }; + revertHeldReports[`${ONYXKEYS.COLLECTION.REPORT}${heldReport.reportID}`] = { + parentReportActionID: heldReport.parentReportActionID, + parentReportID: heldReport.parentReportID, + chatReportID: heldReport.chatReportID, + }; } } @@ -806,7 +842,7 @@ function getReportFromHoldRequestsOnyxData({ // Held transactions just moved out, leaving total/nonReimbursableTotal stale on this report — // offline consumers (e.g. the Pay button) would read the wrong amount until server reconciles. // unheldTotal stays as-is: every remaining transaction is unheld, so it already equals the new total. - const shouldUpdateOriginalReportTotals = holdTransactions.length > 0 && iouReport?.unheldTotal !== undefined; + const shouldUpdateOriginalReportTotals = !shouldMoveScanFailedTransactions && holdTransactions.length > 0 && iouReport?.unheldTotal !== undefined; const optimisticData: Array> = [ { @@ -937,6 +973,11 @@ function getReportFromHoldRequestsOnyxData({ key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, value: bringHeldTransactionsBack, }, + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.REPORT}`, + value: revertHeldReports, + }, ]; if (shouldUpdateOriginalReportTotals) { diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index b6fd46e96813..f101d8943236 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -52,6 +52,7 @@ import type { QuickAction, RecentWaypoint, Report, + Rule, Transaction, TransactionViolation, } from '@src/types/onyx'; @@ -100,11 +101,13 @@ type CreateTransactionParams = { optimisticTransactionIDs: string[]; optimisticChatReportID: string | undefined; currentUserLocalCurrency: string | undefined; + isDraftChatReport: boolean; isTrackIntentUser: boolean | undefined; delegateAccountID: number | undefined; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; conciergeChat: OnyxEntry; + rules: OnyxCollection; }; type SetMoneyRequestCommuterExclusionFieldsParams = { @@ -149,11 +152,13 @@ function createTransaction({ optimisticTransactionIDs, optimisticChatReportID, currentUserLocalCurrency, + isDraftChatReport, isTrackIntentUser, delegateAccountID, formatPhoneNumber, getCurrencyDecimals, conciergeChat, + rules, }: CreateTransactionParams) { const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); @@ -179,6 +184,7 @@ function createTransaction({ trackExpense({ report, isDraftPolicy: false, + isDraftChatReport, existingTransaction: transaction, participantParams: { payeeEmail: currentUserEmail, @@ -216,6 +222,7 @@ function createTransaction({ delegateAccountID, reportActionsList: undefined, getCurrencyDecimals, + rules, }); } else { const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); @@ -263,32 +270,62 @@ function createTransaction({ delegateAccountID, formatPhoneNumber, getCurrencyDecimals, + rules, }); } } } -function getMoneyRequestParticipantOptions( - currentUserAccountID: number, - report: OnyxEntry, - policy: OnyxEntry, - personalDetails: OnyxEntry, - conciergeReportID: string | undefined, - privateIsArchived: boolean | undefined, - reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined, - reportDraft: OnyxEntry | undefined, - translate: LocalizedTranslate, - dateFnsLocale: DateFnsLocale | undefined, -): Array { +type GetMoneyRequestParticipantOptionsParams = { + currentUserAccountID: number; + report: OnyxEntry; + policy: OnyxEntry; + personalDetails: OnyxEntry; + conciergeReportID: string | undefined; + privateIsArchived: boolean | undefined; + rules: OnyxCollection; + reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined; + reportDraft: OnyxEntry | undefined; + translate: LocalizedTranslate; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + dateFnsLocale: DateFnsLocale | undefined; +}; + +function getMoneyRequestParticipantOptions({ + currentUserAccountID, + report, + policy, + personalDetails, + conciergeReportID, + privateIsArchived, + rules, + reportAttributesDerived, + reportDraft, + translate, + convertToDisplayString, + dateFnsLocale, +}: GetMoneyRequestParticipantOptionsParams): Array { const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserAccountID); return selectedParticipants.map((participant) => { const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; return participantAccountID ? getParticipantsOption(participant, personalDetails, translate) - : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived, reportDraft, currentUserAccountID, { - translate, - dateFnsLocale, - }); + : getReportOption( + participant, + privateIsArchived, + policy, + personalDetails, + conciergeReportID, + reportAttributesDerived, + reportDraft, + currentUserAccountID, + { + translate, + dateFnsLocale, + convertToDisplayString, + }, + rules, + ); }); } diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 9680e161ed89..c7a950d3a23f 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -172,6 +172,10 @@ type RequestMoneyInformation = { gpsPoint?: GPSPoint; action?: IOUAction; transactionParams: RequestMoneyTransactionParams; + newReportTotal?: number; + newReimbursableTotal?: number; + newNonReimbursableTotal?: number; + newUnheldReimbursableTotal?: number; isRetry?: boolean; shouldPlaySound?: boolean; /** Retry-path cleanup only; the action itself never reads this. */ @@ -199,6 +203,7 @@ type RequestMoneyInformation = { isTrackIntentUser: boolean | undefined; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type MoneyRequestInformationParams = { @@ -214,7 +219,9 @@ type MoneyRequestInformationParams = { existingTransaction?: OnyxEntry; retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceiptRetryParams; newReportTotal?: number; + newReimbursableTotal?: number; newNonReimbursableTotal?: number; + newUnheldReimbursableTotal?: number; testDriveCommentReportActionID?: string; optimisticChatReportID?: string; optimisticCreatedReportActionID?: string; @@ -236,6 +243,7 @@ type MoneyRequestInformationParams = { delegateAccountID: number | undefined; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type MoneyRequestOptimisticParams = { @@ -292,6 +300,7 @@ type BuildOnyxDataForMoneyRequestParams = { shouldSkipReportHighlightRail?: boolean; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type BuildOnyxDataForTestDriveIOUParams = { @@ -458,6 +467,7 @@ function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyR shouldSkipReportHighlightRail, isTrackIntentUser, getCurrencyDecimals, + rules, } = moneyRequestParams; const {policy, policyCategories, policyTagList} = policyParams; const { @@ -1160,6 +1170,7 @@ function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyR hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, + rules, }); onyxData.optimisticData?.push(violationsOnyxData); onyxData.optimisticData?.push({ @@ -1270,7 +1281,9 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma moneyRequestReportID = '', retryParams, newReportTotal, + newReimbursableTotal, newNonReimbursableTotal, + newUnheldReimbursableTotal, testDriveCommentReportActionID, optimisticChatReportID, optimisticCreatedReportActionID, @@ -1293,6 +1306,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, } = moneyRequestInformation; const {payeeAccountID = currentUserAccountIDParam, payeeEmail = currentUserEmailParam, participant} = participantParams; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; @@ -1396,7 +1410,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma const shouldCreateNewMoneyRequestReport = isSplitExpense ? false - : shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas, action, !!moneyRequestReportID); + : shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas, rules, action, !!moneyRequestReportID); // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation const optimisticTransactionID = existingTransactionID ?? providedOptimisticTransactionID ?? rand64(); @@ -1424,6 +1438,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma reportTransactions, betas, getCurrencyDecimals, + rules, }) : buildOptimisticIOUReport(payeeAccountID, payerAccountID, reportAmount, chatReport.reportID, currency, getCurrencyDecimals, undefined, undefined, optimisticReportID); } else if (isPolicyExpenseChat) { @@ -1432,11 +1447,17 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma const previousReimbursableTotal = getReimbursableTotal(iouReport); const previousUnheldReimbursableTotal = getUnheldReimbursableTotal(iouReport); iouReport = {...iouReport}; + const isCurrencyMatching = iouReport?.currency === currency; + // A `new*Total` override is already expressed in the report's currency, so unlike the raw per-transaction + // arithmetic below, it does not need the transaction's own currency to match the report's. That guard is + // precisely why an expense in another currency otherwise never reaches the total. + // Compared with `!== undefined` so a legitimate total of 0 is applied instead of being read as "no override". + const hasReportTotalOverride = newReportTotal !== undefined; // Because of the Expense reports are stored as negative values, we subtract the total from the amount - if (iouReport?.currency === currency) { + if (isCurrencyMatching || hasReportTotalOverride) { if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined) { // Use newReportTotal in scenarios where the total is based on more than just the current transaction, and we need to override it manually - if (newReportTotal) { + if (hasReportTotalOverride) { iouReport.total = newReportTotal; } else { iouReport.total -= reportAmount; @@ -1445,23 +1466,32 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma if (!reimbursable) { if (newNonReimbursableTotal !== undefined) { iouReport.nonReimbursableTotal = newNonReimbursableTotal; - } else { + } else if (isCurrencyMatching) { iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - reportAmount; } - } else { + } else if (isCurrencyMatching) { // Reimbursable transaction: reflect the change in the freshly tracked reimbursableTotal too. iouReport.reimbursableTotal = previousReimbursableTotal - reportAmount; } + + // The reimbursable totals are what the report preview and details actually read, so they are + // overridable in their own right. `total` alone leaves them pinned at whatever they were seeded with. + if (newReimbursableTotal !== undefined) { + iouReport.reimbursableTotal = newReimbursableTotal; + } + if (newUnheldReimbursableTotal !== undefined) { + iouReport.unheldReimbursableTotal = newUnheldReimbursableTotal; + } didUpdateOptimisticTotal = true; } if (typeof iouReport.unheldTotal === 'number') { // Use newReportTotal in scenarios where the total is based on more than just the current transaction amount, and we need to override it manually - if (newReportTotal) { + if (hasReportTotalOverride) { iouReport.unheldTotal = newReportTotal; } else { iouReport.unheldTotal -= reportAmount; } - if (reimbursable) { + if (reimbursable && newUnheldReimbursableTotal === undefined && isCurrencyMatching) { iouReport.unheldReimbursableTotal = previousUnheldReimbursableTotal - reportAmount; } } @@ -1688,6 +1718,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, + rules, }); // STEP 5: Build Onyx Data @@ -1698,6 +1729,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma shouldGenerateTransactionThreadReport, isOneOnOneSplit: isSplitExpense, isReverseSplitOperation, + rules, policyParams: { policy, policyCategories, diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 35d843cfe867..a40f34814f31 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -29,6 +29,7 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {shouldSplitScanFailedTransactions} from '@libs/TransactionUtils'; import {buildPolicyData, generatePolicyID} from '@userActions/Policy/Policy'; import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; @@ -50,8 +51,11 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; +import type AdditionalPayOnyxData from './types/AdditionalPayOnyxData'; + import {getAllPersonalDetails, getAllTransactionViolations} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; +import mergeAdditionalPayOnyxData from './mergeAdditionalPayOnyxData'; import {getReportPreviewReportAction} from './MoneyRequestBuilder'; type PayInvoiceArgs = { @@ -77,6 +81,7 @@ type PayInvoiceArgs = { delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type PayMoneyRequestData = { @@ -92,14 +97,6 @@ type PayMoneyRequestData = { >; }; -type SearchPayOnyxKey = typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.SNAPSHOT | typeof ONYXKEYS.COLLECTION.REPORT; - -type AdditionalPayOnyxData = { - optimisticData?: Array>; - successData?: Array>; - failureData?: Array>; -}; - type PayMoneyRequestFunctionParams = { paymentType: PaymentMethodType; chatReport: OnyxTypes.Report; @@ -127,27 +124,9 @@ type PayMoneyRequestFunctionParams = { isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; isFallbackChatReport?: boolean; + rules: OnyxCollection; }; -function mergeAdditionalPayOnyxData< - T extends { - optimisticData?: readonly unknown[]; - successData?: readonly unknown[]; - failureData?: readonly unknown[]; - }, ->(onyxData: T, additionalOnyxData?: AdditionalPayOnyxData): T { - if (!additionalOnyxData) { - return onyxData; - } - - return { - ...onyxData, - optimisticData: [...(onyxData.optimisticData ?? []), ...(additionalOnyxData.optimisticData ?? [])], - successData: [...(onyxData.successData ?? []), ...(additionalOnyxData.successData ?? [])], - failureData: [...(onyxData.failureData ?? []), ...(additionalOnyxData.failureData ?? [])], - }; -} - function getPayMoneyRequestParams({ initialChatReport, iouReport, @@ -174,6 +153,7 @@ function getPayMoneyRequestParams({ isTrackIntentUser, getCurrencyDecimals, isFallbackChatReport, + rules, }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; @@ -200,6 +180,7 @@ function getPayMoneyRequestParams({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; isFallbackChatReport?: boolean; + rules: OnyxCollection; }): PayMoneyRequestData { // TODO: https://github.com/Expensify/App/issues/66512 // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -232,7 +213,7 @@ function getPayMoneyRequestParams({ successData: policySuccessData, params, } = buildPolicyData({ - policyOwnerEmail: currentUserEmailParam, + policyOwner: {email: currentUserEmailParam, accountID: currentUserAccountIDParam}, policyName: defaultWorkspaceName, makeMeAdmin: true, policyID: payerPolicyID, @@ -247,6 +228,9 @@ function getPayMoneyRequestParams({ isSelfTourViewed, // hasActiveAdminPolicies is only needed if lastUsedPaymentMethod is passed hasActiveAdminPolicies: undefined, + // This workspace is created by the invoice payment command, which does not apply CreatePolicy's + // paid-workspace check, so the #admins room keeps starting out pinned here. + hasOwnedPaidPolicy: undefined, }); const {adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID, customUnitRateID, customUnitID, ownerEmail, policyName} = params; @@ -278,6 +262,8 @@ function getPayMoneyRequestParams({ total = unheldReimbursableTotal; } + const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(reportTransactions, iouReport); + const optimisticIOUReportAction = buildOptimisticIOUReportAction({ type: CONST.IOU.REPORT_ACTION_TYPE.PAY, amount: isExpenseReport(iouReport) ? -total : total, @@ -303,7 +289,7 @@ function getPayMoneyRequestParams({ } let optimisticNextStep = null; if (!isInvoiceReport) { - optimisticNextStep = buildOptimisticNextStep({report: iouReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.REIMBURSED, isTrackIntentUser}); + optimisticNextStep = buildOptimisticNextStep({report: iouReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.REIMBURSED, isTrackIntentUser, rules}); } const optimisticChatReport = { @@ -499,8 +485,19 @@ function getPayMoneyRequestParams({ let optimisticHoldReportID; let optimisticHoldActionID; let optimisticHoldReportExpenseActionIDs; - if (!full) { - const holdReportOnyxData = getReportFromHoldRequestsOnyxData({chatReport, iouReport, recipient, policy: reportPolicy, betas, delegateAccountID, getCurrencyDecimals}); + if (!full || shouldMoveScanFailedTransactions) { + const holdReportOnyxData = getReportFromHoldRequestsOnyxData({ + chatReport, + iouReport, + recipient, + policy: reportPolicy, + betas, + delegateAccountID, + getCurrencyDecimals, + shouldMoveHeldTransactions: !full, + shouldMoveScanFailedTransactions, + rules, + }); onyxData.optimisticData?.push(...holdReportOnyxData.optimisticData); onyxData.successData?.push(...holdReportOnyxData.successData); @@ -618,6 +615,7 @@ function cancelPayment( currentUserEmailParam: string, hasViolations: boolean, isTrackIntentUser: boolean | undefined, + rules: OnyxCollection, ) { if (isEmptyObject(expenseReport)) { return; @@ -659,6 +657,7 @@ function cancelPayment( hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, + rules, }); const iouReportActions = getAllReportActions(chatReport.iouReportID); const expenseReportActions = getAllReportActions(expenseReport.reportID); @@ -746,6 +745,7 @@ function cancelPayment( report: expenseReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.REIMBURSED, isTrackIntentUser, + rules, }) ?? null, pendingFields: { nextStep: null, @@ -888,6 +888,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { isTrackIntentUser, getCurrencyDecimals, isFallbackChatReport, + rules, } = params; const policyForBillingRestriction = chatReportPolicy ?? (policy?.id === chatReport.policyID ? policy : undefined); if ( @@ -925,6 +926,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { isTrackIntentUser, getCurrencyDecimals, isFallbackChatReport, + rules, }); // For now, we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with @@ -948,6 +950,7 @@ function markReportPaymentReceived( chatReportActions: OnyxEntry, isTrackIntentUser: boolean | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, ) { if (!chatReport || !iouReport) { return; @@ -981,7 +984,7 @@ function markReportPaymentReceived( const reportPreviewAction = getReportPreviewReportAction(chatReport.reportID, iouReport.reportID, chatReportActions); const optimisticReportPreviewAction = reportPreviewAction ? updateReportPreview(iouReport, reportPreviewAction, getCurrencyDecimals, true) : null; - const optimisticNextStep = buildOptimisticNextStep({report: iouReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.REIMBURSED, isTrackIntentUser}); + const optimisticNextStep = buildOptimisticNextStep({report: iouReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.REIMBURSED, isTrackIntentUser, rules}); const optimisticData: Array> = [ { @@ -1131,6 +1134,7 @@ function payInvoice({ delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, }: PayInvoiceArgs) { const recipient = {accountID: invoiceReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID}; const { @@ -1169,6 +1173,7 @@ function payInvoice({ delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, }); const paymentSelected = paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; @@ -1232,5 +1237,4 @@ function savePreferredPaymentMethod( }); } -export {cancelPayment, completePaymentOnboarding, markReportPaymentReceived, mergeAdditionalPayOnyxData, payInvoice, payMoneyRequest, savePreferredPaymentMethod}; -export type {AdditionalPayOnyxData}; +export {cancelPayment, completePaymentOnboarding, markReportPaymentReceived, payInvoice, payMoneyRequest, savePreferredPaymentMethod}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 1eafb43f3d1d..9b1de8fe7f57 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -51,7 +51,7 @@ import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Locale as DateFnsLocale} from 'date-fns'; -import type {OnyxEntry, OnyxInputValue} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -241,6 +241,10 @@ type PerDiemExpenseInformation = { policyParams?: BasePolicyParams; recentlyUsedParams?: RecentlyUsedParams; transactionParams: PerDiemExpenseTransactionParams; + newReportTotal?: number; + newReimbursableTotal?: number; + newNonReimbursableTotal?: number; + newUnheldReimbursableTotal?: number; existingIOUReport?: OnyxEntry; /** The policy's tags for this expense's policyID, i.e. `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}` from the POLICY_TAGS collection. */ policyTags: OnyxTypes.PolicyTagLists; @@ -263,6 +267,7 @@ type PerDiemExpenseInformation = { delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type PerDiemExpenseInformationParams = { @@ -273,6 +278,10 @@ type PerDiemExpenseInformationParams = { policyParams?: BasePolicyParams; recentlyUsedParams?: RecentlyUsedParams; existingIOUReport?: OnyxEntry; + newReportTotal?: number; + newReimbursableTotal?: number; + newNonReimbursableTotal?: number; + newUnheldReimbursableTotal?: number; moneyRequestReportID?: string; /** The policy's tags for this expense's policyID, i.e. `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}` from the POLICY_TAGS collection. */ policyTags: OnyxTypes.PolicyTagLists; @@ -291,6 +300,7 @@ type PerDiemExpenseInformationParams = { delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type PerDiemExpenseInformationForSelfDM = { @@ -328,6 +338,7 @@ type GetPerDiemExpensePolicyIDParams = { participantParams: RequestMoneyParticipantParams; existingIOUReport?: OnyxEntry; betas: OnyxEntry; + rules: OnyxCollection; currentUserAccountIDParam: number; }; @@ -337,7 +348,7 @@ type GetPerDiemExpensePolicyIDParams = { * transaction. Keep in sync with STEP 1/STEP 2 in `getPerDiemExpenseInformation` (and the chat report/moneyRequestReportID * resolution in `submitPerDiemExpense`) if their resolution order changes. */ -function getPerDiemExpensePolicyID({report, participantParams, existingIOUReport, betas, currentUserAccountIDParam}: GetPerDiemExpensePolicyIDParams): string | undefined { +function getPerDiemExpensePolicyID({report, participantParams, existingIOUReport, betas, rules, currentUserAccountIDParam}: GetPerDiemExpensePolicyIDParams): string | undefined { const {payeeAccountID = currentUserAccountIDParam, participant} = participantParams; const payerAccountID = Number(participant.accountID); const isPolicyExpenseChat = participant.isPolicyExpenseChat; @@ -359,7 +370,7 @@ function getPerDiemExpensePolicyID({report, participantParams, existingIOUReport } else if (chatReport) { iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const shouldCreateNew = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, false, betas); + const shouldCreateNew = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, false, betas, rules); if (iouReport && !shouldCreateNew) { return iouReport.policyID; @@ -380,6 +391,10 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI policyParams = {}, recentlyUsedParams = {}, existingIOUReport: existingIOUReportParam, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, moneyRequestReportID = '', policyTags, isASAPSubmitBetaEnabled, @@ -397,6 +412,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, } = perDiemExpenseInformation; const {payeeAccountID = currentUserAccountIDParam, payeeEmail = currentUserEmailParam, participant} = participantParams; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; @@ -450,7 +466,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, false, betas); + const shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, false, betas, rules); // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation const optimisticTransactionID = uiProvidedOptimisticTransactionID ?? NumberUtils.rand64(); @@ -470,6 +486,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI reportTransactions, betas, getCurrencyDecimals, + rules, }) : buildOptimisticIOUReport(payeeAccountID, payerAccountID, amount, chatReport.reportID, currency, getCurrencyDecimals); } else if (isPolicyExpenseChat) { @@ -478,17 +495,44 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI const previousReimbursableTotal = getReimbursableTotal(iouReport); const previousUnheldReimbursableTotal = getUnheldReimbursableTotal(iouReport); iouReport = {...iouReport}; + const isCurrencyMatching = iouReport?.currency === currency; + // A `new*Total` override is already expressed in the report's currency, so unlike the raw per-transaction + // arithmetic below, it does not need this expense's own currency to match the report's. A per diem rate can + // carry its own currency, so without this a duplicated per diem in another currency never reaches the total. + // Compared with `!== undefined` so a legitimate total of 0 is applied instead of being read as "no override". + const hasReportTotalOverride = newReportTotal !== undefined; // Because of the Expense reports are stored as negative values, we subtract the total from the amount - if (iouReport?.currency === currency) { + if (isCurrencyMatching || hasReportTotalOverride) { if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined) { - iouReport.total -= amount; - // Per diems are reimbursable, so mirror the change on the freshly tracked reimbursable total. - iouReport.reimbursableTotal = previousReimbursableTotal - amount; + if (hasReportTotalOverride) { + iouReport.total = newReportTotal; + } else { + iouReport.total -= amount; + } + + if (newReimbursableTotal !== undefined) { + iouReport.reimbursableTotal = newReimbursableTotal; + } else if (isCurrencyMatching) { + // Per diems are reimbursable, so mirror the change on the freshly tracked reimbursable total. + iouReport.reimbursableTotal = previousReimbursableTotal - amount; + } + if (newNonReimbursableTotal !== undefined) { + iouReport.nonReimbursableTotal = newNonReimbursableTotal; + } + if (newUnheldReimbursableTotal !== undefined) { + iouReport.unheldReimbursableTotal = newUnheldReimbursableTotal; + } } if (typeof iouReport.unheldTotal === 'number') { - iouReport.unheldTotal -= amount; - iouReport.unheldReimbursableTotal = previousUnheldReimbursableTotal - amount; + if (hasReportTotalOverride) { + iouReport.unheldTotal = newReportTotal; + } else { + iouReport.unheldTotal -= amount; + } + if (newUnheldReimbursableTotal === undefined && isCurrencyMatching) { + iouReport.unheldReimbursableTotal = previousUnheldReimbursableTotal - amount; + } } } } else { @@ -601,6 +645,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI isASAPSubmitBetaEnabled, policy, isTrackIntentUser, + rules, }); // STEP 5: Build Onyx Data @@ -648,6 +693,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, }); return { @@ -1003,6 +1049,10 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf recentlyUsedParams = {}, transactionParams, existingIOUReport, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, policyTags, isASAPSubmitBetaEnabled, currentUserAccountIDParam, @@ -1024,6 +1074,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf isTrackIntentUser, dateFnsLocale, getCurrencyDecimals, + rules, } = submitPerDiemExpenseInformation; const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; @@ -1057,6 +1108,10 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf recentlyUsedParams, transactionParams, existingIOUReport, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, moneyRequestReportID, policyTags, isASAPSubmitBetaEnabled, @@ -1074,6 +1129,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, }); const customUnitRate = getPerDiemRateCustomUnitRate(policyParams.policy, customUnit.customUnitRateID); diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 597d3cdf890e..4018d5c8fe4a 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -209,6 +209,8 @@ function replaceReceipt({ state: state ?? CONST.IOU.RECEIPT_STATE.OPEN, filename: file.name, receiptTraceId, + // Clear the old count while the replacement is pending. + pageCount: null, }; const newTransaction = transaction && {...transaction, receipt: receiptOptimistic}; const retryParams: ReplaceReceiptRetryParams = { diff --git a/src/libs/actions/IOU/RejectMoneyRequest.ts b/src/libs/actions/IOU/RejectMoneyRequest.ts index 1ca08bf35151..32babe030d83 100644 --- a/src/libs/actions/IOU/RejectMoneyRequest.ts +++ b/src/libs/actions/IOU/RejectMoneyRequest.ts @@ -42,7 +42,7 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -84,6 +84,11 @@ type RejectMoneyRequestOptions = { setExistingRejectedReport?: (report: OnyxEntry) => void; }; +type RejectMoneyRequestRulesAndOptions = { + rules: OnyxCollection; + options?: RejectMoneyRequestOptions; +}; + function dismissRejectUseExplanation() { const parameters: SetNameValuePairParams = { name: ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, @@ -122,6 +127,7 @@ type PrepareRejectMoneyRequestDataParams = { betas: OnyxEntry; delegateAccountID: number | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; options?: RejectMoneyRequestOptions; shouldUseBulkAction?: boolean; }; @@ -136,6 +142,7 @@ function prepareRejectMoneyRequestData({ betas, delegateAccountID, getCurrencyDecimals, + rules, options, shouldUseBulkAction, }: PrepareRejectMoneyRequestDataParams): RejectMoneyRequestData | undefined { @@ -496,6 +503,7 @@ function prepareRejectMoneyRequestData({ reportTransactions, betas, getCurrencyDecimals, + rules, }); const [, createdActionForExpenseReport, iouAction] = buildOptimisticMoneyRequestEntities({ getCurrencyDecimals, @@ -936,7 +944,7 @@ function rejectMoneyRequest( betas: OnyxEntry, delegateAccountID: number | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], - options?: RejectMoneyRequestOptions, + {rules, options}: RejectMoneyRequestRulesAndOptions, ): Route | undefined { const data = prepareRejectMoneyRequestData({ transactionID, @@ -948,6 +956,7 @@ function rejectMoneyRequest( betas, delegateAccountID, getCurrencyDecimals, + rules, options, }); if (!data) { @@ -1037,6 +1046,7 @@ function rejectExpenseReport( currentUserAvatarSource: AvatarSource | undefined, isTrackIntentUser: boolean | undefined, delegateAccountID: number | undefined, + rules: OnyxCollection, ) { const {reportID} = report; const isRejectToSubmitter = targetAccountID === report.ownerAccountID; @@ -1068,12 +1078,14 @@ function rejectExpenseReport( predictedNextStatus: CONST.REPORT.STATUS_NUM.OPEN, isRejectedReport: true, isTrackIntentUser, + rules, }) : buildOptimisticNextStep({ report, predictedNextStatus: CONST.REPORT.STATUS_NUM.SUBMITTED, bypassNextApproverID: targetAccountID, isTrackIntentUser, + rules, }); const optimisticData: Array> = [ diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 63bec6fa12f0..c82392aac4fc 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -93,15 +93,16 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; -import type {AdditionalPayOnyxData} from './PayMoneyRequest'; +import type AdditionalPayOnyxData from './types/AdditionalPayOnyxData'; import {getAllReportNameValuePairs, getAllTransactionViolations} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; -import {mergeAdditionalPayOnyxData} from './PayMoneyRequest'; +import mergeAdditionalPayOnyxData from './mergeAdditionalPayOnyxData'; type ApproveMoneyRequestFunctionParams = { expenseReport: OnyxEntry; expenseReportPolicy: OnyxEntry; + rules: OnyxCollection; currentUserAccountIDParam: number; currentUserEmailParam: string; hasViolations: boolean; @@ -124,6 +125,7 @@ type ApproveMoneyRequestFunctionParams = { type SubmitReportFunctionParams = { expenseReport: OnyxEntry; policy: OnyxEntry; + rules: OnyxCollection; currentUserAccountIDParam: number; currentUserEmailParam: string; hasViolations: boolean; @@ -283,7 +285,7 @@ function canIOUBePaid( !isPayBlockedByArchivedState(iouReport, policy, isChatReportArchived) && !isAutoReimbursable && !isPayAtEndExpenseReport && - (!isExpenseReport(iouReport) || arePaymentsEnabled(policy as OnyxEntry)) + (!isExpenseReport(iouReport) || arePaymentsEnabled(policy)) ); } @@ -458,6 +460,7 @@ function getReportOriginalCreationTimestamp(expenseReport?: OnyxEntry, isTrackIntentUser: boolean | undefined, + rules: OnyxCollection, ) { if (!expenseReport) { return; @@ -862,6 +868,7 @@ function reopenReport( report: expenseReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.OPEN, policy, + rules, currentUserAccountIDParam, currentUserEmailParam, hasViolations, @@ -1011,6 +1018,7 @@ function retractReport( isASAPSubmitBetaEnabled: boolean, delegateEmail: string | undefined, isTrackIntentUser: boolean | undefined, + rules: OnyxCollection, ) { if (!expenseReport) { return; @@ -1024,6 +1032,7 @@ function retractReport( report: expenseReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.OPEN, policy, + rules, currentUserAccountIDParam, currentUserEmailParam, hasViolations, @@ -1165,6 +1174,7 @@ function unapproveExpenseReport( delegateEmail: string | undefined, isTrackIntentUser: boolean | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, ) { if (isEmptyObject(expenseReport)) { return; @@ -1182,6 +1192,7 @@ function unapproveExpenseReport( report: expenseReport, predictedNextStatus: CONST.REPORT.STATUS_NUM.SUBMITTED, policy, + rules, currentUserAccountIDParam, currentUserEmailParam, hasViolations, @@ -1306,6 +1317,7 @@ function unapproveExpenseReport( function submitReport({ expenseReport, policy, + rules, currentUserAccountIDParam, currentUserEmailParam, hasViolations, @@ -1338,7 +1350,7 @@ function submitReport({ const trimmedManagerEmail = managerEmail?.trim(); const managerAccountIDFromEmail = trimmedManagerEmail ? getAccountIDForSubmitManagerEmail(trimmedManagerEmail, policy?.employeeList) : undefined; const resolvedManagerAccountIDFromEmail = managerAccountIDFromPopover ?? managerAccountIDFromEmail; - const submitReportManagerAccountID = getSubmitReportManagerAccountID(policy, expenseReport, submitterLogin); + const submitReportManagerAccountID = getSubmitReportManagerAccountID(policy, expenseReport, submitterLogin, rules); // When an explicit manager email can't be resolved to an accountID, send the email alone rather than a mismatched // accountID from the approval chain, which would point the server at someone other than the chosen approver. @@ -1379,6 +1391,7 @@ function submitReport({ report: expenseReport, predictedNextStatus: isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED, policy, + rules, currentUserAccountIDParam, currentUserEmailParam, hasViolations, @@ -1607,6 +1620,7 @@ function submitReport({ betas, delegateAccountID, getCurrencyDecimals, + rules, }); optimisticData.push(...holdReportOnyxData.optimisticData); @@ -1685,6 +1699,7 @@ function assignReportToMe( isASAPSubmitBetaEnabled: boolean, isTrackIntentUser: boolean | undefined, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + rules: OnyxCollection, ) { const takeControlReportAction = buildOptimisticChangeApproverReportAction(accountID, accountID, formatPhoneNumber); @@ -1694,6 +1709,7 @@ function assignReportToMe( shouldFixViolations: false, isUnapprove: true, policy, + rules, currentUserAccountIDParam: accountID, currentUserEmailParam: email, hasViolations, @@ -1787,6 +1803,9 @@ type AddReportApproverOptions = { /** Policy associated with the expense report. */ policy: OnyxEntry; + /** Approval-workflow rules for the policy. */ + rules: OnyxCollection; + /** Whether the report currently has violations. */ hasViolations: boolean; @@ -1807,6 +1826,7 @@ function addReportApprover({ accountID, email, policy, + rules, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, @@ -1820,6 +1840,7 @@ function addReportApprover({ shouldFixViolations: false, isUnapprove: true, policy, + rules, currentUserAccountIDParam: accountID, currentUserEmailParam: email, hasViolations, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 10eb3e5da269..5fc12df49c75 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -134,6 +134,10 @@ type CreateDistanceRequestInformation = { existingTransaction?: OnyxEntry; transactionParams: DistanceRequestTransactionParams; policyParams?: BasePolicyParams; + newReportTotal?: number; + newReimbursableTotal?: number; + newNonReimbursableTotal?: number; + newUnheldReimbursableTotal?: number; isASAPSubmitBetaEnabled: boolean; transactionViolations: OnyxCollection; quickAction: OnyxEntry; @@ -154,6 +158,7 @@ type CreateDistanceRequestInformation = { /** Optimistic chat reportID to build the new chat report at, so it matches the ID the confirmation screen already subscribed to (brand-new P2P recipient). */ optimisticChatReportID?: string; + rules: OnyxCollection; }; type CreateSplitsTransactionParams = Omit & { @@ -182,6 +187,7 @@ type CreateSplitsAndOnyxDataParams = { isTrackIntentUser: boolean | undefined; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type StartSplitBilActionParams = { @@ -227,6 +233,7 @@ type CompleteSplitBillActionParams = { sessionEmail?: string; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type SplitBillActionsParams = { @@ -263,6 +270,7 @@ type SplitBillActionsParams = { isTrackIntentUser: boolean | undefined; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; /** @@ -302,6 +310,7 @@ function splitBill({ formatPhoneNumber, participantsPolicyTags, getCurrencyDecimals, + rules, }: SplitBillActionsParams) { const parsedComment = getParsedComment(comment); const {splitData, splits, onyxData} = createSplitsAndOnyxData({ @@ -339,6 +348,7 @@ function splitBill({ isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }); const parameters: SplitBillParams = { @@ -414,6 +424,7 @@ function splitBillAndOpenReport({ formatPhoneNumber, participantsPolicyTags, getCurrencyDecimals, + rules, }: SplitBillActionsParams) { const parsedComment = getParsedComment(comment); const {splitData, splits, onyxData} = createSplitsAndOnyxData({ @@ -451,6 +462,7 @@ function splitBillAndOpenReport({ formatPhoneNumber, participantsPolicyTags, getCurrencyDecimals, + rules, }); const parameters: SplitBillParams = { @@ -915,6 +927,7 @@ function completeSplitBill({ isTrackIntentUser, sessionEmail, getCurrencyDecimals, + rules, }: CompleteSplitBillActionParams) { if (!reportAction) { return; @@ -1035,7 +1048,7 @@ function completeSplitBill({ } let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; - const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false, betas); + const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false, betas, rules); // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation const optimisticTransactionID = NumberUtils.rand64(); @@ -1062,6 +1075,7 @@ function completeSplitBill({ reportTransactions, betas, getCurrencyDecimals, + rules, }) : buildOptimisticIOUReport( sessionAccountID, @@ -1182,6 +1196,7 @@ function completeSplitBill({ delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, }); splits.push({ @@ -1506,6 +1521,7 @@ function createSplitsAndOnyxData({ isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }: CreateSplitsAndOnyxDataParams): SplitsAndOnyxData { const currentUserEmailForIOUSplit = addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); @@ -1796,7 +1812,7 @@ function createSplitsAndOnyxData({ // STEP 2: Get existing IOU/Expense report and update its total OR build a new optimistic one let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport.iouReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; const isScanRequest = isScanRequestTransactionUtils(splitTransaction); - const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, isScanRequest, betas); + const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, isScanRequest, betas, rules); if (!oneOnOneIOUReport || shouldCreateNewOneOnOneIOUReport) { const optimisticExpenseReportID = generateReportID(); @@ -1820,6 +1836,7 @@ function createSplitsAndOnyxData({ reportTransactions, betas, getCurrencyDecimals, + rules, }) : buildOptimisticIOUReport(currentUserAccountID, accountID, splitAmount, oneOnOneChatReport.reportID, currency, getCurrencyDecimals); } else if (isOwnPolicyExpenseChat) { @@ -1980,6 +1997,7 @@ function createSplitsAndOnyxData({ delegateAccountID, isTrackIntentUser, getCurrencyDecimals, + rules, }); const individualSplit = { @@ -2046,6 +2064,10 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest existingTransaction, transactionParams, policyParams = {}, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, isASAPSubmitBetaEnabled, transactionViolations, quickAction, @@ -2064,6 +2086,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest participantsPolicyTags, optimisticChatReportID, getCurrencyDecimals, + rules, } = distanceRequestInformation; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; const parsedComment = getParsedComment(transactionParams.comment); @@ -2160,6 +2183,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }); onyxData = splitOnyxData; @@ -2210,6 +2234,10 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest parentChatReport: currentChatReport, existingIOUReport, existingTransaction, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, moneyRequestReportID, participantParams: { participant, @@ -2260,6 +2288,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest optimisticChatReportID, formatPhoneNumber, getCurrencyDecimals, + rules, }); onyxData = moneyRequestOnyxData; diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index 710e8a96a2b9..d403d3d8870d 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -643,7 +643,6 @@ type ResetSplitExpensesByDateRangeParams = { /** Whether the split is created in a self-DM */ isSelfDMSplit: boolean; - /** The output currency of the personal policy */ personalPolicyOutputCurrency: string | undefined; /** Resolves the symbol of a currency */ diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3d024da4c316..3e321cb2f562 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -124,6 +124,7 @@ type UpdateSplitTransactionsParams = { formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; /** @@ -208,6 +209,7 @@ function updateSplitTransactions({ formatPhoneNumber, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateSplitTransactionsParams) { const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); // For selfDM-origin splits the caller can't resolve a real `expenseReport` (the draft/source @@ -686,6 +688,7 @@ function updateSplitTransactions({ isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, } as MoneyRequestInformationParams; if (isReverseSplitOperation) { @@ -794,7 +797,9 @@ function updateSplitTransactions({ moneyRequestReportID: moneyRequestReportIDForSplit, existingTransaction, existingTransactionID, - newReportTotal: reportTotals.get(splitExpense?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)) ?? 0, + // No `?? 0` fallback: a missing entry must stay `undefined` so the builder keeps applying its own + // per-transaction arithmetic. It now honours a real `0`, which the old truthiness check dropped. + newReportTotal: reportTotals.get(splitExpense?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)), newNonReimbursableTotal: (transactionReport?.nonReimbursableTotal ?? 0) - changesInReportTotal, isSplitExpense: true, isReverseSplitOperation, @@ -812,6 +817,7 @@ function updateSplitTransactions({ isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }); let updateMoneyRequestParamsOnyxData: OnyxData = {}; @@ -907,8 +913,10 @@ function updateSplitTransactions({ isOffline, delegateAccountID, isTrackIntentUser, + violations: transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${existingTransactionID}`], getCurrencyDecimals, getCurrencySymbol, + rules, }); if (currentSplit) { currentSplit.modifiedExpenseReportActionID = params.reportActionID; @@ -1407,18 +1415,15 @@ function updateSplitTransactions({ optimisticData: deleteExpenseOptimisticData, failureData: deleteExpenseFailureData, successData: deleteExpenseSuccessData, - } = getDeleteTrackExpenseInformation( - splitTransactionReport, - undeletedTransaction?.transactionID, - currentReportAction, - undefined, - currentUserPersonalDetails.accountID, - undefined, - undefined, - undefined, - undefined, - isReportArchived || undeletedTransaction?.transactionID === forceDeleteSplitTransactionID, - ); + } = getDeleteTrackExpenseInformation({ + chatReport: splitTransactionReport, + transactionID: undeletedTransaction?.transactionID, + reportAction: currentReportAction, + isChatReportArchived: undefined, + currentUserAccountID: currentUserPersonalDetails.accountID, + transactionThreadReportActions: allReportActionsList?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportAction?.childReportID}`], + shouldRemoveIOUTransaction: isReportArchived || undeletedTransaction?.transactionID === forceDeleteSplitTransactionID, + }); // getDeleteTrackExpenseInformation only handles deleting the transaction report thread, so we need to update the report preview action here if (originalReportPreviewAction) { @@ -1428,6 +1433,8 @@ function updateSplitTransactions({ updatedReportPreviewAction: (updatedReportPreviewAction ?? originalReportPreviewAction) as OnyxTypes.ReportAction, shouldAddUpdatedReportPreviewActionToOnyxData: false, currentUserAccountID: currentUserPersonalDetails.accountID, + // shouldDeleteTransactionThread is false, so the transaction-thread report actions are never read here. + transactionThreadReportActionsParam: undefined, }); updatedReportPreviewAction = cleanUpTransactionThreadReportOnyxData.updatedReportPreviewAction; } diff --git a/src/libs/actions/IOU/StartExpenseFlows.ts b/src/libs/actions/IOU/StartExpenseFlows.ts new file mode 100644 index 000000000000..8bc89345aa6b --- /dev/null +++ b/src/libs/actions/IOU/StartExpenseFlows.ts @@ -0,0 +1,436 @@ +/** + * Starters for the expense-creation flows: the add-expense dropdown options, the draft-transaction/participant-selector flow, and the draft-workspace confirmation flow. + */ +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; +import Log from '@libs/Log'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import Navigation from '@libs/Navigation/Navigation'; +import {shouldShowPolicy} from '@libs/PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getPolicyExpenseChat, getReportOrDraftReport, getTransactionCommentObject, getTransactionDetails} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {isInvalidMerchantValue} from '@libs/ValidationUtils'; + +import {createDraftWorkspace} from '@userActions/Policy/Policy'; +import {openUnreportedExpense} from '@userActions/Report'; +import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; + +import CONST from '@src/CONST'; +import type {IOUAction, IOURequestType} from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {BillingGraceEndPeriod, IntroSelected, Policy, Transaction} from '@src/types/onyx'; +import type {CreatableWorkspaceType} from '@src/types/onyx/Policy'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {createDraftTransaction, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, setMoneyRequestReportID, startDistanceRequest, startMoneyRequest} from './MoneyRequest'; + +/** + * Returns the dropdown options for the add expense button + * @param iouReport - The IOU report to add an expense to + * @param policy - The policy of the IOU report + * @param backToReport - The report to return to after adding an expense + * @returns The dropdown options for the add expense button + */ +type GetAddExpenseDropdownOptionsParams = { + translate: LocalizedTranslate; + icons: Record<'Location' | 'ReceiptPlus' | 'Plus', IconAsset>; + iouReportID: string | undefined; + policy: OnyxEntry; + userBillingGracePeriodEnds: OnyxCollection; + draftTransactionIDs: string[] | undefined; + amountOwed: OnyxEntry; + ownerBillingGracePeriodEnd: OnyxEntry; + iouRequestBackToReport?: string; + unreportedExpenseBackToReport?: string; + lastDistanceExpenseType?: IOURequestType; + currentUserAccountID: number; + blockDistanceRequestIfNeeded?: () => boolean; +}; + +function getAddExpenseDropdownOptions({ + translate, + icons, + iouReportID, + policy, + userBillingGracePeriodEnds, + draftTransactionIDs, + amountOwed, + ownerBillingGracePeriodEnd, + iouRequestBackToReport, + unreportedExpenseBackToReport, + lastDistanceExpenseType, + currentUserAccountID, + blockDistanceRequestIfNeeded, +}: GetAddExpenseDropdownOptionsParams): Array>> { + const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); + + return [ + // Teachers Unite only supports expenses via split expense + ...(isReportTeachersUnite + ? [] + : [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: icons.Plus, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, + onSelected: () => { + if (!iouReportID) { + return; + } + if ( + policy && + policy.type !== CONST.POLICY.TYPE.PERSONAL && + shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) + ) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: icons.Location, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, + onSelected: () => { + if (!iouReportID) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + if (blockDistanceRequestIfNeeded?.()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); + }, + }, + ]), + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, + text: translate('iou.addExistingExpense'), + icon: icons.ReceiptPlus, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_EXISTING, + onSelected: () => { + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + openUnreportedExpense(iouReportID, unreportedExpenseBackToReport); + }, + }, + ]; +} + +function createDraftWorkspaceAndNavigateToConfirmationScreen( + introSelected: OnyxEntry, + transactionID: string, + actionName: IOUAction, + workspaceName: string, + currentUserAccountID: number, + currentUserEmail: string, + currentUserLocalCurrency: string, + policyType?: CreatableWorkspaceType, + backToReportID?: string, +): void { + const isCategorizing = actionName === CONST.IOU.ACTION.CATEGORIZE; + const {expenseChatReportID, policyID, policyName} = createDraftWorkspace({ + introSelected, + workspaceName, + currentUserAccountID, + currentUserEmail, + currency: currentUserLocalCurrency, + type: policyType, + }); + setMoneyRequestParticipants(transactionID, [ + { + selected: true, + accountID: 0, + isPolicyExpenseChat: true, + reportID: expenseChatReportID, + policyID, + searchText: policyName, + }, + ]); + 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 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 = { + reportID: string | undefined; + actionName: IOUAction; + reportActionID: string | undefined; + reportActions: OnyxEntry; + introSelected: OnyxEntry; + draftTransactionIDs: string[] | undefined; + activePolicy: OnyxEntry; + userBillingGracePeriodEnds: OnyxCollection; + amountOwed: OnyxEntry; + ownerBillingGracePeriodEnd?: OnyxEntry; + isRestrictedToPreferredPolicy?: boolean; + preferredPolicyID?: string; + transaction: OnyxEntry; + currentUserAccountID: number; + currentUserEmail: string; + currentUserLocalCurrency: string; + /** + * For the SUBMIT action, which destination the user picked from the track-expense whisper: + * - 'friend' (default): show the existing recipient picker (individuals + workspaces). + * - 'employer': route to a submit-enabled workspace, auto-selecting/creating one as needed. + */ + submitDestination?: ValueOf; + /** Localized default name for a workspace created on the fly (e.g. "Submit to my employer" with no existing workspace). */ + defaultWorkspaceName?: string; + filteredPoliciesCount: number; + firstPolicyID: string | undefined; +}; + +function createDraftTransactionAndNavigateToParticipantSelector({ + reportID, + actionName, + reportActionID, + reportActions, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + isRestrictedToPreferredPolicy = false, + preferredPolicyID, + transaction, + currentUserAccountID, + currentUserEmail, + currentUserLocalCurrency, + submitDestination = CONST.IOU.SUBMIT_DESTINATION.FRIEND, + defaultWorkspaceName = '', + filteredPoliciesCount, + firstPolicyID, +}: CreateDraftTransactionParams): void { + const transactionID = transaction?.transactionID; + if (!transactionID || !reportID) { + return; + } + + const linkedTrackedExpenseReportAction = Object.values(reportActions ?? {}) + .filter(Boolean) + .find((action) => isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === transactionID); + + const {created, amount, currency, merchant, mccGroup} = getTransactionDetails(transaction) ?? {}; + const isMerchantValid = !isInvalidMerchantValue(merchant); + const baseComment = getTransactionCommentObject(transaction); + // Use modifiedAttendees if present (for edited transactions), otherwise use the attendees from comment + const comment = { + ...baseComment, + attendees: transaction?.modifiedAttendees ?? baseComment.attendees, + }; + + removeDraftTransactionsByIDs(draftTransactionIDs); + + // `transaction` is an OnyxEntry, so spreading it widens the object to a partial and the `as Transaction` assertion is required. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above + createDraftTransaction({ + ...transaction, + actionableWhisperReportActionID: reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: reportID, + created, + modifiedCreated: undefined, + modifiedAmount: undefined, + modifiedCurrency: undefined, + amount, + isAmountSet: true, + currency, + comment, + merchant, + modifiedMerchant: '', + isMerchantSet: !!isMerchantValid, + modifiedAttendees: undefined, + mccGroup, + participants: [], + } as Transaction); + + if (actionName === CONST.IOU.ACTION.CATEGORIZE) { + if (activePolicy && shouldRestrictUserBillableActions(activePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(activePolicy.id)); + return; + } + + if (activePolicy && shouldShowPolicy(activePolicy, false, currentUserEmail)) { + const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID, activePolicy.id)?.reportID; + setMoneyRequestParticipants(transactionID, [ + { + selected: true, + accountID: 0, + isPolicyExpenseChat: true, + reportID: policyExpenseReportID, + policyID: activePolicy.id, + searchText: activePolicy?.name, + }, + ]); + if (policyExpenseReportID) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID: policyExpenseReportID}), + ROUTES.REPORT_WITH_ID.getRoute(reportID), + ), + ); + } else { + Log.warn('policyExpenseReportID is not valid during expense categorizing'); + } + return; + } + if (filteredPoliciesCount === 0 || filteredPoliciesCount > 1) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: actionName, + iouType: CONST.IOU.TYPE.SUBMIT, + transactionID, + reportID, + upgradePath: actionName === CONST.IOU.ACTION.CATEGORIZE ? CONST.UPGRADE_PATHS.CATEGORIES : '', + shouldSubmitExpense: true, + }), + ROUTES.REPORT_WITH_ID.getRoute(reportID), + ), + ); + return; + } + + const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID, firstPolicyID)?.reportID; + setMoneyRequestParticipants(transactionID, [ + { + selected: true, + accountID: 0, + isPolicyExpenseChat: true, + reportID: policyExpenseReportID, + policyID: firstPolicyID, + searchText: activePolicy?.name, + }, + ]); + if (policyExpenseReportID) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID: policyExpenseReportID}), + ROUTES.REPORT_WITH_ID.getRoute(reportID), + ), + ); + } else { + Log.warn('policyExpenseReportID is not valid during expense categorizing'); + } + return; + } + + if (actionName === CONST.IOU.ACTION.SHARE) { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ACCOUNTANT.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, reportID), ROUTES.REPORT_WITH_ID.getRoute(reportID)), + ); + return; + } + + // "Submit to my employer" routes the expense into a workspace the user can submit to, based on how many they belong to. + // Per issue #92704 the count spans every paid workspace the user is a member of (Collect/Control/Submit), so we reuse the + // shared shouldShowPolicy-based count (filteredPoliciesCount/firstPolicyID) that also backs the workspaces-only picker. + if (actionName === CONST.IOU.ACTION.SUBMIT && submitDestination === CONST.IOU.SUBMIT_DESTINATION.EMPLOYER) { + // No accessible workspace: spin up a new Submit (submit2026) workspace and drop the expense into its draft report. + if (filteredPoliciesCount === 0) { + // `reportID` is where the expense lives (the self DM), which is not necessarily what the user is looking at: + // they may have drilled into the expense thread first. Going back resolves the report route to a POP_TO on + // the tab navigator, which rebuilds the central pane from the route, so returning to the self DM here would + // swap the report out from under them. + const visibleReportID = isReportTopmostSplitNavigator() ? Navigation.getTopmostReportId() : undefined; + createDraftWorkspaceAndNavigateToConfirmationScreen( + introSelected, + transactionID, + actionName, + defaultWorkspaceName, + currentUserAccountID, + currentUserEmail, + currentUserLocalCurrency, + CONST.POLICY.TYPE.SUBMIT, + visibleReportID ?? reportID, + ); + return; + } + + // Exactly one accessible workspace: skip the destination picker and submit straight to that workspace. + if (filteredPoliciesCount === 1 && firstPolicyID) { + const policyExpenseReport = getPolicyExpenseChat(currentUserAccountID, firstPolicyID); + if (policyExpenseReport) { + // The draft inherits the source expense's unreported ID from the self DM. The picker we skip here is what + // normally rebinds it to the destination chat, so without this the confirmation page still reads the draft + // as unreported: it renders "None" for the report and resolves the policy from the self DM instead of the + // destination workspace. + setMoneyRequestReportID(transactionID, policyExpenseReport.reportID); + setMoneyRequestParticipantsFromReport(transactionID, policyExpenseReport, currentUserAccountID).then(() => { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.SUBMIT, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReport.reportID)); + }); + return; + } + } + + // Multiple accessible workspaces: show the destination picker limited to workspaces only (no individual recipients). + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({ + action: actionName, + iouType: CONST.IOU.TYPE.SUBMIT, + transactionID, + reportID, + isWorkspacesOnly: true, + }), + ROUTES.REPORT_WITH_ID.getRoute(reportID), + ), + ); + return; + } + + if (actionName === CONST.IOU.ACTION.SUBMIT || filteredPoliciesCount > 0) { + // Check if user is restricted to preferred workspace for submit tracked expenses + if (isRestrictedToPreferredPolicy && preferredPolicyID) { + const policyExpenseReport = getPolicyExpenseChat(currentUserAccountID, preferredPolicyID); + + if (policyExpenseReport) { + // Same picker-skip as the single-workspace branch above, so the draft needs the same rebinding. + setMoneyRequestReportID(transactionID, policyExpenseReport.reportID); + setMoneyRequestParticipantsFromReport(transactionID, policyExpenseReport, currentUserAccountID).then(() => { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.SUBMIT, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReport.reportID)); + }); + return; + } + } + + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID}), + ROUTES.REPORT_WITH_ID.getRoute(reportID), + ), + ); + return; + } + + return createDraftWorkspaceAndNavigateToConfirmationScreen(introSelected, transactionID, actionName, '', currentUserAccountID, currentUserEmail, currentUserLocalCurrency); +} + +export {createDraftTransactionAndNavigateToParticipantSelector, createDraftWorkspaceAndNavigateToConfirmationScreen, getAddExpenseDropdownOptions}; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 32216b419209..bce189fcdfc9 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -52,7 +52,6 @@ import { getReportOrDraftReport, getReportRecipientAccountIDs, getReportTransactions, - isDraftReport, isHiddenForCurrentUser, isMoneyRequestReport as isMoneyRequestReportReportUtils, isPolicyExpenseChat as isPolicyExpenseChatReportUtil, @@ -206,15 +205,16 @@ type GetTrackExpenseInformationParams = { delegateAccountID: number | undefined; /** Policy type for the workspace created from a draft report (e.g. submit2026 for the "Submit to my employer" flow). Defaults to a team workspace. */ policyType?: CreatableWorkspaceType; - // TODO: Remove optional (?) once all callers are updated in follow-up PRs of https://github.com/Expensify/App/issues/66414 - isDraftChatReport?: boolean; + isDraftChatReport: boolean; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; type DeleteTrackExpenseParams = { chatReportID: string | undefined; chatReport: OnyxEntry | undefined; chatReportActions: OnyxEntry; + transactionThreadReportActions: OnyxEntry; transactionID: string | undefined; reportAction: OnyxTypes.ReportAction; iouReport: OnyxEntry; @@ -652,18 +652,33 @@ function buildOnyxDataForTrackExpense({ return onyxData; } -function getDeleteTrackExpenseInformation( - chatReport: OnyxEntry, - transactionID: string | undefined, - reportAction: OnyxTypes.ReportAction, - isChatReportArchived: boolean | undefined, - currentUserAccountID: number, +type GetDeleteTrackExpenseInformationParams = { + chatReport: OnyxEntry; + transactionID: string | undefined; + reportAction: OnyxTypes.ReportAction; + isChatReportArchived: boolean | undefined; + currentUserAccountID: number; + transactionThreadReportActions: OnyxEntry; + shouldDeleteTransactionFromOnyx?: boolean; + isMovingTransactionFromTrackExpense?: boolean; + actionableWhisperReportActionID?: string; + resolution?: string; + shouldRemoveIOUTransaction?: boolean; +}; + +function getDeleteTrackExpenseInformation({ + chatReport, + transactionID, + reportAction, + isChatReportArchived, + currentUserAccountID, + transactionThreadReportActions, shouldDeleteTransactionFromOnyx = true, isMovingTransactionFromTrackExpense = false, actionableWhisperReportActionID = '', resolution = '', shouldRemoveIOUTransaction = true, -) { +}: GetDeleteTrackExpenseInformationParams) { // STEP 1: Get all collections we're updating const transaction = getAllTransactions()?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; // TODO: https://github.com/Expensify/App/issues/66512 @@ -738,6 +753,7 @@ function getDeleteTrackExpenseInformation( transactionThreadID, shouldDeleteTransactionThread, currentUserAccountID, + transactionThreadReportActionsParam: transactionThreadReportActions, }); optimisticData.push(...cleanUpTransactionThreadReportOnyxData.optimisticData); @@ -876,6 +892,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T currentUserLocalCurrency, policyType, getCurrencyDecimals, + rules, } = params; const {payeeAccountID = currentUserAccountIDParam, payeeEmail = currentUserEmailParam, participant} = participantParams; const {policy} = policyParams; @@ -1001,14 +1018,11 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T ); } - // Check if the report is a draft - const isDraftReportLocal = isDraftChatReport ?? isDraftReport(chatReport?.reportID); - let createdWorkspaceParams: CreateWorkspaceParams | undefined; - if (isDraftReportLocal) { + if (isDraftChatReport) { const workspaceData = buildPolicyData({ - policyOwnerEmail: undefined, + policyOwner: undefined, makeMeAdmin: policy?.makeMeAdmin, policyName: policy?.name ?? defaultWorkspaceName ?? '', policyID: policy?.id, @@ -1025,6 +1039,9 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T conciergeChat, // hasActiveAdminPolicies is only needed if lastUsedPaymentMethod is passed hasActiveAdminPolicies: undefined, + // This workspace is created by AddTrackedExpenseToPolicy, which does not apply CreatePolicy's + // paid-workspace check, so the #admins room keeps starting out pinned here. + hasOwnedPaidPolicy: undefined, betas, isSelfTourViewed, }); @@ -1052,7 +1069,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T iouReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } const isScanRequest = isScanRequestTransactionUtils(existingTransaction); - shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas); + shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas, rules); if (!iouReport || shouldCreateNewMoneyRequestReport) { const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticExpenseReportID, created, amount, currency, merchant); @@ -1067,6 +1084,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T optimisticIOUReportID: optimisticExpenseReportID, reportTransactions, getCurrencyDecimals, + rules, }); } else { iouReport = {...iouReport}; @@ -1240,18 +1258,20 @@ const getConvertTrackedExpenseInformation = ( optimisticData: deleteOptimisticData, successData: deleteSuccessData, failureData: deleteFailureData, - } = getDeleteTrackExpenseInformation( - getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedTrackedExpenseReportID}`], + } = getDeleteTrackExpenseInformation({ + chatReport: getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedTrackedExpenseReportID}`], transactionID, - linkedTrackedExpenseReportAction, - isLinkedTrackedExpenseReportArchived, + reportAction: linkedTrackedExpenseReportAction, + isChatReportArchived: isLinkedTrackedExpenseReportArchived, currentUserAccountID, - false, - true, + // isMovingTransactionFromTrackExpense is true, so the transaction thread is never deleted and these report actions are unused here. + transactionThreadReportActions: undefined, + shouldDeleteTransactionFromOnyx: false, + isMovingTransactionFromTrackExpense: true, actionableWhisperReportActionID, resolution, - true, - ); + shouldRemoveIOUTransaction: true, + }); optimisticData?.push(...deleteOptimisticData); successData?.push(...deleteSuccessData); @@ -1651,6 +1671,10 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep participantParams, policyParams = {}, transactionParams, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, gpsPoint, action, shouldPlaySound = true, @@ -1676,6 +1700,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, } = requestMoneyInformation; const {payeeAccountID} = participantParams; const parsedComment = getParsedComment(transactionParams.comment ?? ''); @@ -1764,6 +1789,10 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep participantParams, policyParams, transactionParams, + newReportTotal, + newReimbursableTotal, + newNonReimbursableTotal, + newUnheldReimbursableTotal, moneyRequestReportID, existingTransactionID, optimisticTransactionID: requestMoneyInformation.optimisticTransactionID, @@ -1788,6 +1817,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }); const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; @@ -1985,6 +2015,7 @@ function convertBulkTrackedExpensesToIOU({ isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }: { transactions: OnyxTypes.Transaction[]; iouReport: OnyxEntry; @@ -2003,6 +2034,7 @@ function convertBulkTrackedExpensesToIOU({ isTrackIntentUser: boolean | undefined; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }) { const iouReportID = iouReport?.reportID; @@ -2125,6 +2157,7 @@ function convertBulkTrackedExpensesToIOU({ isTrackIntentUser, formatPhoneNumber, getCurrencyDecimals, + rules, }); const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); @@ -2458,6 +2491,7 @@ function trackExpense(params: CreateTrackExpenseParams) { isDraftChatReport, currentUserLocalCurrency, getCurrencyDecimals, + rules, } = params; const {accountID: currentUserAccountIDParam, email: currentUserEmailParam = ''} = currentUser; const {participant, payeeAccountID, payeeEmail} = participantParams; @@ -2627,6 +2661,7 @@ function trackExpense(params: CreateTrackExpenseParams) { // Only "Submit to my employer" creates a Submit (submit2026) workspace from a draft; everything else keeps the default (team) type. policyType: action === CONST.IOU.ACTION.SUBMIT && policy?.type === CONST.POLICY.TYPE.SUBMIT ? CONST.POLICY.TYPE.SUBMIT : undefined, getCurrencyDecimals, + rules, }) ?? {}; const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport?.reportID; const onyxData: TrackedExpenseParams['onyxData'] = trackExpenseInformationOnyxData; @@ -2967,6 +3002,7 @@ function deleteTrackExpense({ chatReportID, chatReport, chatReportActions, + transactionThreadReportActions, transactionID, reportAction, iouReport, @@ -3008,6 +3044,7 @@ function deleteTrackExpense({ reportAction, transactions, transactionThreadReport, + transactionThreadReportActions, violations, iouReport, chatReport: chatIOUReport, @@ -3024,18 +3061,17 @@ function deleteTrackExpense({ const whisperAction = getTrackExpenseActionableWhisper(transactionID, chatReportID, chatReportActions); const actionableWhisperReportActionID = whisperAction?.reportActionID; - const {parameters, optimisticData, successData, failureData} = getDeleteTrackExpenseInformation( + const {parameters, optimisticData, successData, failureData} = getDeleteTrackExpenseInformation({ chatReport, transactionID, reportAction, isChatReportArchived, currentUserAccountID, - undefined, - undefined, + transactionThreadReportActions, actionableWhisperReportActionID, - CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, - false, - ); + resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, + shouldRemoveIOUTransaction: false, + }); // STEP 6: Make the API request API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 1b7640efd3f2..af6a4861c771 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -5,6 +5,7 @@ import type {UpdateMoneyRequestParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {getChangedTagLevels} from '@libs/MerchantRuleSuggestionUtils'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; import {hasDependentTags, isGroupPolicy, isTaxTrackingEnabled} from '@libs/PolicyUtils'; @@ -28,18 +29,21 @@ import { getClearedPendingFields, getDistanceRateTaxUpdates, getMerchant, + getTag, getUpdatedTransaction, hasLocallyKnownDistance, hasSubmissionBlockingViolationInReport, haveWaypointAddressesChanged, isDistanceRequest as isDistanceRequestTransactionUtils, isFetchingWaypointsFromServer, + isMapBasedDistanceRequest, isOnHold, isScanning, removeTransactionFromDuplicateTransactionViolation, } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; +import {getMerchantRuleSuggestionRollback, trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -47,6 +51,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; @@ -92,6 +97,7 @@ type UpdateMoneyRequestDateParams = { personalPolicyOutputCurrency: string | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; type SearchSnapshotOnyxData = { @@ -243,6 +249,7 @@ function updateMoneyRequestDate({ personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateMoneyRequestDateParams) { const transaction = transactionParam ?? getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const isTrackExpense = isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport); @@ -292,6 +299,7 @@ function updateMoneyRequestDate({ personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol, + rules, }); return; } @@ -337,6 +345,7 @@ function updateMoneyRequestDate({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } @@ -344,9 +353,27 @@ function updateMoneyRequestDate({ API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); } +/** + * Adds a tracked edit's rollback to the update carrying it, so a rejected edit takes its offer down with it. Must run + * before the write, since the failure data is read when the request is queued. + */ +function addMerchantRuleSuggestionRollback( + onyxData: OnyxData, + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + editedTagLevels?: number[], +) { + const rollback = getMerchantRuleSuggestionRollback(transactionID, field, editedTagLevels); + if (!rollback) { + return; + } + onyxData.failureData?.push(rollback); +} + /** Updates the billable field of an expense */ function updateMoneyRequestBillable({ transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -363,8 +390,10 @@ function updateMoneyRequestBillable({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string | undefined; + transaction?: OnyxEntry; transactionThreadReport: OnyxEntry; parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; @@ -381,6 +410,7 @@ function updateMoneyRequestBillable({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { if (!transactionID || !transactionThreadReport?.reportID) { return; @@ -406,12 +436,24 @@ function updateMoneyRequestBillable({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); + trackMerchantRuleSuggestion({ + transactionID, + field: CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, + reportID: transactionThreadReport.reportID, + policy, + policyCategories, + transaction, + parentReport, + }); } function updateMoneyRequestReimbursable({ transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -426,10 +468,13 @@ function updateMoneyRequestReimbursable({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string | undefined; + transaction?: OnyxEntry; transactionThreadReport: OnyxEntry; parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; @@ -444,8 +489,10 @@ function updateMoneyRequestReimbursable({ delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { if (!transactionID || !transactionThreadReport?.reportID) { return; @@ -469,10 +516,22 @@ function updateMoneyRequestReimbursable({ isOffline, delegateAccountID, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); + trackMerchantRuleSuggestion({ + transactionID, + field: CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, + reportID: transactionThreadReport.reportID, + policy, + policyCategories, + transaction, + parentReport, + }); } /** Updates the merchant field of an expense */ @@ -494,8 +553,10 @@ function updateMoneyRequestMerchant({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string; transaction?: OnyxEntry; @@ -514,8 +575,10 @@ function updateMoneyRequestMerchant({ delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { const transactionChanges: TransactionChanges = { merchant: value, @@ -552,8 +615,10 @@ function updateMoneyRequestMerchant({ hash, delegateAccountID, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); } const {params, onyxData} = data; @@ -580,6 +645,7 @@ function updateMoneyRequestAttendees({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string; transactionThreadReport: OnyxEntry; @@ -599,6 +665,7 @@ function updateMoneyRequestAttendees({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { const transactionChanges: TransactionChanges = { attendees, @@ -622,6 +689,7 @@ function updateMoneyRequestAttendees({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES, params, onyxData); @@ -819,6 +887,10 @@ type UpdateMoneyRequestTagParams = { parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; tag: string; + /** Which level of a multi-level tag was edited, so the "Create a rule" callout can seed that level alone */ + tagListIndex?: number; + /** Whether the edit came from a list of expenses, where the "Create a rule" callout has nowhere to appear */ + isEditedFromExpenseList?: boolean; policy: OnyxEntry; policyTagList: OnyxEntry; policyRecentlyUsedTags: OnyxEntry; @@ -831,8 +903,10 @@ type UpdateMoneyRequestTagParams = { delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; /** Updates the tag of an expense */ @@ -843,6 +917,8 @@ function updateMoneyRequestTag({ parentReport, iouReportOwnerLogin, tag, + tagListIndex, + isEditedFromExpenseList, policy, policyTagList, policyRecentlyUsedTags, @@ -855,8 +931,10 @@ function updateMoneyRequestTag({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateMoneyRequestTagParams) { const transactionChanges: TransactionChanges = { tag, @@ -880,10 +958,33 @@ function updateMoneyRequestTag({ isOffline, delegateAccountID, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); + // Callers that edit one level of a multi-level tag say which. The rest, like the Search table, hand over a whole + // tag, so the edited levels come from comparing it with the one `transaction` still holds. Worked out before the + // write, because the rollback below forgets the same levels and the write reads its failure data when queued. + let editedTagLevels: number[] | undefined; + if (tagListIndex !== undefined) { + editedTagLevels = [tagListIndex]; + } else if (transaction) { + editedTagLevels = getChangedTagLevels(getTag(transaction), tag); + } + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, editedTagLevels); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); + trackMerchantRuleSuggestion({ + transactionID, + field: CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, + reportID: transactionThreadReport?.reportID, + policy, + policyCategories, + transaction, + parentReport, + editedTagLevels, + isEditedFromExpenseList, + }); } /** Updates the created tax amount of an expense */ @@ -904,6 +1005,7 @@ function updateMoneyRequestTaxAmount({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string; transactionThreadReport: OnyxEntry; @@ -921,6 +1023,7 @@ function updateMoneyRequestTaxAmount({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { const transactionChanges = { taxAmount, @@ -942,12 +1045,14 @@ function updateMoneyRequestTaxAmount({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_AMOUNT, params, onyxData); } type UpdateMoneyRequestTaxRateParams = { transactionID: string | undefined; + transaction?: OnyxEntry; transactionThreadReport: OnyxEntry; parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; @@ -963,13 +1068,16 @@ type UpdateMoneyRequestTaxRateParams = { delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; /** Updates the created tax rate of an expense */ function updateMoneyRequestTaxRate({ transactionID, + transaction, transactionThreadReport, parentReport, iouReportOwnerLogin, @@ -985,8 +1093,10 @@ function updateMoneyRequestTaxRate({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateMoneyRequestTaxRateParams) { const transactionChanges = { taxCode, @@ -1008,11 +1118,23 @@ function updateMoneyRequestTaxRate({ isASAPSubmitBetaEnabled, delegateAccountID, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); + trackMerchantRuleSuggestion({ + transactionID, + field: CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, + reportID: transactionThreadReport?.reportID, + policy, + policyCategories, + transaction, + parentReport, + }); } type UpdateMoneyRequestDistanceParams = { @@ -1039,8 +1161,10 @@ type UpdateMoneyRequestDistanceParams = { isTrackIntentUser: boolean | undefined; personalPolicyOutputCurrency: string | undefined; reportPolicyTags: OnyxEntry; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; /** Updates the waypoints of a distance expense */ @@ -1068,8 +1192,10 @@ function updateMoneyRequestDistance({ isTrackIntentUser, personalPolicyOutputCurrency, reportPolicyTags, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateMoneyRequestDistanceParams) { const transactionChanges: TransactionChanges = { // Don't sanitize waypoints here - keep all fields for Onyx optimistic data (e.g., keyForList) @@ -1114,8 +1240,10 @@ function updateMoneyRequestDistance({ delegateAccountID, isTrackIntentUser, personalPolicyOutputCurrency, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); } const {params, onyxData} = data; @@ -1188,6 +1316,7 @@ function updateMoneyRequestCategory({ parentReport, iouReportOwnerLogin, category, + isEditedFromExpenseList, policy, policyTagList, policyCategories, @@ -1199,8 +1328,10 @@ function updateMoneyRequestCategory({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string; transaction?: OnyxEntry; @@ -1208,6 +1339,8 @@ function updateMoneyRequestCategory({ parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; category: string; + /** Whether the edit came from a list of expenses, where the "Create a rule" callout has nowhere to appear */ + isEditedFromExpenseList?: boolean; policy: OnyxEntry; policyTagList: OnyxEntry; policyCategories: OnyxEntry; @@ -1219,8 +1352,10 @@ function updateMoneyRequestCategory({ delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { const transactionChanges: TransactionChanges = { category, @@ -1244,10 +1379,23 @@ function updateMoneyRequestCategory({ hash, delegateAccountID, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); + trackMerchantRuleSuggestion({ + transactionID, + field: CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, + reportID: transactionThreadReport?.reportID, + policy, + policyCategories, + transaction, + parentReport, + isEditedFromExpenseList, + }); } /** Updates the description of an expense */ @@ -1258,6 +1406,7 @@ function updateMoneyRequestDescription({ parentReport, iouReportOwnerLogin, comment, + isEditedFromExpenseList, policy, policyTagList, policyCategories, @@ -1268,8 +1417,10 @@ function updateMoneyRequestDescription({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transactionID: string; transaction?: OnyxEntry; @@ -1277,6 +1428,8 @@ function updateMoneyRequestDescription({ parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; comment: string; + /** Whether the edit came from a list of expenses, where the "Create a rule" callout has nowhere to appear */ + isEditedFromExpenseList?: boolean; policy: OnyxEntry; policyTagList: OnyxEntry; policyCategories: OnyxEntry; @@ -1287,8 +1440,10 @@ function updateMoneyRequestDescription({ delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + violations: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { const parsedComment = getParsedComment(comment); const transactionChanges: TransactionChanges = { @@ -1325,13 +1480,26 @@ function updateMoneyRequestDescription({ hash, delegateAccountID, isTrackIntentUser, + violations, getCurrencyDecimals, getCurrencySymbol, + rules, }); } const {params, onyxData} = data; params.description = parsedComment; + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); + trackMerchantRuleSuggestion({ + transactionID, + field: CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, + reportID: transactionThreadReport?.reportID, + policy, + policyCategories, + transaction, + parentReport, + isEditedFromExpenseList, + }); } /** Updates the distance rate of an expense */ @@ -1364,6 +1532,7 @@ function updateMoneyRequestDistanceRate({ reportPolicyTags, getCurrencyDecimals, getCurrencySymbol, + rules, }: { transaction: OnyxEntry; transactionThreadReport: OnyxEntry; @@ -1393,6 +1562,7 @@ function updateMoneyRequestDistanceRate({ reportPolicyTags: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { const transactionChanges: TransactionChanges = { customUnitRateID: rateID, @@ -1458,6 +1628,7 @@ function updateMoneyRequestDistanceRate({ personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol, + rules, }); if (created && transaction?.transactionID && transactions && transactionViolations) { removeTransactionFromDuplicateTransactionViolation(data.onyxData, transaction.transactionID, transactions, transactionViolations); @@ -1497,6 +1668,7 @@ type UpdateMoneyRequestAmountAndCurrencyParams = { isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; /** Updates the amount and currency fields of an expense */ @@ -1527,6 +1699,7 @@ function updateMoneyRequestAmountAndCurrency({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }: UpdateMoneyRequestAmountAndCurrencyParams) { const transactionChanges = { amount, @@ -1569,8 +1742,10 @@ function updateMoneyRequestAmountAndCurrency({ hash, delegateAccountID, isTrackIntentUser, + violations: transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`], getCurrencyDecimals, getCurrencySymbol, + rules, }); removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } @@ -1610,6 +1785,7 @@ type GetUpdateMoneyRequestParamsType = { personalPolicyOutputCurrency?: string; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; type UpdateMoneyRequestDataKeys = @@ -1622,8 +1798,13 @@ type UpdateMoneyRequestDataKeys = | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.NVP_RECENT_ATTENDEES | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT; + | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT + // Carried on failure only, to forget an edit the server rejected + | typeof ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION; +/** + * @param params.violations - pass all violations including those generated on the server. Otherwise, server violations will be lost in the local optimistic calculation. + */ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): UpdateMoneyRequestData { const { transactionID, @@ -1656,6 +1837,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol, + rules, } = params; const optimisticData: Array< OnyxUpdate< @@ -1716,6 +1898,9 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U if (shouldFlagMerchantPending) { pendingFields.merchant = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; } + + // Odometer and pure manual receipts are uploaded, not regenerated, so their page count has to survive the edit. + const shouldClearReceiptPageCount = shouldFlagMerchantPending && isMapBasedDistanceRequest(transaction); const clearedPendingFields = getClearedPendingFields(transactionChanges); // `getClearedPendingFields` only clears `merchant` for distance edits, so when we artificially // flag it for waypoint/rate edits we must also clear it here. Otherwise the flag persists past @@ -1947,6 +2132,8 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: { ...updatedTransaction, + // Clear the stale page count until the regenerated receipt arrives. + ...(shouldClearReceiptPageCount && updatedTransaction?.receipt ? {receipt: {...updatedTransaction.receipt, pageCount: null}} : {}), pendingFields, errorFields: null, reportID: newTransactionReportID ?? updatedTransaction?.reportID, @@ -2230,6 +2417,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isASAPSubmitBetaEnabled, policy, isTrackIntentUser, + rules, }); optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -2386,6 +2574,11 @@ function getUpdateTrackExpenseParams( dataToIncludeInParams.distance = transactionChanges.distance; } + // Same page count clear as `getUpdateMoneyRequestParams`. + const shouldClearReceiptPageCount = + ('waypoints' in transactionChanges || 'distance' in transactionChanges || 'customUnitRateID' in transactionChanges || 'selectedRouteKey' in transactionChanges) && + isMapBasedDistanceRequest(transaction); + const apiParams: UpdateMoneyRequestParams = { ...dataToIncludeInParams, reportID: chatReport?.reportID, @@ -2485,6 +2678,7 @@ function getUpdateTrackExpenseParams( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: { ...updatedTransaction, + ...(shouldClearReceiptPageCount && updatedTransaction?.receipt ? {receipt: {...updatedTransaction.receipt, pageCount: null}} : {}), pendingFields, errorFields: null, }, diff --git a/src/libs/actions/IOU/mergeAdditionalPayOnyxData.ts b/src/libs/actions/IOU/mergeAdditionalPayOnyxData.ts new file mode 100644 index 000000000000..4292fd29122c --- /dev/null +++ b/src/libs/actions/IOU/mergeAdditionalPayOnyxData.ts @@ -0,0 +1,22 @@ +import type AdditionalPayOnyxData from './types/AdditionalPayOnyxData'; + +function mergeAdditionalPayOnyxData< + T extends { + optimisticData?: readonly unknown[]; + successData?: readonly unknown[]; + failureData?: readonly unknown[]; + }, +>(onyxData: T, additionalOnyxData?: AdditionalPayOnyxData): T { + if (!additionalOnyxData) { + return onyxData; + } + + return { + ...onyxData, + optimisticData: [...(onyxData.optimisticData ?? []), ...(additionalOnyxData.optimisticData ?? [])], + successData: [...(onyxData.successData ?? []), ...(additionalOnyxData.successData ?? [])], + failureData: [...(onyxData.failureData ?? []), ...(additionalOnyxData.failureData ?? [])], + }; +} + +export default mergeAdditionalPayOnyxData; diff --git a/src/libs/actions/IOU/reconcileMovedScanFailedReport.ts b/src/libs/actions/IOU/reconcileMovedScanFailedReport.ts new file mode 100644 index 000000000000..a9ee904b0366 --- /dev/null +++ b/src/libs/actions/IOU/reconcileMovedScanFailedReport.ts @@ -0,0 +1,204 @@ +/** + * Builds the Onyx updates that retire the optimistic report created for scan-failed expenses on payment, and points + * navigation, the moved transactions and their threads at the report the backend created for them instead. + */ +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import type {NavigationRoute} from '@libs/Navigation/types'; +import {getAllReportActions, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import {getAllReports} from '.'; + +/** Every key this reconciliation writes to, so the updates it builds are checked against the real Onyx value types. */ +type ReconciliationUpdate = OnyxUpdate; + +const REPORT_ID_ROUTE_SEGMENTS = ['r', 'e', 'search/r', 'search/view']; + +function escapeForRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Swaps a report ID inside a route path, matching only a whole `/` pair. The leading + * slash is optional because the `ROUTES` helpers build paths without one while `Navigation.getActiveRoute` returns + * them with one. A plain substring swap would also corrupt a longer ID that merely starts with the same digits. + */ +function replaceReportIDInPath(path: string, oldReportID: string, newReportID: string): string { + const pattern = new RegExp(`(^|/)(${REPORT_ID_ROUTE_SEGMENTS.map(escapeForRegExp).join('|')})/${escapeForRegExp(oldReportID)}(?=$|[/?#])`, 'g'); + return path.replaceAll(pattern, `$1$2/${newReportID}`); +} + +/** + * Points every route that still references the report at `oldReportID`, through its own `reportID` param or through an + * encoded `backTo`, at `newReportID`. Patching only the focused route is not enough: with an RHP open, the report + * screen underneath keeps the stale ID and renders the not-found page once the RHP is dismissed. + */ +function pointRoutesToReport(routes: NavigationRoute[], oldReportID: string, newReportID: string) { + for (const route of routes) { + const params: unknown = route.params; + const hasParams = !!params && typeof params === 'object'; + const routeReportID = hasParams && 'reportID' in params && typeof params.reportID === 'string' ? params.reportID : undefined; + const routeBackTo = hasParams && 'backTo' in params && typeof params.backTo === 'string' ? params.backTo : undefined; + + const updatedParams: {reportID?: string; backTo?: string} = {}; + if (routeReportID === oldReportID) { + updatedParams.reportID = newReportID; + } + if (routeBackTo) { + const updatedBackTo = replaceReportIDInPath(routeBackTo, oldReportID, newReportID); + if (updatedBackTo !== routeBackTo) { + updatedParams.backTo = updatedBackTo; + } + } + + if (!isEmptyObject(updatedParams) && route.key) { + Navigation.setParams(updatedParams, route.key); + } + + if (route.state?.routes) { + pointRoutesToReport(route.state.routes, oldReportID, newReportID); + } + } +} + +/** The expenses the client optimistically moved into `optimisticReportID`, read back from the actions it created there. */ +function getMovedScanFailedTransactionIDs(optimisticReportID: string): Set { + const transactionIDs = new Set(); + for (const reportAction of Object.values(getAllReportActions(optimisticReportID))) { + if (!isMoneyRequestAction(reportAction)) { + continue; + } + const transactionID = getOriginalMessage(reportAction)?.IOUTransactionID; + if (transactionID) { + transactionIDs.add(transactionID); + } + } + return transactionIDs; +} + +function getTransactionThreadsByParentActionID(allReports: Record, optimisticReportID: string): Map { + const threadIDByParentActionID = new Map(); + for (const report of Object.values(allReports)) { + if (report?.parentReportID !== optimisticReportID || !report.parentReportActionID || !report.reportID) { + continue; + } + threadIDByParentActionID.set(report.parentReportActionID, report.reportID); + } + return threadIDByParentActionID; +} + +/** + * Builds the Onyx updates that retire the optimistic report created for scan-failed expenses on payment, once the + * backend has answered with the report it actually created for them. + * + * The backend does not reuse `optimisticHoldReportID` for this split, so the optimistic report has to go, but it must + * never be dropped from under the user. Every route showing it is pointed at `realReportID` first, and the moved + * expenses, their transaction threads and the workspace chat follow it, so nothing is left pointing at a report that + * no longer exists. Callers only reach this once the backend report is known. Guessing a destination would strand the + * user on the wrong report. + * + * `hasResponseValue` tells whether the response already carries a field for a key. Whatever the backend sent wins, + * because these updates ride in `successData` and are merged on top of the response. + * + * Returns the updates to apply alongside the response rather than writing them, so the swap lands in the same Onyx + * transaction as the backend data and the user never observes an in-between state. + */ +function reconcileMovedScanFailedReport( + optimisticReportID: string, + realReportID: string, + realActionIDByTransactionID: Map, + hasResponseValue: (key: string, field: string) => boolean = () => false, +): ReconciliationUpdate[] { + const allReports = getAllReports(); + const optimisticReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`]; + if (!optimisticReport) { + return []; + } + + const chatReportID = optimisticReport.parentReportID; + const reportPreviewActionID = optimisticReport.parentReportActionID; + + const updates: ReconciliationUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, + value: null, + }, + ]; + + if (chatReportID && reportPreviewActionID) { + updates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: {[reportPreviewActionID]: null}, + }); + } + + const chatReportKey = `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}` as const; + const isChatPointingAtOptimisticReport = allReports?.[chatReportKey]?.iouReportID === optimisticReportID; + if (chatReportID && isChatPointingAtOptimisticReport && !hasResponseValue(chatReportKey, 'iouReportID')) { + updates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: chatReportKey, + value: {iouReportID: realReportID}, + }); + } + + const threadIDByParentActionID = getTransactionThreadsByParentActionID(allReports ?? {}, optimisticReportID); + for (const optimisticAction of Object.values(getAllReportActions(optimisticReportID))) { + if (!isMoneyRequestAction(optimisticAction)) { + continue; + } + const transactionID = getOriginalMessage(optimisticAction)?.IOUTransactionID; + const threadReportID = threadIDByParentActionID.get(optimisticAction.reportActionID); + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + if (transactionID && !hasResponseValue(transactionKey, 'reportID')) { + updates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: transactionKey, + value: {reportID: realReportID}, + }); + } + + // The response does not always carry the backend report's actions. Without them the thread keeps its own data + // and is re-parented by the backend on the next fetch. + const realReportActionID = transactionID ? realActionIDByTransactionID.get(transactionID) : undefined; + if (!threadReportID || !realReportActionID) { + continue; + } + + updates.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`, + value: {parentReportID: realReportID, parentReportActionID: realReportActionID, chatReportID: realReportID}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${realReportID}`, + value: {[realReportActionID]: {childReportID: threadReportID}}, + }, + ); + } + + if (navigationRef.isReady()) { + pointRoutesToReport(navigationRef.getRootState()?.routes ?? [], optimisticReportID, realReportID); + } + + return updates; +} + +export default reconcileMovedScanFailedReport; +export {getMovedScanFailedTransactionIDs, replaceReportIDInPath}; diff --git a/src/libs/actions/IOU/types/AdditionalPayOnyxData.ts b/src/libs/actions/IOU/types/AdditionalPayOnyxData.ts new file mode 100644 index 000000000000..7a88de9dd80a --- /dev/null +++ b/src/libs/actions/IOU/types/AdditionalPayOnyxData.ts @@ -0,0 +1,13 @@ +import type ONYXKEYS from '@src/ONYXKEYS'; + +import type {OnyxUpdate} from 'react-native-onyx'; + +type SearchPayOnyxKey = typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.SNAPSHOT | typeof ONYXKEYS.COLLECTION.REPORT; + +type AdditionalPayOnyxData = { + optimisticData?: Array>; + successData?: Array>; + failureData?: Array>; +}; + +export default AdditionalPayOnyxData; diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 86475e5dcbce..452e886afd14 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -50,9 +50,9 @@ type CreateTrackExpenseParams = { previousOdometerDraft?: OnyxEntry; delegateAccountID: number | undefined; reportActionsList: OnyxCollection | undefined; - // TODO: Remove optional (?) once all callers are updated in follow-up PRs of https://github.com/Expensify/App/issues/66414 - isDraftChatReport?: boolean; + isDraftChatReport: boolean; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; export type {CreateTrackExpenseParams, TrackExpenseAccountantParams}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 74e4522749a7..690f913020b0 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -12,6 +12,7 @@ import normalizePath from '@libs/Navigation/helpers/normalizePath'; import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; import swapBackgroundTabForRHPTarget from '@libs/Navigation/helpers/swapBackgroundTabForRHPTarget'; import willRouteNavigateToRHP from '@libs/Navigation/helpers/willRouteNavigateToRHP'; +import isNativeOAuthCallbackURL from '@libs/Navigation/linkingConfig/isNativeOAuthCallbackURL'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; @@ -151,15 +152,19 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string) }); } +const NEW_EXPENSIFY_ORIGINS = [CONST.NEW_EXPENSIFY_URL, CONST.STAGING_NEW_EXPENSIFY_URL, CONST.QA_NEW_EXPENSIFY_URL]; + function getInternalNewExpensifyPath(href: string) { if (!href) { return ''; } + const attrPath = Url.getPathFromURL(href); - return (Url.hasSameExpensifyOrigin(href, CONST.NEW_EXPENSIFY_URL) || Url.hasSameExpensifyOrigin(href, CONST.STAGING_NEW_EXPENSIFY_URL) || href.startsWith(CONST.DEV_NEW_EXPENSIFY_URL)) && - !CONST.PATHS_TO_TREAT_AS_EXTERNAL.find((path) => attrPath.startsWith(path)) - ? attrPath - : ''; + // The dev server's port varies, so dev is matched by prefix instead of by origin. + const hasNewExpensifyOrigin = NEW_EXPENSIFY_ORIGINS.some((origin) => Url.hasSameExpensifyOrigin(href, origin)) || href.startsWith(CONST.DEV_NEW_EXPENSIFY_URL); + const isExternalPath = CONST.PATHS_TO_TREAT_AS_EXTERNAL.some((path) => attrPath.startsWith(path)); + + return hasNewExpensifyOrigin && !isExternalPath ? attrPath : ''; } function getInternalExpensifyPath(href: string) { @@ -476,6 +481,8 @@ function openReportFromDeepLink( introSelected, // Unauthenticated public-room path: there is no signed-in user, so no Concierge chat exists to thread. conciergeChat: undefined, + // The public room already exists on the server, so no optimistic report is created and the personal details are never read. + personalDetails: undefined, parentReportActionID: '0', isFromDeepLink: true, betas, @@ -521,6 +528,12 @@ function openReportFromDeepLink( return; } + // The native OAuth callback is consumed by the auth session that opened it. linkingConfig.filter already drops + // it for signed-in users, but this post-sign-in navigate runs outside react-navigation's linking. + if (isNativeOAuthCallbackURL(url)) { + return; + } + // Navigate to the report after sign-in/sign-up. waitForUserSignIn().then(() => { // A Submit-via-PDF secure access link must reach the report regardless of onboarding status: the report screen diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts new file mode 100644 index 000000000000..b13079f442cc --- /dev/null +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -0,0 +1,175 @@ +import {arePolicyRulesEnabled, isControlPolicy} from '@libs/PolicyUtils'; +import {isInvoiceReport} from '@libs/ReportUtils'; +import {isDistanceRequest, isMerchantMissing, isPerDiemRequest} from '@libs/TransactionUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleSuggestion, Policy, PolicyCategories, Report, Transaction} from '@src/types/onyx'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; + +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +type TrackMerchantRuleSuggestionParams = { + /** The edited expense */ + transactionID: string | undefined; + + /** The field that was edited */ + field: MerchantRuleSuggestionField; + + /** The edited expense's transaction thread, where the callout can show */ + reportID: string | undefined; + + /** The workspace that would own the rule */ + policy: OnyxEntry; + + /** That workspace's categories, needed to tell whether Rules are reachable at all */ + policyCategories: OnyxEntry; + + /** The edited expense itself, which decides whether a merchant rule could ever match it */ + transaction: OnyxEntry; + + /** The report holding the expense, which is what says the expense is really on this workspace */ + parentReport: OnyxEntry; + + /** Which levels of a multi-level tag were edited */ + editedTagLevels?: number[]; + + /** + * Whether the edit was made straight from a list of expenses, rather than from the expense itself. Such an edit + * records no offer, because the callout has nowhere to appear at the moment it is made. + */ + isEditedFromExpenseList?: boolean; +}; + +/** + * Records an edit that could become a merchant rule, so the expense can offer to create one. + * + * Written optimistically rather than from `successData`, so the offer appears at once, offline included: a queued + * write has no response to key off until reconnect, and this app works offline. `getMerchantRuleSuggestionRollback` + * is the failure-side counterpart. + * + * Edits accumulate per expense until the offer is taken, so one rule can carry category, tag and tax together. Only + * the most recently edited expense offers. Recorded for anyone on the workspace; `useMerchantRuleSuggestion` decides + * who actually sees the callout. + */ +function trackMerchantRuleSuggestion({ + transactionID, + field, + reportID, + policy, + policyCategories, + transaction, + parentReport, + editedTagLevels, + isEditedFromExpenseList = false, +}: TrackMerchantRuleSuggestionParams) { + // Skip workspaces that could not hold a merchant rule, otherwise an edit made with Rules off would surface the + // moment somebody turned Rules on. Control only, matching the rule page the callout leads to, so an edit on a + // Collect workspace does not pay for a write that could never be shown. + if (!transactionID || !reportID || !isControlPolicy(policy) || !arePolicyRulesEnabled(policy, policyCategories)) { + return; + } + + // An offer nothing can show is an offer nobody asked for. Editing from a list of expenses leaves no expense detail + // on screen, so the record would sit there unseen and fire on whatever expense the user opened next. + if (isEditedFromExpenseList) { + return; + } + + // The policy handed in is the one whose fields the editor offered, which is not always the one that owns the + // expense. An expense held in a self DM borrows the workspace it would move to, so its edits must not be recorded + // against a workspace it has not reached. Invoices are excluded outright, since merchant rules govern expenses. + if (parentReport?.policyID !== policy?.id || isInvoiceReport(parentReport)) { + return; + } + + // A rule matches on merchant, so an expense that has none, or whose merchant is not the user's to set, can never + // be matched by the rule this offer would create. Distance and per diem expenses derive their merchant. + if (isMerchantMissing(transaction) || isDistanceRequest(transaction) || isPerDiemRequest(transaction)) { + return; + } + + // Merged rather than set, so dismissals survive and `editedFields` accumulates. `isRetired` belongs to the offer + // being replaced, so it is cleared: a new edit is a new offer. + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { + transactionID, + reportID, + editedFields: {[transactionID]: {[field]: true}}, + // Keyed by level so editing several levels of one tag accumulates, the same way fields do. + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), + seenInReportID: null, + isRetired: null, + }); +} + +/** + * The rollback for a tracked edit, to sit in an update's `failureData`. A rejected edit puts the old value back, and + * an offer left behind would seed a rule from a value the expense no longer holds. Forgetting the field is enough: + * once an expense has none left, it stops offering. + * + * Known limitation: if this field was already tracked from an earlier, successful edit, this still forgets it rather + * than restoring that earlier state, since the flag carries no history to restore. Narrower than the offline case + * above, and self-heals on the next edit, so it is left as is. + * + * @param editedTagLevels - the levels recorded alongside a tag edit, forgotten with it + */ +function getMerchantRuleSuggestionRollback( + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + editedTagLevels?: number[], +): OnyxUpdate | undefined { + if (!transactionID) { + return undefined; + } + + return { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, + value: { + editedFields: {[transactionID]: {[field]: null}}, + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, null]))}} : {}), + }, + }; +} + +/** + * Records the report the callout rendered in, which is what makes leaving that report retire the offer. + * + * @param reportID - the report hosting the expense detail view the callout appeared on + */ +function markMerchantRuleSuggestionSeen(reportID: string) { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {seenInReportID: reportID}); +} + +/** + * Hides the callout for this expense for the rest of the session. Other expenses still offer, and a new session + * offers this one again. + */ +function dismissMerchantRuleSuggestion(suggestion: MerchantRuleSuggestion) { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { + dismissedTransactionIDs: [...new Set([...(suggestion.dismissedTransactionIDs ?? []), suggestion.transactionID])], + }); +} + +/** + * Forgets an expense's recorded fields, and the tag levels alongside them, so the next rule starts fresh. Called when + * the offer is taken. + */ +function clearMerchantRuleSuggestionFields(transactionID: string) { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {editedFields: {[transactionID]: null}, editedTagLevels: {[transactionID]: null}}); +} + +/** Ends the current offer without silencing the expense. Returning shows nothing; editing it again offers afresh. */ +function retireMerchantRuleSuggestion() { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); +} + +export { + trackMerchantRuleSuggestion, + getMerchantRuleSuggestionRollback, + dismissMerchantRuleSuggestion, + markMerchantRuleSuggestionSeen, + retireMerchantRuleSuggestion, + clearMerchantRuleSuggestionFields, +}; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 2f05e638c36b..6791d35fc5b8 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -44,6 +44,7 @@ import type { Report, ReportAction, ReportActions, + Rule, Transaction, TransactionViolations, } from '@src/types/onyx'; @@ -197,7 +198,7 @@ function getTransactionsForMergingFromAPI(transactionID: string) { * Fetches eligible transactions for merging locally * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call */ -function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection, isAdmin = false) { +function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection, rules: OnyxCollection, isAdmin = false) { const transactionsArray = Object.values(transactions ?? {}); const eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => { @@ -209,7 +210,7 @@ function getTransactionsForMergingLocally(transactionID: string, targetTransacti return ( areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction) && - (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, isAdmin))) + (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, isAdmin, rules))) ); }); @@ -225,6 +226,7 @@ function getTransactionsForMerging({ policy, report, currentUserLogin, + rules, }: { isOffline: boolean; targetTransaction: Transaction; @@ -233,6 +235,7 @@ function getTransactionsForMerging({ report: OnyxEntry; currentUserLogin: string | undefined; cardList?: CardList; + rules: OnyxCollection; }) { const transactionID = targetTransaction.transactionID; if (!transactionID) { @@ -265,7 +268,7 @@ function getTransactionsForMerging({ } if (isOffline) { - getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, isAdmin); + getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, isAdmin); } else { getTransactionsForMergingFromAPI(transactionID); } @@ -289,6 +292,7 @@ function getOnyxTargetTransactionData({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }: { targetTransaction: Transaction; targetTransactionViolations: OnyxEntry; @@ -307,6 +311,7 @@ function getOnyxTargetTransactionData({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }) { let data: UpdateMoneyRequestData; const isUnreportedExpense = !mergeTransaction.reportID || mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; @@ -358,6 +363,7 @@ function getOnyxTargetTransactionData({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); } @@ -367,7 +373,8 @@ function getOnyxTargetTransactionData({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, value: { - receipt: mergeTransaction.receipt ?? null, + // Clear the old count so it does not describe the newly chosen receipt. + receipt: mergeTransaction.receipt ? {pageCount: null, ...mergeTransaction.receipt} : null, }, }); @@ -419,6 +426,7 @@ type MergeTransactionRequestParams = { sourceIOUAction: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + rules: OnyxCollection; }; /** * Merges two transactions by updating the target transaction with selected fields and deleting the source transaction. @@ -453,6 +461,7 @@ function mergeTransactionRequest({ sourceIOUAction, getCurrencyDecimals, getCurrencySymbol, + rules, }: MergeTransactionRequestParams) { // For both unreported expenses and expense reports, negate the display amount when storing // This preserves the user's chosen sign while following the storage convention @@ -505,6 +514,7 @@ function mergeTransactionRequest({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + rules, }); // Optimistic delete the source transaction and also delete its report if it was a single expense report @@ -645,18 +655,17 @@ function mergeTransactionRequest({ if (!sourceIouAction) { Log.warn("Can't find the iouAction for the transaction in the selfDM report."); } else { - const {optimisticData, successData, failureData} = getDeleteTrackExpenseInformation( - selfDMReport, - sourceTransaction.transactionID, - sourceIouAction, - false, - currentUserAccountIDParam, - undefined, - undefined, + const {optimisticData, successData, failureData} = getDeleteTrackExpenseInformation({ + chatReport: selfDMReport, + transactionID: sourceTransaction.transactionID, + reportAction: sourceIouAction, + isChatReportArchived: false, + currentUserAccountID: currentUserAccountIDParam, + transactionThreadReportActions: sourceTransactionThreadReportActions, actionableWhisperReportActionID, - CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, - false, - ); + resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, + shouldRemoveIOUTransaction: false, + }); sourceTransactionOptimisticData.push(...optimisticData); sourceTransactionSuccessData.push(...successData); diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index c178b2c8ff11..328e3ed3c435 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -1,8 +1,12 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + import {getReportPreviewReportAction} from '@libs/actions/IOU/MoneyRequestBuilder'; +import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {translate as translateForLocale} from '@libs/Localize'; import {getIsOffline} from '@libs/NetworkState'; +import {format, formatToParts} from '@libs/NumberFormatUtils'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {isPolicyFieldListEmpty} from '@libs/PolicyUtils'; import {getLinkedTransactionID, isDeletedAction} from '@libs/ReportActionsUtils'; @@ -226,6 +230,8 @@ export default createOnyxDerivedValueConfig({ ONYXKEYS.CONCIERGE_REPORT_ID, ONYXKEYS.NVP_INTRO_SELECTED, ONYXKEYS.COLLECTION.REPORT_METADATA, + ONYXKEYS.CURRENCY_LIST, + ONYXKEYS.COLLECTION.RULE, ONYXKEYS.NETWORK, ], compute: ( @@ -243,6 +249,8 @@ export default createOnyxDerivedValueConfig({ conciergeReportID, introSelected, reportMetadata, + currencyList, + rules, ], {currentValue, sourceValues, triggeredKeys}, ) => { @@ -250,6 +258,40 @@ export default createOnyxDerivedValueConfig({ const isOffline = getIsOffline(); const dateFnsLocale = IntlStore.getDateFnsLocale(preferredLocale); const translate: LocalizedTranslate = (path, ...parameters) => translateForLocale(preferredLocale, path, ...parameters); + // Non-React computation: there is no component to inject the currency formatters from CurrencyListContextProvider, + // so mirror the provider's implementations here using the CURRENCY_LIST dependency and the preferred locale. + const getCurrencyDecimals = (currencyCode: string): number => currencyList?.[currencyCode]?.decimals ?? CONST.DEFAULT_CURRENCY_DECIMALS; + const getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'] = (currencyCode) => currencyList?.[currencyCode]?.symbol; + const convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'] = (amountInCents, currencyCode) => { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = getCurrencyDecimals(sanitizedCurrency); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals); + return format(preferredLocale, convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies + // See: https://github.com/Expensify/PHP-Libs/pull/834 + minimumFractionDigits: decimals, + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, + }); + }; + const convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'] = (amountInCents, currencyCode = CONST.CURRENCY.USD) => { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = getCurrencyDecimals(sanitizedCurrency); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); + return formatToParts(preferredLocale, convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + minimumFractionDigits: decimals, + maximumFractionDigits: 2, + }) + .filter((x) => x.type !== 'currency') + .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) + .map((x) => x.value) + .join(''); + }; // Check if display names changed when personal details are updated let displayNameChanges: Set | typeof RECOMPUTE_ALL | null = null; if (hasKeyTriggeredCompute(ONYXKEYS.PERSONAL_DETAILS_LIST, triggeredKeys)) { @@ -282,7 +324,10 @@ export default createOnyxDerivedValueConfig({ (hasKeyTriggeredCompute(ONYXKEYS.NVP_PREFERRED_LOCALE, triggeredKeys) && preferredLocale !== currentValue?.locale) || displayNameChanges === RECOMPUTE_ALL || hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, triggeredKeys) || - hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); + hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys) || + // Amount-bearing report names format with the currency list's decimals/symbols, so names computed before + // the list arrived (or with a stale list) must all be redone. This loads roughly once per session. + hasKeyTriggeredCompute(ONYXKEYS.CURRENCY_LIST, triggeredKeys); const policyChangedReportKeys: string[] = []; // Reports whose policy change touched only fields that don't feed the report name (type, approvalMode, @@ -570,7 +615,7 @@ export default createOnyxDerivedValueConfig({ }); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const hasFieldViolations = hasVisibleReportFieldViolations(report, policy, session?.accountID); + const hasFieldViolations = hasVisibleReportFieldViolations(report, policy, session?.accountID, rules); let brickRoadStatus; let actionBadge; @@ -644,7 +689,11 @@ export default createOnyxDerivedValueConfig({ reportAttributes: currentValue?.reports, reportTransactions: reportsTransactions ?? {}, isTrackIntentUser: isTrackIntentUserSelector(introSelected), + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol, pendingDeleteMemberAccountIDs, + rules, }), isEmpty: generateIsEmptyReport(report, isReportArchived), brickRoadStatus, diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index ff9c58dec5a1..f2c9b14e81a1 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -1,6 +1,6 @@ import getCollectionDelta from '@libs/getCollectionDelta'; import Log from '@libs/Log'; -import {endSpan, getSpan, getSpanByPrefix, startSpan} from '@libs/telemetry/activeSpans'; +import {endSpan, getSpan, getSpanByPrefix, getUniqueSpanByPrefix, startSpan} from '@libs/telemetry/activeSpans'; import detectOnyxDerivedLoop from '@libs/telemetry/detectOnyxDerivedLoop'; import CONST from '@src/CONST'; @@ -116,13 +116,15 @@ function init() { const spanId = `${CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE}_${key}`; // No-splash flows end ManualAppStartup before the startup response lands, so without this fallback onlyIfParent drops every recompute it triggers. const startupSpan = getSpan(CONST.TELEMETRY.SPAN_APP_STARTUP) ?? getSpanByPrefix(CONST.TELEMETRY.SPAN_STARTUP_DATA.APPLY); + // A recompute can be triggered by several sends at once, so with more than one in flight there is no correct parent to pick. + const parentSpan = startupSpan ?? getUniqueSpanByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); startSpan(spanId, { name: CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE, op: CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE, - parentSpan: startupSpan, + parentSpan, // A span with no parent is sent as its own transaction, one per recompute. onlyIfParent: true, - attributes: {derivedKey: key, [CONST.TELEMETRY.ATTRIBUTE_IS_STARTUP]: !!startupSpan}, + attributes: {derivedKey: key, triggeredKeys: [...triggeredKeys].join(','), [CONST.TELEMETRY.ATTRIBUTE_IS_STARTUP]: !!startupSpan}, }); try { diff --git a/src/libs/actions/OnyxUpdateManager/index.ts b/src/libs/actions/OnyxUpdateManager/index.ts index db4c73469580..ac348c82010b 100644 --- a/src/libs/actions/OnyxUpdateManager/index.ts +++ b/src/libs/actions/OnyxUpdateManager/index.ts @@ -1,10 +1,12 @@ import {isClientTheLeader} from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import {setAuthToken} from '@libs/Network/NetworkStore'; -import {registerPauseWatchdogEscalation, unpause as unpauseSequentialQueue} from '@libs/Network/SequentialQueue'; +import {getCurrentRequest, registerPauseWatchdogEscalation, unpause as unpauseSequentialQueue} from '@libs/Network/SequentialQueue'; import {finalReconnectAppAfterActivatingReliableUpdates, getMissingOnyxUpdates, reconnectApp, reconnectAppWithSideEffects} from '@userActions/App'; import {getEffectiveLastUpdateID, getPersistedLastUpdateID} from '@userActions/OnyxUpdates'; +import {getOngoingRequest} from '@userActions/PersistedRequests'; +import {isReconnectFamilyRequest, reconnectCoverageFrom} from '@userActions/RequestConflictUtils'; import updateSessionAuthTokens from '@userActions/Session/updateSessionAuthTokens'; import CONST from '@src/CONST'; @@ -284,6 +286,20 @@ function handleMissingOnyxUpdates(onyxUpdatesFromServer: O return false; } + const ongoingCatchUp = getOngoingRequest(); + if (isReconnectFamilyRequest(ongoingCatchUp) && reconnectCoverageFrom(ongoingCatchUp) <= lastUpdateIDFromClient) { + Log.info('Gap detected in update IDs from the server, but an in-flight reconnect already covers it, so waiting for its response', false, { + lastUpdateIDFromClient, + previousUpdateIDFromServer, + ongoingCommand: ongoingCatchUp.command, + reconnectCoverageFrom: reconnectCoverageFrom(ongoingCatchUp), + }); + + setMissingOnyxUpdatesQueryPromise(getCurrentRequest().then(() => validateAndApplyDeferredUpdates(clientLastUpdateID))); + + return true; + } + console.debug(`[OnyxUpdateManager] Client is fetching missing updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(previousUpdateIDFromServer)}`); Log.info('Gap detected in update IDs from the server so fetching incremental updates', false, { lastUpdateIDFromClient, diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts index 1e13621ef14b..c192abd387db 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts @@ -4,6 +4,8 @@ import Log from '@libs/Log'; import type {AnyDeferredUpdatesDictionary, AnyDetectGapAndSplitResult} from '@userActions/OnyxUpdateManager/types'; import {getEffectiveLastUpdateID} from '@userActions/OnyxUpdates'; +import CONST from '@src/CONST'; + import {applyUpdates} from './applyUpdates'; import {clear, enqueue, getUpdates} from './DeferredOnyxUpdates'; @@ -107,7 +109,7 @@ function detectGapsAndSplit(lastUpdateIDFromClient: number): AnyDetectGapAndSpli * apply the updates in order after the missing updates are fetched and applied */ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousParams?: {newLastUpdateIDFromClient: number; latestMissingUpdateID: number}): Promise { - const lastUpdateIDFromClient = clientLastUpdateID ?? getEffectiveLastUpdateID(); + const lastUpdateIDFromClient = Math.max(clientLastUpdateID ?? CONST.DEFAULT_NUMBER_ID, getEffectiveLastUpdateID()); Log.info('[DeferredUpdates] Processing deferred updates', false, {lastUpdateIDFromClient, previousParams}); @@ -133,7 +135,7 @@ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousPa // the initial "updatesAfterGaps" and all new deferred updates will be applied in order, // as long as there was no new gap detected. Otherwise, repeat the process. - const newLastUpdateIDFromClient = clientLastUpdateID ?? getEffectiveLastUpdateID(); + const newLastUpdateIDFromClient = Math.max(clientLastUpdateID ?? CONST.DEFAULT_NUMBER_ID, getEffectiveLastUpdateID()); enqueue(updatesAfterGaps, {shouldPauseSequentialQueue: false}); diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 156754d0a350..5ac284da37ea 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -22,10 +22,12 @@ let lastUpdateIDAppliedToClient: number | undefined = 0; // Highest update ID staged for the deferred WRITE flush but not yet persisted. Gap detection treats these as // applied so queued WRITE responses don't look like gaps; reset if the flush fails so recovery can kick in. -let lastUpdateIDPendingFlush = 0; +let lastUpdateIDPendingWriteFlush = 0; + +let lastUpdateIDPendingPusherApply = 0; function getEffectiveLastUpdateID(): number { - return Math.max(lastUpdateIDAppliedToClient ?? 0, lastUpdateIDPendingFlush); + return Math.max(lastUpdateIDAppliedToClient ?? 0, lastUpdateIDPendingWriteFlush); } function getPersistedLastUpdateID(): number { @@ -41,7 +43,8 @@ Onyx.connectWithoutView({ // The persisted watermark is only ever cleared by Onyx.clear (sign-out), so drop the pending marker // too — a stale value from the previous session would mask real gaps after signing back in. if (val === undefined) { - lastUpdateIDPendingFlush = 0; + lastUpdateIDPendingWriteFlush = 0; + lastUpdateIDPendingPusherApply = 0; } }, }); @@ -203,12 +206,19 @@ function apply({lastUpdateID, type, request, response, upd Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID)); } // The persisted watermark now covers the staged WRITE updates, so the pending marker is no longer needed - if (lastUpdateIDPendingFlush && lastUpdateIDPendingFlush <= Number(lastUpdateID)) { - lastUpdateIDPendingFlush = 0; + if (lastUpdateIDPendingWriteFlush && lastUpdateIDPendingWriteFlush <= Number(lastUpdateID)) { + lastUpdateIDPendingWriteFlush = 0; + } + if (lastUpdateIDPendingPusherApply && lastUpdateIDPendingPusherApply <= Number(lastUpdateID)) { + lastUpdateIDPendingPusherApply = 0; } return result; }) .catch((error) => { + // Intentionally cleared for any failed apply, including HTTPS and Airship: the marker is a flat max, so + // keeping it after an unrelated lower-ID failure would mask that gap. Errs toward a redundant refetch. + lastUpdateIDPendingPusherApply = 0; + if (shouldAdvanceLastUpdateID) { Log.alert('[OnyxUpdateManagerError] Applying the updates failed, not advancing lastUpdateID so the client can recover on the next reconnect', { type, @@ -229,18 +239,22 @@ function apply({lastUpdateID, type, request, response, upd // SequentialQueue only flushes after this promise settles, so awaiting the flush here would deadlock. if (request.data?.apiRequestType === CONST.API_REQUEST_TYPE.WRITE) { if (shouldAdvanceLastUpdateID) { - lastUpdateIDPendingFlush = Math.max(lastUpdateIDPendingFlush, Number(lastUpdateID)); + lastUpdateIDPendingWriteFlush = Math.max(lastUpdateIDPendingWriteFlush, Number(lastUpdateID)); } advanceLastUpdateIDAfterApply(applyPromise.then(() => getCurrentFlushPromise())).catch(() => { // The staged updates never applied, so stop counting them as pending — the next gap check // then sees the missing range against the persisted watermark and triggers recovery. - lastUpdateIDPendingFlush = 0; + lastUpdateIDPendingWriteFlush = 0; }); return applyPromise; } return advanceLastUpdateIDAfterApply(applyPromise); } if (type === CONST.ONYX_UPDATE_TYPES.PUSHER && updates) { + if (shouldAdvanceLastUpdateID) { + lastUpdateIDPendingPusherApply = Math.max(lastUpdateIDPendingPusherApply, Number(lastUpdateID)); + } + return advanceLastUpdateIDAfterApply(applyPusherOnyxUpdates(updates, Number(lastUpdateID))); } if (type === CONST.ONYX_UPDATE_TYPES.AIRSHIP && updates) { @@ -268,23 +282,32 @@ function saveUpdateInformation(updateParams: OnyxUpdatesFr type DoesClientNeedToBeUpdatedParams = { clientLastUpdateID?: number; previousUpdateID?: number; + updateType?: AnyOnyxUpdatesFromServer['type']; }; +function isSerializedBehindPusherApply(updateType?: AnyOnyxUpdatesFromServer['type']): boolean { + return updateType === CONST.ONYX_UPDATE_TYPES.PUSHER; +} + /** * This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state * and return if an update is needed * @param previousUpdateID The previousUpdateID contained in the response object * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient + * @param updateType the transport the update being checked arrived on */ -function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID}: DoesClientNeedToBeUpdatedParams): boolean { +function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID, updateType}: DoesClientNeedToBeUpdatedParams): boolean { // If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state if (!previousUpdateID) { return false; } - // Updates staged for the deferred WRITE flush count as applied here, otherwise the responses of queued - // WRITE requests would look like gaps until the flush runs and needlessly pause the queue to refetch. - const lastUpdateIDFromClient = Math.max(clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0, lastUpdateIDPendingFlush); + // QueuedOnyxUpdates defers the Onyx write for WRITE requests, so their own responses arrive before the watermark moves. + const lastUpdateIDFromClient = Math.max( + clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0, + lastUpdateIDPendingWriteFlush, + isSerializedBehindPusherApply(updateType) ? lastUpdateIDPendingPusherApply : 0, + ); // If we don't have any value in lastUpdateIDFromClient, this is the first time we're receiving anything, so we need to do a last reconnectApp if (!lastUpdateIDFromClient) { diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 97108c62f66c..37086756c81e 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -17,13 +17,13 @@ import type { SetPolicyCategoryMaxAmountParams, SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams, SetPolicyCategoryReceiptsRequiredParams, - SetPolicyCategoryTaxParams, SetPolicyShowCategoryGLCodesParams, SetWorkspaceCategoryDescriptionHintParams, UpdatePolicyCategoryGLCodeParams, } from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ApiUtils from '@libs/ApiUtils'; +import {getRuleCategoryName, matchesCategoryTaxRule} from '@libs/CategoryTaxRulesUtils'; import * as CategoryUtils from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -945,7 +945,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: const policyCategoryToUpdate = policyData.categories?.[policyCategory.oldName]; const policyCategoryApproverRule = CategoryUtils.getCategoryApproverRule(policy?.rules?.approvalRules ?? [], policyCategory.oldName); - const policyCategoryExpenseRule = CategoryUtils.getCategoryExpenseRule(policy?.rules?.expenseRules ?? [], policyCategory.oldName); const approvalRules = policy?.rules?.approvalRules ?? []; const expenseRules = policy?.rules?.expenseRules ?? []; const mccGroup = policy?.mccGroup ?? {}; @@ -955,16 +954,22 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: const updatedMccGroup = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName); const updatedMccGroupWithClearedPendingAction = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName, true); - if (policyCategoryExpenseRule) { - const ruleIndex = updatedExpenseRules.findIndex((rule) => rule.id === policyCategoryExpenseRule.id); - policyCategoryExpenseRule.applyWhen = policyCategoryExpenseRule.applyWhen.map((applyWhen) => ({ - ...applyWhen, - ...(applyWhen.field === CONST.POLICY.FIELDS.CATEGORY && - applyWhen.value === policyCategory.oldName && { - value: policyCategory.newName, - }), - })); - updatedExpenseRules[ruleIndex] = policyCategoryExpenseRule; + // Found by its category condition rather than by `id`, because a rule created in NewDot has none: comparing ids + // made every rule without one equal, so a rename overwrote whichever sat earliest in the array and destroyed it. + // The match is rewritten as a copy for the same reason — the rule read off the policy is the live object. + const expenseRuleIndex = updatedExpenseRules.findIndex((rule) => matchesCategoryTaxRule(rule, policyCategory.oldName)); + const ruleToRename = updatedExpenseRules.at(expenseRuleIndex); + if (expenseRuleIndex !== -1 && ruleToRename) { + updatedExpenseRules[expenseRuleIndex] = { + ...ruleToRename, + applyWhen: ruleToRename.applyWhen.map((applyWhen) => ({ + ...applyWhen, + ...(applyWhen.field === CONST.POLICY.FIELDS.CATEGORY && + applyWhen.value === policyCategory.oldName && { + value: policyCategory.newName, + }), + })), + }; } // Its related by name, so the corresponding rule has to be updated to handle offline scenario @@ -1059,6 +1064,7 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: value: { rules: { approvalRules, + expenseRules, }, mccGroup, }, @@ -1833,72 +1839,224 @@ function setPolicyCategoryApprover(policyID: string, categoryName: string, appro API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); } -function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, taxID: string) { - if (!policy?.id) { +/** + * The categories an expense rule already matches on, for membership tests that would otherwise scan the array once per + * name. A rate isn't required, since a rule is found here to have one written to it. + */ +function getCategoriesWithExpenseRule(expenseRules: ExpenseRule[]): Set { + return new Set(expenseRules.map(getRuleCategoryName)); +} + +/** Builds the expense rule that carries a category's default tax rate. */ +function buildCategoryTaxRule(categoryName: string, taxID: string): ExpenseRule { + return { + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: {externalID: taxID}, + }, + applyWhen: [ + { + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + field: CONST.POLICY.FIELDS.CATEGORY, + value: categoryName, + }, + ], + }; +} + +/** + * Applies a tax rate to each category, returning the whole rules array. + * + * Onyx replaces arrays wholesale, so a write can never patch one rule. Every stage supplies the complete array. + */ +function withCategoryTaxRates(expenseRules: ExpenseRule[], taxRatesByCategory: Map): ExpenseRule[] { + const updated = expenseRules.map((rule) => { + const categoryName = getRuleCategoryName(rule); + const taxID = categoryName ? taxRatesByCategory.get(categoryName) : undefined; + + if (!taxID) { + return rule; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + return {...rule, tax: {field_id_TAX: {...rule.tax?.field_id_TAX, externalID: taxID}}}; + }); + + const categoriesWithExpenseRule = getCategoriesWithExpenseRule(expenseRules); + const added = [...taxRatesByCategory.entries()].flatMap(([categoryName, taxID]) => + !taxID || categoriesWithExpenseRule.has(categoryName) ? [] : [buildCategoryTaxRule(categoryName, taxID)], + ); + + return [...updated, ...added]; +} + +/** + * Sets the same default tax rate on one or more categories. + * + * No `successData`, like `setPolicyCategoryApprover`: it would carry a whole-array snapshot, and `API.write` persists + * those, so queued offline writes would replay stale arrays over each other. The server response is authoritative. + * + * The command is per-category, so a bulk save is one write each, and every rollback is the array as it stood before the + * save. Onyx replaces the array wholesale, so a rollback can't patch a single rule: whichever failure lands last is the + * array. Snapshots that each spared their own siblings therefore contradicted each other once two writes failed, and + * the survivor made a rejected category look saved. One identical baseline makes the order stop mattering. + * + * The cost is that a partial failure discards the writes that succeeded too, so they read stale until the next policy + * read. That beats showing a rate the server rejected, which is what the admin would otherwise save on top of. + */ +function setPolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[], taxID: string) { + if (!policy?.id || categoryNames.length === 0 || !taxID) { + Log.warn('Invalid params for setPolicyCategoryTaxes'); return; } const policyID = policy.id; - const expenseRules = policy?.rules?.expenseRules ?? []; - const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); - const existingCategoryExpenseRule = updatedExpenseRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + const expenseRules = policy.rules?.expenseRules ?? []; + const requested = new Map(categoryNames.map((categoryName) => [categoryName, taxID])); - if (!existingCategoryExpenseRule) { - updatedExpenseRules.push({ - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: { - externalID: taxID, - }, + // Every write shares this array. They are all enqueued at once, so the optimistic state has to be the same + // end state for each of them rather than a running total that the last write would truncate. + const optimisticExpenseRules = withCategoryTaxRates(expenseRules, requested); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {rules: {expenseRules: optimisticExpenseRules}}, }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: categoryName, - }, - ], - }); - } else { - const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); - const expenseRule = updatedExpenseRules.at(indexToUpdate); + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {rules: {expenseRules}}, + }, + ], + }; - if (expenseRule && indexToUpdate !== -1) { - expenseRule.tax.field_id_TAX.externalID = taxID; - } + for (const categoryName of categoryNames) { + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, categoryName, taxID}, onyxData); + } +} + +/** Sets a single category's default tax rate. */ +function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, taxID: string) { + setPolicyCategoryTaxes(policy, [categoryName], taxID); +} + +/** + * Removes the tax defaults for one or more categories. There is no dedicated delete command: writing the workspace's own + * default rate is what clears an override, since `getCategoryDefaultTaxRate` falls back to that same rate when no rule + * exists. The rules are dropped optimistically so the rows go straight away. + * + * Dropped rather than marked as deleting, so no grey pending row offline. A rule can't carry a pending flag: clearing + * one on success means rewriting the whole array, `API.write` persists that snapshot, and queued writes then replay + * stale arrays over each other. That was the state before dc9c34f9a97 removed `pendingAction` from `ExpenseRule`. + * + * Every rollback is the array as it stood before the delete, for the reason in `setPolicyCategoryTaxes`: Onyx replaces + * the array wholesale, so per-category snapshots contradict each other and the last failure to land hides the rest. + */ +function deletePolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[]) { + const defaultExternalID = policy?.taxRates?.defaultExternalID; + if (!policy?.id || !defaultExternalID || categoryNames.length === 0) { + Log.warn('Invalid params for deletePolicyCategoryTaxes'); + return; + } + const policyID = policy.id; + const expenseRules = policy.rules?.expenseRules ?? []; + const categoriesWithExpenseRule = getCategoriesWithExpenseRule(expenseRules); + const targets = categoryNames.filter((categoryName) => categoriesWithExpenseRule.has(categoryName)); + + if (targets.length === 0) { + return; } + // Shared like the save path: the writes are enqueued together, so each needs the same end state. + const targetedCategories = new Set(targets); + const optimisticExpenseRules = expenseRules.filter((rule) => { + const categoryName = getRuleCategoryName(rule); + return !categoryName || !targetedCategories.has(categoryName); + }); + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - rules: { - expenseRules: updatedExpenseRules, - }, - }, + value: {rules: {expenseRules: optimisticExpenseRules}}, }, ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - rules: { - expenseRules, - }, - }, + value: {rules: {expenseRules}}, }, ], }; - const parameters: SetPolicyCategoryTaxParams = { - policyID, - categoryName, - taxID, + for (const categoryName of targets) { + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, categoryName, taxID: defaultExternalID}, onyxData); + } +} + +/** Removes a single category's default tax rate. */ +function deletePolicyCategoryTax(policy: OnyxEntry, categoryName: string) { + deletePolicyCategoryTaxes(policy, [categoryName]); +} + +/** + * Moves a category's default tax rate to another category. + * + * The command is per-category, so a move is two writes: clearing the old category and setting the new one. They share + * one rollback array — the state before the move — because Onyx replaces the array wholesale. Rolling each write back + * against its own baseline instead lets the second failure overwrite what the first restored, dropping both rules. + * + * If exactly one write fails the local array goes back to the state before the move, so the one that succeeded reads + * stale until the next policy read. Two commands with no transaction between them can't do better, and it beats + * leaving the rate on two categories at once. + * + * The server can be left mid-move for the same reason — the rate on both categories, or on neither — and no rollback + * here can undo a request that already landed. A compensating write is not the answer: it can fail too, and offline it + * queues behind the failure it is meant to repair. The next policy read reconciles, and until the API can move a rate + * in one command that is the honest bound on this flow. + */ +function movePolicyCategoryTax(policy: OnyxEntry, fromCategoryName: string, toCategoryName: string, taxID: string) { + const defaultExternalID = policy?.taxRates?.defaultExternalID; + if (!policy?.id || !defaultExternalID || !fromCategoryName || !toCategoryName || !taxID) { + Log.warn('Invalid params for movePolicyCategoryTax'); + return; + } + const policyID = policy.id; + const originalExpenseRules = policy.rules?.expenseRules ?? []; + const withoutOldCategory = originalExpenseRules.filter((rule) => !matchesCategoryTaxRule(rule, fromCategoryName)); + const optimisticExpenseRules = withCategoryTaxRates(withoutOldCategory, new Map([[toCategoryName, taxID]])); + + // Writing the workspace default rate is what clears an override, so the move's first half is a write like any other. + const writes = [ + {categoryName: fromCategoryName, taxID: defaultExternalID}, + {categoryName: toCategoryName, taxID}, + ]; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {rules: {expenseRules: optimisticExpenseRules}}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {rules: {expenseRules: originalExpenseRules}}, + }, + ], }; - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); + for (const write of writes) { + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, ...write}, onyxData); + } } function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: string, areAttendeesRequired: boolean, policyCategories: PolicyCategories = {}) { @@ -1979,6 +2137,8 @@ export { DEFAULT_MCC_GROUP, isDefaultMccGroupID, clearCategoryErrors, + deletePolicyCategoryTax, + deletePolicyCategoryTaxes, createPolicyCategory, deleteWorkspaceCategories, downloadCategoriesCSV, @@ -1988,6 +2148,7 @@ export { openPolicyCategoriesPage, removePolicyCategoryReceiptsRequired, removePolicyCategoryItemizedReceiptsRequired, + movePolicyCategoryTax, renamePolicyCategory, setPolicyCategoryApprover, setPolicyCategoryAttendeesRequired, @@ -2000,6 +2161,7 @@ export { setPolicyCategoryReceiptsRequired, setPolicyCategoryItemizedReceiptsRequired, setPolicyCategoryTax, + setPolicyCategoryTaxes, setPolicyCustomUnitDefaultCategory, setWorkspaceCategoryDescriptionHint, setWorkspaceCategoryEnabled, diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index 0b73898db9a1..57ce88875666 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -3,13 +3,12 @@ import type {CopyPolicySettingsParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {hasExplicitFlagAmount} from '@libs/FlagForReviewRulesUtils'; -import {generateHexadecimalValue} from '@libs/NumberUtils'; import {categoryHasAnyRequireFieldsRule} from '@libs/RequireFieldsRulesUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CopyPolicySettings as CopyPolicySettingsState, Policy, PolicyCategories, PolicyTagLists, PolicyCategory} from '@src/types/onyx'; -import type {CustomUnit, PolicyFeatureName} from '@src/types/onyx/Policy'; +import type {CustomUnit, PolicyFeatureName, Rate} from '@src/types/onyx/Policy'; import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; @@ -68,7 +67,7 @@ const PARTS_TO_POLICY_FIELDS = { codingRules: ['rules'], distanceRates: ['areDistanceRatesEnabled', 'customUnits'], perDiem: ['arePerDiemRatesEnabled', 'customUnits'], - invoices: ['areInvoicesEnabled', 'invoice'], + invoices: ['areInvoicesEnabled', 'areInvoiceFieldsEnabled', 'invoice', 'fieldList'], // travelSettings is handled separately (buildTravelSettingsPatch): the Spotnana identity // fields (spotnanaCompanyID/associatedTravelDomainAccountID) and hasAcceptedTerms are per-policy // and must not be copied — each target is re-provisioned with its own entity by the backend. @@ -126,32 +125,53 @@ function findCustomUnitByName(policy: Policy | undefined, unitName: string): Cus /** * Returns the customUnits patch to merge into the target policy when distanceRates and/or perDiem are - * being copied. The source unit data is written under the target's existing unit ID — a new ID is - * generated only when the target has no unit of that type yet. + * being copied. This mirrors what Auth's CopyPolicySettings persists: the source unit is written under + * the target's existing unit ID, and each source rate keeps the target's rate ID when the target + * already has a rate of the same name. + * + * Units and rates the target doesn't have yet are left out. Auth mints their IDs and pushes them to + * Onyx as a merge, so optimistically inventing a different ID would leave the same unit or rate on + * screen twice once the server push lands. + * + * Skipping the optimistic update for those new rates is fine because the copy runs behind a blocking + * modal, so nothing is on screen waiting for them. Showing them optimistically would require sending + * an optimisticRateIDs parameter and having Auth use those IDs instead of minting its own, which we + * can add later if we need it. */ function buildCustomUnitsPatch(sourcePolicy: Policy, targetPolicy: Policy, isDistanceSelected: boolean, isPerDiemSelected: boolean): {customUnits: Record} | undefined { - if (!isDistanceSelected && !isPerDiemSelected) { - return undefined; - } - + const unitNames = [...(isDistanceSelected ? [CONST.CUSTOM_UNITS.NAME_DISTANCE] : []), ...(isPerDiemSelected ? [CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL] : [])]; const patch: Record = {}; - if (isDistanceSelected) { - const sourceDistance = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); - if (sourceDistance) { - const targetDistance = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); - const targetUnitID = targetDistance?.customUnitID ?? generateHexadecimalValue(13); - patch[targetUnitID] = {...sourceDistance, customUnitID: targetUnitID}; + for (const unitName of unitNames) { + const sourceUnit = findCustomUnitByName(sourcePolicy, unitName); + const targetUnit = findCustomUnitByName(targetPolicy, unitName); + if (!sourceUnit || !targetUnit) { + continue; + } + + const targetRateIDByName = new Map(); + for (const targetRate of Object.values(targetUnit.rates ?? {})) { + if (!targetRate.name || !targetRate.customUnitRateID || targetRateIDByName.has(targetRate.name)) { + continue; + } + targetRateIDByName.set(targetRate.name, targetRate.customUnitRateID); } - } - if (isPerDiemSelected) { - const sourcePerDiem = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); - if (sourcePerDiem) { - const targetPerDiem = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); - const targetUnitID = targetPerDiem?.customUnitID ?? generateHexadecimalValue(13); - patch[targetUnitID] = {...sourcePerDiem, customUnitID: targetUnitID}; + const rates: Record = {}; + for (const sourceRate of Object.values(sourceUnit.rates ?? {})) { + const sourceRateName = sourceRate.name; + const targetRateID = sourceRateName ? targetRateIDByName.get(sourceRateName) : undefined; + if (!sourceRateName || !targetRateID) { + continue; + } + + // Each target rate ID can only stand in for one source rate, so a second source rate of the + // same name falls through to the server push like any other rate the target doesn't have. + targetRateIDByName.delete(sourceRateName); + rates[targetRateID] = {...sourceRate, customUnitRateID: targetRateID}; } + + patch[targetUnit.customUnitID] = {...sourceUnit, customUnitID: targetUnit.customUnitID, rates}; } if (Object.keys(patch).length === 0) { @@ -279,13 +299,19 @@ function buildCategoryRulesPatch(sourceCategories: PolicyCategories, targetCateg * Returns the partial Policy patch derived from the selected `parts`, excluding fields whose * mapping is handled separately (customUnits, timeTracking, receiptPartners, categories, tags collection keys). */ -function buildPolicyFieldPatch(sourcePolicy: Policy, parts: Part[]): Partial { +function buildPolicyFieldPatch(sourcePolicy: Policy, targetPolicy: Policy, parts: Part[]): Partial { const patch: Partial = {}; + const shouldCopyReportFields = parts.includes('reports'); + const shouldCopyInvoiceFields = parts.includes('invoices'); + for (const part of parts) { for (const field of PARTS_TO_POLICY_FIELDS[part]) { if (field === 'customUnits') { continue; } + if (field === 'fieldList') { + continue; + } if (part === 'codingRules' && field === 'rules') { continue; } @@ -293,6 +319,18 @@ function buildPolicyFieldPatch(sourcePolicy: Policy, parts: Part[]): Partial)[field] = sourcePolicy[field as keyof Policy]; } } + + if (shouldCopyReportFields || shouldCopyInvoiceFields) { + const shouldCopyField = (field: NonNullable[string]) => { + const isInvoiceField = field.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + return (shouldCopyReportFields && !isInvoiceField) || (shouldCopyInvoiceFields && isInvoiceField); + }; + const retainedTargetFields = Object.entries(targetPolicy.fieldList ?? {}).filter(([, field]) => !shouldCopyField(field)); + const copiedSourceFields = Object.entries(sourcePolicy.fieldList ?? {}).filter(([, field]) => shouldCopyField(field)); + const mergedFields = [...retainedTargetFields, ...copiedSourceFields]; + patch.fieldList = Object.fromEntries(mergedFields); + } + return patch; } @@ -338,7 +376,6 @@ function buildCopyPolicySettingsData( const successData: Array> = []; const failureData: Array> = []; - const policyFieldPatch = buildPolicyFieldPatch(sourcePolicy, parts); const pendingFields = buildExpandedPendingFields(parts); const clearedPendingFields = buildClearedPendingFields(parts); @@ -385,6 +422,7 @@ function buildCopyPolicySettingsData( for (const targetPolicy of targetPolicies) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${targetPolicy.id}` as const; + const policyFieldPatch = buildPolicyFieldPatch(sourcePolicy, targetPolicy, parts); const customUnitsPatch = buildCustomUnitsPatch(sourcePolicy, targetPolicy, isDistanceSelected, isPerDiemSelected); const timeTrackingPatch = isTimeTrackingSelected ? buildTimeTrackingPatch(sourcePolicy) : undefined; const travelSettingsPatch = isTravelSelected ? buildTravelSettingsPatch(sourcePolicy, targetPolicy) : undefined; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e0f5e18043b6..8c349023e6ff 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -24,7 +24,9 @@ import type { EnablePolicyConnectionsParams, EnablePolicyExpensifyCardsParams, EnablePolicyHRParams, + EnablePolicyInvoiceFieldsParams, EnablePolicyInvoicingParams, + EnablePolicyMCPParams, EnablePolicyReportFieldsParams, EnablePolicyTaxesParams, EnablePolicyWorkflowsParams, @@ -222,6 +224,7 @@ type CreateWorkspaceFromIOUPaymentOptions = { reportPreviewAction: ReportAction | undefined; currentUserAccountID: number; currentUserEmail: string; + currentUserDisplayName: string | undefined; iouReportOwnerEmail: string; currentUserLocalCurrency: string; lastWorkspaceNumber: number | undefined; @@ -229,6 +232,8 @@ type CreateWorkspaceFromIOUPaymentOptions = { reportActionsList: OnyxCollection; doesEmployeePersonalDetailExist: boolean; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + /** Whether the current user already owns a paid workspace. CreatePolicy leaves the #admins room unpinned when they do. */ + hasOwnedPaidPolicy: boolean; }; type PolicyCashExpenseMode = ValueOf; @@ -241,8 +246,13 @@ type CurrentUser = { avatar?: AvatarSource; }; +type PolicyOwner = { + email: string | undefined; + accountID: number | undefined; +}; + type BuildPolicyDataOptions = { - policyOwnerEmail?: string; + policyOwner?: PolicyOwner; makeMeAdmin?: boolean; policyName: string; policyID?: string; @@ -274,6 +284,8 @@ type BuildPolicyDataOptions = { // TODO: Make it required once we complete refactoring the buildPolicyData function to use isSelfTourViewed. Refactor issue: https://github.com/Expensify/App/issues/66424 isSelfTourViewed?: boolean; hasActiveAdminPolicies: boolean | undefined; + /** Whether the current user already owns a paid workspace. CreatePolicy leaves the #admins room unpinned when they do. */ + hasOwnedPaidPolicy: boolean | undefined; betas?: OnyxEntry; personalTrackGoal?: string; }; @@ -940,8 +952,8 @@ function setWorkspaceApprovalMode( currentUserAccountID: number, currentUserEmail: string, isTrackIntentUser: boolean | undefined, + rules: OnyxCollection, additionalData?: SetWorkspaceApprovalModeAdditionalData, - rules?: OnyxCollection, ) { if (!policy) { return; @@ -1023,6 +1035,7 @@ function setWorkspaceApprovalMode( isASAPSubmitBetaEnabled, predictedNextStatus: report?.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, isTrackIntentUser, + rules, }); reportsOptimisticData.push({ @@ -2454,7 +2467,7 @@ function clearDuplicateWorkspace() { Onyx.set(ONYXKEYS.DUPLICATE_WORKSPACE, {}); } -function getDisplayNameForWorkspace(email: string, displayNameOverride?: string) { +function getDisplayNameForWorkspace(email: string, userDisplayName: string | undefined) { const emailParts = email.split('@'); const domain = emailParts.at(1) ?? ''; const isSMSDomain = `@${domain}` === CONST.SMS.DOMAIN; @@ -2466,8 +2479,7 @@ function getDisplayNameForWorkspace(email: string, displayNameOverride?: string) return Str.UCFirst(domain.split('.').at(0) ?? ''); } - const userDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); - const displayName = displayNameOverride?.trim() ?? userDetails?.displayName?.trim(); + const displayName = userDisplayName?.trim(); if (displayName) { return Str.UCFirst(displayName); } @@ -2491,7 +2503,9 @@ function generateDefaultWorkspaceName(email: string, lastWorkspaceNumber: number return localeTranslate('workspace.new.myGroupWorkspace', {workspaceNumber: lastWorkspaceNumber !== undefined ? lastWorkspaceNumber + 1 : undefined}); } - const displayNameForWorkspace = getDisplayNameForWorkspace(email, displayNameOverride); + const userDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); + const displayName = displayNameOverride?.trim() ?? userDetails?.displayName?.trim(); + const displayNameForWorkspace = getDisplayNameForWorkspace(email, displayName); return localeTranslate('workspace.new.workspaceName', displayNameForWorkspace, lastWorkspaceNumber !== undefined ? lastWorkspaceNumber + 1 : undefined); } @@ -2702,7 +2716,7 @@ function buildDefaultTitleFieldList(pendingFields?: Record & {params: CreateWorkspaceParams} { const { - policyOwnerEmail = '', + policyOwner, makeMeAdmin = false, policyName, policyID = generatePolicyID(), @@ -2743,11 +2757,15 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData field.target !== CONST.REPORT_FIELD_TARGETS.INVOICE)) + : undefined; + const invoiceFieldListWithoutPendingDelete = fieldListWithoutPendingDelete + ? Object.fromEntries(Object.entries(fieldListWithoutPendingDelete).filter(([, field]) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE)) + : undefined; + const copiedFieldList = { + ...(isReportsFeatureSelected ? reportFieldListWithoutPendingDelete : undefined), + ...(isInvoiceFieldsFeatureSelected ? invoiceFieldListWithoutPendingDelete : undefined), + }; const connectionsWithoutPendingDelete = filterPendingDeleteData(sourcePolicy?.connections); const taxRatesWithoutPendingDelete = { ...sourcePolicy?.taxRates, @@ -3491,6 +3520,7 @@ function buildOptimisticDuplicatePolicy( areRulesEnabled: isRulesFeatureSelected, areWorkflowsEnabled: isWorkflowsFeatureSelected, areReportFieldsEnabled: isReportsFeatureSelected ? sourcePolicy?.areReportFieldsEnabled : false, + areInvoiceFieldsEnabled: isInvoiceFieldsFeatureSelected ? sourcePolicy?.areInvoiceFieldsEnabled : false, areConnectionsEnabled: isConnectionsFeatureSelected, arePerDiemRatesEnabled: isPerDiemFeatureSelected, isTravelEnabled: isTravelFeatureSelected ? sourcePolicy?.isTravelEnabled : undefined, @@ -3501,7 +3531,7 @@ function buildOptimisticDuplicatePolicy( employeeList: isMemberFeatureSelected ? employeeListWithoutPendingDelete : {[sourcePolicy.owner]: sourcePolicy?.employeeList?.[sourcePolicy.owner]}, id: duplicatedPolicyID, name: duplicatedPolicyName, - fieldList: isReportsFeatureSelected ? fieldListWithoutPendingDelete : undefined, + fieldList: Object.keys(copiedFieldList).length > 0 ? copiedFieldList : undefined, connections: isConnectionsFeatureSelected ? connectionsWithoutPendingDelete : undefined, customUnits: getCustomUnitsForDuplication(sourcePolicy, isDistanceRatesFeatureSelected, isPerDiemFeatureSelected, { distanceCustomUnitID: duplicatedDistanceCustomUnitID, @@ -3521,6 +3551,7 @@ function buildOptimisticDuplicatePolicy( description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, type: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, areReportFieldsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + areInvoiceFieldsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, ...(willCopyRulesDocument ? {rulesDocumentURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {}), }, avatarURL: duplicatedPolicyFile?.uri, @@ -3683,6 +3714,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp description: null, type: null, areReportFieldsEnabled: null, + areInvoiceFieldsEnabled: null, rulesDocumentURL: null, }, }, @@ -4376,6 +4408,7 @@ function createWorkspaceFromIOUPayment({ reportPreviewAction, currentUserAccountID, currentUserEmail, + currentUserDisplayName, iouReportOwnerEmail, currentUserLocalCurrency, lastWorkspaceNumber, @@ -4383,6 +4416,7 @@ function createWorkspaceFromIOUPayment({ reportActionsList, doesEmployeePersonalDetailExist, getCurrencyDecimals, + hasOwnedPaidPolicy, }: CreateWorkspaceFromIOUPaymentOptions): WorkspaceFromIOUCreationData | undefined { // This flow only works for IOU reports if (!iouReport || !ReportUtils.isIOUReportUsingReport(iouReport)) { @@ -4391,7 +4425,7 @@ function createWorkspaceFromIOUPayment({ // Generate new variables for the policy const policyID = generatePolicyID(); - const workspaceName = generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, localeTranslate); + const workspaceName = generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, localeTranslate, currentUserDisplayName); 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 const {customUnits, customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(iouReport?.currency || currentUserLocalCurrency); @@ -4408,7 +4442,7 @@ function createWorkspaceFromIOUPayment({ expenseReportActionData: workspaceChatReportActionData, expenseCreatedReportActionID: workspaceChatCreatedReportActionID, pendingChatMembers, - } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName, currentUserAccountID, currentUserEmail); + } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName, currentUserAccountID, currentUserEmail, undefined, hasOwnedPaidPolicy); if (!employeeAccountID || !oldPersonalPolicyID) { return; @@ -4987,6 +5021,54 @@ function enablePolicyHR(policyID: string, enabled: boolean) { } } +function enablePolicyMCP(policyID: string, enabled: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isMCPEnabled: enabled, + pendingFields: { + isMCPEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + isMCPEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isMCPEnabled: !enabled, + pendingFields: { + isMCPEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyMCPParams = {policyID, enabled}; + + API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_MCP, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + goBackWhenEnableFeature(); + } +} + function enablePolicyReceiptPartners(policyID: string, enabled: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -5141,16 +5223,19 @@ function enableCompanyCards(policyID: string, enabled: boolean, shouldGoBack = t } } -function enablePolicyReportFields(policyID: string, enabled: boolean) { +function enablePolicyFields(policyID: string, enabled: boolean, command: typeof WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS | typeof WRITE_COMMANDS.ENABLE_POLICY_INVOICE_FIELDS) { + const enableFieldKey = + command === WRITE_COMMANDS.ENABLE_POLICY_INVOICE_FIELDS ? CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED : CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED; + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - areReportFieldsEnabled: enabled, + [enableFieldKey]: enabled, pendingFields: { - areReportFieldsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + [enableFieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, }, @@ -5161,7 +5246,7 @@ function enablePolicyReportFields(policyID: string, enabled: boolean) { key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { pendingFields: { - areReportFieldsEnabled: null, + [enableFieldKey]: null, }, }, }, @@ -5171,18 +5256,26 @@ function enablePolicyReportFields(policyID: string, enabled: boolean) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - areReportFieldsEnabled: !enabled, + [enableFieldKey]: !enabled, pendingFields: { - areReportFieldsEnabled: null, + [enableFieldKey]: null, }, }, }, ], }; - const parameters: EnablePolicyReportFieldsParams = {policyID, enabled}; + const parameters: EnablePolicyReportFieldsParams | EnablePolicyInvoiceFieldsParams = {policyID, enabled}; + + API.writeWithNoDuplicatesEnableFeatureConflicts(command, parameters, onyxData); +} + +function enablePolicyReportFields(policyID: string, enabled: boolean) { + enablePolicyFields(policyID, enabled, WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS); +} - API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS, parameters, onyxData); +function enablePolicyInvoiceFields(policyID: string, enabled: boolean) { + enablePolicyFields(policyID, enabled, WRITE_COMMANDS.ENABLE_POLICY_INVOICE_FIELDS); } function enablePolicyTaxes(policyID: string, enabled: true, currentTaxRates: TaxRatesWithDefault | undefined, policyData?: PolicyData): void; @@ -6537,14 +6630,6 @@ function setPolicyProhibitedExpenses(policyID: string, prohibitedExpenses: Prohi * @param policyID * @param prohibitedExpense */ -function setPolicyProhibitedExpense(policyID: string, prohibitedExpense: keyof ProhibitedExpenses, currentProhibitedExpense: ProhibitedExpenses | undefined) { - const prohibitedExpenses = { - ...currentProhibitedExpense, - [prohibitedExpense]: !currentProhibitedExpense?.[prohibitedExpense], - }; - - setPolicyProhibitedExpenses(policyID, prohibitedExpenses, currentProhibitedExpense); -} /** * Call the API to set the max expense age for the given policy @@ -7968,8 +8053,10 @@ export { enableCompanyCards, enablePolicyConnections, enablePolicyHR, + enablePolicyMCP, enablePolicyReceiptPartners, enablePolicyReportFields, + enablePolicyInvoiceFields, enablePolicyTaxes, enablePolicyWorkflows, enablePolicyTimeTracking, @@ -8022,7 +8109,6 @@ export { setPolicyMaxExpenseAmount, setPolicyMaxExpenseAge, updateCustomRules, - setPolicyProhibitedExpense, setPolicyProhibitedExpenses, setDuplicateWorkspaceData, clearDuplicateWorkspace, @@ -8059,4 +8145,4 @@ export { setPolicyTimeTrackingDefaultRate, upgradeSubmit, }; -export type {BuildPolicyDataKeys, CurrentUser}; +export type {BuildPolicyDataKeys, CurrentUser, PolicyOwner}; diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts index 716766d6034d..01cd2864f232 100644 --- a/src/libs/actions/Policy/ReportField.ts +++ b/src/libs/actions/Policy/ReportField.ts @@ -1,11 +1,17 @@ import * as API from '@libs/API'; import type { + CreateWorkspaceInvoiceFieldListValueParams, + CreateWorkspaceInvoiceFieldParams, CreateWorkspaceReportFieldListValueParams, CreateWorkspaceReportFieldParams, + DeletePolicyInvoiceField, DeletePolicyReportField, + EnableWorkspaceInvoiceFieldListValueParams, EnableWorkspaceReportFieldListValueParams, OpenPolicyReportFieldsPageParams, + RemoveWorkspaceInvoiceFieldListValueParams, RemoveWorkspaceReportFieldListValueParams, + UpdateWorkspaceInvoiceFieldInitialValueParams, UpdateWorkspaceReportFieldInitialValueParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -54,7 +60,8 @@ type DeleteReportFieldsListValueParams = { type CreateReportFieldParams = Pick & { listValues: string[]; disabledListValues: boolean[]; - policyExpenseReportIDs: Array | undefined; + policyReportIDs: Array | undefined; + isInvoiceField?: boolean; policy: OnyxEntry; }; @@ -101,6 +108,19 @@ function openPolicyReportFieldsPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_REPORT_FIELDS_PAGE, params); } +function openPolicyInvoicesPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyInvoicesPage invalid params', {policyID}); + return; + } + + const params: OpenPolicyReportFieldsPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_INVOICES_PAGE, params); +} + /** * Sets the initial form values for the workspace report fields form. */ @@ -170,7 +190,7 @@ function deleteReportFieldsListValue({valueIndexes, listValues, disabledListValu /** * Creates a new report field. */ -function createReportField({name, type, initialValue, listValues, disabledListValues, policyExpenseReportIDs, policy}: CreateReportFieldParams) { +function createReportField({name, type, initialValue, listValues, disabledListValues, policyReportIDs, isInvoiceField = false, policy}: CreateReportFieldParams) { if (!policy) { Log.warn('Policy data is not present'); return; @@ -182,7 +202,12 @@ function createReportField({name, type, initialValue, listValues, disabledListVa } const previousFieldList = policy?.fieldList ?? {}; - const fieldID = WorkspaceReportFieldUtils.generateFieldID(name); + const target = isInvoiceField ? CONST.REPORT_FIELD_TARGETS.INVOICE : CONST.REPORT_FIELD_TARGETS.EXPENSE; + const defaultFieldID = WorkspaceReportFieldUtils.generateFieldID(name); + const defaultFieldKey = ReportUtils.getReportFieldKey(defaultFieldID); + const defaultField = previousFieldList[defaultFieldKey]; + const shouldUseTargetSpecificFieldID = isInvoiceField || (defaultField && !WorkspaceReportFieldUtils.isReportFieldTargetValid(defaultField, target)); + const fieldID = shouldUseTargetSpecificFieldID ? WorkspaceReportFieldUtils.generateFieldID(name, target) : defaultFieldID; const fieldKey = ReportUtils.getReportFieldKey(fieldID); // User selected type Text but entered a formula Initial value, treat it as a Formula type for optimistic UI @@ -192,7 +217,7 @@ function createReportField({name, type, initialValue, listValues, disabledListVa const optimisticReportFieldDataForPolicy: Omit, 'value'> = { name, type: optimisticType, - target: 'expense', + target, defaultValue: initialValue, values: listValues, disabledOptions: disabledListValues, @@ -215,7 +240,7 @@ function createReportField({name, type, initialValue, listValues, disabledListVa errorFields: null, }, }, - ...(policyExpenseReportIDs ?? []).map( + ...(policyReportIDs ?? []).map( (reportID): OnyxUpdate => ({ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, onyxMethod: Onyx.METHOD.MERGE, @@ -241,7 +266,7 @@ function createReportField({name, type, initialValue, listValues, disabledListVa }, }, }, - ...(policyExpenseReportIDs ?? []).map( + ...(policyReportIDs ?? []).map( (reportID): OnyxUpdate => ({ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, onyxMethod: Onyx.METHOD.MERGE, @@ -271,12 +296,18 @@ function createReportField({name, type, initialValue, listValues, disabledListVa failureData, }; - const parameters: CreateWorkspaceReportFieldParams = { - policyID: policy?.id, - reportFields: JSON.stringify([optimisticReportFieldDataForPolicy]), - }; - - API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD, parameters, onyxData); + const parameters: CreateWorkspaceReportFieldParams | CreateWorkspaceInvoiceFieldParams = isInvoiceField + ? { + policyID: policy?.id, + invoiceFields: JSON.stringify([optimisticReportFieldDataForPolicy]), + } + : { + policyID: policy?.id, + reportFields: JSON.stringify([optimisticReportFieldDataForPolicy]), + }; + + const createCommand = isInvoiceField ? WRITE_COMMANDS.CREATE_WORKSPACE_INVOICE_FIELD : WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD; + API.write(createCommand, parameters, onyxData); } function deleteReportFields({policy, reportFieldsToUpdate}: DeleteReportFieldsParams) { @@ -286,19 +317,24 @@ function deleteReportFields({policy, reportFieldsToUpdate}: DeleteReportFieldsPa } const allReportFields = policy?.fieldList ?? {}; + const resolvedReportFieldKeys = reportFieldsToUpdate.filter((reportFieldKey) => !!allReportFields[reportFieldKey]); + if (resolvedReportFieldKeys.length === 0) { + Log.warn('No valid report fields to delete', {reportFieldsToUpdate}); + return; + } - const updatedReportFields = Object.fromEntries(Object.entries(allReportFields).filter(([key]) => !reportFieldsToUpdate.includes(key))); - const optimisticReportFields = reportFieldsToUpdate.reduce>>>((acc, reportFieldKey) => { + const updatedReportFields = Object.fromEntries(Object.entries(allReportFields).filter(([key]) => !resolvedReportFieldKeys.includes(key))); + const optimisticReportFields = resolvedReportFieldKeys.reduce>>>((acc, reportFieldKey) => { acc[reportFieldKey] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; return acc; }, {}); - const successReportFields = reportFieldsToUpdate.reduce>((acc, reportFieldKey) => { + const successReportFields = resolvedReportFieldKeys.reduce>((acc, reportFieldKey) => { acc[reportFieldKey] = null; return acc; }, {}); - const failureReportFields = reportFieldsToUpdate.reduce>>>((acc, reportFieldKey) => { + const failureReportFields = resolvedReportFieldKeys.reduce>>>((acc, reportFieldKey) => { acc[reportFieldKey] = {pendingAction: null}; return acc; }, {}); @@ -337,12 +373,24 @@ function deleteReportFields({policy, reportFieldsToUpdate}: DeleteReportFieldsPa ], }; - const parameters: DeletePolicyReportField = { - policyID: policy?.id, - reportFields: JSON.stringify(Object.values(updatedReportFields)), - }; - - API.write(WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD, parameters, onyxData); + const fieldsToDelete = resolvedReportFieldKeys.map((reportFieldKey) => allReportFields[reportFieldKey]).filter((reportField): reportField is PolicyReportField => !!reportField); + const isInvoiceField = fieldsToDelete.length > 0 && fieldsToDelete.every((reportField) => reportField.target === CONST.REPORT_FIELD_TARGETS.INVOICE); + const updatedReportFieldsValues = Object.values(updatedReportFields); + const remainingInvoiceFields = updatedReportFieldsValues.filter((reportField) => reportField.target === CONST.REPORT_FIELD_TARGETS.INVOICE); + const remainingExpenseFields = updatedReportFieldsValues.filter((reportField) => !reportField.target || reportField.target === CONST.REPORT_FIELD_TARGETS.EXPENSE); + const invoiceFieldsPayload = remainingInvoiceFields; + const parameters: DeletePolicyReportField | DeletePolicyInvoiceField = isInvoiceField + ? { + policyID: policy?.id, + invoiceFields: JSON.stringify(invoiceFieldsPayload), + } + : { + policyID: policy?.id, + reportFields: JSON.stringify(remainingExpenseFields), + }; + const deleteCommand = isInvoiceField ? WRITE_COMMANDS.DELETE_POLICY_INVOICE_FIELD : WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD; + + API.write(deleteCommand, parameters, onyxData); } /** @@ -410,12 +458,19 @@ function updateReportFieldInitialValue({policy, reportFieldID, newInitialValue}: }, ], }; - const parameters: UpdateWorkspaceReportFieldInitialValueParams = { - policyID: policy?.id, - reportFields: JSON.stringify([updatedReportField]), - }; - - API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE, parameters, onyxData); + const isInvoiceField = updatedReportField?.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const parameters: UpdateWorkspaceReportFieldInitialValueParams | UpdateWorkspaceInvoiceFieldInitialValueParams = isInvoiceField + ? { + policyID: policy?.id, + invoiceFields: JSON.stringify([updatedReportField]), + } + : { + policyID: policy?.id, + reportFields: JSON.stringify([updatedReportField]), + }; + const updateCommand = isInvoiceField ? WRITE_COMMANDS.UPDATE_WORKSPACE_INVOICE_FIELD_INITIAL_VALUE : WRITE_COMMANDS.UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE; + + API.write(updateCommand, parameters, onyxData); } function updateReportFieldListValueEnabled({policy, reportFieldID, valueIndexes, enabled}: UpdateReportFieldListValueEnabledParams) { @@ -454,12 +509,19 @@ function updateReportFieldListValueEnabled({policy, reportFieldID, valueIndexes, ], }; - const parameters: EnableWorkspaceReportFieldListValueParams = { - policyID: policy?.id, - reportFields: JSON.stringify([updatedReportField]), - }; - - API.write(WRITE_COMMANDS.ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE, parameters, onyxData); + const isInvoiceField = reportField?.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const parameters: EnableWorkspaceReportFieldListValueParams | EnableWorkspaceInvoiceFieldListValueParams = isInvoiceField + ? { + policyID: policy?.id, + invoiceFields: JSON.stringify([updatedReportField]), + } + : { + policyID: policy?.id, + reportFields: JSON.stringify([updatedReportField]), + }; + const enableCommand = isInvoiceField ? WRITE_COMMANDS.ENABLE_WORKSPACE_INVOICE_FIELD_LIST_VALUE : WRITE_COMMANDS.ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE; + + API.write(enableCommand, parameters, onyxData); } /** @@ -494,12 +556,19 @@ function addReportFieldListValue({policy, reportFieldID, valueName}: AddReportFi ], }; - const parameters: CreateWorkspaceReportFieldListValueParams = { - policyID: policy?.id, - reportFields: JSON.stringify([updatedReportField]), - }; - - API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE, parameters, onyxData); + const isInvoiceField = reportField?.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const parameters: CreateWorkspaceReportFieldListValueParams | CreateWorkspaceInvoiceFieldListValueParams = isInvoiceField + ? { + policyID: policy?.id, + invoiceFields: JSON.stringify([updatedReportField]), + } + : { + policyID: policy?.id, + reportFields: JSON.stringify([updatedReportField]), + }; + const createCommand = isInvoiceField ? WRITE_COMMANDS.CREATE_WORKSPACE_INVOICE_FIELD_LIST_VALUE : WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE; + + API.write(createCommand, parameters, onyxData); } /** @@ -542,12 +611,19 @@ function removeReportFieldListValue({policy, reportFieldID, valueIndexes}: Remov ], }; - const parameters: RemoveWorkspaceReportFieldListValueParams = { - policyID: policy?.id, - reportFields: JSON.stringify([updatedReportField]), - }; - - API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE, parameters, onyxData); + const isInvoiceField = reportField?.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const parameters: RemoveWorkspaceReportFieldListValueParams | RemoveWorkspaceInvoiceFieldListValueParams = isInvoiceField + ? { + policyID: policy?.id, + invoiceFields: JSON.stringify([updatedReportField]), + } + : { + policyID: policy?.id, + reportFields: JSON.stringify([updatedReportField]), + }; + const removeCommand = isInvoiceField ? WRITE_COMMANDS.REMOVE_WORKSPACE_INVOICE_FIELD_LIST_VALUE : WRITE_COMMANDS.REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE; + + API.write(removeCommand, parameters, onyxData); } export type {CreateReportFieldParams}; @@ -563,6 +639,7 @@ export { updateReportFieldInitialValue, updateReportFieldListValueEnabled, openPolicyReportFieldsPage, + openPolicyInvoicesPage, addReportFieldListValue, removeReportFieldListValue, }; diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index ba77ff4687d3..4b43465bbb27 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -410,94 +410,6 @@ function setWorkspaceTagEnabled(policyData: PolicyData, tagsToUpdate: Record((acc, key) => { - if (tagListIndexes.includes(policyData.tags[key].orderWeight)) { - acc[key] = { - ...acc[key], - required: isRequired, - errors: undefined, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - pendingFields: { - required: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }; - - return acc; - } - - return acc; - }, {}), - }; - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - value: policyTagsOptimisticData, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - value: { - ...Object.keys(policyData.tags).reduce((acc, key) => { - if (tagListIndexes.includes(policyData.tags[key].orderWeight)) { - acc[key] = { - ...acc[key], - errors: undefined, - pendingAction: null, - pendingFields: { - required: null, - }, - }; - return acc; - } - - return acc; - }, {}), - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - value: { - ...Object.keys(policyData.tags).reduce((acc, key) => { - acc[key] = { - ...acc[key], - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), - pendingAction: null, - pendingFields: { - required: null, - }, - }; - return acc; - }, {}), - }, - }, - ], - }; - - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); - const parameters: SetPolicyTagListsRequired = { - policyID, - tagListIndexes, - isRequired, - }; - - API.write(WRITE_COMMANDS.SET_POLICY_TAG_LISTS_REQUIRED, parameters, onyxData); -} - function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { const policyID = policyData.policy?.id; const policyTag = PolicyUtils.getTagLists(policyData.tags)?.at(0); @@ -1547,7 +1459,6 @@ export { clearPolicyTagListErrorField, deletePolicyTags, enablePolicyTags, - setWorkspaceTagRequired, openPolicyTagsPage, renamePolicyTag, renamePolicyTagList, diff --git a/src/libs/actions/PolicyConnections.ts b/src/libs/actions/PolicyConnections.ts index 0d6f09ddb886..6ad127d2c387 100644 --- a/src/libs/actions/PolicyConnections.ts +++ b/src/libs/actions/PolicyConnections.ts @@ -1,5 +1,5 @@ import * as API from '@libs/API'; -import type {OpenPolicyAccountingPageParams, OpenPolicyHRPageParams} from '@libs/API/parameters'; +import type {OpenPolicyAccountingPageParams, OpenPolicyHRPageParams, OpenPolicyRecruitingPageParams} from '@libs/API/parameters'; import {READ_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -69,6 +69,34 @@ function openPolicyHRPage(policyID: string) { }); } +function openPolicyRecruitingPage(policyID: string) { + const hasConnectionsDataBeenFetchedKey = `${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${policyID}` as const; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: hasConnectionsDataBeenFetchedKey, + value: true, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: hasConnectionsDataBeenFetchedKey, + value: false, + }, + ]; + + const parameters: OpenPolicyRecruitingPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_RECRUITING_PAGE, parameters, { + successData, + failureData, + }); +} + function updateConnectionConfig>( policyID: string, connectionName: TConnectionName, @@ -78,4 +106,4 @@ function updateConnectionConfig) { +/** + * Marks every unread report as read. Pass `reportIDs` to limit it to a subset, e.g. only the reports listed under one + * Inbox tab; when omitted, every unread report is marked read. + */ +function markAllMessagesAsRead(reportNameValuePairs: OnyxCollection, reportIDs?: string[]) { if (isAnonymousUser()) { return; } @@ -46,7 +50,8 @@ function markAllMessagesAsRead(reportNameValuePairs: OnyxCollection = {}; const failureReports: Record = {}; const reportIDList: string[] = []; - for (const report of Object.values(allReports ?? {})) { + const reportsToMark = reportIDs ? reportIDs.map((reportID) => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) : Object.values(allReports ?? {}); + for (const report of reportsToMark) { if (!report) { continue; } diff --git a/src/libs/actions/Report/PreMountedDraftReport.ts b/src/libs/actions/Report/PreMountedDraftReport.ts new file mode 100644 index 000000000000..8fb957f305cb --- /dev/null +++ b/src/libs/actions/Report/PreMountedDraftReport.ts @@ -0,0 +1,36 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxMultiSetInput} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** + * Copies an already-built draft report into COLLECTION.REPORT so a pre-mounted destination screen can render immediately. + * Also writes a persisted marker so an interrupted flow (app killed before submit) can be cleaned up on the next launch. + */ +function preMountDraftReport(reportID: string, draftReport: Report) { + const preMountData: OnyxMultiSetInput = {}; + preMountData[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = draftReport; + preMountData[`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`] = true; + return Onyx.multiSet(preMountData); +} + +/** + * Removes a report created by `preMountDraftReport`, for when the caller backs out before submission actually happens. + */ +function clearPreMountedDraftReport(reportID: string) { + return Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + [`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: null, + }); +} + +/** + * Clears only the pre-mount marker left by `preMountDraftReport`. + */ +function clearPreMountedDraftReportMarker(reportID: string) { + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`, null); +} + +export {preMountDraftReport, clearPreMountedDraftReport, clearPreMountedDraftReportMarker}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index eeec59263951..acc3acefb061 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -177,6 +177,7 @@ import { } from '@libs/ReportUtils'; import {buildOptimisticSnapshotData, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import playSound, {SOUNDS} from '@libs/Sound'; +import {startSendMessagePhase} from '@libs/telemetry/sendMessageSpans'; import { getAmount, getCurrency, @@ -192,6 +193,7 @@ import {appendParam, getSearchParamFromPath} from '@libs/Url'; import {buildSecureDownloadURL} from '@libs/UrlUtils'; import Visibility from '@libs/Visibility'; +import {showSupportalPermissionDenied} from '@userActions/App'; import {cacheAttachment, removeCachedAttachment} from '@userActions/Attachment'; import {clearByKey} from '@userActions/CachedPDFPaths'; import {setDownload} from '@userActions/Download'; @@ -245,6 +247,7 @@ import type { ReportAction, ReportAttributesDerivedValue, ReportUserIsTyping, + Rule, SidePanelContext, Transaction, TransactionViolation, @@ -327,16 +330,12 @@ type ParticipantInfo = { }; type OpenReportActionParams = { - /** The ID of the report to open */ reportID: string | undefined; - - /** The intro selected by the user */ introSelected: OnyxEntry; /** The ID used to fetch a specific range of report actions related to the current reportActionID when opening a chat */ reportActionID?: string; - /** The list of participants */ participants?: ParticipantInfo[]; // TODO: personalDetails should be a required field in follow-up PRs https://github.com/Expensify/App/issues/73656 @@ -352,28 +351,22 @@ type OpenReportActionParams = { /** Whether or not this report is being opened from a deep link */ isFromDeepLink?: boolean; - /** Whether this is a new thread being created */ isNewThread?: boolean; /** The transaction object for legacy transactions that don't have a transaction thread or money request preview yet */ transaction?: Transaction; - /** The violations for the transaction, if any */ transactionViolations?: TransactionViolations; /** The parent report ID for the transaction thread (optional, defaults to transaction.reportID) */ parentReportID?: string; - /** Whether to add pending fields to the report */ shouldAddPendingFields?: boolean; /** The optimistic selfDM report when it exists on the server but was filtered out from OpenApp response (e.g., no actions yet) */ optimisticSelfDMReport?: Report; - /** The current user's login */ currentUserLogin?: string; - - /** The current user's account ID */ currentUserAccountID: number; /** Whether the user has seen the self tour */ @@ -384,7 +377,6 @@ type OpenReportActionParams = { // TODO: This will be required eventually. Refactor issue: https://github.com/Expensify/App/issues/66424 hasCompletedGuidedSetupFlow?: boolean; - /** Whether the report has report actions or not */ hasReportActions: boolean | undefined; /** Whether opening the report should update its read state. Set to false when fetching report data without the user actually viewing the conversation */ @@ -393,8 +385,8 @@ type OpenReportActionParams = { /** Beta features list */ betas: OnyxEntry; - /** This will be required eventually. Refactor issue: https://github.com/Expensify/App/issues/66411 */ - conciergeChat?: OnyxEntry; + /** The Concierge chat report used to build the guided setup onboarding data */ + conciergeChat: OnyxEntry; }; type PregeneratedResponseParams = { @@ -462,12 +454,12 @@ type MergeReportsProps = { policyCategories?: OnyxEntry; policyTagList: OnyxEntry; allTransactionViolation?: OnyxCollection; - allReports: OnyxCollection; + allReports?: OnyxCollection; allReportsTransactions?: Record; - sourceReportActions: Record>; - sourceParentReportActions: Record>; + allReportActions?: Record>; hash?: number; bankAccountList: OnyxEntry; + rules: OnyxCollection; isTrackIntentUser: boolean | undefined; personalPolicyOutputCurrency: string | undefined; selfDMReportActions: OnyxEntry; @@ -1270,6 +1262,7 @@ function addActions({ successData, failureData, }); + startSendMessagePhase(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); if (conciergeThreadReportID && resolvedReportActionID) { Onyx.update(conciergeThreadOnyxData).then(() => Navigation.navigate(getReportRouteForCurrentContext({reportID: conciergeThreadReportID}))); @@ -1691,7 +1684,6 @@ function openReport(params: OpenReportActionParams) { } const participantLoginList = participants.map((p) => p.login).filter((login) => !!login); - // TODO: allPersonalDetails fallback should be removed in follow-up PRs https://github.com/Expensify/App/issues/73656 const participantAccountIDList = participants.map((p) => p.accountID).filter((id): id is number => id !== undefined); const existingReportName = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName; const isCreatingNewReport = !isEmptyObject(newReportObject); @@ -1992,6 +1984,7 @@ function openReport(params: OpenReportActionParams) { const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(participantLoginList); for (const [index, login] of participantLoginList.entries()) { const accountID = participantAccountIDs.at(index) ?? -1; + // TODO: allPersonalDetails fallback should be removed in follow-up PRs https://github.com/Expensify/App/issues/73656 const isOptimisticAccount = !(personalDetails ?? allPersonalDetails)?.[accountID]; if (!isOptimisticAccount) { @@ -2126,18 +2119,31 @@ function pruneReportActionPagesToNewestWindow(reportID: string | undefined, sort * @param introSelected The intro selected data for guided setup * @param avatar The avatar file to upload for the group chat (optional) */ -function createGroupChat( - reportID: string, - participantsPersonalDetails: OnyxEntry, - newReportObject: OptimisticChatReport, - currentUserLogin: string, - introSelected: OnyxEntry, - isSelfTourViewed: boolean, - hasCompletedGuidedSetupFlow: boolean, - betas: OnyxEntry, - currentUserAccountID: number, - avatar?: File | CustomRNImageManipulatorResult, -) { +type CreateGroupChatParams = { + reportID: string; + participantsPersonalDetails: OnyxEntry; + newReportObject: OptimisticChatReport; + currentUserLogin: string; + introSelected: OnyxEntry; + isSelfTourViewed: boolean; + hasCompletedGuidedSetupFlow: boolean; + conciergeChat: OnyxEntry; + currentUserAccountID: number; + avatar?: File | CustomRNImageManipulatorResult; +}; + +function createGroupChat({ + reportID, + participantsPersonalDetails, + newReportObject, + currentUserLogin, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergeChat, + currentUserAccountID, + avatar, +}: CreateGroupChatParams) { const participantLoginList = Object.values(participantsPersonalDetails ?? {}) .map((participant) => participant?.login) .filter((login): login is string => !!login); @@ -2323,8 +2329,7 @@ function createGroupChat( } // Preserve guided setup data when creating group chats - // Deferred: thread the real conciergeChat when the createGroupChat cascade is migrated (https://github.com/Expensify/App/issues/66411) - const guidedSetup = getGuidedSetupDataForOpenReport(introSelected, currentUserAccountID, undefined, isSelfTourViewed, hasCompletedGuidedSetupFlow); + const guidedSetup = getGuidedSetupDataForOpenReport(introSelected, currentUserAccountID, conciergeChat, isSelfTourViewed, hasCompletedGuidedSetupFlow); if (guidedSetup) { optimisticData.push(...guidedSetup.optimisticData); successData.push(...guidedSetup.successData); @@ -2383,13 +2388,8 @@ function getOptimisticChatReport(accountID: number, currentUserAccountID: number } type CreateTransactionThreadReportParams = { - /** The intro selected by the user */ introSelected: OnyxEntry; - - /** The current user's login */ currentUserLogin: string; - - /** The current user's account ID */ currentUserAccountID: number; /** Beta features list */ @@ -2404,7 +2404,6 @@ type CreateTransactionThreadReportParams = { /** The transaction object for legacy transactions that don't have a transaction thread or money request preview yet */ transaction?: Transaction; - /** The violations for the transaction, if any */ transactionViolations?: TransactionViolations; /** The personal details of the participants */ @@ -2418,7 +2417,6 @@ type CreateTransactionThreadReportParams = { // TODO: This will be required eventually. Refactor issue: https://github.com/Expensify/App/issues/66424 hasCompletedGuidedSetupFlow?: boolean; - /** The Concierge chat report */ conciergeChat: OnyxEntry; }; @@ -2547,6 +2545,9 @@ type NavigateToAndOpenReportParams = { hasCompletedGuidedSetupFlow: boolean | undefined; betas: OnyxEntry; conciergeChat: OnyxEntry; + + /** Whether the current session is a supportal session.*/ + isSupportalSession: boolean; shouldDismissModal?: boolean; shouldRevalidateExistingChat?: boolean; hasReportActions?: boolean; @@ -2565,6 +2566,7 @@ function navigateToAndOpenReport({ hasCompletedGuidedSetupFlow, betas, conciergeChat, + isSupportalSession, shouldDismissModal = true, shouldRevalidateExistingChat = false, hasReportActions, @@ -2573,6 +2575,15 @@ function navigateToAndOpenReport({ const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins); const chat = getChatByParticipants([...participantAccountIDs, currentUserAccountID]); const createAndOpenNewOptimisticChat = (sourceCachedReportID?: string) => { + // The server rejects chat creation during a support session, so block it here instead of letting the agent land on an + // optimistic report that then fails with a generic error. This has to live inside the create closure (not before the + // existing-vs-new branch below) because the shouldRevalidateExistingChat path re-enters it asynchronously for chats + // that only turn out to be notFound later. Opening an existing chat stays allowed. + if (isSupportalSession) { + showSupportalPermissionDenied({command: WRITE_COMMANDS.OPEN_REPORT}); + return; + } + const fallbackChat = buildOptimisticChatReport({ participantList: [...participantAccountIDs, currentUserAccountID], notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, @@ -2618,7 +2629,17 @@ function navigateToAndOpenReport({ if (!shouldRevalidateExistingChat) { if (isOnboardingPending) { - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({ + reportID: chat.reportID, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + betas, + personalDetails, + hasReportActions, + currentUserAccountID, + conciergeChat, + }); } navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); return; @@ -2646,7 +2667,7 @@ 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, betas, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, personalDetails, hasReportActions, currentUserAccountID, conciergeChat}); navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); } @@ -2658,8 +2679,11 @@ type NavigateToAndCreateGroupChatParams = { introSelected: OnyxEntry; isSelfTourViewed: boolean; hasCompletedGuidedSetupFlow: boolean; - betas: OnyxEntry; + conciergeChat: OnyxEntry; currentUserAccountID: number; + + /** Whether the current session is a supportal session.*/ + isSupportalSession: boolean; avatarUri?: string; avatarFile?: File | CustomRNImageManipulatorResult | undefined; }; @@ -2673,11 +2697,19 @@ function navigateToAndCreateGroupChat(params: NavigateToAndCreateGroupChatParams introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, - betas, + conciergeChat, currentUserAccountID, + isSupportalSession, avatarUri, avatarFile, } = params; + + // Creating a group chat is always a create, so it is blocked outright during a support session. + if (isSupportalSession) { + showSupportalPermissionDenied({command: WRITE_COMMANDS.OPEN_REPORT}); + return; + } + const userLogins = Object.values(participantsPersonalDetails ?? {}) .map((participant) => participant?.login) .filter((login): login is string => !!login); @@ -2685,18 +2717,18 @@ function navigateToAndCreateGroupChat(params: NavigateToAndCreateGroupChatParams // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID const newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName, avatarUri ?? '', currentUserAccountID, optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - createGroupChat( - newChat.reportID, + createGroupChat({ + reportID: newChat.reportID, participantsPersonalDetails, - newChat, + newReportObject: newChat, currentUserLogin, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, - betas, + conciergeChat, currentUserAccountID, - avatarFile, - ); + avatar: avatarFile, + }); navigateToReport(newChat.reportID, {afterTransition: clearGroupChat}); } @@ -2784,7 +2816,7 @@ function navigateToAndOpenReportWithAccountIDs( }); // Re-open existing chats to re-validate server-side access and refresh stale local state. - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, personalDetails, hasReportActions, currentUserAccountID, conciergeChat}); navigateToReport(chat.reportID, {shouldDismissModal: false}); } @@ -3883,7 +3915,7 @@ function toggleSubscribeToChildReport({ hasReportActions, }: ToggleSubscribeToChildReportParams) { if (childReportID) { - openReport({reportID: childReportID, introSelected, betas, isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: childReportID, introSelected, betas, personalDetails, isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions, currentUserAccountID, conciergeChat}); const parentReportActionID = parentReportAction.reportActionID; if (!prevNotificationPreference || isHiddenForCurrentUser(prevNotificationPreference)) { updateNotificationPreference( @@ -4014,6 +4046,7 @@ function updateReportField({ recentlyUsedReportFields, shouldFixViolations = false, isTrackIntentUser, + rules, }: { report: Report; reportField: PolicyReportField; @@ -4026,6 +4059,7 @@ function updateReportField({ recentlyUsedReportFields: OnyxEntry; shouldFixViolations: boolean | undefined; isTrackIntentUser: boolean | undefined; + rules: OnyxCollection; }) { const reportID = report.reportID; const fieldKey = getReportFieldKey(reportField.fieldID); @@ -4044,15 +4078,24 @@ function updateReportField({ hasViolations: hasViolationsParam, isASAPSubmitBetaEnabled, isTrackIntentUser, + rules, }); - const optimisticData: Array> = [ + const isInvoiceField = report.type === CONST.REPORT.TYPE.INVOICE && reportField.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const optimisticReportFieldValue = isInvoiceField ? null : reportField; + const failureReportFieldValue = isInvoiceField ? null : previousReportField; + + const optimisticData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.RECENTLY_USED_REPORT_FIELDS + > + > = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { fieldList: { - [fieldKey]: reportField, + [fieldKey]: optimisticReportFieldValue, }, nextStep: optimisticNextStep, pendingFields: { @@ -4070,6 +4113,17 @@ function updateReportField({ }, ]; + if (isInvoiceField) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, + value: { + [fieldKey]: reportField, + [reportField.fieldID]: reportField, + }, + }); + } + if (reportField.type === 'dropdown' && reportField.value) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -4080,13 +4134,17 @@ function updateReportField({ }); } - const failureData: Array> = [ + const failureData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.RECENTLY_USED_REPORT_FIELDS + > + > = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { fieldList: { - [fieldKey]: previousReportField, + [fieldKey]: failureReportFieldValue, }, nextStep: report.nextStep ?? null, pendingFields: { @@ -4109,6 +4167,17 @@ function updateReportField({ }, ]; + if (isInvoiceField) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, + value: { + [fieldKey]: previousReportField, + [reportField.fieldID]: previousReportField, + }, + }); + } + if (reportField.type === 'dropdown') { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -4119,7 +4188,7 @@ function updateReportField({ }); } - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -4144,6 +4213,21 @@ function updateReportField({ }, ]; + if (isInvoiceField) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, + value: { + [fieldKey]: { + pendingAction: null, + }, + [reportField.fieldID]: { + pendingAction: null, + }, + }, + }); + } + const parameters = { reportID, reportFields: JSON.stringify({[fieldKey]: reportField}), @@ -4348,6 +4432,9 @@ function navigateToConciergeChat( // TODO: Pass the correct hasCompletedGuidedSetupFlow from Onyx data in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 hasCompletedGuidedSetupFlow: undefined, betas, + // 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, shouldDismissModal, linkToOptions, }); @@ -4377,6 +4464,7 @@ type BuildNewReportOptimisticDataParams = { betas: OnyxEntry; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; reportName?: string; }; @@ -4391,12 +4479,13 @@ function buildNewReportOptimisticData({ betas, isTrackIntentUser, getCurrencyDecimals, + rules, reportName, }: BuildNewReportOptimisticDataParams) { const {accountID, login, email} = ownerPersonalDetails; const timeOfCreation = DateUtils.getDBTime(); const parentReport = getPolicyExpenseChat(accountID, policy?.id); - const optimisticReportData = buildOptimisticEmptyReport(reportID, accountID, login, parentReport, reportPreviewReportActionID, policy, timeOfCreation, betas, getCurrencyDecimals); + const optimisticReportData = buildOptimisticEmptyReport(reportID, accountID, login, parentReport, reportPreviewReportActionID, policy, timeOfCreation, betas, getCurrencyDecimals, rules); if (reportName) { optimisticReportData.reportName = reportName; @@ -4411,6 +4500,7 @@ function buildNewReportOptimisticData({ hasViolations: hasViolationsParam, isASAPSubmitBetaEnabled, isTrackIntentUser, + rules, }); if (optimisticNextStep) { optimisticReportData.nextStep = optimisticNextStep; @@ -4615,6 +4705,8 @@ function buildNewReportOptimisticData({ }; } +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function createNewReport( ownerPersonalDetails: CurrentUserPersonalDetails, hasViolationsParam: boolean, @@ -4623,6 +4715,7 @@ function createNewReport( betas: OnyxEntry, isTrackIntentUser: boolean | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, shouldNotifyNewAction = false, shouldDismissEmptyReportsConfirmation?: boolean, options: {managedCardTransactionID?: string; reportName?: string} = {}, @@ -4643,6 +4736,7 @@ function createNewReport( betas, isTrackIntentUser, getCurrencyDecimals, + rules, reportName, }); @@ -4827,11 +4921,11 @@ function navigateToConciergeChatAndDeleteReport( ); } -function cleanUpOptimisticPersonalDetailsForFailedChat(report: OnyxEntry, currentUserAccountID: number) { +function cleanUpOptimisticPersonalDetailsForFailedChat(report: OnyxEntry, currentUserAccountID: number, optimisticPersonalDetails: OnyxEntry) { const personalDetailsToRemove: PersonalDetailsList = {}; for (const accountID of Object.keys(report?.participants ?? {}).map(Number)) { - if (accountID === currentUserAccountID || !allPersonalDetails?.[accountID]?.isOptimisticPersonalDetail) { + if (accountID === currentUserAccountID || !optimisticPersonalDetails?.[accountID]?.isOptimisticPersonalDetail) { continue; } personalDetailsToRemove[accountID] = null; @@ -4854,6 +4948,7 @@ function clearCreateChatError( reportOwnerPersonalDetail: OnyxEntry, currentUserPersonalDetail: OnyxEntry, conciergePersonalDetail: OnyxEntry, + optimisticPersonalDetails: OnyxEntry, ) { const metaData = getReportMetadata(report?.reportID); const isOptimisticReport = metaData?.isOptimisticReport; @@ -4863,7 +4958,7 @@ function clearCreateChatError( } if (report?.errorFields?.createChat && isOptimisticReport) { - cleanUpOptimisticPersonalDetailsForFailedChat(report, currentUserAccountID); + cleanUpOptimisticPersonalDetailsForFailedChat(report, currentUserAccountID, optimisticPersonalDetails); } navigateToConciergeChatAndDeleteReport( @@ -5075,7 +5170,7 @@ function showReportActionNotification( if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE) { const movedFromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(reportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; const movedToReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(reportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; - LocalNotification.showModifiedExpenseNotification({report, reportAction, onClick, movedFromReport, movedToReport, currentUserLogin, reportAttributes}); + LocalNotification.showModifiedExpenseNotification({report, reportAction, onClick, movedFromReport, movedToReport, currentUserAccountID, currentUserLogin, reportAttributes}); } else { LocalNotification.showCommentNotification(report, reportAction, onClick, reportAttributes); } @@ -6037,24 +6132,25 @@ function savePrivateNotesDraft(reportID: string, note: string) { } function searchForReports(isOffline: boolean, searchInput: string, policyID?: string, isUserSearch = false) { + const searchLoadingKey = isUserSearch ? ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS : ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS; // We do not try to make this request while offline because it sets a loading indicator optimistically if (isOffline) { - Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); + Onyx.set(searchLoadingKey, false); return; } - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, + key: searchLoadingKey, value: false, }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, + key: searchLoadingKey, value: false, }, ]; @@ -6075,17 +6171,18 @@ function searchForReports(isOffline: boolean, searchInput: string, policyID?: st } function performServerSearch(searchInput: string, policyID?: string, isUserSearch = false) { + const searchLoadingKey = isUserSearch ? ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS : ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS; // We are not getting isOffline from components as useEffect change will re-trigger the search on network change const isOffline = isOfflineNetwork(); if (isOffline || !searchInput.trim().length) { - Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); + Onyx.set(searchLoadingKey, false); return; } // Why not set this in optimistic data? It won't run until the API request happens and while the API request is debounced // we want to show the loading state right away. Otherwise, we will see a flashing UI where the client options are sorted and // tell the user there are no options, then we start searching, and tell them there are no options again. - Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, true); + Onyx.set(searchLoadingKey, true); searchForReports(isOffline, searchInput, policyID, isUserSearch); } @@ -7216,6 +7313,7 @@ function moveIOUReportToPolicy( policy: Policy, reportPreviewAction: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, isFromSettlementButton?: boolean, reportTransactions: Transaction[] = [], ): {policyExpenseChatReportID?: string; useTemporaryOptimisticExpenseChatReportID: boolean} | undefined { @@ -7244,6 +7342,7 @@ function moveIOUReportToPolicy( optimisticExpenseChatReportID, reportPreviewAction, getCurrencyDecimals, + rules, reportTransactions, ); @@ -7271,6 +7370,7 @@ function moveIOUReportToPolicyAndInviteSubmitter( submitterLogin: string | undefined, doesSubmitterPersonalDetailExist: boolean, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, reportTransactions: Transaction[] = [], ): {policyExpenseChatReportID?: string} | undefined { if (!policy || !iouReport) { @@ -7403,7 +7503,7 @@ function moveIOUReportToPolicyAndInviteSubmitter( failureData: convertedFailureData, movedExpenseReportAction, movedReportAction, - } = convertIOUReportToExpenseReport(iouReport, policy, policyID, optimisticPolicyExpenseChatReportID, reportPreviewAction, getCurrencyDecimals, reportTransactions); + } = convertIOUReportToExpenseReport(iouReport, policy, policyID, optimisticPolicyExpenseChatReportID, reportPreviewAction, getCurrencyDecimals, rules, reportTransactions); optimisticData.push(...convertedOptimisticData); successData.push(...convertedSuccessData); @@ -7429,6 +7529,7 @@ function convertIOUReportToExpenseReport( optimisticPolicyExpenseChatReportID: string, reportPreviewAction: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, reportTransactions: Transaction[] = [], ) { const optimisticData: Array> = []; @@ -7445,7 +7546,7 @@ function convertIOUReportToExpenseReport( total: -(iouReport?.total ?? 0), }; - const nextApproverAccountID = getNextApproverAccountID(iouReport, true); + const nextApproverAccountID = getNextApproverAccountID(iouReport, rules, true); if (iouReport.managerID !== nextApproverAccountID) { expenseReport.stateNum = CONST.REPORT.STATE_NUM.OPEN; expenseReport.statusNum = CONST.REPORT.STATUS_NUM.OPEN; @@ -7697,6 +7798,7 @@ function buildOptimisticChangePolicyData({ reportPreviewAction, isTrackIntentUser, getCurrencyDecimals, + rules, }: { report: Report; parentReport: OnyxEntry; @@ -7712,6 +7814,7 @@ function buildOptimisticChangePolicyData({ reportPreviewAction: OnyxEntry; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }) { const optimisticData: Array< OnyxUpdate< @@ -7736,7 +7839,7 @@ function buildOptimisticChangePolicyData({ const reportIDToThreadsReportIDsMap = buildReportIDToThreadsReportIDsMap(); updatePolicyIdForReportAndThreads(reportID, policy.id, reportIDToThreadsReportIDsMap, optimisticData, failureData); - const newManagerAccountID = getSubmitToAccountID(policy, report, ownerLogin); + const newManagerAccountID = getSubmitToAccountID(policy, report, ownerLogin, rules); const shouldResetApprovalChain = isProcessingReport(report) && newManagerAccountID !== report.managerID && managerLogin && isPolicyMember(policy, managerLogin); if (shouldResetApprovalChain) { optimisticData.push({ @@ -7767,7 +7870,7 @@ function buildOptimisticChangePolicyData({ value: { stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, - managerID: getNextApproverAccountID(report, true), + managerID: getNextApproverAccountID(report, rules, true), }, }); @@ -7817,6 +7920,7 @@ function buildOptimisticChangePolicyData({ isASAPSubmitBetaEnabled, bypassNextApproverID: shouldResetApprovalChain ? newManagerAccountID : undefined, isTrackIntentUser, + rules, }); optimisticData.push({ @@ -8194,6 +8298,7 @@ function changeReportPolicy({ isTrackIntentUser, getCurrencyDecimals, reportTransactions, + rules, }: { report: Report; parentReport: OnyxEntry; @@ -8210,6 +8315,7 @@ function changeReportPolicy({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; reportTransactions: Transaction[]; + rules: OnyxCollection; }) { if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report) || shouldBlockChangeReportPolicyForMapOrGPSRequirement(reportTransactions, policy)) { return; @@ -8229,6 +8335,7 @@ function changeReportPolicy({ reportPreviewAction, isTrackIntentUser, getCurrencyDecimals, + rules, }); const params = { @@ -8264,6 +8371,7 @@ function changeReportPolicyAndInviteSubmitter({ isTrackIntentUser, getCurrencyDecimals, reportTransactions, + rules, }: { report: Report; parentReport: OnyxEntry; @@ -8281,6 +8389,7 @@ function changeReportPolicyAndInviteSubmitter({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; reportTransactions: Transaction[]; + rules: OnyxCollection; }) { if ( !report.reportID || @@ -8343,6 +8452,7 @@ function changeReportPolicyAndInviteSubmitter({ reportPreviewAction, isTrackIntentUser, getCurrencyDecimals, + rules, }); const optimisticData = [...optimisticAddMembersData, ...optimisticChangePolicyData]; @@ -8522,8 +8632,8 @@ function mergeReports({ allReportsTransactions, bankAccountList, allReports: allReportsParam, - sourceReportActions, - sourceParentReportActions, + allReportActions = {}, + rules, isTrackIntentUser, personalPolicyOutputCurrency, selfDMReportActions, @@ -8554,6 +8664,7 @@ function mergeReports({ transactions: transactionsToMove, allTransactionViolation, reports, + rules, skippedReportIDs: sourceReportIDs, isTrackIntentUser, personalPolicyOutputCurrency, @@ -8613,7 +8724,7 @@ function mergeReports({ }); // Mark comments on the source report as deleted - const reportActions = sourceReportActions[sourceReportID]; + const reportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${sourceReportID}`]; deleteOptimisticData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${sourceReportID}`, @@ -8630,7 +8741,7 @@ function mergeReports({ const parentReportID = sourceReport.parentReportID; const parentReportActionID = sourceReport.parentReportActionID; if (parentReportID && parentReportActionID) { - const parentReportAction = sourceParentReportActions[sourceReportID]; + const parentReportAction = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`]?.[parentReportActionID]; const { optimisticData: parentOptimisticData, successData: parentSuccessData, @@ -8659,6 +8770,14 @@ function mergeReports({ if (hash) { const optimisticSnapshotData: SearchResultDataType = {}; const failureSnapshotData: SearchResultDataType = {}; + for (const transaction of transactionsToMove) { + optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { + ...transaction, + reportID: destinationReportID, + }; + + failureSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + } for (const sourceReportID of sourceReportIDs) { const sourceReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceReportID}`]; if (sourceReport) { @@ -8701,8 +8820,8 @@ function mergeReports({ } const parameters: MergeReportsParams = { - destinationReportID, - sourceReportIDs, + reportID: destinationReportID, + sourceReportIDList: sourceReportIDs, transactionIDToReportActionAndThreadData: JSON.stringify(transactionIDToReportActionAndThreadData), }; diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 11a4f5633c57..dd7f98d63bf6 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -30,11 +30,13 @@ const enablePolicyFeatureCommand = [ WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS, WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS, WRITE_COMMANDS.ENABLE_POLICY_HR, + WRITE_COMMANDS.ENABLE_POLICY_MCP, WRITE_COMMANDS.TOGGLE_RECEIPT_PARTNERS, WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES, WRITE_COMMANDS.ENABLE_POLICY_TAGS, WRITE_COMMANDS.ENABLE_POLICY_TAXES, WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS, + WRITE_COMMANDS.ENABLE_POLICY_INVOICE_FIELDS, WRITE_COMMANDS.ENABLE_POLICY_WORKFLOWS, WRITE_COMMANDS.SET_POLICY_RULES_ENABLED, WRITE_COMMANDS.ENABLE_POLICY_INVOICING, @@ -386,6 +388,7 @@ export { resolveOpenReportDuplicationConflictAction, resolveReconnectDuplicationConflictAction, readUpdateIDFrom, + reconnectCoverageFrom, isFullDownloadRequest, isReconnectFamilyRequest, resolveCommentDeletionConflicts, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 5f7420340b54..8135e84842eb 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -41,6 +41,7 @@ import { getSubmitReportManagerAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, + isAdminOfCardEnabledPolicy, isDelayedSubmissionEnabled, isSubmitAndClose, isSubmitPolicy, @@ -58,12 +59,11 @@ import { hasOnlyHeldExpenses, hasViolations as hasViolationsReportUtils, isExpenseReport, - isInvoiceReport, isIOUReport as isIOUReportUtil, } from '@libs/ReportUtils'; import {buildSearchQueryJSON, buildSearchQueryString, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; -import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; +import {isTransactionGroupListItemType, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; import {hasOnlyPendingCardTransactions} from '@libs/TransactionUtils'; @@ -81,10 +81,12 @@ import type { IntroSelected, LastPaymentMethod, LastPaymentMethodType, + OnyxInputOrEntry, Policy, Report, ReportAction, ReportActions, + Rule, SaveSearch, Transaction, TransactionViolations, @@ -103,8 +105,8 @@ import {SafeString} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; import Onyx from 'react-native-onyx'; -import type {AdditionalPayOnyxData} from './IOU/PayMoneyRequest'; import type {RejectMoneyRequestData} from './IOU/RejectMoneyRequest'; +import type AdditionalPayOnyxData from './IOU/types/AdditionalPayOnyxData'; import {markExportInitiatedLocally} from './Export'; import {payMoneyRequest} from './IOU/PayMoneyRequest'; @@ -168,6 +170,21 @@ function getReportFromSearchSnapshot(reportID: string | undefined, searchData: S return snapshotReport ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; } +function getReportActionsFromSearchSnapshot( + reportID: string | undefined, + searchData: SearchResultDataType | undefined, + allReportActions: OnyxCollection, +): OnyxEntry { + if (!reportID) { + return undefined; + } + + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; + + // Prefer the search snapshot so the actions match what the user selected on the search page, fall back to live report actions. + return searchData?.[key] ?? allReportActions?.[key]; +} + function getPolicyFromSearchSnapshot(policyID: string | undefined, searchData: SearchResultDataType | undefined, policies: OnyxCollection | undefined): OnyxEntry { if (!policyID) { return undefined; @@ -260,6 +277,7 @@ type HandleActionButtonPressParams = { delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; allViolations: OnyxCollection; + rules: OnyxCollection; conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; }; @@ -301,6 +319,7 @@ function handleActionButtonPress({ delegateAccountID, isTrackIntentUser, allViolations, + rules, conciergeChat, getCurrencyDecimals, }: HandleActionButtonPressParams) { @@ -356,6 +375,7 @@ function handleActionButtonPress({ isTrackIntentUser, conciergeChat, getCurrencyDecimals, + rules, }); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: @@ -389,6 +409,7 @@ function handleActionButtonPress({ isTrackIntentUser, ownerLogin: submitterLogin, allViolations, + rules, getCurrencyDecimals, }); return; @@ -418,6 +439,7 @@ function handleActionButtonPress({ [policyForSubmit], submitterLogin, getCurrencyDecimals, + rules, currentSearchKey, managerEmail, managerAccountID, @@ -434,6 +456,7 @@ function handleActionButtonPress({ [policyForSubmit], submitterLogin, getCurrencyDecimals, + rules, currentSearchKey, undefined, undefined, @@ -500,16 +523,16 @@ function getLastPolicyPaymentMethod( return result as ValueOf | undefined; } -function getReportType(reportID?: string) { - if (isIOUReportUtil(reportID)) { +function getReportType(report: OnyxInputOrEntry | SelectedReports) { + if (isIOUReportUtil(report?.reportID)) { return CONST.REPORT.TYPE.IOU; } - if (isInvoiceReport(reportID)) { + if (report?.type === CONST.REPORT.TYPE.INVOICE) { return CONST.REPORT.TYPE.INVOICE; } - if (isExpenseReport(reportID)) { + if (isExpenseReport(report?.reportID)) { return CONST.REPORT.TYPE.EXPENSE; } @@ -594,6 +617,7 @@ type GetPayActionCallbackParams = { isTrackIntentUser: boolean | undefined; conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + rules: OnyxCollection; }; function getPayActionCallback({ @@ -623,13 +647,13 @@ function getPayActionCallback({ isTrackIntentUser, conciergeChat, getCurrencyDecimals, + rules, }: GetPayActionCallbackParams) { - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, personalPolicyID, lastPaymentMethod, getReportType(item.reportID)); - if (!item.reportID) { Log.info('[SearchPay] Dropping row pay: item has no reportID'); return; } + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, personalPolicyID, lastPaymentMethod, getReportType(snapshotReport)); if (!lastPolicyPaymentMethod || !Object.values(CONST.IOU.PAYMENT_TYPE).includes(lastPolicyPaymentMethod)) { goToItem(); @@ -674,6 +698,7 @@ function getPayActionCallback({ isTrackIntentUser, conciergeChat, getCurrencyDecimals, + rules, }); } @@ -695,6 +720,7 @@ type GetApproveActionCallbackParams = { isTrackIntentUser: boolean | undefined; ownerLogin: string | undefined; allViolations: OnyxCollection; + rules: OnyxCollection; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; }; @@ -716,6 +742,7 @@ function getApproveActionCallback({ isTrackIntentUser, ownerLogin, allViolations, + rules, getCurrencyDecimals, }: GetApproveActionCallbackParams) { if (!item.reportID) { @@ -729,6 +756,7 @@ function getApproveActionCallback({ approveMoneyRequest({ expenseReport: snapshotReport, expenseReportPolicy: reportPolicy, + rules, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserLogin ?? '', hasViolations, @@ -772,7 +800,7 @@ function getOnyxLoadingData( ...(isSearchAPI && {isLoading: true}), ...(isSearchRequest && {state: CONST.SEARCH.SNAPSHOT_STATE.LOADING}), ...(offset !== undefined ? {offset} : {}), - ...(shouldClearTotals ? {count: null, total: null, currency: null} : {}), + ...(shouldClearTotals ? {count: null, reportCount: null, total: null, currency: null} : {}), }, }, }, @@ -833,7 +861,7 @@ function getOnyxLoadingData( return {optimisticData, finallyData, failureData}; } -function saveSearch({queryJSON, newName}: {queryJSON: Readonly; newName?: string}) { +function saveSearch({id, queryJSON, newName}: {id: string; queryJSON: Readonly; newName?: string}) { const saveSearchName = newName ?? queryJSON?.inputQuery ?? ''; const jsonQuery = JSON.stringify(queryJSON); @@ -842,7 +870,7 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: { + [id]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: saveSearchName, query: queryJSON.inputQuery, @@ -856,7 +884,7 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: null, + [id]: null, }, }, ]; @@ -866,13 +894,13 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: { + [id]: { pendingAction: null, }, }, }, ]; - write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: saveSearchName}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, savedSearchID: id, newName: saveSearchName}, {optimisticData, failureData, successData}); } function seedMyExpensesSearch(currentUserAccountID: number, searchName: string, savedSearches: OnyxEntry) { @@ -934,43 +962,50 @@ function seedMyExpensesSearch(currentUserAccountID: number, searchName: string, }, ]; - write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: searchName}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, savedSearchID: queryJSON.hash.toString(), newName: searchName}, {optimisticData, failureData, successData}); } -function deleteSavedSearch(hash: number) { +function deleteSavedSearch(savedSearchID: string) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.SAVED_SEARCHES}`, + key: ONYXKEYS.SAVED_SEARCHES, value: { - [hash]: { + [savedSearchID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }, }, }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.SAVED_SEARCHES}`, + key: ONYXKEYS.SAVED_SEARCHES, + value: { + [savedSearchID]: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SEARCH_FILTERS, value: { - [hash]: null, + [savedSearchIDToSearchKey(savedSearchID)]: null, }, }, ]; const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.SAVED_SEARCHES}`, + key: ONYXKEYS.SAVED_SEARCHES, value: { - [hash]: { + [savedSearchID]: { pendingAction: null, }, }, }, ]; - write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {savedSearchID}, {optimisticData, failureData, successData}); } /** @@ -1161,7 +1196,10 @@ function search({ const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; const inFlightRequest = inFlightSearchRequests.get(dedupeKey); if (inFlightRequest) { - const needsTotalsUpgrade = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && shouldCalculateTotals && !inFlightRequest.shouldCalculateTotals; + // Not just EXPENSE: any type can now need totals (e.g. EXPENSE_REPORT's reportCount). Gating this + // to one type let a totals request for another type collide with an in-flight request and get + // silently dropped, so the total never arrived and the bulk-actions button spun forever. + const needsTotalsUpgrade = shouldCalculateTotals && !inFlightRequest.shouldCalculateTotals; // A user-submitted query colliding with an unflagged in-flight request (e.g. a programmatic refresh // of the same query) must still reach the backend flagged, or it never enters recent searches. const needsSaveRecentSearchUpgrade = shouldSaveRecentSearch && !inFlightRequest.shouldSaveRecentSearch; @@ -1188,7 +1226,7 @@ function search({ const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals, shouldSaveRecentSearch}; inFlightSearchRequests.set(dedupeKey, inFlightRequestState); - const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals); + const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals); const {backendQueryJSON, limit, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, @@ -1210,6 +1248,22 @@ function search({ }); } + const optimisticData: Array> = [...(onyxLoadingData.optimisticData ?? [])]; + const failureData: Array> = [...(onyxLoadingData.failureData ?? [])]; + const finallyData = onyxLoadingData.finallyData; + + if (searchKey) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SEARCH_FILTERS, + value: { + [searchKey]: { + query: query.inputQuery, + }, + }, + }); + } + const startRequest = () => makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) .then((result) => { @@ -1380,12 +1434,15 @@ function clearFooterConversion() { Onyx.set(ONYXKEYS.SEARCH_FOOTER_CONVERSION, null); } +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function submitMoneyRequestOnSearch( hash: number, reportList: Report[], policy: Policy[], submitterLogin: string | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, currentSearchKey?: SearchKey, managerEmail?: string, managerAccountID?: number, @@ -1497,7 +1554,7 @@ function submitMoneyRequestOnSearch( const trimmedManagerEmail = managerEmail?.trim(); const managerAccountIDFromEmail = trimmedManagerEmail ? getAccountIDForSubmitManagerEmail(trimmedManagerEmail, firstPolicy?.employeeList) : undefined; - const submitReportManagerAccountID = getSubmitReportManagerAccountID(firstPolicy, firstReport, submitterLogin); + const submitReportManagerAccountID = getSubmitReportManagerAccountID(firstPolicy, firstReport, submitterLogin, rules); // When an explicit manager email can't be resolved to an accountID, send the email alone rather than a mismatched // accountID from the approval chain, which would point the server at someone other than the chosen approver. @@ -1627,6 +1684,8 @@ function exportToIntegrationOnSearch(hash: number, reportIDs: string[], connecti }); } +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function rejectMoneyRequestInBulk( reportID: string, comment: string, @@ -1637,6 +1696,7 @@ function rejectMoneyRequestInBulk( betas: OnyxEntry, delegateAccountID: number | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, hash?: number, ) { const optimisticData: Array> = []; @@ -1666,6 +1726,7 @@ function rejectMoneyRequestInBulk( betas, delegateAccountID, getCurrencyDecimals, + rules, shouldUseBulkAction: true, }); if (data) { @@ -1695,6 +1756,8 @@ type TransactionReportInfo = { reportID?: string; }; +// Refactoring this to a params object would touch every call site and is out of scope here. +// eslint-disable-next-line @typescript-eslint/max-params function rejectMoneyRequestsOnSearch( hash: number, selectedTransactions: Record, @@ -1706,6 +1769,7 @@ function rejectMoneyRequestsOnSearch( betas: OnyxEntry, delegateAccountID: number | undefined, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + rules: OnyxCollection, ) { const transactionIDs = Object.keys(selectedTransactions); @@ -1729,6 +1793,9 @@ function rejectMoneyRequestsOnSearch( let urlToNavigateBack; for (const [reportID, selectedTransactionIDs] of Object.entries(transactionsByReport)) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!report) { + Log.info('[BulkReject] Report is missing from live Onyx', false, {reportID}); + } const totalReportTransactions = report?.transactionCount ?? 0; // Subtract pending deletes to get accurate count when transactions are deleted offline @@ -1738,7 +1805,19 @@ function rejectMoneyRequestsOnSearch( const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const isPolicyDelayedSubmissionEnabled = policy ? isDelayedSubmissionEnabled(policy) : false; if (isPolicyDelayedSubmissionEnabled && areAllExpensesSelected) { - rejectMoneyRequestInBulk(reportID, comment, policy, selectedTransactionIDs, currentUserAccountIDParam, currentUserLogin, betas, delegateAccountID, getCurrencyDecimals, hash); + rejectMoneyRequestInBulk( + reportID, + comment, + policy, + selectedTransactionIDs, + currentUserAccountIDParam, + currentUserLogin, + betas, + delegateAccountID, + getCurrencyDecimals, + rules, + hash, + ); } else { // Share a single destination ID across all rejections from the same source report const sharedRejectedToReportID = generateReportID(); @@ -1748,9 +1827,12 @@ function rejectMoneyRequestsOnSearch( }; for (const transactionID of selectedTransactionIDs) { rejectMoneyRequest(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, delegateAccountID, getCurrencyDecimals, { - sharedRejectedToReportID, - existingRejectedReport, - setExistingRejectedReport, + rules, + options: { + sharedRejectedToReportID, + existingRejectedReport, + setExistingRejectedReport, + }, }); } } @@ -1952,6 +2034,14 @@ function queueExportSearchWithTemplate( return exportID; } +/** + * Queues a manual bulk payment for every report matching the given search query. The backend pages through all matches itself, + * so this covers reports beyond the currently loaded page(s) when "Select all" is checked in Search. + */ +function queueBulkPayReports(jsonQuery: string) { + write(WRITE_COMMANDS.QUEUE_BULK_PAY_REPORTS, {jsonQuery}); +} + /** Export templates pre-grouped for the Export menus: each group is sorted alphabetically and rendered with a divider between groups */ type ExportTemplateGroups = { /** Custom templates (custom integrations + account/policy in-app templates) */ @@ -1970,6 +2060,8 @@ type ExportTemplateGroups = { * @param includeBasicExport - Whether to include the basic export (CSV download) template in the default group * @param includeMultipleTaxExport - Whether to include the Canadian Multiple Tax Export template. Defaults to whether the given policy outputs in CAD, so callers that * export across several workspaces (e.g. a bulk selection in Search) can instead pass whether every selected workspace outputs in CAD. + * @param includeReconciliationAllExpenses - Whether to include the Reconciliation - All Expenses template. Defaults to whether the current user is a workspace or card admin of the given policy + * and that policy has a card product enabled. Callers that export across several workspaces can instead pass whether any such workspace qualifies. * @returns The export templates pre-grouped into the custom group and the default group, each sorted alphabetically */ function getExportTemplates( @@ -1981,6 +2073,7 @@ function getExportTemplates( includeReportLevelExport = true, includeBasicExport = false, includeMultipleTaxExport = policy?.outputCurrency === CONST.CURRENCY.CAD, + includeReconciliationAllExpenses = isAdminOfCardEnabledPolicy(policy), ): ExportTemplateGroups { // Helper function to normalize template data into consistent ExportTemplate format const normalizeTemplate = ( @@ -2012,6 +2105,13 @@ function getExportTemplates( exportTemplates.push(normalizeTemplate(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT, {name: translate('export.multipleTaxExport')}, CONST.EXPORT_TEMPLATE_TYPES.INTEGRATIONS)); } + // Workspace and card admins get this template so they can complete month-end accruals from NewDot, including Card Statements + if (includeReconciliationAllExpenses) { + exportTemplates.push( + normalizeTemplate(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES, {name: translate('export.reconciliationAllExpenses')}, CONST.EXPORT_TEMPLATE_TYPES.INTEGRATIONS), + ); + } + // Conditionally include the basic export (CSV download) template so it's sorted alphabetically alongside the other default templates if (includeBasicExport) { exportTemplates.push(normalizeTemplate(CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV, {name: translate('export.basicExport')}, CONST.EXPORT_TEMPLATE_TYPES.INTEGRATIONS)); @@ -2087,24 +2187,26 @@ function getPayOption( ) { const transactionKeys = Object.keys(selectedTransactions ?? {}); const firstTransaction = selectedTransactions?.[transactionKeys.at(0) ?? '']; - const firstReport = selectedReports.at(0); + const payableReports = selectedReports.filter((report) => report.canPay); + const firstPayableReport = payableReports.at(0); + const getSelectedReportType = (report: SelectedReports | undefined) => report?.type ?? getReportType(report); const hasLastPaymentMethod = selectedReports.length > 0 - ? selectedReports.every((report) => !!getLastPolicyPaymentMethod(report.policyID, personalPolicyID, lastPaymentMethods)) + ? payableReports.every((report) => !!getLastPolicyPaymentMethod(report.policyID, personalPolicyID, lastPaymentMethods)) : transactionKeys.every((transactionIDKey) => !!getLastPolicyPaymentMethod(selectedTransactions[transactionIDKey].policyID, personalPolicyID, lastPaymentMethods)); const shouldShowBulkPayOption = selectedReports.length > 0 - ? selectedReports.every( + ? payableReports.length > 0 && + payableReports.every( (report) => - report.canPay && - getReportType(report.reportID) === getReportType(firstReport?.reportID) && + getSelectedReportType(report) === getSelectedReportType(firstPayableReport) && shouldShowBulkOptionForRemainingTransactions(selectedTransactions, selectedReportIDs, transactionKeys), ) : transactionKeys.every( (transactionIDKey) => selectedTransactions[transactionIDKey].action === CONST.SEARCH.ACTION_TYPES.PAY && - getReportType(selectedTransactions[transactionIDKey].reportID) === getReportType(firstTransaction?.reportID), + getReportType(selectedTransactions[transactionIDKey].report) === getReportType(firstTransaction?.report), ); return { @@ -2374,6 +2476,7 @@ export { exportSearchItemsToCSV, queueExportSearchItemsToCSV, queueExportSearchWithTemplate, + queueBulkPayReports, updateAdvancedFilters, setSearchContext, deleteSavedSearch, @@ -2400,6 +2503,7 @@ export { openSearchCategoryFiltersPage, getPolicyFromSearchSnapshot, getReportFromSearchSnapshot, + getReportActionsFromSearchSnapshot, resolveSearchPayPaymentMethod, }; export type {TransactionPreviewData}; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 69b54b71081d..81c76b2a9d06 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -27,6 +27,7 @@ import getPlatform from '@libs/getPlatform'; import HttpUtils from '@libs/HttpUtils'; import Log from '@libs/Log'; import {findMatchingDynamicSuffix} from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import * as MainQueue from '@libs/Network/MainQueue'; @@ -51,17 +52,21 @@ import {clearCachedAttachments} from '@userActions/Attachment'; import clearOnyxAndSeedFullReconnect from '@userActions/clearOnyxAndSeedFullReconnect'; import {clearOnyxForDelegateTransition} from '@userActions/Delegate'; import * as Device from '@userActions/Device'; +import {setErrorFields} from '@userActions/FormActions'; import type HybridAppSettings from '@userActions/HybridApp/types'; import {close} from '@userActions/Modal'; import redirectToSignIn from '@userActions/SignInRedirect'; import * as Welcome from '@userActions/Welcome'; import CONFIG from '@src/CONFIG'; +import type {SignOutReason} from '@src/CONST'; import CONST, {FRAUD_PROTECTION_EVENT} from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ADD_WORK_EMAIL_INPUT_IDS from '@src/types/form/AddWorkEmailForm'; import type {TryNewDot} from '@src/types/onyx'; import type Credentials from '@src/types/onyx/Credentials'; import type Locale from '@src/types/onyx/Locale'; @@ -270,7 +275,6 @@ function getSupportalReason(supportEmail: string): Promise { */ async function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise> { Log.info('Flushing logs before signing out', true, {}, true); - logReceiptQueueSnapshot('signOut'); const {partnerName, partnerPassword} = getPartnerCredentials(params.autoGeneratedLogin); // When the user signs out, clear native startup prefetch state so the next cold start cannot replay @@ -342,6 +346,10 @@ function isExpiredSession(sessionCreationDate: number): boolean { return new Date().getTime() - sessionCreationDate >= CONST.SESSION_EXPIRATION_TIME_MS; } +function isDelegateSession(sessionParam?: OnyxEntry): boolean { + return sessionParam?.authTokenType === CONST.AUTH_TOKEN_TYPES.DELEGATE; +} + const KEYS_TO_PRESERVE_SUPPORTAL = [ ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, @@ -357,8 +365,9 @@ const KEYS_TO_PRESERVE_SUPPORTAL = [ // This allows the report screen to load correctly when the delegate token expires and the delegate is returned to their original account. ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, ONYXKEYS.NETWORK, - ONYXKEYS.SHOULD_USE_STAGING_SERVER, + ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.IS_DEBUG_MODE_ENABLED, + ONYXKEYS.BETA_OVERRIDES, // Preserve IS_USING_IMPORTED_STATE so that when transitioning to/from supportal, // we know if we're in imported state mode and should skip API calls that would cause infinite loading @@ -367,7 +376,13 @@ const KEYS_TO_PRESERVE_SUPPORTAL = [ ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, ]; -function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, shouldSignOutFromOldDot = true, shouldForceUseStashedSession?: boolean) { +function signOutAndRedirectToSignIn( + shouldResetToHome?: boolean, + shouldStashSession?: boolean, + shouldSignOutFromOldDot = true, + shouldForceUseStashedSession?: boolean, + signOutReason: SignOutReason = CONST.SIGN_OUT_REASON.USER_SIGN_OUT, +) { Log.info('Redirecting to Sign In because signOut() was called'); hideContextMenu(false); @@ -458,7 +473,7 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess if (response?.hasOldDotAuthCookies) { Log.info('Redirecting to OldDot sign out'); asyncOpenURL( - redirectToSignIn().then(() => { + redirectToSignIn(signOutReason).then(() => { Onyx.multiSet(onyxSetParams); }), `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${CONST.OLDDOT_URLS.SIGN_OUT}`, @@ -470,6 +485,7 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess // and then redirect to the oldDot supportal page to restore the stashed session // Clear the Onyx DB of stale data that might be present from a previous session // of the customer account + logReceiptQueueSnapshot('signOut', CONST.SIGN_OUT_REASON.SUPPORTAL_RESTORE); Onyx.clear(KEYS_TO_PRESERVE_SUPPORTAL).then(() => { Onyx.multiSet(onyxSetParams).then(() => { buildOldDotURL(CONST.OLDDOT_URLS.SUPPORTAL_RESTORE_STASHED_LOGIN).then((oldDotURL) => { @@ -481,7 +497,7 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess } else if (isPerformingSupportalLogout && !hasStashedSession(stashedSession, stashedCredentials)) { // If the supportal agent was not logged in, we call `redirectToSignIn` to clear the Onyx DB // and then redirect to supportal and restore the stashed session - redirectToSignIn().then(() => { + redirectToSignIn(CONST.SIGN_OUT_REASON.SUPPORTAL_LOGOUT).then(() => { Onyx.multiSet(onyxSetParams).then(() => { buildOldDotURL(CONST.OLDDOT_URLS.SUPPORTAL_RESTORE_STASHED_LOGIN).then((oldDotURL) => { // Open the oldDot URL to restore the stashed session and go back to OD supportal page @@ -493,6 +509,7 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess // Preserve SESSION during clear to avoid a login page flash, then restore the stashed session. // Seed LAST_FULL_RECONNECT_TIME so subscribeToFullReconnect doesn't fire a duplicate // ReconnectApp once the openApp() below lands NVP_RECONNECT_APP_IF_FULL_RECONNECT_BEFORE. + logReceiptQueueSnapshot('signOut', CONST.SIGN_OUT_REASON.STASHED_SESSION_RESTORE); clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_SUPPORTAL).then(() => { Onyx.multiSet(onyxSetParams).then(() => { Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, {}); @@ -511,7 +528,7 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess }); }); } else { - redirectToSignIn().then(() => { + redirectToSignIn(signOutReason).then(() => { Onyx.multiSet(onyxSetParams); if (hasSwitchedAccountInHybridMode) { @@ -694,7 +711,8 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin } function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettings, tryNewDot: TryNewDot | undefined, credentialsParam: Credentials | undefined) { - const {hybridApp, ...newDotOnyxValues} = hybridAppSettings; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- OldDot's handoff payload still carries the boolean, and this is where it gets converted to ACTIVE_SERVER + const {hybridApp, [ONYXKEYS.SHOULD_USE_STAGING_SERVER]: shouldUseStagingServer, ...newDotOnyxValues} = hybridAppSettings; const clearOnyxIfSigningIn = () => { if (!hybridApp.useNewDotSignInPage) { @@ -708,7 +726,7 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin } Log.info(`[HybridApp] Clearing onyx after transition from OldDot. useNewDotSignInPage set to ${hybridApp.useNewDotSignInPage}`); - return redirectToSignIn(); + return redirectToSignIn(CONST.SIGN_OUT_REASON.HYBRID_APP_TRANSITION); }; const resetDidUserLoginDuringSessionIfNeeded = () => { @@ -752,6 +770,7 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin authToken: hybridApp?.delegateAccessData?.oldDotCurrentAuthToken, encryptedAuthToken: decodeURIComponent(hybridApp?.delegateAccessData?.oldDotCurrentEncryptedAuthToken ?? ''), accountID: hybridApp?.delegateAccessData?.oldDotCurrentAccountID, + authTokenType: hybridApp?.delegateAccessData?.isDelegateAccess ? CONST.AUTH_TOKEN_TYPES.DELEGATE : undefined, }, [ONYXKEYS.CREDENTIALS]: { autoGeneratedLogin: credentialsParam?.autoGeneratedLogin ?? hybridApp.delegateAccessData?.oldDotAutoGeneratedLogin, @@ -772,6 +791,7 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin authToken: hybridApp?.delegateAccessData?.oldDotCurrentAuthToken, encryptedAuthToken: decodeURIComponent(hybridApp?.delegateAccessData?.oldDotCurrentEncryptedAuthToken ?? ''), accountID: hybridApp?.delegateAccessData?.oldDotCurrentAccountID, + authTokenType: hybridApp?.delegateAccessData?.isDelegateAccess ? CONST.AUTH_TOKEN_TYPES.DELEGATE : undefined, }, // When a logged-in user opens NewDot for the first time after migrating to the new SignInPage, there will be no credentials on the NewDot side. // After migration, NewDot is responsible for creating credentials, but this only happens during sign-in. @@ -797,7 +817,7 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin readyToShowAuthScreens: !hybridApp?.useNewDotSignInPage, }; - const onyxUpdates: Array> = [ + const onyxUpdates: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.HYBRID_APP, @@ -805,11 +825,25 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin }, ]; + // A payload without the field says nothing about the server, and writing a default here would pin staging and adhoc builds to production. + // `setActiveServer` is the wrong helper: it reports the choice back to OldDot through HybridAppModule. + if (shouldUseStagingServer !== undefined) { + onyxUpdates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACTIVE_SERVER, + value: shouldUseStagingServer ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION, + }); + } + for (const [key, value] of Object.entries(newDotOnyxValues)) { + if (value === undefined) { + continue; + } + onyxUpdates.push({ onyxMethod: Onyx.METHOD.MERGE, key, - value: value ?? {}, + value, } as OnyxUpdate); } @@ -859,7 +893,7 @@ function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undef * Will create a temporary login for the user in the passed authenticate response which is used when * re-authenticating after an authToken expires. */ -function signInWithShortLivedAuthToken(authToken: string, isSAML = false) { +function signInWithShortLivedAuthToken(authToken: string, isSAML = false, exitTo?: string) { const {optimisticData, failureData, finallyData} = getShortLivedLoginParams(false, isSAML); const authMethod = isSAML ? CONST.AUTH_METHOD.SAML : CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN; // Set the in-flight guard synchronously, before awaiting device info. optimisticData below (which also sets this key) @@ -872,6 +906,26 @@ function signInWithShortLivedAuthToken(authToken: string, isSAML = false) { API.read(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, {authToken, skipReauthentication: true, authMethod, deviceInfo}, {optimisticData, failureData, finallyData}); }); NetworkStore.setLastShortAuthToken(authToken); + if (!exitTo) { + return; + } + + const login = credentials.login; + // waitForUserSignIn keeps a single resolver that openReportFromDeepLink may already hold, so wait on the routes instead. + Navigation.waitForProtectedRoutes().then(() => { + // A failed sign-in leaves this waiting, so a later sign-in by another account must not land on this page. + if (!login || deprecatedSession.email?.toLowerCase() !== login.toLowerCase()) { + return; + } + try { + // Rebuilt like a cold start restore of this path. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + navigationRef.resetRoot({...getAdaptedStateFromPath(exitTo as Route), stale: true}); + } catch (error) { + // A path saved by an older build may no longer exist, and the sign-in already landed on Home. + Log.warn('Unable to return to the last visited path after SAML sign in', {error}); + } + }); } /** @@ -1165,8 +1219,8 @@ const reauthenticatePusher = throttle( () => { Log.info('[Pusher] Re-authenticating and then reconnecting'); reauthenticate(SIDE_EFFECT_REQUEST_COMMANDS.AUTHENTICATE_PUSHER) - .then((wasSuccessful) => { - if (!wasSuccessful) { + .then((result) => { + if (!result.wasSuccessful) { return; } Pusher.reconnect(); @@ -1592,42 +1646,66 @@ const canAnonymousUserAccessRoute = (route: string) => { return false; }; -function AddWorkEmail(workEmail: string) { - const optimisticData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, - value: { - onboardingWorkEmail: workEmail, - isLoading: true, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, - value: null, - }, - ]; +/** The forms that submit a work email. Onboarding has its own, the workspace company card and Expensify card flows share one. */ +type AddWorkEmailFormID = typeof ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM | typeof ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM; - const successData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, - value: { - isLoading: false, - }, - }, - ]; - - const failureData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, - value: { - isLoading: false, - }, - }, - ]; +/** + * Adds a work email to the account. + * + * @param workEmail the work email to add. + * @param formID the form that submitted the request. Its loading state and errors follow the request, so the submit button stops spinning and the failure + * renders inline. Defaults to the onboarding form, which is where this action is called from during onboarding. + */ +function AddWorkEmail(workEmail: string, formID: AddWorkEmailFormID = ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM) { + const isOnboardingFlow = formID === ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM; + + const optimisticData: Array> = isOnboardingFlow + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, + value: { + onboardingWorkEmail: workEmail, + isLoading: true, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + value: null, + }, + ] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM, + value: { + isLoading: true, + errorFields: null, + }, + }, + ]; + + const getLoadingFinishedData = (): Array> => + isOnboardingFlow + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, + value: { + isLoading: false, + }, + }, + ] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM, + value: { + isLoading: false, + }, + }, + ]; // We need to inspect the response to detect the closed-account error and surface a specific translation key, which API.write cannot do. // eslint-disable-next-line rulesdir/no-api-side-effects-method @@ -1636,28 +1714,41 @@ function AddWorkEmail(workEmail: string) { {workEmail}, { optimisticData, - successData, - failureData, + successData: getLoadingFinishedData(), + failureData: getLoadingFinishedData(), }, ).then((response) => { if (response?.jsonCode !== CONST.JSON_CODE.EXP_ERROR) { return; } + let errorTranslationKey: TranslationPaths | undefined; if (response?.message?.includes(CONST.MERGE_ACCOUNT_2FA_ERROR)) { - Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.workEmail2FAError'); - return; + errorTranslationKey = 'onboarding.workEmail2FAError'; + } else if (response?.message?.includes(CONST.MERGE_ACCOUNT_SINGLE_SIGN_ON_ERROR)) { + errorTranslationKey = 'onboarding.singleSignOnError'; + } else if (response?.message === CONST.WORK_ACCOUNT_CLOSED_ERROR || response?.title === CONST.WORK_ACCOUNT_CLOSED_ERROR) { + errorTranslationKey = 'onboarding.mergeBlockScreen.workAccountClosedSubtitle'; } - if (response?.message?.includes(CONST.MERGE_ACCOUNT_SINGLE_SIGN_ON_ERROR)) { - Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.singleSignOnError'); + // Outside of onboarding we show the failure on the form the user is looking at, instead of writing onboarding-only state that the caller doesn't render. + // The backend also rejects this command with errors we have no specific copy for (e.g. a 403), so fall back to a generic message rather than showing nothing. + if (!isOnboardingFlow) { + setErrorFields(ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM, { + [ADD_WORK_EMAIL_INPUT_IDS.EMAIL]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey(errorTranslationKey ?? 'common.genericErrorMessage'), + }); return; } - if (response?.message === CONST.WORK_ACCOUNT_CLOSED_ERROR || response?.title === CONST.WORK_ACCOUNT_CLOSED_ERROR) { - Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.mergeBlockScreen.workAccountClosedSubtitle'); + if (errorTranslationKey) { + Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, errorTranslationKey); return; } + + // When the work email is a domain-controlled login for an existing account, surface a specific subtitle in the blocking screen instead of the generic one. + if (response?.message === CONST.WORK_DOMAIN_CONTROLLED_ERROR || response?.title === CONST.WORK_DOMAIN_CONTROLLED_ERROR) { + Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.mergeBlockScreen.domainControlledSubtitle'); + } Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {isMergingAccountBlocked: true}); }); } @@ -1826,6 +1917,7 @@ export { waitForUserSignIn, hasAuthToken, isExpiredSession, + isDelegateSession, canAnonymousUserAccessRoute, signInWithSupportAuthToken, getSupportalReason, diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts index aff760caf7f0..28aebdfa6664 100644 --- a/src/libs/actions/Session/updateSessionAuthTokens.ts +++ b/src/libs/actions/Session/updateSessionAuthTokens.ts @@ -1,16 +1,19 @@ import Log from '@libs/Log'; import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; +import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ValueOf} from 'type-fest'; + import Onyx from 'react-native-onyx'; -export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { +export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string, authTokenType?: ValueOf) { // Startup prefetches are persisted natively across launches. Drop any queue/token-refresh // config tied to the previous auth token before saving the replacement. clearPrefetchOnAppStart().catch((error) => { Log.warn('[NitroFetch] clearPrefetchOnAppStart failed', {error}); }); - return Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, creationDate: new Date().getTime()}); + return Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, authTokenType, creationDate: new Date().getTime()}); } diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 802054dfd46c..a427dff57de8 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -2,8 +2,10 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; +import {logReceiptQueueSnapshot} from '@libs/telemetry/ReceiptObservability'; import CONFIG from '@src/CONFIG'; +import type {SignOutReason} from '@src/CONST'; import type {OnyxKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -47,21 +49,27 @@ Onyx.connectWithoutView({ }, }); -function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: boolean): Promise { - // Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out. - // We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting - // flashes of unwanted default state. - const keysToPreserve: OnyxKey[] = []; - keysToPreserve.push(ONYXKEYS.NVP_PREFERRED_LOCALE); - keysToPreserve.push(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING); - keysToPreserve.push(ONYXKEYS.PREFERRED_THEME); - keysToPreserve.push(ONYXKEYS.NVP_SEARCH_SIDEBAR); - keysToPreserve.push(ONYXKEYS.ACTIVE_CLIENTS); - keysToPreserve.push(ONYXKEYS.DEVICE_ID); - keysToPreserve.push(ONYXKEYS.SHOULD_USE_STAGING_SERVER); - keysToPreserve.push(ONYXKEYS.IS_DEBUG_MODE_ENABLED); - keysToPreserve.push(ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS); - keysToPreserve.push(ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS); +// Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out. +// We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting +// flashes of unwanted default state. +const KEYS_TO_PRESERVE_ON_SIGN_OUT: OnyxKey[] = [ + ONYXKEYS.NVP_PREFERRED_LOCALE, + ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, + ONYXKEYS.PREFERRED_THEME, + ONYXKEYS.NVP_SEARCH_SIDEBAR, + ONYXKEYS.ACTIVE_CLIENTS, + ONYXKEYS.DEVICE_ID, + ONYXKEYS.ACTIVE_SERVER, + ONYXKEYS.IS_DEBUG_MODE_ENABLED, + ONYXKEYS.BETA_OVERRIDES, + ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, + ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, +]; + +function clearStorageAndRedirect(signOutReason: SignOutReason, errorMessage?: string, isSAMLReauthentication?: boolean): Promise { + logReceiptQueueSnapshot('signOut', signOutReason); + + const keysToPreserve: OnyxKey[] = [...KEYS_TO_PRESERVE_ON_SIGN_OUT]; // After signing out, set ourselves as offline if we were offline before logging out and we are not forcing it. // If we are forcing offline, ignore it while signed out, otherwise it would require a refresh because there's no way to toggle the switch to go back online while signed out. @@ -92,8 +100,9 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: Onyx.merge(ONYXKEYS.CREDENTIALS, {login: currentSessionEmail, autoGeneratedLogin: null, autoGeneratedPassword: null}); Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true}); - // A forced re-auth is involuntary, so an in-progress trip survives it. + // A forced re-auth is involuntary, so an in-progress trip and the current page survive it. keysToPreserve.push(ONYXKEYS.GPS_DRAFT_DETAILS); + keysToPreserve.push(ONYXKEYS.LAST_VISITED_PATH); } return Onyx.clear(keysToPreserve).then(async () => { @@ -127,13 +136,15 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: * * Normally this method would live in Session.js, but that would cause a circular dependency with Network.js. * + * @param signOutReason Which path tore down the session. Telemetry only, it must not drive behavior. * @param errorMessage Error message to be displayed on the sign in page * @param isSAMLReauthentication Whether the redirection was triggered by reauthentication for SAML required account */ -function redirectToSignIn(errorMessage?: string, isSAMLReauthentication?: boolean): Promise { - return clearStorageAndRedirect(errorMessage, isSAMLReauthentication).then(() => { +function redirectToSignIn(signOutReason: SignOutReason, errorMessage?: string, isSAMLReauthentication?: boolean): Promise { + return clearStorageAndRedirect(signOutReason, errorMessage, isSAMLReauthentication).then(() => { clearSessionStorage(); }); } export default redirectToSignIn; +export {KEYS_TO_PRESERVE_ON_SIGN_OUT}; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index b179b0b79bfd..e41975829492 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -37,7 +37,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; @@ -1278,6 +1278,7 @@ function getShareDestination( policy: OnyxEntry, conciergeReportID: string | undefined, translate: LocalizedTranslate, + rules: OnyxCollection, reportAttributes?: OnyxTypes.ReportAttributesDerivedValue['reports'], pendingDeleteMemberAccountIDs?: string[], ): ShareDestination { @@ -1302,7 +1303,7 @@ function getShareDestination( const login = personalDetails?.[participantAccountID]?.login ?? ''; subtitle = formatPhoneNumber(login || displayName); } else { - subtitle = ReportUtils.getChatRoomSubtitle(report, policy, conciergeReportID, translate) ?? ''; + subtitle = ReportUtils.getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules) ?? ''; } return { icons: ReportUtils.getIcons( diff --git a/src/libs/actions/TestTool.ts b/src/libs/actions/TestTool.ts index ee79314f0a4f..f77ae36772d9 100644 --- a/src/libs/actions/TestTool.ts +++ b/src/libs/actions/TestTool.ts @@ -65,3 +65,4 @@ function toggleTestToolsModal() { } export default toggleTestToolsModal; +export {getBackToParam}; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 80f4b596e98e..da40fff61d98 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -76,6 +76,7 @@ import type { ReportAction, ReportActions, ReviewDuplicates, + Rule, Transaction, TransactionViolation, TransactionViolations, @@ -508,6 +509,7 @@ type DismissDuplicateTransactionViolationProps = { policy: OnyxEntry; isASAPSubmitBetaEnabled: boolean; allTransactions: OnyxCollection; + rules: OnyxCollection; currentTransactionViolations?: Array<{ transactionID: string; violations: TransactionViolations; @@ -526,6 +528,7 @@ function dismissDuplicateTransactionViolation({ policy, isASAPSubmitBetaEnabled, allTransactions, + rules, currentTransactionViolations = [], isTrackIntentUser, }: DismissDuplicateTransactionViolationProps) { @@ -557,6 +560,7 @@ function dismissDuplicateTransactionViolation({ hasViolations: hasOtherViolationsBesideDuplicates, isASAPSubmitBetaEnabled, isTrackIntentUser, + rules, }); optimisticData.push({ @@ -852,6 +856,7 @@ type ChangeTransactionsReportProps = { transactions: Transaction[]; allTransactionViolation?: OnyxCollection; reports: OnyxCollection; + rules: OnyxCollection; /** Report IDs that should be skipped when generating Onyx updates (e.g. because they are being deleted) */ skippedReportIDs?: string[]; isTrackIntentUser: boolean | undefined; @@ -874,6 +879,7 @@ function getChangeTransactionsReportOnyxData({ transactions, allTransactionViolation = {}, reports, + rules, skippedReportIDs, isTrackIntentUser, personalPolicyOutputCurrency, @@ -1655,7 +1661,7 @@ function getChangeTransactionsReportOnyxData({ let movedAction; if (reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { movedAction = buildOptimisticUnreportedTransactionAction(transactionThreadReportID, oldReportID); - } else if (!isOpenReport(oldReport)) { + } else if (!isOpenReport(newReport)) { movedAction = buildOptimisticMovedTransactionAction(transactionThreadReportID, oldReportID); } @@ -1940,6 +1946,7 @@ function getChangeTransactionsReportOnyxData({ predictedNextStatus, shouldFixViolations: shouldFixViolationsForReport, isTrackIntentUser, + rules, }); const optimisticPendingFields = { diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index c0e468815667..91a6b6d5b591 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -4,20 +4,10 @@ import {isCategoryMissing} from '@libs/CategoryUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import {isValidMerchant, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import Permissions from '@libs/Permissions'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {getTagLists, isGroupPolicy, isMultiLevelTags, resolveCurrentTaxCode} from '@libs/PolicyUtils'; -import {getIOUActionForTransactionID, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import { - canEditFieldOfMoneyRequest, - canEditMoneyRequest, - canUserPerformWriteAction, - findSelfDMReportID, - isArchivedReport, - isInvoiceReport, - isIOUReport, - shouldEnableNegative, -} from '@libs/ReportUtils'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canEditFieldOfMoneyRequest, canEditMoneyRequest, canUserPerformWriteAction, isArchivedReport, isInvoiceReport, isIOUReport, shouldEnableNegative} from '@libs/ReportUtils'; import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; import { calculateTaxAmount, @@ -45,6 +35,7 @@ import type { ReportAction, ReportActions, ReportNameValuePairs, + Rule, Transaction, TransactionViolations, } from '@src/types/onyx'; @@ -55,11 +46,12 @@ import type {ValueOf} from 'type-fest'; /** * Actions for inline editing of transactions from the Search results table and the Expense Report page. * - * Each function delegates to the corresponding IOU action which owns the canonical Onyx record, - * the API write, failure rollback, and snapshot updates (when a hash is provided). + * These functions are pure: every Onyx value they need (the transaction and violation + * collections, the resolved reports/report action, session, betas, etc.) is passed in by + * the caller (`useTransactionInlineEdit`), which reads it via `useOnyx`. Each function + * delegates to the corresponding IOU action which owns the canonical Onyx record, the API + * write, failure rollback, and snapshot updates (when a hash is provided). */ -import Onyx from 'react-native-onyx'; - import { updateMoneyRequestAmountAndCurrency, updateMoneyRequestCategory, @@ -79,64 +71,6 @@ type TransactionEditPermissions = { canEditTag: boolean; }; -let allTransactions: NonNullable> = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - callback: (value) => { - allTransactions = value ?? {}; - }, -}); - -let allTransactionViolations: NonNullable> = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - callback: (value) => { - allTransactionViolations = value ?? {}; - }, -}); - -let allReports: NonNullable> = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT, - callback: (value) => { - allReports = value ?? {}; - }, -}); - -let allReportActions: NonNullable> = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - callback: (value) => { - allReportActions = value ?? {}; - }, -}); - -let currentUserAccountID: number = CONST.DEFAULT_NUMBER_ID; -let currentUserEmail = ''; -Onyx.connectWithoutView({ - key: ONYXKEYS.SESSION, - callback: (value) => { - currentUserEmail = value?.email ?? ''; - currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; - }, -}); - -let allBetas: Beta[] | undefined; -Onyx.connectWithoutView({ - key: ONYXKEYS.BETAS, - callback: (value) => { - allBetas = value ?? undefined; - }, -}); - -let introSelected: OnyxEntry; -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_INTRO_SELECTED, - callback: (value) => { - introSelected = value; - }, -}); - const NO_EDIT: Readonly = Object.freeze({ canEditDate: false, canEditMerchant: false, @@ -156,6 +90,8 @@ type TransactionEditPermissionsParams = { /** Actions of the parent (money request) report, used by canEditMoneyRequest to check whether the report was forwarded since the last submit */ parentReportActions: OnyxEntry; + rules: OnyxCollection; + policy?: OnyxEntry; transactionThreadReport?: OnyxEntry; @@ -194,12 +130,36 @@ type GetIouParamsInput = { policyRecentlyUsedTags: OnyxEntry; isSelfTourViewed: boolean | undefined; hasCompletedGuidedSetupFlow: boolean | undefined; + conciergeChat: OnyxEntry; distanceOriginalPolicy?: OnyxEntry; personalDetailsList: OnyxEntry; delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + + /** The transaction being edited plus any of its duplicates, scoped by the caller. */ + transactions: OnyxCollection; + + /** Violations for the transaction being edited plus any of its duplicates, scoped by the caller. */ + transactionViolations: OnyxCollection; + + /** Betas the current user has access to, forwarded when a transaction thread report has to be built. */ + betas: Beta[] | undefined; + + /** Resolved by the caller through usePermissions so local beta overrides apply here too. */ + isASAPSubmitBetaEnabled: boolean; + + /** Onboarding intro data, needed to build a transaction thread report when one doesn't exist yet. */ + introSelected: OnyxEntry; + + /** The current user's account ID. */ + currentUserAccountID: number; + + /** The current user's email/login. */ + currentUserEmail: string; + + rules: OnyxCollection; }; type TransactionInlineEditParams = GetIouParamsInput & { @@ -227,6 +187,7 @@ function getIouParamsForTransaction({ reportPolicyTags, policyRecentlyUsedCategories, policyRecentlyUsedTags, + conciergeChat, isSelfTourViewed, hasCompletedGuidedSetupFlow, personalDetailsList, @@ -234,52 +195,33 @@ function getIouParamsForTransaction({ isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, + transactionViolations, + betas, + isASAPSubmitBetaEnabled, + introSelected, + currentUserAccountID, + currentUserEmail, + rules, }: GetIouParamsInput) { - const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; - const isUnreportedExpense = !transaction?.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - - let resolvedParentReport = parentReport; - if (!resolvedParentReport?.reportID && transaction?.reportID && transaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - resolvedParentReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; - } - - let resolvedParentReportAction = parentReportAction; - if (!resolvedParentReportAction && resolvedParentReport?.reportID) { - const reportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${resolvedParentReport.reportID}`] ?? {}; - resolvedParentReportAction = getIOUActionForTransactionID(Object.values(reportActions), transactionID); - } - - if (isUnreportedExpense) { - const selfDMReportID = findSelfDMReportID(allReports); - if (selfDMReportID) { - resolvedParentReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`] ?? resolvedParentReport; - - if (!resolvedParentReportAction) { - const selfDMReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`] ?? {}; - resolvedParentReportAction = getIOUActionForTransactionID(Object.values(selfDMReportActions), transactionID); - } - } - } + // transaction is passed in by the caller; only the violations scoped to this transaction are derived here. + const transactionViolationsForTransaction = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + // parentReport (already resolved to the self DM for unreported expenses), parentReportAction, and + // transactionThreadReport are resolved by the caller (useTransactionInlineEdit) via useOnyx, so they + // are used directly here. The only remaining resolution is building a transaction thread report when + // one doesn't exist in Onyx yet. let resolvedTransactionThreadReport = transactionThreadReport; - const transactionThreadReportID = resolvedTransactionThreadReport?.reportID ?? transaction?.transactionThreadReportID ?? resolvedParentReportAction?.childReportID; - - if (!resolvedTransactionThreadReport && transactionThreadReportID) { - resolvedTransactionThreadReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]; - } - - if (!resolvedTransactionThreadReport && resolvedParentReportAction && transaction) { + if (!resolvedTransactionThreadReport && parentReportAction && transaction) { resolvedTransactionThreadReport = createTransactionThreadReport({ introSelected, - // Deferred: thread the real conciergeChat when this cascade is migrated (https://github.com/Expensify/App/issues/66411) - conciergeChat: undefined, + conciergeChat, currentUserLogin: currentUserEmail, currentUserAccountID, - betas: allBetas, - iouReport: resolvedParentReport, - iouReportAction: resolvedParentReportAction, + betas, + iouReport: parentReport, + iouReportAction: parentReportAction, transaction, - transactionViolations: transactionViolations ?? undefined, + transactionViolations: transactionViolationsForTransaction ?? undefined, personalDetails: personalDetailsList, isSelfTourViewed, hasCompletedGuidedSetupFlow, @@ -289,19 +231,21 @@ function getIouParamsForTransaction({ return { transactionID, transactionThreadReport: resolvedTransactionThreadReport, - parentReport: resolvedParentReport, - iouReportOwnerLogin: getLoginByAccountID(resolvedParentReport?.ownerAccountID, personalDetailsList), + parentReport, + iouReportOwnerLogin: getLoginByAccountID(parentReport?.ownerAccountID, personalDetailsList), policy, policyForTrackExpense, policyCategories, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserEmail, - isASAPSubmitBetaEnabled: Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas), + isASAPSubmitBetaEnabled, delegateAccountID, isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, reportPolicyTags, + rules, + violations: transactionViolationsForTransaction, // Field-specific extras transaction, policyTagList: policyTags, @@ -319,8 +263,8 @@ function editTransactionDateInline(params: TransactionInlineEditParams, newDate: // updateMoneyRequestDate uses 'policyTags' (not policyTagList) policyTags: iouParams.policyTagList, value: newDate, - transactions: allTransactions, - transactionViolations: allTransactionViolations, + transactions: params.transactions, + transactionViolations: params.transactionViolations, isOffline: params.isOffline, hash: params.hash, distanceOriginalPolicy: params.distanceOriginalPolicy, @@ -330,6 +274,9 @@ 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; } @@ -351,6 +298,7 @@ function editTransactionDescriptionInline(params: TransactionInlineEditParams, n ...iouParams, comment: newDescription, hash: params.hash, + isEditedFromExpenseList: true, }); } @@ -361,20 +309,25 @@ function editTransactionCategoryInline(params: TransactionInlineEditParams, newC ...iouParams, category: newCategory, hash: params.hash, + isEditedFromExpenseList: true, }); } /** Updates the amount and currency of an expense from the Search results table or the Expense Report page. */ function editTransactionAmountInline(params: TransactionInlineEditParams, newAmount: number) { - const iouParams = getIouParamsForTransaction(params); + // 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. const iouType = isInvoiceReport(params.parentReport) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; - const allowNegative = shouldEnableNegative(params.parentReport, iouParams.policy, iouType); + const allowNegative = shouldEnableNegative(params.parentReport, params.policy, iouType); const isP2P = isIOUReport(params.parentReport); if (!isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P)) { return; } + const iouParams = getIouParamsForTransaction(params); + // Keep the existing currency — only the amount is changing from the search table const currency = iouParams.transaction?.modifiedCurrency ?? iouParams.transaction?.currency ?? CONST.CURRENCY.USD; // Recalculate tax from the existing tax code and the new amount @@ -390,8 +343,8 @@ function editTransactionAmountInline(params: TransactionInlineEditParams, newAmo taxCode, taxValue: taxPercentage, allowNegative, - transactions: allTransactions, - transactionViolations: allTransactionViolations, + transactions: params.transactions, + transactionViolations: params.transactionViolations, policyRecentlyUsedCurrencies: [], hash: params.hash, }); @@ -406,6 +359,7 @@ function editTransactionTagInline(params: TransactionInlineEditParams, newTag: s policyRecentlyUsedTags: iouParams.policyRecentlyUsedTags, hash: params.hash, isOffline: params.isOffline, + isEditedFromExpenseList: true, }); } @@ -432,6 +386,7 @@ function getTransactionEditPermissions({ originalTransaction, disabled, shouldSelectPolicyForUnreported, + rules, }: TransactionEditPermissionsParams): TransactionEditPermissions { if (disabled || !transaction) { return NO_EDIT; @@ -456,7 +411,8 @@ function getTransactionEditPermissions({ // For unreported expenses, parentReportAction may not be loaded; they are // always editable by the owner. const canEdit = - isUnreported || (isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, parentReport, policy, parentReportActions)); + isUnreported || + (isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, rules, isChatReportArchived, parentReport, policy, parentReportActions)); if (!canEdit) { return NO_EDIT; } @@ -528,6 +484,7 @@ function getTransactionEditPermissions({ transaction, report: parentReport, policy, + rules, }) ); }; diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 4e87112d3579..e84373b19b47 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -52,11 +52,14 @@ function areDescriptorMapsEqual(a: Record) { const nextDescriptors = siblingDescriptorsByTransactionID ?? null; const sameIDs = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); - if (sameIDs && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { - return Promise.resolve(); - } + const isUnchanged = sameIDs && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors); + // Track the newest array even when the write is skipped: callers compare this reference by identity to tell + // "my seed is still active" from "someone re-seeded after me", and a stale one makes a newer seed look older. lastSetIDs = ids; lastSetDescriptors = nextDescriptors; + if (isUnchanged) { + return Promise.resolve(); + } return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors)]); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 548329ea67ac..b03c801ce809 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -7,7 +7,6 @@ import type { CloseAccountParams, DeleteContactMethodParams, GetStatementPDFParams, - PusherPingParams, RequestContactMethodValidateCodeParams, ResendValidateCodeParams, RevokeDeviceParams, @@ -31,11 +30,8 @@ import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; -import {getIsOffline} from '@libs/NetworkState'; -import * as NumberUtils from '@libs/NumberUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import Pusher from '@libs/Pusher'; -import type {PingPongEvent} from '@libs/Pusher/types'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -48,10 +44,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ExpenseRuleForm, FlagForReviewRuleForm, MerchantRuleForm, MerchantTypeRuleForm, RequireFieldsRuleForm, SpendRuleForm} from '@src/types/form'; -import type {AppReview, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, NewLogin, ReportAttributesDerivedValue} from '@src/types/onyx'; +import type {AppReview, Beta, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, NewLogin, ReportAttributesDerivedValue} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {AnyOnyxServerUpdate, OnyxServerUpdate, OnyxUpdateEvent} from '@src/types/onyx/OnyxUpdatesFromServer'; +import type {AnyOnyxServerUpdate, OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type {CurrentUserPersonalDetails, Status} from '@src/types/onyx/PersonalDetails'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; @@ -154,7 +150,7 @@ function revokeDevice(login: NewLogin, autoGeneratedLogin: string | undefined) { return; } Log.info('Redirecting to Sign In because the current device was revoked'); - redirectToSignIn(); + redirectToSignIn(CONST.SIGN_OUT_REASON.DEVICE_REVOKED); }); } @@ -524,6 +520,7 @@ function requestValidateCodeAction(params?: ResendValidateCodeParams) { key: ONYXKEYS.VALIDATE_ACTION_CODE, value: { lastValidateCodeRequestedAt: requestedAt, + lastValidateCodeReason: params?.reasonCode ?? null, isLoading: true, pendingFields: { actionVerified: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -557,6 +554,7 @@ function requestValidateCodeAction(params?: ResendValidateCodeParams) { key: ONYXKEYS.VALIDATE_ACTION_CODE, value: { lastValidateCodeRequestedAt: null, + lastValidateCodeReason: null, isLoading: false, errorFields: { actionVerified: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.requestContactMethodValidateCode'), @@ -601,7 +599,7 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) { }, }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.LOGINS, @@ -625,6 +623,14 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) { validated: true, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + lastValidateCodeRequestedAt: null, + lastValidateCodeReason: null, + }, + }, ]; const failureData: Array> = [ @@ -798,81 +804,6 @@ function playSoundForMessageType(pushJSON: Array { - Log.info(`[Pusher PINGPONG] Received a PONG event from the server`, false, pushJSON); - lastPongReceivedTimestamp = Date.now(); - - const pongEvent = pushJSON as PingPongEvent; - const latency = Date.now() - Number(pongEvent.pingTimestamp); - Log.info(`[Pusher PINGPONG] The event took ${latency} ms`); - }); -} - -const PING_INTERVAL_LENGTH_IN_SECONDS = 30; - -const MISSING_PONG_THRESHOLD_IN_SECONDS = 2 * PING_INTERVAL_LENGTH_IN_SECONDS; - -function pingPusher() { - if (getIsOffline()) { - Log.info('[Pusher PINGPONG] Skipping PING because the client is offline'); - return; - } - const pingID = NumberUtils.rand64(); - const pingTimestamp = Date.now(); - - // In local development, there can end up being multiple intervals running because when JS code is replaced with hot module replacement, the old interval is not cleared - // and keeps running. This little bit of logic will attempt to keep multiple pings from happening. - if (pingTimestamp - lastPingSentTimestamp < PING_INTERVAL_LENGTH_IN_SECONDS * 1000) { - return; - } - lastPingSentTimestamp = pingTimestamp; - - const parameters: PusherPingParams = {pingID, pingTimestamp}; - - // The heartbeat is a probe, not a user's write, so it must not be persisted to disk, retried, or hold the - // head of the queue while a real write waits behind it. The Logging middleware already logs any failure. - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.PUSHER_PING, parameters).catch(() => {}); - Log.info(`[Pusher PINGPONG] Sending a PING to the server: ${pingID} timestamp: ${pingTimestamp}`); - - const timeSinceLastPongReceived = pingTimestamp - lastPongReceivedTimestamp; - if (timeSinceLastPongReceived > MISSING_PONG_THRESHOLD_IN_SECONDS * 1000) { - Log.info(`[Pusher PINGPONG] The server has not sent a PONG in ${timeSinceLastPongReceived} ms, leaving recovery to the Pusher SDK`); - } -} - -let pingPusherIntervalID: ReturnType; -function initializePusherPingPong(currentUserAccountID: number) { - // Only run the ping pong from the leader client - if (!ActiveClientManager.isClientTheLeader()) { - Log.info("[Pusher PINGPONG] Not starting PING PONG because this instance isn't the leader client"); - return; - } - - Log.info(`[Pusher PINGPONG] Starting Pusher PING PONG and pinging every ${PING_INTERVAL_LENGTH_IN_SECONDS} seconds`); - - lastPongReceivedTimestamp = Date.now(); - - // Subscribe to the pong event from Pusher. Unfortunately, there is no way of knowing when the client is actually subscribed - // so there could be a little delay before the client is actually listening to this event. - subscribeToPusherPong(currentUserAccountID); - - // If things are initializing again (which is fine because it will reinitialize each time Pusher authenticates), clear the old intervals - if (pingPusherIntervalID) { - clearInterval(pingPusherIntervalID); - } - - pingPusherIntervalID = setInterval(pingPusher, PING_INTERVAL_LENGTH_IN_SECONDS * 1000); -} - /** * Handles the newest events from Pusher where a single mega multipleEvents contains * an array of singular events all in one event @@ -904,9 +835,7 @@ function subscribeToUserEvents( const updates = { type: CONST.ONYX_UPDATE_TYPES.PUSHER, lastUpdateID: Number(pushEventData.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID), - // specific key type is not known from the Pusher event data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updates: (pushEventData.updates as Array>) ?? [], + updates: pushEventData.updates ?? [], previousUpdateID: Number(pushJSON.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID), }; Log.info('[subscribeToUserEvents] Applying Onyx updates'); @@ -954,8 +883,6 @@ function subscribeToUserEvents( reconnectApp(); return Promise.resolve(); }); - - initializePusherPingPong(currentUserAccountID); } /** @@ -1012,11 +939,11 @@ function updateChatPriorityMode(mode: ValueOf, autom } } -function setShouldUseStagingServer(shouldUseStagingServer: boolean) { +function setActiveServer(server: ValueOf) { if (CONFIG.IS_HYBRID_APP) { - HybridAppModule.shouldUseStaging(shouldUseStagingServer); + HybridAppModule.shouldUseStaging(server === CONST.SERVER.STAGING); } - Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, shouldUseStagingServer); + Onyx.set(ONYXKEYS.ACTIVE_SERVER, server); } function togglePlatformMute(platform: Platform, mutedPlatforms: Partial>) { @@ -1392,6 +1319,18 @@ function setShouldShowBranchNameInTitle(value: boolean) { Onyx.set(ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE, value); } +function setBetaOverride(beta: Beta, value: boolean) { + Onyx.merge(ONYXKEYS.BETA_OVERRIDES, {[beta]: value}); +} + +function clearBetaOverride(beta: Beta) { + Onyx.merge(ONYXKEYS.BETA_OVERRIDES, {[beta]: null}); +} + +function clearBetaOverrides() { + Onyx.set(ONYXKEYS.BETA_OVERRIDES, null); +} + function lockAccount(currentUserAccountID: number, accountID: number | undefined, domainAccountID: number | undefined, domainName: string | undefined) { let domainOptimisticData: DomainOnyxUpdate[] = []; let domainFailureData: DomainOnyxUpdate[] = []; @@ -1675,7 +1614,7 @@ function verifyAddSecondaryLoginCode(validateCode: string) { }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PENDING_CONTACT_ACTION, @@ -1684,6 +1623,14 @@ function verifyAddSecondaryLoginCode(validateCode: string) { isLoading: false, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + lastValidateCodeRequestedAt: null, + lastValidateCodeReason: null, + }, + }, ]; const failureData: Array> = [ @@ -1991,7 +1938,7 @@ export { updatePreferredSkinTone, setInboxTab, updateChatPriorityMode, - setShouldUseStagingServer, + setActiveServer, togglePlatformMute, joinScreenShare, clearScreenShareRequest, @@ -2014,6 +1961,9 @@ export { clearValidateCodeActionError, setIsDebugModeEnabled, setShouldShowBranchNameInTitle, + setBetaOverride, + clearBetaOverride, + clearBetaOverrides, lockAccount, requestUnlockAccount, respondToProactiveAppReview, diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 91125b9267e8..0f1aa7cd1384 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -7,11 +7,13 @@ import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; import { applyApprovalWorkflowRulesDiff, buildApprovalWorkflowRules, + buildApprovalWorkflowRulesForSave, calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getApprovalWorkflowRulesForPolicy, getOverLimitForwardsToDisplayName, getWorkflowMemberEmails, + hasRuleBasedDefaultWorkflow, mergeWorkflowMembersWithAvailableMembers, reconcileApprovalWorkflowRulesForCreate, reconcileApprovalWorkflowRulesForEdit, @@ -25,6 +27,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; import type Rule from '@src/types/onyx/Rule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -364,7 +368,11 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov const removeDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); const rulesAfterRemoval = applyApprovalWorkflowRulesDiff(existingRules, removeDiff); - const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const newRules = buildApprovalWorkflowRulesForSave(approvalWorkflow, { + existingRules: rulesAfterRemoval, + employees: policy.employeeList ?? {}, + defaultApprover: getDefaultApprover(policy), + }); const createDiff = reconcileApprovalWorkflowRulesForCreate(newRules, memberEmails, {existingRules: rulesAfterRemoval}); const rulesDiff = {...removeDiff, ...createDiff}; @@ -379,6 +387,30 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov } } +/** + * Change the Policy's default first approver. Used to keep `policy.approver` in sync when we change + * who the first approver for the default workflow is + */ +function updatePolicyDefaultApprover(approvalWorkflow: ApprovalWorkflow, policy: Policy) { + if (!approvalWorkflow.isDefault) { + return; + } + + const previousDefaultApprover = getDefaultApprover(policy); + const newDefaultApprover = approvalWorkflow.approvers.at(0)?.email; + if (!newDefaultApprover || newDefaultApprover === previousDefaultApprover) { + return; + } + + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policy.id}` as const; + + const optimisticData: Array> = [{onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: {approver: newDefaultApprover}}]; + const failureData: Array> = [{onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: {approver: previousDefaultApprover}}]; + + const parameters: UpdateWorkspaceApprovalParams = {policyID: policy.id, employees: '[]', defaultApprover: newDefaultApprover}; + write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData}); +} + type UpdateApprovalWorkflowRulesParams = { approvalWorkflow: ApprovalWorkflow; initialApprovalWorkflow: ApprovalWorkflow; @@ -407,12 +439,64 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, const rulesAfterMembers = applyApprovalWorkflowRulesDiff(rulesAfterRemoval, memberDiff); // 3. Reconcile the approver chain with the membership-updated rules. - const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const newRules = buildApprovalWorkflowRulesForSave(approvalWorkflow, { + existingRules: rulesAfterMembers, + employees: policy.employeeList ?? {}, + defaultApprover: getDefaultApprover(policy), + }); const chainDiff = reconcileApprovalWorkflowRulesForEdit(newRules, newMemberEmails, {existingRules: rulesAfterMembers}); const rulesDiff = {...removeFromOthersDiff, ...memberDiff, ...chainDiff}; setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); + updatePolicyDefaultApprover(approvalWorkflow, policy); +} + +type BuildReturnToDefaultWorkflowDiffParams = { + /** The workflow being deleted, whose members are returning to the default workflow. */ + approvalWorkflow: ApprovalWorkflow; + + /** The policy's default workflow, or undefined when the page couldn't resolve one. */ + defaultApprovalWorkflow: ApprovalWorkflow | undefined; + + /** The policy's rules with the deleted workflow's rules already taken out. */ + existingRules: Record; + + /** The policy's employees, used to check where these members would fall back to without rules. */ + employees: PolicyEmployeeList; + + /** The policy's default approver. */ + defaultApprover: string; +}; + +/** + * List a deleted workflow's members in the default workflow's rules. + */ +function buildReturnToDefaultWorkflowDiff({ + approvalWorkflow, + defaultApprovalWorkflow, + existingRules, + employees, + defaultApprover, +}: BuildReturnToDefaultWorkflowDiffParams): ApprovalWorkflowRulesDiff { + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); + if (!defaultApprovalWorkflow || memberEmails.length === 0) { + return {}; + } + + const hasRuleBasedDefault = hasRuleBasedDefaultWorkflow(existingRules); + const buildDefaultRules = (isDefault: boolean) => buildApprovalWorkflowRules({...defaultApprovalWorkflow, members: approvalWorkflow.members, isDefault}); + + const foldDiff = reconcileApprovalWorkflowRulesForCreate(buildDefaultRules(hasRuleBasedDefault), memberEmails, {existingRules}); + if (Object.keys(foldDiff).every((ruleID) => ruleID in existingRules)) { + return foldDiff; + } + + if (memberEmails.every((email) => employees[email]?.submitsTo === defaultApprover)) { + return {}; + } + + return hasRuleBasedDefault ? foldDiff : reconcileApprovalWorkflowRulesForCreate(buildDefaultRules(true), memberEmails, {existingRules}); } /** @@ -421,20 +505,28 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, * Returns false without calling the API when this policy has no rules covering the workflow's members, so the * caller can fall back to the `employeeList` path. */ -function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection): boolean { +function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection, defaultApprovalWorkflow?: ApprovalWorkflow): boolean { if (!policy) { return false; } const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); - const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); + const removeDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); - if (isEmptyObject(rulesDiff)) { + if (isEmptyObject(removeDiff)) { return false; } - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); + const returnToDefaultDiff = buildReturnToDefaultWorkflowDiff({ + approvalWorkflow, + defaultApprovalWorkflow, + existingRules: applyApprovalWorkflowRulesDiff(existingRules, removeDiff), + employees: policy.employeeList ?? {}, + defaultApprover: getDefaultApprover(policy), + }); + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff: {...removeDiff, ...returnToDefaultDiff}, previousRules: rules}); return true; } diff --git a/src/libs/actions/applyOnyxUpdatesReliably.ts b/src/libs/actions/applyOnyxUpdatesReliably.ts index 11b276ec1eab..1204577b892d 100644 --- a/src/libs/actions/applyOnyxUpdatesReliably.ts +++ b/src/libs/actions/applyOnyxUpdatesReliably.ts @@ -48,7 +48,7 @@ export default function applyOnyxUpdatesReliably( } const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID; - if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) { + if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID, updateType: updates.type})) { return onyxApply(updates).then(); } diff --git a/src/libs/actions/connections/Campfire.ts b/src/libs/actions/connections/Campfire.ts new file mode 100644 index 000000000000..75651056b503 --- /dev/null +++ b/src/libs/actions/connections/Campfire.ts @@ -0,0 +1,125 @@ +import {write} from '@libs/API'; +import type {ConnectPolicyToCampfireParams, UpdateCampfireSubsidiaryParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CampfireConnectionsConfig} from '@src/types/onyx/Policy'; + +import type {OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +function connectToCampfire(policyID: string, apiKey: string) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`, + value: { + stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.CAMPFIRE_SYNC_CONNECTION, + connectionName: CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, + timestamp: new Date().toISOString(), + }, + }, + ]; + const parameters: ConnectPolicyToCampfireParams = { + policyID, + apiKey, + }; + write(WRITE_COMMANDS.CONNECT_POLICY_TO_CAMPFIRE, parameters, { + optimisticData, + }); +} + +function clearCampfireErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: {errorFields: {[fieldName]: null}}, + }, + }, + }); +} + +function prepareCampfireOnyxData( + policyID: string, + settingName: TSettingName, + settingValue: Partial, + oldSettingValue: Partial | null, +) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: { + [settingName]: settingValue ?? null, + pendingFields: { + [settingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: { + pendingFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: { + [settingName]: oldSettingValue ?? null, + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + return {optimisticData, successData, failureData}; +} + +function updateCampfireSubsidiary(policyID: string, subsidiaryID: CampfireConnectionsConfig['subsidiaryID'], oldSubsidiaryID?: CampfireConnectionsConfig['subsidiaryID']) { + const onyxData = prepareCampfireOnyxData(policyID, CONST.CAMPFIRE_CONFIG.SUBSIDIARY_ID, subsidiaryID, oldSubsidiaryID ?? null); + const params: UpdateCampfireSubsidiaryParams = { + policyID, + subsidiaryID, + }; + write(WRITE_COMMANDS.UPDATE_CAMPFIRE_SUBSIDIARY, params, onyxData); +} + +export {connectToCampfire, clearCampfireErrorField, updateCampfireSubsidiary}; diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index a1ced70d6e21..71fd3c499b22 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -166,6 +166,9 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) { case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: { return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_DUALENTRY, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.DUALENTRY_SYNC_CONNECTION}; } + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: { + return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_CAMPFIRE, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.CAMPFIRE_SYNC_CONNECTION}; + } default: return undefined; } @@ -381,6 +384,9 @@ function copyExistingPolicyConnection(connectedPolicyID: string, targetPolicyID: case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: stageInProgress = CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.DUALENTRY_SYNC_CONNECTION; break; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + stageInProgress = CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.CAMPFIRE_SYNC_CONNECTION; + break; default: stageInProgress = null; } diff --git a/src/libs/actions/connections/merge/index.ts b/src/libs/actions/connections/merge/index.ts index 099793d9369e..73cf1ea2e2d4 100644 --- a/src/libs/actions/connections/merge/index.ts +++ b/src/libs/actions/connections/merge/index.ts @@ -19,11 +19,12 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; -/** Config fields shared by the Merge-backed connections (Merge HR and Merge ATS) that can hold an update error. */ -type MergeConnectionErrorFieldName = 'approvalMode' | 'finalApprover' | 'groups' | 'filters' | 'approverField'; +type HRConnectionErrorFieldName = 'approvalMode' | 'finalApprover' | 'groups'; +type RecruitingConnectionErrorFieldName = 'approvalMode' | 'finalApprover' | 'filters' | 'approverField'; +type MergeConnectionErrorFieldName = HRConnectionErrorFieldName | RecruitingConnectionErrorFieldName; -function getMergeSetupLink(policyID: string, integration: MergeHRProviderSlug | MergeATSProviderSlug) { - const params: ConnectPolicyToMergeParams = {policyID, integration}; +function getMergeSetupLink(policyID: string, integration: MergeHRProviderSlug | MergeATSProviderSlug, category: ValueOf) { + const params: ConnectPolicyToMergeParams = {policyID, integration, category}; const commandURL = getCommandURL({ command: READ_COMMANDS.CONNECT_POLICY_TO_MERGE, shouldSkipWebProxy: true, diff --git a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts new file mode 100644 index 000000000000..97475426bd62 --- /dev/null +++ b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts @@ -0,0 +1,150 @@ +import {registerAgentAccountIDMapping} from '@libs/AgentAccountIDMapping'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {OnyxCollection} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** + * A new agent is stored under a client-generated optimistic accountID, because only the server can assign the real + * one (the agent's login is based on it). CreateAgent's success response maps the optimistic accountID to the real + * one on OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING. + * + * Like replaceOptimisticReportWithActualReport, this module listens to that mapping. For each entry it redirects + * any open agent settings screen to the real accountID, remaps the agent DM participants, clears the optimistic + * data, and clears the consumed entry. The cleanup lives here instead of createAgent()'s successData so it always + * runs after the redirect. + * + * Nothing is migrated onto the real keys: the mapping is only flushed to Onyx after the queue has drained, so any + * pending state still on the optimistic keys is stale by then. The ReplaceOptimisticAgentAccountID middleware + * 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]); + +// 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 +// least once, so it cannot hang. +let allReports: OnyxCollection; +// Definite assignment: the Promise executor runs synchronously, so this is set before anything reads it. +let resolveReportsHydration!: () => void; +const reportsHydrationPromise = new Promise((resolve) => { + resolveReportsHydration = resolve; +}); +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (value) => { + allReports = value; + resolveReportsHydration(); + }, +}); + +// Number.isSafeInteger() also rejects the non-numeric values a malformed persisted mapping entry could hold at runtime. +function isValidAgentAccountID(accountID: number): boolean { + return Number.isSafeInteger(accountID) && accountID > 0; +} + +// The whole navigation state is walked because an agent screen buried under another one would otherwise keep the +// dead ID and 404 on back navigation. Matching on screen name + accountID param avoids redirecting unrelated routes +// that contain the same digits. +function redirectAgentSettingsScreens(optimisticAccountID: number, realAccountID: number) { + const statesToVisit: Array> = [navigationRef.getRootState()]; + while (statesToVisit.length > 0) { + const state = statesToVisit.pop(); + for (const route of state?.routes ?? []) { + const isOptimisticAgentRoute = + AGENT_SETTINGS_SCREENS.has(route.name) && + !!route.params && + typeof route.params === 'object' && + 'accountID' in route.params && + Number(route.params.accountID) === optimisticAccountID; + // Unfocused routes (e.g. beneath a modal) only receive SET_PARAMS when their navigator's state key is the target. + if (isOptimisticAgentRoute && route.key) { + Navigation.setParams({accountID: realAccountID}, route.key, state?.key); + } + if (route.state) { + statesToVisit.push(route.state); + } + } + } +} + +// CreateAgent's response already swaps the DM participants, so this only repairs reports still keyed by the +// optimistic accountID. A server-provided real participant is never overwritten. +function remapReportParticipants(optimisticAccountID: number, realAccountID: number) { + for (const report of Object.values(allReports ?? {})) { + const optimisticParticipant = report?.participants?.[optimisticAccountID]; + if (!optimisticParticipant) { + continue; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, { + participants: { + [realAccountID]: report.participants?.[realAccountID] ?? optimisticParticipant, + [optimisticAccountID]: null, + }, + }); + } +} + +function replaceOptimisticAgentWithActualAgent(optimisticAccountID: number, realAccountID: number) { + // An identity mapping would clear the real agent's data below, and a malformed realAccountID would redirect + // screens to a bogus key, so such entries are only dropped from the mapping. + if (!isValidAgentAccountID(optimisticAccountID) || !isValidAgentAccountID(realAccountID) || optimisticAccountID === realAccountID) { + Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[optimisticAccountID]: null}); + return; + } + + // Recorded before the waits below so resolveAgentAccountID() already covers actions fired while the cleanup is + // pending. This is the only path that runs for a persisted mapping consumed on app start. + registerAgentAccountIDMapping(optimisticAccountID, realAccountID); + + // Neither navigation nor the report collection is guaranteed to be ready when a persisted mapping is consumed + // on app start. The optimistic data is cleared in the same callback so an open agent screen can never be left + // pointing at a deleted key. + Promise.all([reportsHydrationPromise, Navigation.isNavigationReady()]).then(() => { + TransitionTracker.runAfterTransitions({ + callback: () => { + // Redirect before clearing the optimistic data so no open screen flashes its not-found view. + if (navigationRef.isReady()) { + redirectAgentSettingsScreens(optimisticAccountID, realAccountID); + } + + remapReportParticipants(optimisticAccountID, realAccountID); + + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[optimisticAccountID]: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null); + Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[optimisticAccountID]: null}); + }, + }); + }); +} + +// No UI subscribes to the mapping, so connectWithoutView() is used. Firing with the persisted value on app start +// also consumes any entry left over from a previous session. +Onyx.connectWithoutView({ + key: ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, + callback: (mapping) => { + if (!mapping) { + return; + } + + for (const [optimisticAccountID, mappedAccountID] of Object.entries(mapping)) { + // Already-consumed (nullish) entries are skipped. Clearing them again would just fire this callback once more. + const realAccountID: unknown = mappedAccountID; + if (realAccountID === null || realAccountID === undefined) { + continue; + } + replaceOptimisticAgentWithActualAgent(Number(optimisticAccountID), mappedAccountID); + } + }, +}); + +export {replaceOptimisticAgentWithActualAgent}; + +export default {}; diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 4f94629c2236..67f9b83e4b91 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -226,7 +226,16 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm // betas and conciergeChat are safe to pass as undefined because introSelected is undefined, so the // guided-setup code path that uses them is never reached. Passing them explicitly so the compiler // flags this when they become required. Refactor issues: https://github.com/Expensify/App/issues/66424 - openReport({reportID: parentReportID, introSelected: undefined, betas: undefined, conciergeChat: undefined, hasReportActions, currentUserAccountID}); + // personalDetails is undefined because the parent report already exists, so no optimistic report is created and they are never read. + openReport({ + reportID: parentReportID, + introSelected: undefined, + betas: undefined, + conciergeChat: undefined, + personalDetails: undefined, + hasReportActions, + currentUserAccountID, + }); }); } else { callback(); @@ -235,7 +244,16 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm // betas and conciergeChat are safe to pass as undefined because introSelected is undefined, so the // guided-setup code path that uses them is never reached. Passing them explicitly so the compiler // flags this when they become required. Refactor issues: https://github.com/Expensify/App/issues/66424 - openReport({reportID: parentReportID, introSelected: undefined, betas: undefined, conciergeChat: undefined, hasReportActions, currentUserAccountID}); + // personalDetails is undefined because the parent report already exists, so no optimistic report is created and they are never read. + openReport({ + reportID: parentReportID, + introSelected: undefined, + betas: undefined, + conciergeChat: undefined, + personalDetails: undefined, + hasReportActions, + currentUserAccountID, + }); } return; } diff --git a/src/libs/cleanupPreMountedDraftReports.ts b/src/libs/cleanupPreMountedDraftReports.ts new file mode 100644 index 000000000000..fdb0b8c3c198 --- /dev/null +++ b/src/libs/cleanupPreMountedDraftReports.ts @@ -0,0 +1,78 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxMultiSetInput} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** + * Startup cleanup for speculative report rows left by interrupted draft pre-mounts. + * REPORT_PRE_MOUNTED_DRAFT holds no report data, only a boolean marker saying report_ was copied from reportDraft_ + * by preMountDraftReport. The draft itself lives in REPORT_DRAFT (written by createDraftWorkspace) until submit runs + * the real CreateWorkspace, which clears the draft and takes over report_. + */ +function getPreMountedDraftReportCleanupData(markers: OnyxCollection, reportDrafts: OnyxCollection): OnyxMultiSetInput { + const cleanupData: OnyxMultiSetInput = {}; + + for (const key of Object.keys(markers ?? {})) { + const reportID = key.slice(ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT.length); + const markerKey: `${typeof ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${string}` = `${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`; + cleanupData[markerKey] = null; + + // If the draft still exists, submit never ran (CreateWorkspace clears the draft on submit), so report_ is still + // just our speculative copy and is safe to delete. If the draft is gone, submit happened and report_ is real now. + if (reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]) { + const reportKey: `${typeof ONYXKEYS.COLLECTION.REPORT}${string}` = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + cleanupData[reportKey] = null; + } + } + + return cleanupData; +} + +/** + * Reads the draft reports once to decide which speculative rows are safe to delete. Runs before React mounts, + * outside any component, so there's no hook available - takes a single snapshot instead and disconnects + * immediately, since nothing here renders or needs to stay subscribed. + */ +function cleanupReportDrafts(markers: OnyxCollection) { + let reportDraftsConnection: ReturnType; + + function handleReportDrafts(reportDrafts: OnyxCollection) { + Onyx.disconnect(reportDraftsConnection); + Onyx.multiSet(getPreMountedDraftReportCleanupData(markers, reportDrafts)); + } + + reportDraftsConnection = Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT, + callback: handleReportDrafts, + }); +} + +/** + * Clears speculative report rows left by an interrupted draft pre-mount. This runs before React mounts because + * an app termination does not run component cleanup. A missing draft means submission already converted the draft, + * so only the stale pre-mount marker is removed and the real report is preserved. + */ +function cleanupPreMountedDraftReports() { + // Same reasoning as cleanupReportDrafts above: no hook available yet, so take a single snapshot + // and disconnect immediately. + let markersConnection: ReturnType; + + function handleMarkers(markers: OnyxCollection) { + Onyx.disconnect(markersConnection); + if (!Object.keys(markers ?? {}).length) { + return; + } + + cleanupReportDrafts(markers); + } + + markersConnection = Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT, + callback: handleMarkers, + }); +} + +export {getPreMountedDraftReportCleanupData}; +export default cleanupPreMountedDraftReports; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index bcab317638b4..7cd27ecbd005 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -790,12 +790,8 @@ const getFileValidationErrorText = ( title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.sizeLimitExceeded', maxSize / 1024 / 1024), }; - case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: - return { - title: translate('attachmentPicker.attachmentError'), - reason: translate('attachmentPicker.folderNotAllowedMessage'), - }; case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: + // This error can only occur for a multi-file selection, so it intentionally has no single-file case below. return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.maxFileLimitExceeded'), @@ -806,6 +802,11 @@ const getFileValidationErrorText = ( } switch (validationError.error) { + case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: + return { + title: translate(validationError.isValidatingMultipleFiles ? 'attachmentPicker.someFilesCantBeUploaded' : 'attachmentPicker.attachmentError'), + reason: translate('attachmentPicker.folderNotAllowedMessage'), + }; case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.wrongFileType'), diff --git a/src/libs/fileDownload/checkFileExists/index.ts b/src/libs/fileDownload/checkFileExists/index.ts index 7c899c091147..c24910585db0 100644 --- a/src/libs/fileDownload/checkFileExists/index.ts +++ b/src/libs/fileDownload/checkFileExists/index.ts @@ -3,16 +3,35 @@ import {logReceiptStatFailed} from '@libs/telemetry/ReceiptObservability'; import RNFS from 'react-native-fs'; -/** - * Checks if a file exists at the given path without loading it into memory. - * This is a memory-safe alternative to readFileAsync for validation. - * - * @param path - The file path to check (typically starts with file://) - * @returns Promise that resolves to true if file exists, false otherwise - */ -function checkFileExists(path: string | undefined): Promise { +/** Why a stat failed, so a deleted file is distinguishable from one that is there but unreadable. */ +type FileCheckError = { + /** The raw stat error text. Carries the file path, so it stays out of Sentry. */ + message: string; + + /** The errno, e.g. ENOENT for missing or EPERM for a locked device. */ + code?: string; +}; + +/** Outcome of a file check: whether the file is there, and if not, why the stat failed. */ +type FileCheckResult = { + /** True only when the path resolves to a file. A directory or a failed stat both read as false. */ + exists: boolean; + + /** Absent when the file exists, or when no path was given to check. */ + error?: FileCheckError; +}; + +function toFileCheckError(error: unknown): FileCheckError { + if (error instanceof Error) { + const {code} = error as Error & {code?: unknown}; + return {message: error.message, code: typeof code === 'string' ? code : undefined}; + } + return {message: String(error)}; +} + +function checkFileExistsWithReason(path: string | undefined): Promise { if (!path) { - return Promise.resolve(false); + return Promise.resolve({exists: false}); } const rawPath = path.startsWith('file://') ? path.slice(7) : path; @@ -21,14 +40,32 @@ function checkFileExists(path: string | undefined): Promise { // a literal "%23", which is indistinguishable from an encoded "#". Try decoded, then raw. const decodedPath = fileURIToPath(path); - const statIsFile = (candidate: string) => RNFS.stat(candidate).then((fileStat) => fileStat.isFile()); + const statIsFile = (candidate: string) => RNFS.stat(candidate).then((fileStat): FileCheckResult => ({exists: fileStat.isFile()})); - const statFailed = (error: unknown) => { - logReceiptStatFailed(typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : undefined); - return false; - }; + return statIsFile(decodedPath).catch((decodedError: unknown) => { + if (decodedPath === rawPath) { + return {exists: false, error: toFileCheckError(decodedError)}; + } + return statIsFile(rawPath).catch((): FileCheckResult => ({exists: false, error: toFileCheckError(decodedError)})); + }); +} - return statIsFile(decodedPath).catch((error: unknown) => (decodedPath === rawPath ? statFailed(error) : statIsFile(rawPath).catch(() => statFailed(error)))); +/** + * Checks if a file exists at the given path without loading it into memory. + * This is a memory-safe alternative to readFileAsync for validation. + * + * @param path - The file path to check (typically starts with file://) + * @returns Promise that resolves to true if file exists, false otherwise + */ +function checkFileExists(path: string | undefined): Promise { + return checkFileExistsWithReason(path).then(({exists, error}) => { + // Logged here rather than in the shared helper, so the upload path does not report the same errno twice. + if (error) { + logReceiptStatFailed(error.code); + } + return exists; + }); } export default checkFileExists; +export {checkFileExistsWithReason}; diff --git a/src/libs/fileDownload/processPickedAssets/index.native.ts b/src/libs/fileDownload/processPickedAssets/index.native.ts new file mode 100644 index 000000000000..129cf5589f23 --- /dev/null +++ b/src/libs/fileDownload/processPickedAssets/index.native.ts @@ -0,0 +1,132 @@ +import {getFileName, verifyFileFormat} from '@libs/fileDownload/FileUtils'; +import Log from '@libs/Log'; + +import CONST from '@src/CONST'; + +import type {Asset} from 'react-native-image-picker'; + +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; + +import type ProcessPickedAssetsFunction from './types'; + +/** + * Ensures asset has proper fileName and type properties. Callers only pass assets that have a `uri`. + */ +function processAssetWithFallbacks(asset: Asset): Asset { + return { + ...asset, + fileName: asset.fileName ?? getFileName(asset.uri ?? ''), + // Default to JPEG if no type specified + type: asset.type ?? 'image/jpeg', + }; +} + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +/** + * Frees a native image resource. Releasing is best-effort cleanup, so a failure here must never change + * whether the converted asset is kept. + */ +function releaseQuietly(releasable: {release: () => void}) { + try { + releasable.release(); + } catch (error) { + Log.warn('Failed to release native image resource', {error: getErrorMessage(error, 'An unknown error occurred')}); + } +} + +/** + * Transcodes a single HEIC image to JPEG, returning `undefined` if the conversion fails. + * + * The native context and the rendered bitmap are released as soon as they are no longer needed rather + * than waiting for the garbage collector, which has no visibility into the native memory they retain. + * + * This repeats the manipulate/render/save sequence from `heicConverter` rather than calling it, because + * that helper decides what to convert from the `.heic`/`.heif` extension. react-native-image-picker + * relabels the extension without transcoding, so this path has to detect HEIC from the file signature + * instead and cannot go through `convertHeicImage`. + */ +async function convertHeicToJpeg(uri: string): Promise { + const imageManipulatorContext = ImageManipulator.manipulate(uri); + try { + const manipulatedImage = await imageManipulatorContext.renderAsync(); + try { + const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG}); + return { + uri: manipulationResult.uri, + fileName: getFileName(manipulationResult.uri), + type: 'image/jpeg', + width: manipulationResult.width, + height: manipulationResult.height, + }; + } finally { + releaseQuietly(manipulatedImage); + } + } catch (error) { + Log.warn('Failed to convert HEIC image, skipping asset', {error: getErrorMessage(error, 'An unknown error occurred')}); + return undefined; + } finally { + releaseQuietly(imageManipulatorContext); + } +} + +/** + * Convert the picked assets one at a time, transcoding any HEIC images to JPEG. + * + * The conversion is deliberately sequential: `ImageManipulator` decodes each image into a full-size + * bitmap in native memory, so converting a whole selection at once (the picker allows up to + * `CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT` files) holds every bitmap simultaneously and the + * OS terminates the app for exceeding its memory limit. Processing one image at a time keeps the peak + * at a single bitmap regardless of how many files were picked. + */ +const processPickedAssetsSequentially: ProcessPickedAssetsFunction = async (assets, showGeneralAlert, translate) => { + const processedAssets: Asset[] = []; + // Collected instead of alerted inline so the whole selection produces a single alert: alerting per + // asset would leave the user dismissing one native modal after another. + const failureMessages = new Set(); + + for (const asset of assets) { + if (!asset.uri) { + continue; + } + + if (!asset.type?.startsWith('image')) { + // Ensure the asset has proper fileName and type + processedAssets.push(processAssetWithFallbacks(asset)); + continue; + } + + try { + // eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above + const isHEIC = await verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}); + + if (!isHEIC) { + // Ensure the asset has proper fileName and type for non-HEIC images + processedAssets.push(processAssetWithFallbacks(asset)); + continue; + } + + // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature + // eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above + const convertedAsset = await convertHeicToJpeg(asset.uri); + + if (convertedAsset) { + processedAssets.push(convertedAsset); + } else { + failureMessages.add(translate('attachmentPicker.errorWhileConvertingHeic')); + } + } catch (error) { + failureMessages.add(getErrorMessage(error, translate('attachmentPicker.errorWhileSelectingAttachment'))); + } + } + + if (failureMessages.size > 0) { + showGeneralAlert([...failureMessages].join('\n')); + } + + return processedAssets.length > 0 ? processedAssets : undefined; +}; + +export default processPickedAssetsSequentially; diff --git a/src/libs/fileDownload/processPickedAssets/index.ts b/src/libs/fileDownload/processPickedAssets/index.ts new file mode 100644 index 000000000000..7e67741049b5 --- /dev/null +++ b/src/libs/fileDownload/processPickedAssets/index.ts @@ -0,0 +1,9 @@ +import type ProcessPickedAssetsFunction from './types'; + +/** + * Web has no native image picker, so nothing reaches this module. It exists so the native + * implementation resolves through a platform-agnostic import path. + */ +const processPickedAssetsSequentially: ProcessPickedAssetsFunction = (assets) => Promise.resolve(assets.length > 0 ? assets : undefined); + +export default processPickedAssetsSequentially; diff --git a/src/libs/fileDownload/processPickedAssets/types.ts b/src/libs/fileDownload/processPickedAssets/types.ts new file mode 100644 index 000000000000..627ac6e5eb3e --- /dev/null +++ b/src/libs/fileDownload/processPickedAssets/types.ts @@ -0,0 +1,7 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import type {Asset} from 'react-native-image-picker'; + +type ProcessPickedAssetsFunction = (assets: Asset[], showGeneralAlert: (message?: string) => void, translate: LocaleContextProps['translate']) => Promise; + +export default ProcessPickedAssetsFunction; diff --git a/src/libs/getIsNarrowLayout/index.ts b/src/libs/getIsNarrowLayout/index.ts index e901f04e7d26..f483b228da22 100644 --- a/src/libs/getIsNarrowLayout/index.ts +++ b/src/libs/getIsNarrowLayout/index.ts @@ -1,5 +1,6 @@ import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import isInLandscapeMode from '@libs/isInLandscapeMode'; export default function getIsNarrowLayout() { - return getIsSmallScreenWidth(); + return getIsSmallScreenWidth() || isInLandscapeMode(); } diff --git a/src/libs/getSearchColumnContentToMeasure.ts b/src/libs/getSearchColumnContentToMeasure.ts new file mode 100644 index 000000000000..79aaacabfe8c --- /dev/null +++ b/src/libs/getSearchColumnContentToMeasure.ts @@ -0,0 +1,378 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {ExpenseReportListItemType, SearchListItem, TransactionListItemType, TransactionWithdrawalIDGroupListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {SearchColumnType} from '@components/Search/types'; + +import {textVariants} from '@styles/typography'; +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CardList, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import type {MeasurableFont} from './measureTextWidth/types'; + +import {getCompanyCardDescription} from './CardUtils'; +import {getCategoryGLCode, getDecodedLeafCategoryName, isCategoryMissing} from './CategoryUtils'; +import getBase62ReportID from './getBase62ReportID'; +import {getTagGLCode} from './PolicyUtils'; +import {getReportName} from './ReportNameUtils'; +import {getReportStatusTranslation} from './ReportUtils'; +import { + isTransactionCategoryGroupListItemType, + isTransactionListItemType, + isTransactionMerchantGroupListItemType, + isTransactionMonthGroupListItemType, + isTransactionQuarterGroupListItemType, + isTransactionReportGroupListItemType, + isTransactionTagGroupListItemType, + isTransactionWeekGroupListItemType, + isTransactionWithdrawalIDGroupListItemType, + isTransactionYearGroupListItemType, +} from './SearchUIUtils'; +import {getDecodedTagName} from './TagUtils'; +import {getDescription, getExchangeRate, getMerchantName, getTagForDisplay, getTaxName, isDeletedTransaction, isPerDiemRequest, isTimeRequest} from './TransactionUtils'; + +/** + * The data a column needs to resolve its text that doesn't travel on the transaction itself. Read once at the list + * level and passed down rather than subscribed to per row. + */ +type SearchColumnMeasurementContext = { + /** The viewer's non-personal and workspace cards, used to name the card a transaction was made on. */ + nonPersonalAndWorkspaceCards?: CardList; + + /** Every policy's categories, so a transaction's category GL code can be looked up by its policy. */ + policyCategories?: OnyxCollection; + + /** Every policy's tag lists, so a transaction's tag GL code can be looked up by its policy. */ + policyTags?: OnyxCollection; +}; + +/** The policy a transaction belongs to, which is where its category and tag GL codes are defined. */ +function getTransactionPolicyID(item: TransactionListItemType): string | undefined { + return [item.policyID, item.report?.policyID, item.policy?.id].find((policyID): policyID is string => !!policyID); +} + +/** A run of text rendered in a Search table cell, described well enough to measure how wide it renders. */ +type SearchColumnContent = { + /** The text rendered in the cell. */ + text: string | undefined; + + /** Font the text renders in. Omitted for the app's normal body text, which is what most cells use. */ + font?: MeasurableFont; +}; + +/** Width a `UserInfoCell` spends before any text: the avatar plus its trailing padding. */ +const USER_INFO_CELL_AVATAR_WIDTH = variables.avatarSizeXxxSmall + variables.spacing2; + +/** The columns whose cells render inside an `EditableCell`, and so spend width on its padding and border. */ +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.TAG, + CONST.SEARCH.TABLE_COLUMNS.DATE, + CONST.SEARCH.TABLE_COLUMNS.SUBMITTED, + CONST.SEARCH.TABLE_COLUMNS.APPROVED, + CONST.SEARCH.TABLE_COLUMNS.POSTED, + CONST.SEARCH.TABLE_COLUMNS.EXPORTED, +]); + +/** + * The Search columns sized from their content: the free-text ones that share the table's leftover space today, and so + * the ones that truncate while a short column beside them keeps room it doesn't need. + * + * Columns left out hold values of a known size (a date, an amount, a status badge, an icon) and keep their fixed + * widths, so measuring them would cost work without changing the layout. + */ +const DYNAMICALLY_SIZED_SEARCH_COLUMNS = new Set([ + CONST.SEARCH.TABLE_COLUMNS.STATUS, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, + CONST.SEARCH.TABLE_COLUMNS.CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.TAG, + CONST.SEARCH.TABLE_COLUMNS.FROM, + CONST.SEARCH.TABLE_COLUMNS.TO, + CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER, + CONST.SEARCH.TABLE_COLUMNS.PAID_BY, + CONST.SEARCH.TABLE_COLUMNS.TITLE, + CONST.SEARCH.TABLE_COLUMNS.REPORT_ID, + CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID, + CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID, + CONST.SEARCH.TABLE_COLUMNS.GROUP_BANK_ACCOUNT, + CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID, + CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, + CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK, + CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR, + CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER, + CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID, + CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID, + CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS, + CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME, + CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, + CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, + CONST.SEARCH.TABLE_COLUMNS.CARD, + CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, + CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, +]); + +/** + * Columns sized to fit their content exactly rather than sharing the row's spare space. Their content is a fixed-size + * element, not free text, so widening it past what it holds only pads the row out. + */ +const HUGGED_SEARCH_COLUMNS = new Set([CONST.SEARCH.TABLE_COLUMNS.STATUS]); + +/** Each dynamically sized column's header label, so a column can be kept wide enough to show its own heading. */ +const SEARCH_COLUMN_HEADER_TRANSLATION_KEYS: Partial> = { + [CONST.SEARCH.TABLE_COLUMNS.STATUS]: 'common.status', + [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: 'common.merchant', + [CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION]: 'common.description', + [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: 'common.category', + [CONST.SEARCH.TABLE_COLUMNS.TAG]: 'common.tag', + [CONST.SEARCH.TABLE_COLUMNS.FROM]: 'common.from', + [CONST.SEARCH.TABLE_COLUMNS.TO]: 'common.to', + [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: 'search.filters.firstApprover', + [CONST.SEARCH.TABLE_COLUMNS.PAID_BY]: 'search.filters.paidBy', + [CONST.SEARCH.TABLE_COLUMNS.TITLE]: 'common.title', + [CONST.SEARCH.TABLE_COLUMNS.REPORT_ID]: 'common.longReportID', + [CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID]: 'common.reportID', + [CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID]: 'common.withdrawalID', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_BANK_ACCOUNT]: 'common.bankAccount', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID]: 'common.withdrawalID', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY]: 'common.category', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG]: 'common.tag', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT]: 'common.merchant', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH]: 'common.month', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK]: 'common.week', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR]: 'common.year', + [CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER]: 'common.quarter', + [CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID]: 'workspace.common.customField1', + [CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID]: 'workspace.common.customField2', + [CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS]: 'common.internationalReimbursementIDs', + [CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME]: 'workspace.common.workspace', + [CONST.SEARCH.TABLE_COLUMNS.TAX_RATE]: 'iou.taxRate', + [CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE]: 'common.exchangeRate', + [CONST.SEARCH.TABLE_COLUMNS.CARD]: 'common.card', + [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]: 'common.categoryGLCode', + [CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE]: 'common.tagGLCode', +}; + +/** + * Width to add so a short value in an editable cell isn't covered by the edit button when the row is hovered. + * + * Applies to the measured value rather than to the column: a column is only ever this narrow when it has settled at + * exactly what its content needs, since anything wider already clears the button on its own. + */ +function getSearchColumnEditButtonReserve(column: SearchColumnType, contentTextWidth: number): number { + if (!EDITABLE_SEARCH_COLUMNS.has(column) || contentTextWidth >= variables.narrowEditableContentWidth) { + return 0; + } + + return variables.editableCellEditButtonWidth; +} + +/** Width a column needs on top of its text, for the non-text content its cell renders. */ +function getSearchColumnExtraWidth(column: SearchColumnType): number { + const editableCellWidth = EDITABLE_SEARCH_COLUMNS.has(column) ? variables.editableCellChromeWidth : 0; + + switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.FROM: + case CONST.SEARCH.TABLE_COLUMNS.TO: + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: + case CONST.SEARCH.TABLE_COLUMNS.PAID_BY: + return editableCellWidth + USER_INFO_CELL_AVATAR_WIDTH; + case CONST.SEARCH.TABLE_COLUMNS.STATUS: + return editableCellWidth + variables.statusBadgeChromeWidth; + default: + return editableCellWidth; + } +} + +/** + * Returns the text a column renders for one transaction, so the column can be sized from its widest value. + * + * Mirrors the cell rather than the raw field, since the two differ: a scanning expense shows a status string in place + * of its merchant, and a tag is shown with its parent levels stripped. + */ +function getTransactionColumnContentToMeasure( + column: SearchColumnType, + item: TransactionListItemType, + translate: LocalizedTranslate, + context: SearchColumnMeasurementContext = {}, +): SearchColumnContent[] { + switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.STATUS: + // The status renders in a badge rather than the row's own text style, so it is measured in the badge's font. + return [ + { + text: getReportStatusTranslation({stateNum: item.report?.stateNum, statusNum: item.report?.statusNum, isDeleted: isDeletedTransaction(item), translate}), + font: {fontSize: textVariants.finePrint.fontSize}, + }, + ]; + case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: + return [{text: getMerchantName(item, translate)}]; + case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: + return [{text: getDescription(item)}]; + case CONST.SEARCH.TABLE_COLUMNS.CATEGORY: + return [{text: isCategoryMissing(item.category) ? '' : getDecodedLeafCategoryName(item.category ?? '')}]; + case CONST.SEARCH.TABLE_COLUMNS.TAG: + return [{text: getDecodedTagName(getTagForDisplay(item))}]; + case CONST.SEARCH.TABLE_COLUMNS.FROM: + return [{text: item.formattedFrom ?? item.from?.displayName}]; + case CONST.SEARCH.TABLE_COLUMNS.TO: + return [{text: item.formattedTo ?? item.to?.displayName}]; + case CONST.SEARCH.TABLE_COLUMNS.TITLE: + return [{text: getReportName(item.report) ?? item.report?.reportName}]; + case CONST.SEARCH.TABLE_COLUMNS.REPORT_ID: + return [{text: item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? '' : item.reportID}]; + case CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID: + return [{text: item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? '' : getBase62ReportID(Number(item.reportID))}]; + case CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID: + return [{text: item.withdrawalID}]; + case CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID: + return [{text: item.report?.submitterUserID}]; + case CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID: + return [{text: item.report?.submitterPayrollID}]; + case CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS: + return [{text: item.report?.orderDealNumbers}]; + case CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME: + return [{text: item.policy?.name}]; + case CONST.SEARCH.TABLE_COLUMNS.TAX_RATE: + return [{text: isTimeRequest(item) || isPerDiemRequest(item) ? '' : (getTaxName(item.policy, item) ?? item.taxValue ?? '')}]; + case CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE: + return [{text: getExchangeRate(item, item.report?.currency ?? item.policy?.outputCurrency, true)}]; + case CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE: + return [{text: getCategoryGLCode(context.policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getTransactionPolicyID(item)}`], item.category)}]; + case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: + return [{text: getTagGLCode(context.policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getTransactionPolicyID(item)}`], item.tag)}]; + case CONST.SEARCH.TABLE_COLUMNS.CARD: + return [{text: getCompanyCardDescription(translate, item.cardName, item.cardID, context.nonPersonalAndWorkspaceCards, item.feedCountry)}]; + default: + return []; + } +} + +/** + * Returns the text an expense-report row renders for one column. + * + * A report row is a group row rather than a transaction, so its cells read report fields: the report's own name for the + * title, and the formatted display names the row was built with for the people columns. + */ +function getExpenseReportColumnContentToMeasure(column: SearchColumnType, item: ExpenseReportListItemType, translate: LocalizedTranslate): SearchColumnContent[] { + switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.STATUS: + // The status renders in a badge rather than the row's own text style, so it is measured in the badge's font. + return [{text: getReportStatusTranslation({stateNum: item.stateNum, statusNum: item.statusNum, translate}), font: {fontSize: textVariants.finePrint.fontSize}}]; + case CONST.SEARCH.TABLE_COLUMNS.TITLE: + return [{text: item.reportName}]; + case CONST.SEARCH.TABLE_COLUMNS.FROM: + return [{text: item.formattedFrom}]; + case CONST.SEARCH.TABLE_COLUMNS.TO: + return [{text: item.formattedTo}]; + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: + return [{text: item.firstApproverAccountID ? item.formattedFirstApprover : ''}]; + case CONST.SEARCH.TABLE_COLUMNS.PAID_BY: + return [{text: item.paidByAccountID ? item.formattedPaidBy : ''}]; + case CONST.SEARCH.TABLE_COLUMNS.REPORT_ID: + return [{text: item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? '' : item.reportID}]; + case CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID: + return [{text: item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? '' : getBase62ReportID(Number(item.reportID))}]; + case CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID: + return [{text: item.submitterUserID}]; + case CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID: + return [{text: item.submitterPayrollID}]; + case CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS: + return [{text: item.orderDealNumbers}]; + default: + return []; + } +} + +/** + * Returns the text a withdrawal-ID group row renders for one column, which is the Bank reconciliation table's row. + */ +function getWithdrawalIDGroupColumnContentToMeasure(column: SearchColumnType, item: TransactionWithdrawalIDGroupListItemType): SearchColumnContent[] { + switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.GROUP_BANK_ACCOUNT: { + const bankName = CONST.BANK_NAMES_USER_FRIENDLY[item.bankName] ?? CONST.BANK_NAMES_USER_FRIENDLY[CONST.BANK_NAMES.GENERIC_BANK]; + + return [{text: `${bankName} ${item.accountNumber ? `xx${item.accountNumber.slice(-4)}` : ''}`}]; + } + case CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID: + return [{text: item.entryID?.toString()}]; + default: + return []; + } +} + +/** + * Returns the name a group row renders in the column it is grouped by, for the groupings whose row is nothing more than + * that name: a category, a tag, a merchant, or a period. + * + * Returns `null` for a row that isn't one of those, so the caller can go on looking rather than treat it as empty. + */ +function getGroupNameColumnContentToMeasure(column: SearchColumnType, item: SearchListItem, translate: LocalizedTranslate): SearchColumnContent[] | null { + if (isTransactionCategoryGroupListItemType(item)) { + const category = item.formattedCategory ?? item.category; + const isUncategorized = !category || category === CONST.SEARCH.CATEGORY_EMPTY_VALUE; + + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY ? [{text: isUncategorized ? translate('reportLayout.uncategorized') : category}] : []; + } + + if (isTransactionTagGroupListItemType(item)) { + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG ? [{text: item.formattedTag ?? item.tag}] : []; + } + + if (isTransactionMerchantGroupListItemType(item)) { + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT ? [{text: item.formattedMerchant ?? item.merchant}] : []; + } + + if (isTransactionMonthGroupListItemType(item)) { + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH ? [{text: item.formattedMonth}] : []; + } + + if (isTransactionWeekGroupListItemType(item)) { + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK ? [{text: item.formattedWeek}] : []; + } + + if (isTransactionYearGroupListItemType(item)) { + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR ? [{text: item.formattedYear}] : []; + } + + if (isTransactionQuarterGroupListItemType(item)) { + return column === CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER ? [{text: item.formattedQuarter}] : []; + } + + return null; +} + +/** + * Returns the text a column renders for one row, whatever kind of row the table holds. + * + * Each kind reads its own fields, so a column measured for one is not measured the same way for another: a transaction's + * `from` is the person who spent, while a report's is the person who submitted, and they are stored differently. + */ +function getSearchColumnContentToMeasure(column: SearchColumnType, item: SearchListItem, translate: LocalizedTranslate, context: SearchColumnMeasurementContext = {}): SearchColumnContent[] { + if (isTransactionListItemType(item)) { + return getTransactionColumnContentToMeasure(column, item, translate, context); + } + + if (isTransactionReportGroupListItemType(item)) { + return getExpenseReportColumnContentToMeasure(column, item, translate); + } + + if (isTransactionWithdrawalIDGroupListItemType(item)) { + return getWithdrawalIDGroupColumnContentToMeasure(column, item); + } + + return getGroupNameColumnContentToMeasure(column, item, translate) ?? []; +} + +export default getSearchColumnContentToMeasure; +export {DYNAMICALLY_SIZED_SEARCH_COLUMNS, HUGGED_SEARCH_COLUMNS, SEARCH_COLUMN_HEADER_TRANSLATION_KEYS, getSearchColumnEditButtonReserve, getSearchColumnExtraWidth}; +export type {SearchColumnMeasurementContext}; diff --git a/src/libs/isInLandscapeMode/index.native.ts b/src/libs/isInLandscapeMode/index.native.ts index 0a0fb27d3995..e6690c0f8e21 100644 --- a/src/libs/isInLandscapeMode/index.native.ts +++ b/src/libs/isInLandscapeMode/index.native.ts @@ -1,10 +1,11 @@ import DeviceInfo from 'react-native-device-info'; +const isTablet = DeviceInfo.isTablet(); + /** * Returns whether the device is currently in landscape orientation. * Returns false on tablets. */ export default function isInLandscapeMode(windowWidth: number, windowHeight: number): boolean { - const isTablet = DeviceInfo.isTablet(); return !isTablet && windowWidth > windowHeight; } diff --git a/src/libs/isInLandscapeMode/index.ts b/src/libs/isInLandscapeMode/index.ts index b1090972fbd0..8af37386def8 100644 --- a/src/libs/isInLandscapeMode/index.ts +++ b/src/libs/isInLandscapeMode/index.ts @@ -1,7 +1,37 @@ +import {isMobile} from '@libs/Browser'; + +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 screenShortSide = Math.min(window.screen.width, window.screen.height); +const screenLongSide = Math.max(window.screen.width, window.screen.height); + +function getViewportWidth(): number { + return Dimensions.get('window').width; +} + /** - * We only want to change UI according to the landscape mode on native platforms. + * Returns whether the device is currently in landscape orientation. Returns false on tablets and non-mobile browsers. + * Derived from the width instead of the Screen Orientation API (its `change` event lands in a different task than + * `resize`, causing a flicker) and instead of the height (the soft keyboard shrinks height). */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export default function isInLandscapeMode(windowWidth: number, windowHeight: number): boolean { - return false; +function isInLandscapeMode(windowWidth: number = getViewportWidth(), windowHeight?: number): boolean { + if (!isMobilePhoneWeb) { + return false; + } + + const distanceToLongSide = Math.abs(windowWidth - screenLongSide); + const distanceToShortSide = Math.abs(windowWidth - screenShortSide); + + return distanceToLongSide < distanceToShortSide; } + +export default isInLandscapeMode; diff --git a/src/libs/measureTextWidth/widestTextMeasurer.ts b/src/libs/measureTextWidth/widestTextMeasurer.ts new file mode 100644 index 000000000000..76c17b07d537 --- /dev/null +++ b/src/libs/measureTextWidth/widestTextMeasurer.ts @@ -0,0 +1,95 @@ +import CONST from '@src/CONST'; + +import type {MeasurableFont} from './types'; + +import measureTextWidth from './index'; + +const {MEASURED_CANDIDATES_PER_COLUMN} = CONST.TABLES.DYNAMIC_COLUMNS; + +type FontCandidates = { + font: MeasurableFont; + + /** The longest strings seen for this font, ascending by length, capped at `MEASURED_CANDIDATES_PER_COLUMN`. */ + texts: string[]; +}; + +/** + * Collects the text rendered in a column and reports how wide its widest string renders. + * + * Grouped by font, since the same string renders wider in a larger or bolder one, so the longest string overall isn't + * necessarily the widest. Only the longest few per font are kept: character count approximates rendered width well + * enough to pick candidates, and measuring every row of a large table would be wasteful. + * + * They are kept in a small sorted array instead of sorting everything at the end, so adding a row costs the same at any + * table size. On tables with tens of thousands of rows, that sort would otherwise dominate. + */ +type WidestTextMeasurer = { + /** Records a string rendered in this column. Empty strings are ignored. */ + add: (text: string | undefined, font?: MeasurableFont) => void; + + /** + * Measures the widest string collected, or `null` when the platform can't measure text. Returns 0 when nothing was + * collected. + */ + getWidestWidth: () => number | null; +}; + +/** + * Inserts `text` into `texts` (ascending by length) if it is among the longest seen so far, dropping the shortest once + * the cap is reached. + */ +function keepLongestText(texts: string[], text: string) { + if (texts.length >= MEASURED_CANDIDATES_PER_COLUMN && text.length <= (texts.at(0)?.length ?? 0)) { + return; + } + + // The array holds at most `MEASURED_CANDIDATES_PER_COLUMN` entries, so this scan is bounded by that constant. + const insertionIndex = texts.findIndex((candidate) => candidate.length > text.length); + texts.splice(insertionIndex === -1 ? texts.length : insertionIndex, 0, text); + + if (texts.length > MEASURED_CANDIDATES_PER_COLUMN) { + texts.shift(); + } +} + +function createWidestTextMeasurer(): WidestTextMeasurer { + const candidatesByFont = new Map(); + + return { + add: (text, font = {}) => { + if (!text) { + return; + } + + const fontKey = `${font.fontSize ?? ''}|${font.fontWeight ?? ''}|${font.fontFamily ?? ''}`; + const existingCandidates = candidatesByFont.get(fontKey); + + if (existingCandidates) { + keepLongestText(existingCandidates.texts, text); + return; + } + + candidatesByFont.set(fontKey, {font, texts: [text]}); + }, + + getWidestWidth: () => { + let widestWidth = 0; + + for (const {font, texts} of candidatesByFont.values()) { + for (const text of texts) { + const width = measureTextWidth(text, font); + + if (width === null) { + return null; + } + + widestWidth = Math.max(widestWidth, width); + } + } + + return widestWidth; + }, + }; +} + +export default createWidestTextMeasurer; diff --git a/src/libs/memoize/cache/ArrayCache.ts b/src/libs/memoize/cache/ArrayCache.ts index 209250e47675..229810fca976 100644 --- a/src/libs/memoize/cache/ArrayCache.ts +++ b/src/libs/memoize/cache/ArrayCache.ts @@ -71,6 +71,10 @@ function ArrayCache(config: CacheConfig): Cache { return {value}; }, + clear() { + cache.length = 0; + }, + snapshot: { keys: () => cache.map((entry) => entry[0]), values: () => cache.map((entry) => entry[1]), diff --git a/src/libs/memoize/cache/types.ts b/src/libs/memoize/cache/types.ts index 0cff4932212d..53a2bd83e64c 100644 --- a/src/libs/memoize/cache/types.ts +++ b/src/libs/memoize/cache/types.ts @@ -15,6 +15,8 @@ type Cache = { * @returns The value for the key */ getSet: (key: K, valueProducer: () => V) => BoxedValue; + /** Drops every entry */ + clear: () => void; snapshot: { keys: () => K[]; values: () => V[]; diff --git a/src/libs/memoize/index.ts b/src/libs/memoize/index.ts index c29e4cc4bf8d..1defdfbf289b 100644 --- a/src/libs/memoize/index.ts +++ b/src/libs/memoize/index.ts @@ -1,6 +1,9 @@ import type NonPartial from '@src/types/utils/NonPartial'; import type TakeFirst from '@src/types/utils/TupleOperations'; +import {shallowEqual} from 'fast-equals'; +import lodashIsPlainObject from 'lodash/isPlainObject'; + import type {Callable, ClientOptions, Constructable, IsomorphicFn, IsomorphicParameters, IsomorphicReturnType, MemoizedFn, Stats} from './types'; import ArrayCache from './cache/ArrayCache'; @@ -112,4 +115,33 @@ function memoize => lodashIsPlainObject(value); + +/** + * Two arguments are equivalent when they are shallowly equal, or are plain objects whose values are shallowly equal - + * the second level covers arguments rebuilt from unchanged sources (e.g. a mapped Onyx collection). + */ +const areArgumentsEquivalent = (previousArgument: unknown, nextArgument: unknown) => { + if (shallowEqual(previousArgument, nextArgument)) { + return true; + } + if (!isPlainObject(previousArgument) || !isPlainObject(nextArgument)) { + return false; + } + const previousKeys = Object.keys(previousArgument); + if (previousKeys.length !== Object.keys(nextArgument).length) { + return false; + } + return previousKeys.every((key) => key in nextArgument && shallowEqual(previousArgument[key], nextArgument[key])); +}; + +/** + * Compares memoization keys argument by argument. Use it for functions taking large arguments that are rebuilt on every + * call from unchanged sources, where `'shallow'` always misses and `'deep'` would walk the whole payload. + */ +const equivalentArgsComparator = (previousArgs: Key, nextArgs: Key) => + previousArgs.length === nextArgs.length && previousArgs.every((argument, index) => areArgumentsEquivalent(argument, nextArgs[index])); + export default memoize; +export {equivalentArgsComparator}; diff --git a/src/libs/merge/MergeUtils.ts b/src/libs/merge/MergeUtils.ts index 3247b050dedc..8de960d45fbb 100644 --- a/src/libs/merge/MergeUtils.ts +++ b/src/libs/merge/MergeUtils.ts @@ -1,8 +1,10 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; import type useConfirmModal from '@hooks/useConfirmModal'; import DateUtils from '@libs/DateUtils'; +import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; @@ -51,6 +53,24 @@ function getMergeFinalApprover(policy: OnyxEntry, connectionName: MergeC return null; } +function getMergeFinalApproverDisplayName( + finalApprover: string | undefined | null, + policyEmployeePersonalDetails: PersonalDetailsByLogin, + translate: LocaleContextProps['translate'], + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], +): string { + if (!finalApprover) { + return translate('workspace.merge.notSet'); + } + return temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: policyEmployeePersonalDetails[finalApprover], + defaultValue: finalApprover, + shouldFallbackToHidden: false, + translate, + formatPhoneNumber, + }); +} + /** * Returns true when the user has already manually synced ("Sync now") the given Merge connection the maximum number of * times within the rolling window (e.g. 2 times in the last 24 hours). @@ -92,6 +112,7 @@ function showMergeManualSyncLimitModalIfReached( export { getMergeFinalApprover, + getMergeFinalApproverDisplayName, hasMergeAuthenticationError, hasMergeSyncError, isMergeConnected, diff --git a/src/libs/merge/RecruitingUtils.ts b/src/libs/merge/RecruitingUtils.ts index e3b5c39ce57a..ec5b14d454b4 100644 --- a/src/libs/merge/RecruitingUtils.ts +++ b/src/libs/merge/RecruitingUtils.ts @@ -42,6 +42,11 @@ function getConnectedATSProvider(policy: OnyxEntry): RecruitingProviderI return null; } +/** Returns true when any recruiting (ATS) provider is connected to the policy. */ +function isAnyRecruitingConnected(policy: OnyxEntry): boolean { + return isMergeConnected(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS); +} + /** Formats one Merge ATS filter dimension into a display label, or undefined when nothing is selected for it. */ function getFilterDimensionLabel(selectedNames: string[] | undefined): string | undefined { if (!selectedNames?.length) { @@ -111,6 +116,7 @@ export { getMergeATSOfficesLabel, getMergeATSStagesLabel, getMergeATSTagsLabel, + isAnyRecruitingConnected, isMergeATSCompleteSetupNeeded, shouldShowRecruitingConnectionError, }; diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index c19112f0c855..848d9f3fc287 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 ConvertGpsPointsTo2DArray from './migrations/ConvertGpsPointsTo2DArray'; import MoveFilesOutOfDocuments from './migrations/MoveFilesOutOfDocuments'; +import ReplaceShouldUseStagingServerWithActiveServer from './migrations/ReplaceShouldUseStagingServerWithActiveServer'; import {endSpan, getSpan, startSpan} from './telemetry/activeSpans'; export default function () { @@ -17,7 +18,7 @@ export default function () { }); // Add all migrations to an array so they are executed in order - const migrationPromises: Array<() => Promise> = [ConvertGpsPointsTo2DArray, MoveFilesOutOfDocuments]; + const migrationPromises: Array<() => Promise> = [ConvertGpsPointsTo2DArray, MoveFilesOutOfDocuments, ReplaceShouldUseStagingServerWithActiveServer]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts index 6fd3dc60ff21..4fef50c58a13 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts @@ -5,11 +5,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Attachment} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import RNFS from 'react-native-fs'; import Onyx from 'react-native-onyx'; -import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; const OLD_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; const NEW_ATTACHMENT_DIR = `${RNFS.CachesDirectoryPath}/attachments`; @@ -62,7 +61,16 @@ function moveAttachmentCache(): Promise { * later launches skip it entirely. */ function updateAttachmentRecordPaths(): Promise { - return OnyxUtils.get(ONYXKEYS.ATTACHMENT_RECORD_PATHS_MIGRATED).then((hasMigrated) => { + return new Promise>((resolve) => { + // The migration runs before React renders, so its persisted flag cannot be read with useOnyx or supplied by a component + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.ATTACHMENT_RECORD_PATHS_MIGRATED, + callback: (hasMigrated) => { + Onyx.disconnect(connection); + resolve(hasMigrated); + }, + }); + }).then((hasMigrated) => { if (hasMigrated) { return; } diff --git a/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts b/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts new file mode 100644 index 000000000000..6b1f04eafa74 --- /dev/null +++ b/src/libs/migrations/ReplaceShouldUseStagingServerWithActiveServer.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- draining SHOULD_USE_STAGING_SERVER is this file's entire purpose */ +import Log from '@libs/Log'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {OnyxKey, OnyxMultiSetInput, OnyxValue} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** Onyx has no promise-based read. */ +function readOnce(key: TKey): Promise> { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); +} + +/** + * A missing legacy key must write NOTHING: `resolveActiveServer` derives the environment default from + * `value === undefined`, so seeding 'production' would pin every staging and adhoc build to production. + */ +export default async function ReplaceShouldUseStagingServerWithActiveServer(): Promise { + const shouldUseStagingServer = await readOnce(ONYXKEYS.SHOULD_USE_STAGING_SERVER); + if (shouldUseStagingServer === undefined) { + return; + } + + const updates: OnyxMultiSetInput = {[ONYXKEYS.SHOULD_USE_STAGING_SERVER]: null}; + if ((await readOnce(ONYXKEYS.ACTIVE_SERVER)) === undefined) { + updates[ONYXKEYS.ACTIVE_SERVER] = shouldUseStagingServer ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION; + } + + await Onyx.multiSet(updates); + Log.info('[Migrate Onyx] Ran ReplaceShouldUseStagingServerWithActiveServer migration'); +} diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 080249e8d061..24fd3910f569 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -1,4 +1,4 @@ -import checkFileExists from '@libs/fileDownload/checkFileExists'; +import {checkFileExistsWithReason} from '@libs/fileDownload/checkFileExists'; import {readFileAsync} from '@libs/fileDownload/FileUtils'; import ReceiptStorage from '@libs/ReceiptStorage'; import {logReceiptDropped} from '@libs/telemetry/ReceiptObservability'; @@ -35,10 +35,10 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf const localUri = ReceiptStorage.resolve(source) ?? source; - return checkFileExists(localUri).then((exists) => { + return checkFileExistsWithReason(localUri).then(({exists, error}) => { if (!exists) { const transactionID = typeof data.transactionID === 'string' ? data.transactionID : undefined; - logReceiptDropped({receiptTraceId, transactionID, command, source, fileName: name}); + logReceiptDropped({receiptTraceId, transactionID, command, source, fileName: name, statError: error}); return; } const receiptFormData = { diff --git a/src/libs/telemetry/ReceiptObservability.ts b/src/libs/telemetry/ReceiptObservability.ts index bc95ca0b8be7..a00475406e8a 100644 --- a/src/libs/telemetry/ReceiptObservability.ts +++ b/src/libs/telemetry/ReceiptObservability.ts @@ -4,12 +4,14 @@ import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {getIsOffline} from '@libs/NetworkState'; import {rand64} from '@libs/NumberUtils'; +import {isInDurableFolder} from '@libs/ReceiptStorage/durableFolder'; +import type {SignOutReason} from '@src/CONST'; import CONST from '@src/CONST'; +import type {ReceiptSource} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; -/** Prefix on every receipt log line so we can filter the logs without parsing free text. */ -const RECEIPT_LOG_PREFIX = '[Receipt]'; +import RECEIPT_LOG_PREFIX from './receiptLogPrefix'; /** Points in the app lifecycle where we snapshot the receipts that are still pending. */ type ReceiptSnapshotTrigger = 'signOut' | 'background' | 'foreground'; @@ -25,6 +27,24 @@ function getPickerCaptureSource(): ReceiptCaptureSource { return getPlatform() === CONST.PLATFORM.WEB ? 'file' : 'gallery'; } +/** The receipt as it sits on a persisted request, which is all a snapshot can read once the queue is waiting. */ +type QueuedReceipt = { + /** Joins this receipt back to its capture, submit and enqueue log lines. */ + receiptTraceId?: string; + + /** When the upload reached the write queue. Persisted with the request, so it survives an app restart. */ + receiptEnqueuedAt?: number; + + /** Where the file was stored. A remote URL once the server has it. */ + source?: ReceiptSource; + + /** The path on the device that created it, kept so a remote source does not trigger a reload. */ + localSource?: ReceiptSource; + + /** REPLACE_RECEIPT queues the raw File object, which keeps its local path here rather than on `source`. */ + uri?: string; +}; + /** Inputs for the enqueued milestone, taken when the receipt request reaches the write queue. */ type ReceiptEnqueuedParams = { receiptTraceId: string | undefined; @@ -54,6 +74,11 @@ const RECEIPT_BEARING_COMMANDS = new Set([ /** When each receipt was enqueued, keyed by transaction id, so a snapshot can report how long it has waited. */ const enqueuedAtByTransactionID = new Map(); +/** Receipts already reported by a teardown snapshot, keyed by trace id, so two redirects racing do not double-report. */ +const clearReportedAtByReceipt = new Map(); + +const CLEAR_DEDUPE_MS = 60 * 1000; + /** * Upper bound on the enqueue timing map. The snapshot path normally drains it, but a session that never backgrounds * or signs out, like a long-lived web tab, would keep adding one entry per receipt. Once we pass this cap we drop the @@ -144,8 +169,9 @@ function logReceiptEnqueued({receiptTraceId, transactionID, command, persistedQu receiptTraceId, transactionID, command, - isOffline: getIsOffline(), + isOffline: String(getIsOffline()), persistedQueueLength, + platform: getPlatform(), }); } @@ -161,12 +187,14 @@ function logReceiptDropped({ command, source, fileName, + statError, }: { receiptTraceId: string | undefined; transactionID: string | undefined; command: string; source: string | undefined; fileName: string | undefined; + statError: {message: string; code?: string} | undefined; }) { Log.alert(`${RECEIPT_LOG_PREFIX} dropped`, { event: 'dropped', @@ -175,6 +203,31 @@ function logReceiptDropped({ command, source, fileName, + statErrorCode: statError?.code, + statError: statError?.message, + }); +} + +function logReceiptGaveUp({ + receiptTraceId, + transactionID, + command, + errorMessage, + errorName, +}: { + receiptTraceId: string | undefined; + transactionID: string | undefined; + command: string; + errorMessage: string | undefined; + errorName?: string; +}) { + Log.alert(`${RECEIPT_LOG_PREFIX} gaveUp`, { + event: 'gaveUp', + receiptTraceId, + transactionID, + command, + errorMessage, + errorName, }); } @@ -193,15 +246,39 @@ function logReceiptAdoptFailed({error, captureSource}: {error: unknown; captureS }); } +function getQueuedReceiptPath(receipt: QueuedReceipt): ReceiptSource | undefined { + return receipt.localSource ?? receipt.source ?? receipt.uri; +} + +/** One pending receipt, as it will be reported on a snapshot line. */ +type PendingReceiptRow = { + /** Joins the row back to the capture log. A row without one cannot be correlated. */ + receiptTraceId: string | undefined; + + /** Absent for commands that do not carry a transaction id. */ + transactionID: string | undefined; + + /** The write command holding the receipt, e.g. RequestMoney or ReplaceReceipt. */ + command: string; + + /** How long the receipt has waited in the queue. Undefined when the enqueue time was never recorded. */ + msSinceEnqueued: number | undefined; + + /** Whether the stored path points into the receipts folder. A path check, not proof the file is still there. */ + isSourceInDurableFolder: boolean; +}; + /** * Logs one line per receipt still pending in the write queue, tagged with what triggered the snapshot. Stays quiet * when nothing is pending, so the normal case makes no noise. Sent right away so it survives a hard app kill from the * background. */ -function logReceiptQueueSnapshot(trigger: ReceiptSnapshotTrigger) { +function logReceiptQueueSnapshot(trigger: ReceiptSnapshotTrigger, reason?: SignOutReason) { const isOffline = getIsOffline(); const now = Date.now(); + const isClear = trigger === 'signOut'; const pendingTransactionIDs = new Set(); + const rows: PendingReceiptRow[] = []; // Include the ongoing request. Once processNextRequest moves a receipt into the ongoing slot it is the one // actively uploading, but it no longer shows up in getAll. Without this we would skip it here and then drop it @@ -214,7 +291,7 @@ function logReceiptQueueSnapshot(trigger: ReceiptSnapshotTrigger) { continue; } - const data = (request.data ?? {}) as {transactionID?: string; receipt?: {receiptTraceId?: string}}; + const data = (request.data ?? {}) as {transactionID?: string; receipt?: QueuedReceipt}; // Skip when there is no receipt at data.receipt. SplitBill nests it inside the splits JSON, and SendMoney with // no attached receipt has no receipt field. A row without a trace id cannot be joined to the capture log, so // it would only add noise to the snapshot. @@ -225,16 +302,45 @@ function logReceiptQueueSnapshot(trigger: ReceiptSnapshotTrigger) { if (transactionID) { pendingTransactionIDs.add(transactionID); } - const enqueuedAt = transactionID ? enqueuedAtByTransactionID.get(transactionID) : undefined; - Log.info(`${RECEIPT_LOG_PREFIX} queue snapshot`, true, { - event: 'snapshot', - trigger, + // Receipts queued before receiptTraceId existed have none, so fall back to the request's own client index + // rather than the transaction id: ReplaceReceipt can queue twice against one transaction, and keying on the + // transaction would collapse the second row. + const dedupeKey = data.receipt.receiptTraceId ?? (request.requestIndex !== undefined ? `request-${request.requestIndex}` : transactionID); + if (isClear && dedupeKey) { + const reportedAt = clearReportedAtByReceipt.get(dedupeKey); + if (reportedAt !== undefined && now - reportedAt < CLEAR_DEDUPE_MS) { + continue; + } + clearReportedAtByReceipt.set(dedupeKey, now); + } + + const enqueuedAt = data.receipt.receiptEnqueuedAt ?? (transactionID ? enqueuedAtByTransactionID.get(transactionID) : undefined); + + rows.push({ receiptTraceId: data.receipt.receiptTraceId, transactionID, command: request.command, msSinceEnqueued: enqueuedAt !== undefined ? now - enqueuedAt : undefined, - isOffline, + isSourceInDurableFolder: isInDurableFolder(getQueuedReceiptPath(data.receipt)), + }); + } + + const snapshotID = rand64(); + for (const row of rows) { + Log.info(`${RECEIPT_LOG_PREFIX} queue snapshot`, true, { + event: 'snapshot', + trigger, + reason, + snapshotID, + pendingReceiptCount: rows.length, + receiptTraceId: row.receiptTraceId, + transactionID: row.transactionID, + command: row.command, + msSinceEnqueued: row.msSinceEnqueued, + isSourceInDurableFolder: String(row.isSourceInDurableFolder), + isOffline: String(isOffline), + platform: getPlatform(), }); } @@ -245,6 +351,15 @@ function logReceiptQueueSnapshot(trigger: ReceiptSnapshotTrigger) { } enqueuedAtByTransactionID.delete(transactionID); } + + // Expiry is the only thing that drops an entry. Keying this on what the current snapshot emitted would let a + // background snapshot, which never writes to the map, wipe the guard a teardown snapshot just set. + for (const [key, reportedAt] of clearReportedAtByReceipt) { + if (now - reportedAt < CLEAR_DEDUPE_MS) { + continue; + } + clearReportedAtByReceipt.delete(key); + } } export { @@ -253,6 +368,7 @@ export { logReceiptSubmitted, logReceiptEnqueued, logReceiptDropped, + logReceiptGaveUp, logReceiptStatFailed, logReceiptAdoptFailed, logReceiptQueueSnapshot, diff --git a/src/libs/telemetry/activeSpans.ts b/src/libs/telemetry/activeSpans.ts index e6e97833e9c1..f079b6b6a956 100644 --- a/src/libs/telemetry/activeSpans.ts +++ b/src/libs/telemetry/activeSpans.ts @@ -6,13 +6,17 @@ import {SPAN_STATUS_OK, spanTimeInputToSeconds} from '@sentry/core'; import * as Sentry from '@sentry/react-native'; import {AppState} from 'react-native'; +import logBenchmarkSpanEnd, {isBenchmarkSpanEnabled} from './logBenchmarkSpanEnd'; + type ActiveSpanEntry = { span: ReturnType; + spanName: string; startTimeForLog: number; }; const activeSpans = new Map(); +/** Converts an optional Sentry epoch start time into the `performance.now()` clock used for monotonic duration logging. */ function getPerformanceStartTimeForLog(startTime: StartSpanOptions['startTime']): number { const performanceTimestamp = performance.now(); if (startTime === undefined) { @@ -25,7 +29,7 @@ function getPerformanceStartTimeForLog(startTime: StartSpanOptions['startTime']) } function startSpan(spanId: string, options: StartSpanOptions) { - if ((AppState.currentState ?? CONST.APP_STATE.ACTIVE) !== CONST.APP_STATE.ACTIVE) { + if ((AppState.currentState ?? CONST.APP_STATE.ACTIVE) !== CONST.APP_STATE.ACTIVE && !isBenchmarkSpanEnabled(options.name)) { return; } // End any existing span for this name @@ -39,7 +43,7 @@ function startSpan(spanId: string, options: StartSpanOptions) { const startTimeForLog = getPerformanceStartTimeForLog(options.startTime); - activeSpans.set(spanId, {span, startTimeForLog}); + activeSpans.set(spanId, {span, spanName: options.name, startTimeForLog}); return span; } @@ -50,10 +54,14 @@ function endSpan(spanId: string) { if (!entry) { return; } - const {span, startTimeForLog} = entry; + const {span, spanName, startTimeForLog} = entry; const performanceTimestamp = performance.now(); const durationMs = Math.round(performanceTimestamp - startTimeForLog); - console.debug(`[Sentry][${spanId}] Ending span (${durationMs}ms)`, {spanId, durationMs, timestamp: Date.now(), attributes: Sentry.spanToJSON(span).data}); + const attributes = Sentry.spanToJSON(span).data ?? {}; + console.debug(`[Sentry][${spanId}] Ending span (${durationMs}ms)`, {spanId, durationMs, timestamp: Date.now(), attributes}); + if (attributes[CONST.TELEMETRY.ATTRIBUTE_CANCELED] !== true) { + logBenchmarkSpanEnd(spanName, durationMs); + } span.setStatus({code: SPAN_STATUS_OK}); span.setAttribute(CONST.TELEMETRY.ATTRIBUTE_FINISHED_MANUALLY, true); @@ -79,10 +87,18 @@ function cancelAllSpans() { } } +// Reverse insertion order is children-first. Sentry's isFullFinishedSpan filter drops any descendant that has not ended when the root span does. function cancelSpansByPrefix(prefix: string) { - for (const [spanID] of activeSpans.entries()) { - if (spanID.startsWith(prefix)) { - cancelSpan(spanID); + const spanIDs = [...activeSpans.keys()].filter((spanID) => spanID.startsWith(prefix)); + for (const spanID of spanIDs.reverse()) { + cancelSpan(spanID); + } +} + +function getSpanID(target: Span) { + for (const [spanID, entry] of activeSpans.entries()) { + if (entry.span === target) { + return spanID; } } } @@ -92,15 +108,14 @@ function cancelSpansByPrefix(prefix: string) { * only has the raw span). Optionally stamps attributes first. No-op if the span isn't tracked. */ function cancelSpanByInstance(target: Span, attributes?: Record) { - for (const [spanID, entry] of activeSpans.entries()) { - if (entry.span === target) { - if (attributes) { - entry.span.setAttributes(attributes); - } - cancelSpan(spanID); - return; - } + const spanID = getSpanID(target); + if (!spanID) { + return; + } + if (attributes) { + activeSpans.get(spanID)?.span.setAttributes(attributes); } + cancelSpan(spanID); } function getSpan(spanId: string) { @@ -116,10 +131,24 @@ function getSpanByPrefix(prefix: string) { } } +function getUniqueSpanByPrefix(prefix: string) { + let uniqueSpan: Span | undefined; + for (const [spanID, entry] of activeSpans.entries()) { + if (!spanID.startsWith(prefix)) { + continue; + } + if (uniqueSpan) { + return undefined; + } + uniqueSpan = entry.span; + } + return uniqueSpan; +} + function endSpanWithAttributes(spanId: string, attributes: Record) { const span = getSpan(spanId); span?.setAttributes(attributes); endSpan(spanId); } -export {startSpan, endSpan, endSpanWithAttributes, getSpan, getSpanByPrefix, cancelSpan, cancelSpanByInstance, cancelAllSpans, cancelSpansByPrefix}; +export {startSpan, endSpan, endSpanWithAttributes, getSpan, getSpanByPrefix, getUniqueSpanByPrefix, getSpanID, cancelSpan, cancelSpanByInstance, cancelAllSpans, cancelSpansByPrefix}; diff --git a/src/libs/telemetry/forwardLogsToSentry.ts b/src/libs/telemetry/forwardLogsToSentry.ts index a3ca176701ec..173600f8a68c 100644 --- a/src/libs/telemetry/forwardLogsToSentry.ts +++ b/src/libs/telemetry/forwardLogsToSentry.ts @@ -42,7 +42,27 @@ type ForwardedLogPrefix = TupleToUnion; * receipt keys tied to the receipt logs instead of widening the global whitelist. */ const PREFIX_SCOPED_PARAMETERS_WHITELIST = new Map>([ - ['[Receipt]', ['receiptTraceId', 'transactionID', 'event', 'captureSource', 'code']], + [ + '[Receipt]', + [ + 'receiptTraceId', + 'transactionID', + 'event', + 'captureSource', + 'statErrorCode', + 'code', + 'errorMessage', + 'errorName', + 'key', + 'trigger', + 'reason', + 'platform', + 'snapshotID', + 'isSourceInDurableFolder', + 'msSinceEnqueued', + 'pendingReceiptCount', + ], + ], ['[PDFStall]', ['reportID']], ['[OpenReportStall]', ['reportID']], ['[withNavigationFallback]', ['method', 'stack']], diff --git a/src/libs/telemetry/logBenchmarkSpanEnd.ts b/src/libs/telemetry/logBenchmarkSpanEnd.ts new file mode 100644 index 000000000000..8332d5691815 --- /dev/null +++ b/src/libs/telemetry/logBenchmarkSpanEnd.ts @@ -0,0 +1,56 @@ +import writeBenchmarkLog from './writeBenchmarkLog'; + +const BENCHMARK_LOG_TAG = '[EXPENSIFY_BENCHMARK]'; +const configuredSpanNames = String(process.env.EXPO_PUBLIC_BENCHMARK_SENTRY_SPANS ?? ''); + +type BenchmarkSpanEnd = { + event: 'span_end'; + span: string; + durationMs: number; + timestamp: number; +}; + +/** Creates a logger that serializes completed spans only when their names are present in the benchmark allowlist. */ +function createBenchmarkSpanEndLogger(spanNames: string[], writeLog: (message: string, spanName: string) => void): (spanName: string, durationMs: number) => void { + const enabledSpanNames = new Set(spanNames); + + return (spanName, durationMs) => { + if (!enabledSpanNames.has(spanName)) { + return; + } + + const event: BenchmarkSpanEnd = { + event: 'span_end', + span: spanName, + durationMs, + timestamp: Date.now(), + }; + writeLog(`${BENCHMARK_LOG_TAG} ${JSON.stringify(event)}`, spanName); + }; +} + +const enabledBenchmarkSpanNames = new Set(parseBenchmarkSpanNames(configuredSpanNames)); +const logBenchmarkSpanEnd = createBenchmarkSpanEndLogger([...enabledBenchmarkSpanNames], writeBenchmarkLog); + +function isBenchmarkSpanEnabled(spanName: string): boolean { + return enabledBenchmarkSpanNames.has(spanName); +} + +/** Parses the build-time span allowlist, trimming entries and removing duplicates. */ +function parseBenchmarkSpanNames(value = ''): string[] { + if (!value) { + return []; + } + + return [ + ...new Set( + value + .split(',') + .map((spanName) => spanName.trim()) + .filter(Boolean), + ), + ]; +} + +export {BENCHMARK_LOG_TAG, createBenchmarkSpanEndLogger, isBenchmarkSpanEnabled, parseBenchmarkSpanNames}; +export default logBenchmarkSpanEnd; diff --git a/src/libs/telemetry/receiptLogPrefix.ts b/src/libs/telemetry/receiptLogPrefix.ts new file mode 100644 index 000000000000..9aaf568765dd --- /dev/null +++ b/src/libs/telemetry/receiptLogPrefix.ts @@ -0,0 +1,8 @@ +/** + * Prefix on every receipt log line, so the logs can be filtered without parsing free text. It lives on its own rather + * than in ReceiptObservability because validateFormDataParameter runs for every parameter of every request and would + * otherwise pull in PersistedRequests and the API command list just to read this string. + */ +const RECEIPT_LOG_PREFIX = '[Receipt]'; + +export default RECEIPT_LOG_PREFIX; diff --git a/src/libs/telemetry/sendMessageSpans.ts b/src/libs/telemetry/sendMessageSpans.ts new file mode 100644 index 000000000000..aa7c35824fec --- /dev/null +++ b/src/libs/telemetry/sendMessageSpans.ts @@ -0,0 +1,90 @@ +// Lifecycle for the Propagate and PostCommit child spans of ManualSendMessageVisible. Keyed by reportActionID, and every function no-ops unless the parent span is active. + +import CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import {cancelSpan, cancelSpansByPrefix, endSpan, getSpan, startSpan} from './activeSpans'; + +type SendMessagePhase = ValueOf; + +const SEND_MESSAGE_VISIBLE_PREFIX = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_`; + +function getSendMessageVisibleSpanID(reportActionID: string) { + return `${SEND_MESSAGE_VISIBLE_PREFIX}${reportActionID}`; +} + +function getSendMessageVisibleSpan(reportActionID: string) { + return getSpan(getSendMessageVisibleSpanID(reportActionID)); +} + +function getPhaseSpanID(reportActionID: string, phase: SendMessagePhase) { + return `${phase}_${reportActionID}`; +} + +function isPhaseRunning(reportActionID: string, phase: SendMessagePhase) { + return !!getSpan(getPhaseSpanID(reportActionID, phase)); +} + +function startSendMessagePhase(reportActionID: string | undefined, phase: SendMessagePhase) { + if (!reportActionID) { + return; + } + // `startInactiveSpan` with an undefined `parentSpan` falls back to the scope's active span, which would nest the phase under an unrelated transaction. + const parentSpan = getSendMessageVisibleSpan(reportActionID); + if (!parentSpan) { + return; + } + startSpan(getPhaseSpanID(reportActionID, phase), {name: phase, op: phase, parentSpan}); +} + +function endSendMessagePhase(reportActionID: string | undefined, phase: SendMessagePhase) { + if (!reportActionID) { + return; + } + endSpan(getPhaseSpanID(reportActionID, phase)); +} + +// Call from a layout effect. React runs those inside the commit, before layout. A passive effect has no such guarantee. +function markSendMessageCommitted(reportActionID: string | undefined) { + if (!reportActionID || !isPhaseRunning(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE)) { + return; + } + endSendMessagePhase(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); + startSendMessagePhase(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT); +} + +// Call before ending the parent. Sentry drops descendants that have not ended when the root span does. +function endSendMessagePhases(reportActionID: string | undefined) { + if (!reportActionID || !getSendMessageVisibleSpan(reportActionID)) { + return; + } + for (const phase of Object.values(CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE)) { + if (phase === CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT) { + continue; + } + cancelSpan(getPhaseSpanID(reportActionID, phase)); + } + endSendMessagePhase(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT); +} + +// Call before cancelling the parent. Sentry drops descendants that have not ended when the root span does. +function cancelSendMessagePhases(parentSpanID: string | undefined) { + if (!parentSpanID?.startsWith(SEND_MESSAGE_VISIBLE_PREFIX)) { + return; + } + const reportActionID = parentSpanID.slice(SEND_MESSAGE_VISIBLE_PREFIX.length); + for (const phase of Object.values(CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE)) { + cancelSpan(getPhaseSpanID(reportActionID, phase)); + } +} + +// Call on navigate-away, where the reportActionID is unknown. Phase span ids do not share the parent's prefix, so sweeping only the parent leaks them. +function cancelAllSendMessageSpans() { + for (const phase of Object.values(CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE)) { + cancelSpansByPrefix(phase); + } + cancelSpansByPrefix(SEND_MESSAGE_VISIBLE_PREFIX); +} + +export {getSendMessageVisibleSpanID, startSendMessagePhase, markSendMessageCommitted, endSendMessagePhases, cancelSendMessagePhases, cancelAllSendMessageSpans}; diff --git a/src/libs/telemetry/useSendMessageSpanMarks.ts b/src/libs/telemetry/useSendMessageSpanMarks.ts new file mode 100644 index 000000000000..63ec7b4db41e --- /dev/null +++ b/src/libs/telemetry/useSendMessageSpanMarks.ts @@ -0,0 +1,25 @@ +// Phase-span marks for a sent comment row. A layout effect ends Propagate and starts PostCommit. The returned handler goes on onLayout and ends the remaining phase and the parent span. + +import {useLayoutEffect} from 'react'; + +import {endSpan} from './activeSpans'; +import {endSendMessagePhases, getSendMessageVisibleSpanID, markSendMessageCommitted} from './sendMessageSpans'; + +function useSendMessageSpanMarks(reportActionID: string | undefined) { + useLayoutEffect(() => { + if (!reportActionID) { + return; + } + markSendMessageCommitted(reportActionID); + }, [reportActionID]); + + return () => { + if (!reportActionID) { + return; + } + endSendMessagePhases(reportActionID); + endSpan(getSendMessageVisibleSpanID(reportActionID)); + }; +} + +export default useSendMessageSpanMarks; diff --git a/src/libs/telemetry/writeBenchmarkLog/index.ios.ts b/src/libs/telemetry/writeBenchmarkLog/index.ios.ts new file mode 100644 index 000000000000..a3cfb5c0c473 --- /dev/null +++ b/src/libs/telemetry/writeBenchmarkLog/index.ios.ts @@ -0,0 +1,24 @@ +// cspell:ignore devicectl + +import RNFS from 'react-native-fs'; + +const BENCHMARK_DIRECTORY_NAME = 'ExpensifyBenchmark'; + +function writeBenchmarkLog(message: string, spanName: string): void { + // Keep the warning for developers inspecting unified device logs manually. + // eslint-disable-next-line no-console + console.warn(message); + + // Android can collect the warning from adb logcat for the launched process. + // CoreDevice does not expose an equivalent app log stream, so iOS also persists a marker that devicectl can copy from the app container. + const directoryPath = `${RNFS.CachesDirectoryPath}/${BENCHMARK_DIRECTORY_NAME}`; + const markerPath = `${directoryPath}/${encodeURIComponent(spanName)}.log`; + RNFS.mkdir(directoryPath) + .then(() => RNFS.writeFile(markerPath, message, 'utf8')) + .catch((error: Error) => { + // eslint-disable-next-line no-console + console.warn(`Failed to persist benchmark span ${spanName}.`, error); + }); +} + +export default writeBenchmarkLog; diff --git a/src/libs/telemetry/writeBenchmarkLog/index.ts b/src/libs/telemetry/writeBenchmarkLog/index.ts new file mode 100644 index 000000000000..e743155d0257 --- /dev/null +++ b/src/libs/telemetry/writeBenchmarkLog/index.ts @@ -0,0 +1,7 @@ +function writeBenchmarkLog(message: string): void { + // Production builds strip console.info/debug/log. Warnings are retained and are not forwarded by the app's Sentry console integration. + // eslint-disable-next-line no-console + console.warn(message); +} + +export default writeBenchmarkLog; diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index 7532bd243b83..f643713e4b99 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -23,6 +23,11 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; } + // Detect folders before receipt-specific extension and size checks so they retain the folder error. + if (isDataTransferItemDirectory(item)) { + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED}; + } + if (isValidatingReceipts && !isValidReceiptExtension(file)) { return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE}; } @@ -50,10 +55,6 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; } - if (isDataTransferItemDirectory(item)) { - return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED}; - } - const normalizedFile = await normalizeFileObject(fileObject); try { await validateImageForCorruption(normalizedFile); diff --git a/src/libs/validateFormDataParameter.ts b/src/libs/validateFormDataParameter.ts index dd46c97a7941..7337ece03fa9 100644 --- a/src/libs/validateFormDataParameter.ts +++ b/src/libs/validateFormDataParameter.ts @@ -1,4 +1,14 @@ import isFileUploadable from './isFileUploadable'; +import Log from './Log'; +import RECEIPT_LOG_PREFIX from './telemetry/receiptLogPrefix'; + +function getReceiptTraceId(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const {receiptTraceId} = value as {receiptTraceId?: unknown}; + return typeof receiptTraceId === 'string' ? receiptTraceId : undefined; +} /** * Ensures no value of type `object` other than null, Blob, its subclasses, or {uri: string} (native platforms only) is passed to XMLHttpRequest. @@ -21,8 +31,26 @@ function validateFormDataParameter(command: string, key: string, value: unknown) }; if (!isValid(value, true)) { - // eslint-disable-next-line no-console - console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`); + const receiptTraceId = getReceiptTraceId(value); + + // This runs for every key of every command, so only tag the receipt parameter to keep unrelated failures out + // of the [Receipt] queries and out of Sentry, which allow-lists on the prefix. + const isReceiptParameter = key === 'receipt' || receiptTraceId !== undefined; + const message = `${isReceiptParameter ? RECEIPT_LOG_PREFIX : '[FormData]'} An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`; + const parameters = { + event: 'unsupportedParameter', + command, + key, + receiptTraceId, + }; + + if (isReceiptParameter) { + Log.alert(message, parameters); + return; + } + + // Lower severity until we know the rate: this was a console.warn that never left the device, so it could flood. + Log.hmmm(message, parameters); } } diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index 22d38687e563..7c95c84d7044 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -25,11 +25,9 @@ import {View} from 'react-native'; type AddressPageProps = { /** User's private personal details */ address?: Address; - /** Whether app is loading */ isLoadingApp: OnyxEntry; /** Function to call when address form is submitted */ updateAddress: (values: FormOnyxValues) => void; - /** Title of address page */ title: string; /** diff --git a/src/pages/ChronosScheduleOOOPage.tsx b/src/pages/ChronosScheduleOOOPage.tsx index 602749a0c365..4db767d32ed7 100644 --- a/src/pages/ChronosScheduleOOOPage.tsx +++ b/src/pages/ChronosScheduleOOOPage.tsx @@ -36,6 +36,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/ChronosScheduleOOOForm'; import {differenceInCalendarDays} from 'date-fns'; +import {Str} from 'expensify-common'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -74,7 +75,7 @@ function ChronosScheduleOOOPage({route}: ChronosScheduleOOOPageProps) { {value: CONST.CHRONOS.OOO_LEAVE_TYPES.NORMAL, label: translate('chronos.normalOOO')}, ...Object.values(CONST.CHRONOS.OOO_LEAVE_TYPES) .filter((leaveType) => leaveType !== CONST.CHRONOS.OOO_LEAVE_TYPES.NORMAL) - .map((leaveType) => ({value: leaveType, label: leaveType})), + .map((leaveType) => ({value: leaveType, label: Str.recapitalize(leaveType)})), ]; const selectedLeaveTypeItem = leaveTypeItems.find((item) => item.value === selectedLeaveType); const shouldShowReason = selectedLeaveType === CONST.CHRONOS.OOO_LEAVE_TYPES.NORMAL; @@ -317,7 +318,7 @@ function ChronosScheduleOOOPage({route}: ChronosScheduleOOOPageProps) { setIsLeaveTypeModalVisible(true)} diff --git a/src/pages/Debug/ConstantSelector.tsx b/src/pages/Debug/ConstantSelector.tsx index f6863ba881f6..0a532792cc0a 100644 --- a/src/pages/Debug/ConstantSelector.tsx +++ b/src/pages/Debug/ConstantSelector.tsx @@ -23,7 +23,6 @@ type ConstantSelectorProps = { /** Current selected constant */ value?: string; - /** Name of the field */ name: string; /** inputID used by the Form component */ @@ -35,7 +34,7 @@ type ConstantSelectorProps = { policyID?: string; - // The ref is required by InputWrapper, even though it's not used in this component yet. + /** The ref is required by InputWrapper, even though it's not used in this component yet */ ref: ForwardedRef; }; diff --git a/src/pages/Debug/DateTimeSelector.tsx b/src/pages/Debug/DateTimeSelector.tsx index bd28c50f308e..0851979cfd12 100644 --- a/src/pages/Debug/DateTimeSelector.tsx +++ b/src/pages/Debug/DateTimeSelector.tsx @@ -22,14 +22,13 @@ type DateTimeSelectorProps = { /** Current datetime */ value?: string; - /** Name of the field */ name: string; /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types inputID: string; - // The ref is required by InputWrapper, even though it's not used in this component yet. + /** The ref is required by InputWrapper, even though it's not used in this component yet */ ref?: ForwardedRef; }; diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 9a192904aab9..5638653051c7 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -39,10 +39,7 @@ type DebugDetailsProps = { /** The report or report action data to be displayed and edited. */ data: OnyxEntry | OnyxEntry | OnyxEntry | OnyxEntry; - /** Whether the provided policy has enabled tags */ policyHasEnabledTags?: boolean; - - /** ID of the provided policy */ policyID?: string; /** Metadata UI */ diff --git a/src/pages/Debug/DebugJSON.tsx b/src/pages/Debug/DebugJSON.tsx index 67ad85765de8..58e78410437c 100644 --- a/src/pages/Debug/DebugJSON.tsx +++ b/src/pages/Debug/DebugJSON.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ScrollView from '@components/ScrollView'; import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder'; import Text from '@components/Text'; diff --git a/src/pages/Debug/DebugTagPicker.tsx b/src/pages/Debug/DebugTagPicker.tsx index cc9a24af3001..5a396ff00e54 100644 --- a/src/pages/Debug/DebugTagPicker.tsx +++ b/src/pages/Debug/DebugTagPicker.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import TagPicker from '@components/TagPicker'; import Text from '@components/Text'; @@ -24,7 +24,6 @@ type DebugTagPickerProps = { /** The policyID we are getting tags for */ policyID: string; - /** Current tag name */ tagName?: string; /** Callback to submit the selected tag */ diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index c292e5d711f9..10f4203445a0 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ScrollView from '@components/ScrollView'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index cab931463cfe..8370f0b78855 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 74e4893a2b5f..50f60c6b4e40 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx index caa7a2621e90..bb41067e347b 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/Debug/Transaction/DebugTransactionPage.tsx b/src/pages/Debug/Transaction/DebugTransactionPage.tsx index 2d0f1d934279..b974df7a6c32 100644 --- a/src/pages/Debug/Transaction/DebugTransactionPage.tsx +++ b/src/pages/Debug/Transaction/DebugTransactionPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx index 66440f684bdb..0e721e0fd659 100644 --- a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx +++ b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx index 4cad88412734..77dbf3e449e1 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; diff --git a/src/pages/DynamicEditReportFieldPage.tsx b/src/pages/DynamicEditReportFieldPage.tsx index d4724a6dfcd8..6a63a50e7a10 100644 --- a/src/pages/DynamicEditReportFieldPage.tsx +++ b/src/pages/DynamicEditReportFieldPage.tsx @@ -22,6 +22,7 @@ import type {EditRequestNavigatorParamList} from '@libs/Navigation/types'; import {isPolicyFieldListEmpty} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import { + getReportFieldFromReportNameValuePairs, getReportFieldKey, getTitleFieldWithFallback, hasViolations as hasViolationsReportUtils, @@ -30,6 +31,7 @@ import { isReportFieldDisabled, isReportFieldDisabledForUser, isReportFieldOfTypeTitle, + isReportFieldTargetMatchingReport, } from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -53,13 +55,16 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { const fieldKey = getReportFieldKey(route.params.fieldID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.EDIT_REPORT_FIELD.path); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + 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 isTitleField = route.params.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; - let reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey]; - let policyField = policy?.fieldList?.[fieldKey] ?? reportField; + const reportFieldFromNVP = getReportFieldFromReportNameValuePairs(reportNameValuePairs, fieldKey) ?? getReportFieldFromReportNameValuePairs(reportNameValuePairs, route.params.fieldID); + let reportField = + reportFieldFromNVP ?? report?.fieldList?.[fieldKey] ?? report?.fieldList?.[route.params.fieldID] ?? policy?.fieldList?.[fieldKey] ?? policy?.fieldList?.[route.params.fieldID]; + let policyField = policy?.fieldList?.[fieldKey] ?? policy?.fieldList?.[route.params.fieldID] ?? reportField; // If the title field is missing, use fallback so that it can still be edited and matches the OldDot behavior. if (isTitleField && !reportField && !policyField) { @@ -69,7 +74,8 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { } const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const isDisabled = isReportFieldDisabledForUser(report, reportField, policy, currentUserAccountID) && reportField?.type !== CONST.REPORT_FIELD_TYPES.FORMULA; + 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(); @@ -80,11 +86,12 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { const {showConfirmModal} = useConfirmModal(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); const isReportFieldTitle = isReportFieldOfTypeTitle(reportField); - const reportFieldsEnabled = ((isGroupPolicyExpenseReport(report, policy?.type) || isInvoiceReport(report)) && !!policy?.areReportFieldsEnabled) || isReportFieldTitle; + 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)); + report?.fieldList && Object.entries(report.fieldList).some(([key, field]) => key !== fieldKey && field.value === '' && !isReportFieldDisabled(report, reportField, policy, rules)); - if (!reportFieldsEnabled || !reportField || !policyField || !report || isDisabled) { + if (!reportFieldsEnabled || !reportField || !policyField || !report || !isReportFieldTargetMatchingReport(report, reportField) || isDisabled) { return ( { if (isRHPOnReportInSearch) { @@ -146,6 +147,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec transactions, allTransactionViolation: transactionViolations, reports: reportsForCall, + rules, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, diff --git a/src/pages/DynamicReportChangeApproverPage.tsx b/src/pages/DynamicReportChangeApproverPage.tsx index f1f7148787ed..2cb93ac09dd3 100644 --- a/src/pages/DynamicReportChangeApproverPage.tsx +++ b/src/pages/DynamicReportChangeApproverPage.tsx @@ -59,6 +59,7 @@ function DynamicReportChangeApproverPage({report, policy, isLoadingReportData}: const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const hasAutoAppliedRef = useRef(false); const hasNavigatedToAddApproverRef = useRef(false); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_CHANGE_APPROVER.path); @@ -87,9 +88,9 @@ function DynamicReportChangeApproverPage({report, policy, isLoadingReportData}: Navigation.navigate(ROUTES.REPORT_CHANGE_APPROVER_ADD_APPROVER.getRoute(report.reportID)); return; } - assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber); + assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber, rules); Navigation.dismissToPreviousRHP(); - }, [selectedApproverType, report, currentUserDetails.accountID, currentUserDetails.email, policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber]); + }, [selectedApproverType, report, currentUserDetails.accountID, currentUserDetails.email, policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber, rules]); const approverTypes = useMemo(() => { const data: Array> = [ diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 593df359d575..8a5bdca5307b 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -115,9 +115,10 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace isOdometerDistanceRequest: hasOdometerDistanceRequest, }); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); // The snapshot keeps the report row after a workspace change, and only the server can tell whether it still matches the query. const refreshSearch = () => { @@ -154,10 +155,11 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace submitterLogin, doesSubmitterPersonalDetailExist ?? false, getCurrencyDecimals, + rules, reportTransactions, ); if (!invite?.policyExpenseChatReportID) { - moveIOUReportToPolicy(report, policy, reportPreviewAction, getCurrencyDecimals, false, reportTransactions); + moveIOUReportToPolicy(report, policy, reportPreviewAction, getCurrencyDecimals, rules, false, reportTransactions); } refreshSearch(); return; @@ -188,6 +190,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace reportPreviewAction, isTrackIntentUser, reportTransactions, + rules, }); refreshSearch(); return; @@ -209,6 +212,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace reportPreviewAction, isTrackIntentUser, reportTransactions, + rules, }); refreshSearch(); }; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 33f2e711b0e5..36674ba76c2b 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -64,7 +64,6 @@ import { canJoinChat, canLeaveChat, canWriteInReport, - createDraftTransactionAndNavigateToParticipantSelector, getAvailableReportFields, getChatRoomSubtitle, getIcons, @@ -90,8 +89,8 @@ import { isHiddenForCurrentUser, isInvoiceReport as isInvoiceReportUtil, isInvoiceRoom as isInvoiceRoomUtil, - isMoneyRequestReport as isMoneyRequestReportUtil, isMoneyRequest as isMoneyRequestUtil, + isMoneyRequestReport as isMoneyRequestReportUtil, isPolicyExpenseChat as isPolicyExpenseChatUtil, isPublicRoom as isPublicRoomUtil, isReportFieldDisabled, @@ -114,6 +113,7 @@ import {getDeleteConfirmationPrompt, getDeleteExpenseTitle, getOriginalTransacti import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; import {getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU/DeleteMoneyRequest'; +import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; import {deleteTrackExpense, getNavigationUrlAfterTrackExpenseDelete} from '@userActions/IOU/TrackExpense'; import { clearAvatarErrors, @@ -244,6 +244,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const {getCurrencyDecimals} = useCurrencyListActions(); const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserPersonalDetails?.email), [currentUserPersonalDetails?.email]); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {showConfirmModal} = useConfirmModal(); const reportForHeader = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForHeader?.reportID]); @@ -281,14 +282,14 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const ancestors = useAncestors(report); const chatRoomSubtitle = useMemo(() => { - const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, false, isReportArchived); + const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived); if (subtitle) { return subtitle; } return ''; - }, [isReportArchived, report, policy, conciergeReportID, translate]); + }, [isReportArchived, report, policy, conciergeReportID, translate, rules]); const isSystemChat = useMemo(() => isSystemChatUtil(report), [report]); const isGroupChat = useMemo(() => isGroupChatUtil(report), [report]); @@ -335,6 +336,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); const [iouPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); const [requestParentReportActionChildReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(requestParentReportAction?.childReportID)}`); + const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(requestParentReportAction?.childReportID)}`); const isActionOwner = typeof requestParentReportAction?.actorAccountID === 'number' && @@ -359,7 +361,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report !isClosedReport(report) && isTaskModifiable && isTaskActionable; - const canDeleteRequest = isActionOwner && (canDeleteTransaction(moneyRequestReport, isMoneyRequestReportArchived) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; + const canDeleteRequest = isActionOwner && (canDeleteTransaction(moneyRequestReport, rules, isMoneyRequestReportArchived) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); @@ -955,9 +957,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report return fields.find((reportField) => isReportFieldOfTypeTitle(reportField)); }, [report, policy?.fieldList]); const fieldKey = getReportFieldKey(titleField?.fieldID); - const isFieldDisabled = isReportFieldDisabled(report, titleField, policy); + const isFieldDisabled = isReportFieldDisabled(report, titleField, policy, rules); - const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserPersonalDetails?.accountID); + const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserPersonalDetails?.accountID, rules); const nameSectionFurtherDetailsContent = ( { - setIsRemoveMemberConfirmModalVisible(false); - removeFromGroupChat(report, [accountID]); - Navigation.goBack(backPath); + const askForConfirmationToRemove = () => { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate('workspace.people.removeGroupMemberButtonTitle'), + prompt: translate('workspace.people.removeMemberPrompt', displayName), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + + removeFromGroupChat(report, [accountID]); + Navigation.goBack(backPath); + }); }; const navigateToProfile = () => { @@ -105,40 +116,27 @@ function DynamicReportParticipantDetails({report, route}: DynamicReportParticipa )} {isCurrentUserAdmin && ( - <> - - setIsRemoveMemberConfirmModalVisible(false)} - prompt={translate('workspace.people.removeMemberPrompt', displayName)} - confirmText={translate('common.remove')} - cancelText={translate('common.cancel')} + )} {isCurrentUserAdmin && ( - )} diff --git a/src/pages/DynamicReportParticipantsInvitePage.tsx b/src/pages/DynamicReportParticipantsInvitePage.tsx index a76f74f1f7bb..c190fe669a2d 100644 --- a/src/pages/DynamicReportParticipantsInvitePage.tsx +++ b/src/pages/DynamicReportParticipantsInvitePage.tsx @@ -19,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {inviteToGroupChat, searchUserInServer} from '@libs/actions/Report'; import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; @@ -208,6 +209,8 @@ function DynamicReportParticipantsInvitePage({report}: DynamicReportParticipants ListItem={InviteMemberListItem} confirmButtonOptions={{ onConfirm: inviteUsers, + isFooterConfirmEnabled: selectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldShowTextInput textInputOptions={textInputOptions} diff --git a/src/pages/DynamicReportParticipantsPage.tsx b/src/pages/DynamicReportParticipantsPage.tsx index f54dfab4895e..94b3e314fef4 100755 --- a/src/pages/DynamicReportParticipantsPage.tsx +++ b/src/pages/DynamicReportParticipantsPage.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/DynamicRoomInvitePage.tsx b/src/pages/DynamicRoomInvitePage.tsx index f7f9a139a45c..1f5cae4501b1 100644 --- a/src/pages/DynamicRoomInvitePage.tsx +++ b/src/pages/DynamicRoomInvitePage.tsx @@ -24,6 +24,7 @@ import {inviteToRoom, inviteToRoomAction, searchUserInServer} from '@libs/action import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; import {READ_COMMANDS} from '@libs/API/types'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -74,7 +75,7 @@ function DynamicRoomInvitePage({report, policy, didScreenTransitionEnd}: Dynamic }); const delegateAccountID = useDelegateAccountID(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const isReportArchived = useReportIsArchived(report.reportID); const {isLoading, startWithLoading} = usePressLoading(); @@ -247,12 +248,14 @@ function DynamicRoomInvitePage({report, policy, didScreenTransitionEnd}: Dynamic confirmButtonOptions={{ isDisabled: !validSelectedOptions.length, onConfirm: inviteUsers, + isFooterConfirmEnabled: validSelectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} shouldUpdateFocusedIndex shouldPreventAutoScrollOnSelect shouldShowLoadingPlaceholder={!areOptionsInitialized} - isLoadingNewOptions={!!isSearchingForReports} + isLoadingNewOptions={!!isSearchingForUsers} shouldShowTextInput canSelectMultiple footerContent={footerContent} diff --git a/src/pages/DynamicRoomMemberDetailsPage.tsx b/src/pages/DynamicRoomMemberDetailsPage.tsx index d7cb2c0d14c7..a180c3e2db67 100644 --- a/src/pages/DynamicRoomMemberDetailsPage.tsx +++ b/src/pages/DynamicRoomMemberDetailsPage.tsx @@ -1,11 +1,12 @@ import UserAvatar from '@components/Avatar/UserAvatar'; -import Button from '@components/ButtonComposed'; -import ConfirmModal from '@components/ConfirmModal'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -50,8 +51,7 @@ function DynamicRoomMemberDetailsPage({report, route}: DynamicRoomMemberDetailsP const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const policy = usePolicy(report?.policyID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.ROOM_MEMBER_DETAILS.path); - - const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = React.useState(false); + const {showConfirmModal} = useConfirmModal(); const accountID = Number(route.params.accountID); const member = report?.participants?.[accountID]; @@ -61,10 +61,21 @@ function DynamicRoomMemberDetailsPage({report, route}: DynamicRoomMemberDetailsP const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; const isSelectedMemberOwner = accountID === report.ownerAccountID; const shouldDisableRemoveUser = (isPolicyExpenseChat(report) && isPolicyAdmin(policy, details.login)) || isSelectedMemberCurrentUser || isSelectedMemberOwner; - const removeUser = () => { - setIsRemoveMemberConfirmModalVisible(false); - removeFromRoom(report, [accountID]); - Navigation.goBack(backPath); + const askForConfirmationToRemove = () => { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate('workspace.people.removeRoomMemberButtonTitle'), + prompt: translate('workspace.people.removeMemberPrompt', displayName), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + + removeFromRoom(report, [accountID]); + Navigation.goBack(backPath); + }); }; const navigateToProfile = () => { @@ -98,29 +109,17 @@ function DynamicRoomMemberDetailsPage({report, route}: DynamicRoomMemberDetailsP {displayName} )} - <> - - setIsRemoveMemberConfirmModalVisible(false)} - prompt={translate('workspace.people.removeMemberPrompt', displayName)} - confirmText={translate('common.remove')} - cancelText={translate('common.cancel')} + {headerButtons}; let subtitleKey: '' | TranslationPaths | undefined; if (!isEmptyObject(report)) { @@ -365,13 +366,16 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { navigateBackToReportDetails(); }} /> - {headerButtons} {isLoading ? ( - + <> + {tableHeaderComponent} + + ) : ( ; }; diff --git a/src/pages/EnablePayments/Pay/Terms/TermsStep.tsx b/src/pages/EnablePayments/Pay/Terms/TermsStep.tsx index fb79a4e7904e..04c7bf6c8f5e 100644 --- a/src/pages/EnablePayments/Pay/Terms/TermsStep.tsx +++ b/src/pages/EnablePayments/Pay/Terms/TermsStep.tsx @@ -25,7 +25,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import React, {useState} from 'react'; type TermsStepProps = { - /** The user's wallet */ userWallet: OnyxEntry; }; diff --git a/src/pages/EnablePayments/Wallet/AddBankAccount/SetupMethod.tsx b/src/pages/EnablePayments/Wallet/AddBankAccount/SetupMethod.tsx index 0a010cbbbb8d..394d886d3175 100644 --- a/src/pages/EnablePayments/Wallet/AddBankAccount/SetupMethod.tsx +++ b/src/pages/EnablePayments/Wallet/AddBankAccount/SetupMethod.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Section from '@components/Section'; import Text from '@components/Text'; diff --git a/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx b/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx index 926b51b77eb3..21b5215d1976 100644 --- a/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx +++ b/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx @@ -1,6 +1,6 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -52,13 +52,19 @@ function ConfirmationStep({onNext, onMove}: ConfirmationStepProps) { > {translate('walletPage.confirmYourBankAccount')} {translate('bankAccount.letsDoubleCheck')} - + + + + {!!bankName && {bankName}} + {`${translate('bankAccount.accountEnding')} ${accountNumber.slice(-4)}`} + + {!isBankAccountAdded && ( + + + + )} + + {!!error && error.length > 0 && ( ; }; diff --git a/src/pages/ErrorPage/GenericErrorPage.tsx b/src/pages/ErrorPage/GenericErrorPage.tsx index f4336a3be92c..7f97b7a2ebb9 100644 --- a/src/pages/ErrorPage/GenericErrorPage.tsx +++ b/src/pages/ErrorPage/GenericErrorPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import ImageSVG from '@components/ImageSVG'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; diff --git a/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx b/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx index 879b804d38bd..5a348e468d85 100644 --- a/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx +++ b/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx @@ -1,5 +1,5 @@ -import Button from '@components/ButtonComposed'; -import Header from '@components/Header'; +import Button from '@components/Button'; +import HeaderTitle from '@components/HeaderTitle'; import ImageSVG from '@components/ImageSVG'; import Lottie from '@components/Lottie'; import LottieAnimations from '@components/LottieAnimations'; @@ -40,7 +40,9 @@ function UpdateRequiredView() { return ( -
+ + {translate('updateRequiredView.updateRequired')} + {isReduceMotionEnabled ? ( diff --git a/src/pages/ErrorPage/UpdateRequiredView/index.tsx b/src/pages/ErrorPage/UpdateRequiredView/index.tsx index 5009b41bca20..f77cbef955e4 100644 --- a/src/pages/ErrorPage/UpdateRequiredView/index.tsx +++ b/src/pages/ErrorPage/UpdateRequiredView/index.tsx @@ -1,5 +1,5 @@ -import Button from '@components/ButtonComposed'; -import Header from '@components/Header'; +import Button from '@components/Button'; +import HeaderTitle from '@components/HeaderTitle'; import ImageSVG from '@components/ImageSVG'; import Lottie from '@components/Lottie'; import LottieAnimations from '@components/LottieAnimations'; @@ -38,7 +38,9 @@ function UpdateRequiredView() { return ( -
+ + {translate('updateRequiredView.updateAvailable')} + {isReduceMotionEnabled ? ( diff --git a/src/pages/Insights/InsightsPage.tsx b/src/pages/Insights/InsightsPage.tsx new file mode 100644 index 000000000000..c13c817d11f9 --- /dev/null +++ b/src/pages/Insights/InsightsPage.tsx @@ -0,0 +1,51 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import TopBar from '@components/Navigation/TopBar'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; + +import useDocumentTitle from '@hooks/useDocumentTitle'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; + +import type {TabNavigatorParamList} from '@libs/Navigation/types'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import type {BottomTabScreenProps} from '@react-navigation/bottom-tabs'; + +import React from 'react'; + +type InsightsPageProps = BottomTabScreenProps; + +function InsightsPage({route}: InsightsPageProps) { + const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + useDocumentTitle(translate('common.insights')); + + const isKnownDashboard = route.params.dashboardID === CONST.INSIGHTS.DASHBOARD.SPEND; + + if (!isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE) || !isKnownDashboard) { + return ; + } + + return ( + } + testID="InsightsPage" + > + + + + ); +} + +export default InsightsPage; diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 0cea23a6ff87..c37ac14866f9 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -15,6 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import React, {useEffect} from 'react'; @@ -25,8 +26,15 @@ type LogInWithShortLivedAuthTokenPageProps = PlatformStackScreenProps { + // Only a forced SAML re-auth keeps a last visited path, so it has to be read before the sign-in starts. + if (isLoadingLastVisitedPath) { + return; + } + // We have to check for both shortLivedAuthToken and shortLivedToken, as the old mobile app uses shortLivedToken, and is not being actively updated. const token = shortLivedAuthToken || shortLivedToken; @@ -45,10 +53,11 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP return; } - // Try to authenticate using the shortLivedToken if we're not already trying to load the accounts - if (token && !account?.isLoading) { + // Try to authenticate using the shortLivedToken if we're not already trying to load the accounts. + // A forced SAML re-auth leaves account.isLoading true until this sign-in, so it must not block a SAML token. + if (token && (isSAML || !account?.isLoading)) { Log.info('LogInWithShortLivedAuthTokenPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(token, isSAML); + signInWithShortLivedAuthToken(token, isSAML, isSAML ? lastVisitedPath : undefined); // For SAML sign-ins, navigate to HOME explicitly since the SAML flow // doesn't use exitTo deep link routing. For non-SAML flows, let the // navigation system handle exitTo routing naturally via setUpPoliciesAndNavigate. @@ -73,11 +82,11 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP Navigation.navigate(exitTo as Route); }); } - // The only dependencies of the effect are based on props.route + // Runs once the route and the last visited path are known, later Onyx changes must not restart the sign-in. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route]); + }, [route, isLoadingLastVisitedPath]); - if (account?.isLoading) { + if (account?.isLoading || isLoadingLastVisitedPath) { return ; } diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index 42b560a4fb33..770e76fc4c18 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -42,7 +42,7 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { if (isLoggingInAsNewUser) { // We don't want to close react-native app in this particular case. - signOutAndRedirectToSignIn(false, isSupportalLogin); + signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); return; } diff --git a/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx b/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx index 04b9859d2d96..6a02bda6deb4 100644 --- a/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx +++ b/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx @@ -43,8 +43,6 @@ import {getInitialSubPage, getSubPageValues} from './utils'; type MissingPersonalDetailsContentProps = { privatePersonalDetails: OnyxEntry; draftValues: OnyxEntry; - - /** Optional custom header title */ headerTitle?: string; /** Completion handler */ diff --git a/src/pages/MissingPersonalDetails/subPages/PIN.tsx b/src/pages/MissingPersonalDetails/subPages/PIN.tsx index 1f5caa4e596c..ef6151727d2b 100644 --- a/src/pages/MissingPersonalDetails/subPages/PIN.tsx +++ b/src/pages/MissingPersonalDetails/subPages/PIN.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import Text from '@components/Text'; import ValidateCodeInput from '@components/ValidateCodeInput'; diff --git a/src/pages/MissingPersonalDetails/types.context.ts b/src/pages/MissingPersonalDetails/types.context.ts index 340013bd5c7c..e42d375af6b3 100644 --- a/src/pages/MissingPersonalDetails/types.context.ts +++ b/src/pages/MissingPersonalDetails/types.context.ts @@ -10,7 +10,6 @@ type PINStateContextType = { }; type PINActionsContextType = { - /** Set the PIN value */ setPIN: (PIN: string) => void; /** Clear the PIN and reset verification status */ diff --git a/src/pages/MissingPersonalDetails/types.ts b/src/pages/MissingPersonalDetails/types.ts index c759449bf0ce..cb58283f8646 100644 --- a/src/pages/MissingPersonalDetails/types.ts +++ b/src/pages/MissingPersonalDetails/types.ts @@ -3,7 +3,6 @@ import type {SubPageProps} from '@hooks/useSubPage/types'; import type {PersonalDetailsForm} from '@src/types/form'; type CustomSubPageProps = SubPageProps & { - /** User's personal details values */ personalDetailsValues: PersonalDetailsForm; /** Whether the flow should collect a PIN */ diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx index 26226976f4e3..d213e2903af8 100644 --- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx +++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx @@ -15,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import {formatLastFourPAN} from '@libs/TransactionPreviewUtils'; +import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -102,7 +103,7 @@ function AuthorizeCardTransactionPreview({transactionID, amount, currency, merch {shouldShowMerchantOrDescription && ( @@ -128,7 +129,7 @@ function AuthorizeCardTransactionPreview({transactionID, amount, currency, merch {!!displayAmount && ( diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeTransactionActions.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeTransactionActions.tsx index ed718ee54b51..63f4871686d7 100644 --- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeTransactionActions.tsx +++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeTransactionActions.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import LoadingIndicator from '@components/LoadingIndicator'; diff --git a/src/pages/MultifactorAuthentication/PromptPage.tsx b/src/pages/MultifactorAuthentication/PromptPage.tsx index 7fe99ef41232..401e11f39381 100644 --- a/src/pages/MultifactorAuthentication/PromptPage.tsx +++ b/src/pages/MultifactorAuthentication/PromptPage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import LoadingIndicator from '@components/LoadingIndicator'; diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx index b5425dd9ea27..92ac5849946f 100644 --- a/src/pages/MultifactorAuthentication/RevokePage.tsx +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/MultifactorAuthentication/RevokeRow.tsx b/src/pages/MultifactorAuthentication/RevokeRow.tsx index 6c30d33c954f..6b0b14ae91f2 100644 --- a/src/pages/MultifactorAuthentication/RevokeRow.tsx +++ b/src/pages/MultifactorAuthentication/RevokeRow.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import MenuItem from '@components/MenuItem'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index 6eb16fa5d4b5..e7b3d89426b2 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -1,5 +1,6 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useMultifactorAuthentication, useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context'; @@ -253,16 +254,15 @@ function MultifactorAuthenticationValidateCodePage() { message={errorMessage} /> )} - + diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx index c6400d15468d..cc20cebd794e 100644 --- a/src/pages/NewChatConfirmPage.tsx +++ b/src/pages/NewChatConfirmPage.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useIsSupportalSession from '@hooks/useIsSupportalSession'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -133,9 +134,11 @@ function NewChatConfirmPage() { const personalData = useCurrentUserPersonalDetails(); const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [newGroupDraft] = useOnyx(ONYXKEYS.NEW_GROUP_CHAT_DRAFT); + const isSupportalSession = useIsSupportalSession(); const participants = newGroupDraft?.participants ?? []; @@ -201,8 +204,9 @@ function NewChatConfirmPage() { introSelected, isSelfTourViewed: !!guidedSetupAndTourStatus?.isSelfTourViewed, hasCompletedGuidedSetupFlow: !!guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - betas, + conciergeChat, currentUserAccountID: personalData.accountID, + isSupportalSession, avatarUri: newGroupDraft.avatarUri ?? '', avatarFile, }); diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 82192349bb78..db1c6b117b65 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -10,6 +10,7 @@ import type {ListItem, SelectionListWithSectionsHandle} from '@components/Select import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDismissedReferralBanners from '@hooks/useDismissedReferralBanners'; +import useIsSupportalSession from '@hooks/useIsSupportalSession'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -64,7 +65,6 @@ type NewChatPageRef = { }; type NewChatPageProps = { - /** Reference to the outer element */ ref?: Ref; }; @@ -87,6 +87,7 @@ function NewChatPage({ref}: NewChatPageProps) { const selectionListRef = useRef(null); const allPersonalDetails = usePersonalDetails(); const {singleExecution} = useSingleExecution(); + const isSupportalSession = useIsSupportalSession(); const focusTimeoutRef = useRef(null); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); @@ -310,6 +311,7 @@ function NewChatPage({ref}: NewChatPageProps) { hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, betas, conciergeChat, + isSupportalSession, }), )(); }); @@ -424,6 +426,7 @@ function NewChatPage({ref}: NewChatPageProps) { shouldSingleExecuteRowSelect confirmButtonOptions={{ onConfirm: (e, option) => (latestSelectedOptionsRef.current.length > 0 ? createGroup() : selectOption(option)), + isFooterConfirmEnabled: selectedOptions.length > 0, }} rightHandSideComponent={itemRightSideComponent} footerContent={footerContent} diff --git a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts index 5dafb8224daa..afc319be116c 100644 --- a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts +++ b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts @@ -1,5 +1,6 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useIsFocusedRef from '@hooks/useIsFocusedRef'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -39,6 +40,7 @@ function useGroupChatDraftParticipantSync( const isScreenFocusedRef = useIsFocusedRef(); const {dateFnsLocale} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); const draftParticipantsSelector = (draft: NewGroupChatDraft | undefined) => { const isSubscriptionActive = shouldRestoreSelectedOptionsRef.current || !isScreenFocusedRef.current; return isSubscriptionActive ? draft?.participants : undefined; @@ -47,6 +49,7 @@ function useGroupChatDraftParticipantSync( const [draftParticipants, draftParticipantsMetadata] = useOnyx(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, { selector: draftParticipantsSelector, }); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const restoreParticipantsFromDraft = useEffectEvent(() => { // Flip the ref first so the useOnyx selector disables the subscription @@ -65,10 +68,12 @@ function useGroupChatDraftParticipantSync( (getParticipantsOption({accountID: participant.accountID, login: participant.login}, allPersonalDetails, translate) as OptionData) : getUserToInviteOption({ dateFnsLocale, + convertToDisplayString, searchValue: participant?.login, personalDetails: allPersonalDetails, loginList, currentUserEmail, + rules, }); if (option) { result.push({...option, isSelected: true}); diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index d1e45b4ddb5c..7e592572ff88 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -16,7 +17,6 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useCompleteOnboarding from '@hooks/useCompleteOnboarding'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -146,7 +146,6 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount const isOtherSelected = selectedIntegration === 'other'; const groupPolicy = Object.values(allPolicies ?? {}).find((policy) => isGroupPolicy(policy) && isPolicyAdmin(policy, session?.email)); - const {isOffline} = useNetwork(); const {completeOnboardingFlow, isLoading: isCompletingOnboarding} = useCompleteOnboarding(); // Set onboardingPolicyID and onboardingAdminsChatReportID if a workspace is created by the backend for OD signup @@ -273,17 +272,16 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount /> )} - + ); diff --git a/src/pages/OnboardingAccounting/types.ts b/src/pages/OnboardingAccounting/types.ts index 62f37586d81c..beeab8c8077f 100644 --- a/src/pages/OnboardingAccounting/types.ts +++ b/src/pages/OnboardingAccounting/types.ts @@ -6,7 +6,6 @@ import type SCREENS from '@src/SCREENS'; type OnboardingAccountingProps = PlatformStackScreenProps; type BaseOnboardingAccountingProps = OnboardingAccountingProps & { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; }; diff --git a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx index 27d759834b4d..113cdd515238 100644 --- a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx +++ b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import OnboardingHeader from '@components/OnboardingHeader'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/OnboardingEmployees/types.ts b/src/pages/OnboardingEmployees/types.ts index ea33e9196e02..a37d169545cf 100644 --- a/src/pages/OnboardingEmployees/types.ts +++ b/src/pages/OnboardingEmployees/types.ts @@ -6,7 +6,6 @@ import type SCREENS from '@src/SCREENS'; type OnboardingEmployeesProps = PlatformStackScreenProps; type BaseOnboardingEmployeesProps = OnboardingEmployeesProps & { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; }; diff --git a/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx b/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx index 58275ee074fb..ff155f566d65 100644 --- a/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx +++ b/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; import Icon from '@components/Icon'; diff --git a/src/pages/OnboardingInterestedFeatures/types.ts b/src/pages/OnboardingInterestedFeatures/types.ts index 6802a46a0b36..4f90bf7413ad 100644 --- a/src/pages/OnboardingInterestedFeatures/types.ts +++ b/src/pages/OnboardingInterestedFeatures/types.ts @@ -7,7 +7,6 @@ import type IconAsset from '@src/types/utils/IconAsset'; type OnboardingInterestedFeaturesProps = PlatformStackScreenProps; type BaseOnboardingInterestedFeaturesProps = OnboardingInterestedFeaturesProps & { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; }; diff --git a/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx b/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx index 068ac0fb6ed0..d1aa80c97244 100644 --- a/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx +++ b/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import OnboardingHeader from '@components/OnboardingHeader'; diff --git a/src/pages/OnboardingPersonalTrackGoal/types.ts b/src/pages/OnboardingPersonalTrackGoal/types.ts index 7cb9d87f05fa..0a813e201cca 100644 --- a/src/pages/OnboardingPersonalTrackGoal/types.ts +++ b/src/pages/OnboardingPersonalTrackGoal/types.ts @@ -6,7 +6,6 @@ import type SCREENS from '@src/SCREENS'; type OnboardingPersonalTrackGoalProps = PlatformStackScreenProps; type BaseOnboardingPersonalTrackGoalProps = OnboardingPersonalTrackGoalProps & { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; }; diff --git a/src/pages/OnboardingPrivateDomain/types.ts b/src/pages/OnboardingPrivateDomain/types.ts index d83a120432ac..f7b326b67c67 100644 --- a/src/pages/OnboardingPrivateDomain/types.ts +++ b/src/pages/OnboardingPrivateDomain/types.ts @@ -8,9 +8,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; type OnboardingPrivateDomainProps = Record & StackScreenProps; type BaseOnboardingPrivateDomainProps = { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; - route: RouteProp; }; diff --git a/src/pages/OnboardingPurpose/types.ts b/src/pages/OnboardingPurpose/types.ts index 85e7c9d31e79..a8eebfe6de10 100644 --- a/src/pages/OnboardingPurpose/types.ts +++ b/src/pages/OnboardingPurpose/types.ts @@ -6,7 +6,6 @@ import type SCREENS from '@src/SCREENS'; type OnboardingPurposeProps = Record & PlatformStackScreenProps; type BaseOnboardingPurposeProps = OnboardingPurposeProps & { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 478bb23984b2..abbd950d3aa7 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -1,5 +1,5 @@ import AutoEmailLink from '@components/AutoEmailLink'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; diff --git a/src/pages/OnboardingWorkEmail/types.ts b/src/pages/OnboardingWorkEmail/types.ts index 658c1d3a50dd..9cec61e128b5 100644 --- a/src/pages/OnboardingWorkEmail/types.ts +++ b/src/pages/OnboardingWorkEmail/types.ts @@ -8,9 +8,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; type OnboardingWorkEmailProps = Record & StackScreenProps; type BaseOnboardingWorkEmailProps = { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; - route: RouteProp; }; diff --git a/src/pages/OnboardingWorkEmailValidation/types.ts b/src/pages/OnboardingWorkEmailValidation/types.ts index 95befac829d7..a6de87cd1c0b 100644 --- a/src/pages/OnboardingWorkEmailValidation/types.ts +++ b/src/pages/OnboardingWorkEmailValidation/types.ts @@ -8,9 +8,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; type OnboardingWorkEmailValidationProps = Record & StackScreenProps; type BaseOnboardingWorkEmailValidationProps = { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; - route: RouteProp; }; diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 506e9ac2f1ff..e89151dc9757 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -1,5 +1,6 @@ -import Button from '@components/ButtonComposed'; -import LinkButton from '@components/ButtonComposed/composed/LinkButton'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; +import LinkButton from '@components/Button/composed/LinkButton'; import OnboardingHeader from '@components/OnboardingHeader'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -9,7 +10,6 @@ import Text from '@components/Text'; import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -40,7 +40,6 @@ import type {BaseOnboardingWorkspacesProps} from './types'; function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboardingWorkspacesProps) { const icons = useMemoizedLazyExpensifyIcons(['DownArrow']); - const {isOffline} = useNetwork(); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -128,8 +127,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding keyForList: policyInfo.policyID, isDisabled: true, rightElement: ( - + ), })); diff --git a/src/pages/OnboardingWorkspaces/types.ts b/src/pages/OnboardingWorkspaces/types.ts index 3de6308fcfec..bb970580dda8 100644 --- a/src/pages/OnboardingWorkspaces/types.ts +++ b/src/pages/OnboardingWorkspaces/types.ts @@ -8,9 +8,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; type OnboardingWorkspacesProps = StackScreenProps; type BaseOnboardingWorkspacesProps = { - /* Whether to use native styles tailored for native devices */ shouldUseNativeStyles: boolean; - route: RouteProp; }; diff --git a/src/pages/PreMountBufferPage.tsx b/src/pages/PreMountBufferPage.tsx new file mode 100644 index 000000000000..bf299825dd5e --- /dev/null +++ b/src/pages/PreMountBufferPage.tsx @@ -0,0 +1,17 @@ +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; + +import Navigation from '@libs/Navigation/Navigation'; + +// Neutral placeholder shown under the RHP while a destination is pre-mounted. A native +// swipe-dismiss reveals this instead of the pre-inserted destination. Not reachable via a URL/deep link. +function PreMountBufferPage() { + return ( + + ); +} + +export default PreMountBufferPage; diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index 06c59d8fcefa..b4af47485238 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -46,12 +46,10 @@ import {Keyboard} from 'react-native'; type PrivateNotesEditPageProps = WithReportAndPrivateNotesOrNotFoundProps & PlatformStackScreenProps & { - /** The report currently being looked at */ report: Report; }; type PrivateNotesEditPageInternalProps = PrivateNotesEditPageProps & { - /** Draft private note */ privateNoteDraft: string; }; diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx index 645236d2afca..deb528e48d1f 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx @@ -26,7 +26,6 @@ import type {ValueOf} from 'type-fest'; import React, {useCallback, useMemo} from 'react'; type PrivateNotesListPageProps = WithReportAndPrivateNotesOrNotFoundProps & { - /** The report currently being looked at */ report: Report; }; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index a27d725b4c87..47eaa6ba3d06 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -5,6 +5,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -17,6 +18,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useIsSupportalSession from '@hooks/useIsSupportalSession'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -105,6 +107,7 @@ function ProfilePage({route}: ProfilePageProps) { const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: Boolean}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const isSupportalSession = useIsSupportalSession(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.PROFILE.path); const styles = useThemeStyles(); @@ -213,6 +216,7 @@ function ProfilePage({route}: ProfilePageProps) { betas, hasReportActions, conciergeChat, + isSupportalSession, }), ); } @@ -324,13 +328,12 @@ function ProfilePage({route}: ProfilePageProps) { /> )} {shouldShowNotificationPreference && ( - { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NOTIFICATION_PREFERENCES.getRoute(report.reportID))); }} + value={notificationPreference} /> )} {Permissions.canUsePrivateNotes() && !isEmptyObject(report) && !!report.reportID && !isCurrentUser && ( diff --git a/src/pages/ReimbursementAccount/AddressFormFields.tsx b/src/pages/ReimbursementAccount/AddressFormFields.tsx index 20668a58224c..ecd881ad9f47 100644 --- a/src/pages/ReimbursementAccount/AddressFormFields.tsx +++ b/src/pages/ReimbursementAccount/AddressFormFields.tsx @@ -43,43 +43,24 @@ type AddressFormProps = ForwardedFSClassProps & { /** Translate key for Street name */ streetTranslationKey: TranslationPaths; - /** Default values */ defaultValues?: Address; - - /** Form values */ values?: Address; /** Any errors that can arise from form validation */ errors?: AddressErrors; - /** The map for inputID of the inputs */ inputKeys: AddressInputKeys; /** Saves a draft of the input value when used in a form */ shouldSaveDraft?: boolean; - /** Additional styles to apply to container */ containerStyles?: StyleProp; - - /** Indicates if country selector should be displayed */ shouldDisplayCountrySelector?: boolean; - - /** Indicates if state selector should be displayed */ shouldDisplayStateSelector?: boolean; - - /** Label for the state selector */ stateSelectorLabel?: string; - - /** The title of the state selector modal */ stateSelectorModalHeaderTitle?: string; - - /** The title of the state selector search input */ stateSelectorSearchInputTitle?: string; - - /** Callback to be called when the country is changed */ onCountryChange?: (country: unknown) => void; - - /** Indicates if country can be changed by user */ shouldAllowCountryChange?: boolean; }; diff --git a/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx b/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx index 129e56885214..45adb33995bb 100644 --- a/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx +++ b/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx @@ -1,8 +1,11 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; import getBankIcon from '@components/Icon/BankIcons'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; import type {IllustrationName} from '@components/Icon/IllustrationLoader'; import MenuItem from '@components/MenuItem'; +import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -13,17 +16,19 @@ import useChangeBankAccount from '@hooks/useChangeBankAccount'; import {useMemoizedLazyAsset, useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResetBankAccountModal from '@hooks/useResetBankAccountModal'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {requestResetBankAccount, resetReimbursementAccount} from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; import type {ReimbursementAccount} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; +import {View} from 'react-native'; type ConnectedVerifiedBankAccountProps = { /** Bank account currently in setup */ @@ -50,8 +55,8 @@ function ConnectedVerifiedBankAccount({ isNonUSDWorkspace, }: ConnectedVerifiedBankAccountProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const {icon, iconSize, iconStyles} = getBankIcon({bankName: reimbursementAccount?.achData?.bankName, styles}); @@ -98,35 +103,42 @@ function ConnectedVerifiedBankAccount({ shouldShowErrorMessages onClose={resetReimbursementAccount} > - + + + + + + + + + + {bankAccountOwnerName ?? ''} + {formattedBankAccountNumber} + + + + {translate('workspace.bankAccount.accountDescriptionWithCards')} - {shouldShowChangeBankAccount && ( - + {shouldShowChangeBankAccount && ( + + )} + - )} - + diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx index 2722a7cffb66..85881089274e 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; diff --git a/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx b/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx index 7c07e6814d58..bbdf5dea7aaa 100644 --- a/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx @@ -29,7 +29,7 @@ function Agreements({onBackButtonPress, onSubmit, stepNames, currency}: NonUSDPa const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID; const isDocusignStepRequired = requiresDocusignStep(currency); const isSubmittingRef = useRef(false); - const isBankStatementNeeded = currency === CONST.CURRENCY.AUD; + const isBankStatementNeeded = currency === CONST.CURRENCY.AUD || currency === CONST.CURRENCY.CAD; const bankStatementDefaultValue = reimbursementAccountDraft?.[INPUT_IDS.ADDITIONAL_DATA.CORPAY.BANK_STATEMENT] ?? []; const submit = () => { diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx index ca9c2f61146d..a471ba97ad73 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx @@ -24,10 +24,10 @@ import Address from './BeneficialOwnerDetailsFormSubSteps/Address'; import Confirmation from './BeneficialOwnerDetailsFormSubSteps/Confirmation'; import DateOfBirth from './BeneficialOwnerDetailsFormSubSteps/DateOfBirth'; import Documents from './BeneficialOwnerDetailsFormSubSteps/Documents'; -import Last4SSN from './BeneficialOwnerDetailsFormSubSteps/Last4SSN'; import Name from './BeneficialOwnerDetailsFormSubSteps/Name'; import Nationality from './BeneficialOwnerDetailsFormSubSteps/Nationality'; import OwnershipPercentage from './BeneficialOwnerDetailsFormSubSteps/OwnershipPercentage'; +import SSN from './BeneficialOwnerDetailsFormSubSteps/SSN'; const {PAGE_NAME, BENEFICIAL_OWNER_INFO_STEP} = CONST.NON_USD_BANK_ACCOUNT; const SUB_PAGE_NAMES = BENEFICIAL_OWNER_INFO_STEP.SUB_PAGE_NAMES; @@ -48,7 +48,7 @@ const pages = [ }, {pageName: SUB_PAGE_NAMES.DATE_OF_BIRTH, component: DateOfBirth}, {pageName: SUB_PAGE_NAMES.ADDRESS, component: Address}, - {pageName: SUB_PAGE_NAMES.LAST_4_SSN, component: Last4SSN}, + {pageName: SUB_PAGE_NAMES.SSN, component: SSN}, {pageName: SUB_PAGE_NAMES.DOCUMENTS, component: Documents}, {pageName: SUB_PAGE_NAMES.CONFIRMATION, component: Confirmation}, ]; @@ -57,7 +57,6 @@ type BeneficialOwnerDetailsFormPagesProps = { /** Array of step names for the progress indicator */ stepNames?: readonly string[]; - /** ID of current policy */ policyID?: string; /** Callback triggered after the last form page is completed */ @@ -94,7 +93,7 @@ function BeneficialOwnerDetailsFormPages({stepNames, policyID, onFinished, backT const skipPages = useMemo(() => { const pagesToSkip: string[] = []; if (beneficialOwnerNationality !== CONST.COUNTRY.US) { - pagesToSkip.push(SUB_PAGE_NAMES.LAST_4_SSN); + pagesToSkip.push(SUB_PAGE_NAMES.SSN); } const documentsStatus = getNeededDocumentsStatusForBeneficialOwner(currency, countryStepCountryValue, beneficialOwnerAddressCountry); if (!documentsStatus.isProofOfOwnershipNeeded && !documentsStatus.isCopyOfIDNeeded && !documentsStatus.isProofOfAddressNeeded && !documentsStatus.isCodiceFiscaleNeeded) { diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx index 68111ab00ad2..998ad0dd2c54 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx @@ -74,8 +74,8 @@ function Confirmation({onNext, onMove, isEditing, ownerBeingModifiedID}: Confirm ? [ { id: 'ssn', - title: values.ssnLast4, - description: translate('ownershipInfoStep.last4'), + title: values.ssn, + description: translate('common.ssnFull9'), shouldShowRightIcon: true, onPress: () => { onMove(5); @@ -163,7 +163,7 @@ function Confirmation({onNext, onMove, isEditing, ownerBeingModifiedID}: Confirm values.nationality, values.ownershipPercentage, values.proofOfOwnership, - values.ssnLast4, + values.ssn, values.state, values.street, values.zipCode, @@ -178,6 +178,7 @@ function Confirmation({onNext, onMove, isEditing, ownerBeingModifiedID}: Confirm pageTitle={translate('ownershipInfoStep.letsDoubleCheck')} summaryItems={summaryItems} showOnfidoLinks={false} + forwardedFSClass={CONST.FULLSTORY.CLASS.MASK} /> ); } diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Documents.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Documents.tsx index 64325da282b6..fb03ec83f526 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Documents.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Documents.tsx @@ -141,7 +141,9 @@ function Documents({onNext, isEditing, ownerBeingModifiedID}: DocumentsProps) { value={defaultValues[proofOfOwnershipInputID]} inputID={proofOfOwnershipInputID} /> - {translate('ownershipInfoStep.proofOfBeneficialOwnerDescription')} + + {translate(country === CONST.COUNTRY.CA ? 'ownershipInfoStep.proofOfBeneficialOwnerDescriptionCA' : 'ownershipInfoStep.proofOfBeneficialOwnerDescription')} + {(isDocumentNeededStatus.isCopyOfIDNeeded || isDocumentNeededStatus.isProofOfAddressNeeded || isDocumentNeededStatus.isCodiceFiscaleNeeded) && ( )} diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Last4SSN.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx similarity index 58% rename from src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Last4SSN.tsx rename to src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx index 165dd7a492d4..5765297947d3 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Last4SSN.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx @@ -6,7 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubPageProps} from '@hooks/useSubPage/types'; -import {getFieldRequiredErrors, isValidSSNLastFour} from '@libs/ValidationUtils'; +import {getFieldRequiredErrors, isValidSSNFullNine} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -14,33 +14,33 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; import React, {useCallback} from 'react'; -type Last4SSNProps = SubPageProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string}; +type SSNProps = SubPageProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string}; -const {SSN_LAST_4, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA; +const {SSN: SSN_KEY, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA; -function Last4SSN({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID}: Last4SSNProps) { +function SSN({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID}: SSNProps) { const {translate} = useLocalize(); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const last4SSNInputID = `${PREFIX}_${ownerBeingModifiedID}_${SSN_LAST_4}` as const; - const defaultLast4SSN = SafeString(reimbursementAccountDraft?.[last4SSNInputID]); - const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourLast' : 'ownershipInfoStep.whatAreTheLast'); + const ssnInputID = `${PREFIX}_${ownerBeingModifiedID}_${SSN_KEY}` as const; + const defaultSSN = SafeString(reimbursementAccountDraft?.[ssnInputID]); + const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourSSN' : 'ownershipInfoStep.whatsTheOwnersSSN'); const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [last4SSNInputID], translate); + const errors = getFieldRequiredErrors(values, [ssnInputID], translate); - if (values[last4SSNInputID] && !isValidSSNLastFour(SafeString(values[last4SSNInputID]))) { - errors[last4SSNInputID] = translate('bankAccount.error.ssnLast4'); + if (values[ssnInputID] && !isValidSSNFullNine(SafeString(values[ssnInputID]))) { + errors[ssnInputID] = translate('additionalDetailsStep.ssnFull9Error'); } return errors; }, - [last4SSNInputID, translate], + [ssnInputID, translate], ); const handleSubmit = useReimbursementAccountStepFormSubmit({ - fieldIds: [last4SSNInputID], + fieldIds: [ssnInputID], onNext, shouldSaveDraft: isEditing, }); @@ -55,15 +55,16 @@ function Last4SSN({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBei formDisclaimer={translate('beneficialOwnerInfoStep.dontWorry')} validate={validate} onSubmit={handleSubmit} - inputId={last4SSNInputID} - inputLabel={translate('ownershipInfoStep.last4')} + inputId={ssnInputID} + inputLabel={translate('common.ssnFull9')} inputMode={CONST.INPUT_MODE.NUMERIC} - defaultValue={defaultLast4SSN} + defaultValue={defaultSSN} shouldShowHelpLinks={false} - maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN} + maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.FULL_SSN} + forwardedFSClass={CONST.FULLSTORY.CLASS.MASK} shouldDelayAutoFocus /> ); } -export default Last4SSN; +export default SSN; diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx index 50996971f814..0ded4c90dbde 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx @@ -1,11 +1,11 @@ -import Button from '@components/ButtonComposed'; +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 ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -27,14 +27,12 @@ type BeneficialOwnersListProps = { /** Method called when user presses on one of owners to edit its data */ handleOwnerEdit: (value: string) => void; - /** List of owner keys */ ownerKeys: string[]; }; function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit}: BeneficialOwnersListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const {paddingBottom: safeAreaInsetPaddingBottom} = useSafeAreaPaddings(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); @@ -82,18 +80,17 @@ function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit}: messages={{error}} /> )} - + ); diff --git a/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx b/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx index a0dbbd0793e8..10c0f5e85e42 100644 --- a/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx @@ -1,5 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; +import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; @@ -9,13 +10,13 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; import Navigation from '@navigation/Navigation'; import {navigateToConciergeChat} from '@userActions/Report'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -26,9 +27,9 @@ import {View} from 'react-native'; function Finish() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const icons = useMemoizedLazyExpensifyIcons(['NewWindow', 'Shield', 'ChatBubble']); + const icons = useMemoizedLazyExpensifyIcons(['Shield', 'ChatBubble']); const illustrations = useMemoizedLazyIllustrations(['ConciergeBubble', 'ShieldYellow']); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -61,35 +62,42 @@ function Finish() { titleStyles={[styles.mb3, styles.textHeadline]} > {translate('finishStep.thanksFor')} - + + +
{ - Navigation.navigate(getTwoFactorAuthRoute()); - }, - icon: icons.Shield, - shouldShowRightIcon: true, - iconRight: icons.NewWindow, - outerWrapperStyle: shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8, - }, - ]} > {translate('finishStep.weTake')} + + { + Navigation.navigate(getTwoFactorAuthRoute()); + })} + > + + + + + + {translate('finishStep.secure')} + + + + + + +
diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx index f0b59f30048b..f543d94c8d4e 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx @@ -27,7 +27,6 @@ type EnterEmailProps = { /** Callback when the form is submitted */ onSubmit: (values: EmailSubmitParams) => void; - /** Whether the user is a director */ isUserDirector: boolean; /** Whether the page is in loading state or not */ diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/HangTight.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/HangTight.tsx index 3f3ae8856a0d..14b780f59c90 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/HangTight.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/HangTight.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import Icon from '@components/Icon'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; @@ -23,10 +23,7 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; type HangTightProps = { - /** ID of policy */ policyID: string | undefined; - - /** ID of bank account */ bankAccountID: number; }; diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerDetailsFormPages.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerDetailsFormPages.tsx index 84d568391596..1db04deccbbf 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerDetailsFormPages.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerDetailsFormPages.tsx @@ -43,7 +43,6 @@ type SignerDetailsFormPagesProps = { /** Array of step names for the progress indicator */ stepNames?: readonly string[]; - /** ID of current policy */ policyID?: string; /** Callback triggered after the last form page is completed */ diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx index 2bf063c8a602..fc8082182d9d 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; diff --git a/src/pages/ReimbursementAccount/NonUSD/types.ts b/src/pages/ReimbursementAccount/NonUSD/types.ts index c477e045a8d7..26b8fcb5f3ce 100644 --- a/src/pages/ReimbursementAccount/NonUSD/types.ts +++ b/src/pages/ReimbursementAccount/NonUSD/types.ts @@ -2,10 +2,7 @@ type NonUSDPageProps = { /** Handles submit button press */ onSubmit: () => void; - /** Handles back button press */ onBackButtonPress: () => void; - - /** ID of current policy */ policyID?: string; /** Currency of Business Bank Account */ @@ -14,10 +11,7 @@ type NonUSDPageProps = { /** Array of step names for the progress indicator */ stepNames?: readonly string[]; - /** Name of the current sub page */ currentSubPage?: string; - - /** Whether the user is coming from the expensify card */ isComingFromExpensifyCard?: boolean; /** Optional route name to navigate back to after flow completion - will be removed after https://github.com/Expensify/App/issues/73825 is done */ diff --git a/src/pages/ReimbursementAccount/NonUSD/utils/getOwnerDetailsAndOwnerFilesForBeneficialOwners.ts b/src/pages/ReimbursementAccount/NonUSD/utils/getOwnerDetailsAndOwnerFilesForBeneficialOwners.ts index d46853cd884b..83c3993353b0 100644 --- a/src/pages/ReimbursementAccount/NonUSD/utils/getOwnerDetailsAndOwnerFilesForBeneficialOwners.ts +++ b/src/pages/ReimbursementAccount/NonUSD/utils/getOwnerDetailsAndOwnerFilesForBeneficialOwners.ts @@ -11,7 +11,7 @@ const { LAST_NAME, OWNERSHIP_PERCENTAGE, DOB, - SSN_LAST_4, + SSN, STREET, CITY, COUNTRY, @@ -27,7 +27,7 @@ const { PREFIX, } = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA; -const ownerDetailsFields = [FIRST_NAME, LAST_NAME, OWNERSHIP_PERCENTAGE, DOB, SSN_LAST_4, STREET, CITY, STATE, ZIP_CODE, COUNTRY, NATIONALITY]; +const ownerDetailsFields = [FIRST_NAME, LAST_NAME, OWNERSHIP_PERCENTAGE, DOB, SSN, STREET, CITY, STATE, ZIP_CODE, COUNTRY, NATIONALITY]; const ownerFilesFields = [PROOF_OF_OWNERSHIP, COPY_OF_ID, ADDRESS_PROOF, CODICE_FISCALE]; function getOwnerDetailsAndOwnerFilesForBeneficialOwners(ownerKeys: string[], reimbursementAccountDraft: OnyxEntry) { @@ -46,7 +46,7 @@ function getOwnerDetailsAndOwnerFilesForBeneficialOwners(ownerKeys: string[], re continue; } - if (fieldName === SSN_LAST_4 && SafeString(reimbursementAccountDraft?.[ownerDetailsNationalityKey]) !== CONST.COUNTRY.US) { + if (fieldName === SSN && SafeString(reimbursementAccountDraft?.[ownerDetailsNationalityKey]) !== CONST.COUNTRY.US) { continue; } diff --git a/src/pages/ReimbursementAccount/NonUSD/utils/getStartPageForContinueSetup.ts b/src/pages/ReimbursementAccount/NonUSD/utils/getStartPageForContinueSetup.ts index 9151a0ccddbe..9a9c5ed1e279 100644 --- a/src/pages/ReimbursementAccount/NonUSD/utils/getStartPageForContinueSetup.ts +++ b/src/pages/ReimbursementAccount/NonUSD/utils/getStartPageForContinueSetup.ts @@ -48,10 +48,10 @@ function getStartPageForContinueSetup( !!(reimbursementAccountDraft?.consentToPrivacyNotice ?? achData?.corpay?.consentToPrivacyNotice) && !!(reimbursementAccountDraft?.provideTruthfulInformation ?? achData?.corpay?.provideTruthfulInformation); - // AUD accounts must upload a bank statement, which is collected on the Agreements page and only lives in the draft until - // it's submitted on the Docusign page. Accounts that checked the agreements before the bank statement became required won't - // have one, so gate the Docusign resume on it to avoid submitting without a bank statement. - const isBankStatementNeeded = policyCurrency === CONST.CURRENCY.AUD; + // AUD and CAD accounts must upload a bank statement, which is collected on the Agreements page and only lives in the draft + // until it's submitted on the Docusign page. Accounts that checked the agreements before the bank statement became required + // won't have one, so gate the Docusign resume on it to avoid submitting without a bank statement. + const isBankStatementNeeded = policyCurrency === CONST.CURRENCY.AUD || policyCurrency === CONST.CURRENCY.CAD; const isBankStatementUploaded = !!reimbursementAccountDraft?.[INPUT_IDS.ADDITIONAL_DATA.CORPAY.BANK_STATEMENT]?.length; if (nonUSDCountryDraftValue !== '' && achData?.created === undefined) { diff --git a/src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts b/src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts index 02aedc2ee7f7..9619a2c5151e 100644 --- a/src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts +++ b/src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts @@ -10,7 +10,7 @@ type BeneficialOwnerValues = { nationality: string; ownershipPercentage: string; dob: string; - ssnLast4: string; + ssn: string; street: string; city: string; state: string; @@ -30,7 +30,7 @@ function getValuesForOwner(beneficialOwnerBeingModifiedID: string, reimbursement nationality: '', ownershipPercentage: '', dob: '', - ssnLast4: '', + ssn: '', street: '', city: '', state: '', @@ -51,7 +51,7 @@ function getValuesForOwner(beneficialOwnerBeingModifiedID: string, reimbursement nationality: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.NATIONALITY}`, ownershipPercentage: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.OWNERSHIP_PERCENTAGE}`, dob: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.DOB}`, - ssnLast4: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.SSN_LAST_4}`, + ssn: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.SSN}`, street: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.STREET}`, city: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.CITY}`, state: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.STATE}`, @@ -69,7 +69,7 @@ function getValuesForOwner(beneficialOwnerBeingModifiedID: string, reimbursement nationality: reimbursementAccountDraft[INPUT_KEYS.nationality] ?? '', ownershipPercentage: reimbursementAccountDraft[INPUT_KEYS.ownershipPercentage] ?? '', dob: reimbursementAccountDraft[INPUT_KEYS.dob] ?? '', - ssnLast4: reimbursementAccountDraft[INPUT_KEYS.ssnLast4] ?? '', + ssn: reimbursementAccountDraft[INPUT_KEYS.ssn] ?? '', street: reimbursementAccountDraft[INPUT_KEYS.street] ?? '', city: reimbursementAccountDraft[INPUT_KEYS.city] ?? '', state: reimbursementAccountDraft[INPUT_KEYS.state] ?? '', diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index c778612fcc38..73ca52f2ba6e 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -121,6 +121,14 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const hasClearedStalePlaidErrorsRef = useRef(false); const isChangingBankAccountRef = useRef(isChangingBankAccount); const hasShownConnectedBankAccountRef = useRef(false); + // Latches the pending-USD redirect below so the effect dispatches the navigation at most once per mount, even + // though its dependencies change again while the transition is in flight. + const hasRedirectedToPendingValidationRef = useRef(false); + // Set once this page has actually been covered by the validation step. The redirect ref alone cannot tell that + // apart from the redirect still being in flight, because it flips while this page is still focused. + const hasBlurredAfterPendingRedirectRef = useRef(false); + // Set when this page leaves the pending-validation flow, so the unmount cleanup stops preserving the account data. + const isLeavingPendingValidationFlowRef = useRef(false); const prevReimbursementAccount = usePrevious(reimbursementAccount); const prevIsOffline = usePrevious(isOffline); const achData = reimbursementAccount?.achData; @@ -185,8 +193,17 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const isChangingBankAccountInstance = isChangingBankAccountRef.current; return () => { if (!isChangingBankAccountInstance) { + // The draft is always safe to clear. Nothing in the validation step branches on it, while the + // micro-deposit inputs save into it, so leaving it behind prefills the previous attempt's amounts + // against the limited number of validation attempts the next time the account is opened. clearReimbursementAccountDraft(); - clearReimbursementAccount(); + // The account itself must survive an unmount that happens only because this page redirected into the + // validation step of the same flow. ConnectBankAccount reads achData.state and does no fetching, so + // clearing here resets it to DEFAULT_DATA underneath it and renders a blank header-only RHP. Once the + // flow is actually being left, nothing is left to read it and the usual wipe applies again. + if (!hasRedirectedToPendingValidationRef.current || isLeavingPendingValidationFlowRef.current) { + clearReimbursementAccount(); + } } cancelChangingToNewBankAccount(); getPaymentMethods(); @@ -228,6 +245,39 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const isDefaultReimbursementAccountData = deepEqual(reimbursementAccount, CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); const hasLoadedData = reimbursementAccount?.achData && !isDefaultReimbursementAccountData && !reimbursementAccount?.isLoading; + const canManageWorkspaceBankAccount = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); + // The redirect fires from an effect, and effects run even on the renders that return FullPageNotFoundView below. + // The validation step has no authorization guard of its own, so a stale cached account for a workspace the user + // can no longer manage would carry them past the not-authorized screen and into the micro-deposit form. Redirect + // only once the policy has loaded and positively grants access. When it has not, this page falls through to its + // normal render, which shows the loader while the policy is loading and the not-found screen once it is not. + const isAuthorizedToValidateBankAccount = !isLoadingPolicy && !isEmptyObject(policy) && canManageWorkspaceBankAccount && !isPendingDeletePolicy(policy); + // For a pending USD account this page only redirects, so it must never paint the entry point. Derived during render + // because effects run after paint. Not latched on the redirect ref: that flips mid-transition and the next render + // would fall through to the entry point. policyID must match because REIMBURSEMENT_ACCOUNT is persisted, so on a + // cold load achData can still describe another policy's account. Entry points that pass no policyID at all (the + // Wallet ones) are excluded outright, since there is nothing to match them against. + const shouldRedirectToPendingValidation = + policyCurrency === CONST.CURRENCY.USD && + achData?.state === CONST.BANK_ACCOUNT.STATE.PENDING && + !!hasLoadedData && + !isChangingBankAccount && + !!policyIDParam && + achData?.policyID === policyIDParam && + isAuthorizedToValidateBankAccount; + + // Leaves the setup flow entirely. Used everywhere the pending-validation redirect needs an exit, because going back + // to this page would only redirect again. + const leavePendingValidationFlow = useCallback(() => { + // Tells the unmount cleanup that the validation step is not going to keep reading the preserved account data, + // so the usual wipe can run. A ref rather than state, so this cannot repaint the entry point on the way out. + isLeavingPendingValidationFlowRef.current = true; + if (backTo) { + Navigation.goBack(backTo); + return; + } + Navigation.dismissModal(); + }, [backTo]); /** When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx. Calculating `shouldShowContinueSetupButton` immediately on initial render doesn't make sense as @@ -358,11 +408,13 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen return; } - // If USD bank account is in pending state, we should navigate straight to the validation step and skip Continue step - if (policyCurrency === CONST.CURRENCY.USD && achData?.state === CONST.BANK_ACCOUNT.STATE.PENDING) { - setUSDBankAccountStep(CONST.BANK_ACCOUNT.STEP.VALIDATION); - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION); - setShouldShowContinueSetupButton(shouldShowContinueSetupButtonValue); + // Navigate straight to the validation step and skip the Continue step. Done from inside this page so that + // openReimbursementAccountPage has already populated the achData that ConnectBankAccount reads. + if (shouldRedirectToPendingValidation && !hasRedirectedToPendingValidationRef.current) { + hasRedirectedToPendingValidationRef.current = true; + // A push, not a forceReplace: replacing unmounts this page and its cleanup wipes REIMBURSEMENT_ACCOUNT, + // leaving ConnectBankAccount with state 'SETUP' and a blank RHP. + Navigation.navigate(ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID: policyIDParam, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION, backTo})); return; } @@ -378,7 +430,28 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen // achData changes — not to local USDBankAccountStep updates — otherwise it races with prepareNextStep // and briefly pulls USDBankAccountStep back to the server value before the Onyx merge lands. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policyIDParam, achData?.currentStep, shouldShowContinueSetupButtonValue, isNonUSDSetup, isPreviousPolicy, achData?.state, policyCurrency]); + }, [policyIDParam, achData?.currentStep, shouldShowContinueSetupButtonValue, isNonUSDSetup, isPreviousPolicy, achData?.state, policyCurrency, shouldRedirectToPendingValidation, backTo]); + + // Declared after the redirect effect so that on the commit where the redirect fires this runs with the blur flag + // still unset and bails out, rather than racing the navigation it is meant to follow. + useEffect(() => { + if (!hasRedirectedToPendingValidationRef.current) { + return; + } + + if (!isFocused) { + hasBlurredAfterPendingRedirectRef.current = true; + return; + } + + // The user navigated back onto this page, which the back handler cannot intercept for browser back. This page + // only redirects for a pending account, so it would otherwise sit on the loader forever. Leave the flow. + if (!hasBlurredAfterPendingRedirectRef.current || !shouldRedirectToPendingValidation) { + return; + } + + leavePendingValidationFlow(); + }, [isFocused, shouldRedirectToPendingValidation, leavePendingValidationFlow]); useEffect(() => { if (!prevPolicyCurrency || policyCurrency === prevPolicyCurrency) { @@ -576,8 +649,6 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen return ; } - const canManageWorkspaceBankAccount = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); - if (!!policyIDParam && ((!isLoading && (isEmptyObject(policy) || !canManageWorkspaceBankAccount)) || isPendingDeletePolicy(policy))) { return ( @@ -648,6 +719,13 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen ); } + // Keep the loader on screen for a pending USD account so the "Continue setup / Start over" entry point is never + // painted on the way to the validation step. The back button leaves the flow rather than using goBack, which + // switches on achData.currentStep and performs no navigation at all for several of its cases. + if (shouldRedirectToPendingValidation) { + return ; + } + // Once fresh data has loaded, trust the live value to avoid a one-frame flash from the effect-synced state lagging achData. // On a "change bank account" instance never show "continue setup", since the shared data still describes the account being replaced. const shouldShowContinueSetupButtonToDisplay = !isChangingBankAccount && (hasLoadedData ? shouldShowContinueSetupButtonValue : shouldShowContinueSetupButton); diff --git a/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx index b0263959d6a1..0d9aac556000 100644 --- a/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx @@ -32,8 +32,7 @@ type BankInfoProps = { /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - /** Current Policy ID */ - policyID: string; + policyID?: string; }; const BANK_INFO_STEP_KEYS = INPUT_IDS.BANK_INFO_STEP; diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx index e8f094c535e7..1044368fd8a2 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx @@ -37,10 +37,7 @@ const pages = [ ]; type BeneficialOwnerDetailsFormPagesProps = { - /** ID of current policy */ policyID?: string; - - /** ID of the beneficial owner being modified */ beneficialOwnerBeingModifiedID: string; /** Setter for the beneficial owner being modified */ diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx index da0b5bc906e9..6767af890360 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx @@ -29,10 +29,7 @@ type BeneficialOwnersStepProps = { /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - /** Name of the current sub page */ currentSubPage?: string; - - /** ID of current policy */ policyID?: string; /** Back to URL for preserving navigation context */ @@ -98,7 +95,7 @@ function BeneficialOwnersStep({onBackButtonPress, onSubmit, currentSubPage, poli ); const submit = () => { - const beneficialOwnerFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'street', 'city', 'state', 'zipCode']; + const beneficialOwnerFields = ['firstName', 'lastName', 'dob', 'ssn', 'street', 'city', 'state', 'zipCode']; const beneficialOwnersData = beneficialOwnerKeys.map((ownerKey) => beneficialOwnerFields.reduce( (acc, fieldName) => { diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO.tsx index 65a80a64969c..f03619a73549 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO.tsx @@ -47,8 +47,8 @@ function ConfirmationUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifie }, { id: 'ssn', - description: translate('beneficialOwnerInfoStep.last4SSN'), - title: values.ssnLast4, + description: translate('common.ssnFull9'), + title: values.ssn, shouldShowRightIcon: true, onPress: () => { onMove(UBO_STEP_INDEXES.SSN); @@ -75,6 +75,7 @@ function ConfirmationUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifie showOnfidoLinks onfidoLinksTitle={`${translate('beneficialOwnerInfoStep.byAddingThisBankAccount')} `} error={error} + forwardedFSClass={CONST.FULLSTORY.CLASS.MASK} /> ); } diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/SocialSecurityNumberUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/SocialSecurityNumberUBO.tsx index bc2297af5bf0..ccb1c68eb619 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/SocialSecurityNumberUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/SocialSecurityNumberUBO.tsx @@ -6,7 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubPageProps} from '@hooks/useSubPage/types'; -import {getFieldRequiredErrors, isValidSSNLastFour} from '@libs/ValidationUtils'; +import {getFieldRequiredErrors, isValidSSNFullNine} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -14,7 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; import React from 'react'; -const SSN_LAST_4 = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.SSN_LAST_4; +const SSN = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.SSN; const BENEFICIAL_OWNER_PREFIX = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.PREFIX; type SocialSecurityNumberUBOProps = SubPageProps & {beneficialOwnerBeingModifiedID: string}; @@ -24,14 +24,14 @@ function SocialSecurityNumberUBO({onNext, onMove, isEditing, beneficialOwnerBein const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const ssnLast4InputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${SSN_LAST_4}` as const; - const defaultSsnLast4 = SafeString(reimbursementAccountDraft?.[ssnLast4InputID]); - const stepFields = [ssnLast4InputID]; + const ssnInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${SSN}` as const; + const defaultSsn = SafeString(reimbursementAccountDraft?.[ssnInputID]); + const stepFields = [ssnInputID]; const validate = (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, stepFields, translate); - if (values[ssnLast4InputID] && !isValidSSNLastFour(SafeString(values[ssnLast4InputID]))) { - errors[ssnLast4InputID] = translate('bankAccount.error.ssnLast4'); + if (values[ssnInputID] && !isValidSSNFullNine(SafeString(values[ssnInputID]))) { + errors[ssnInputID] = translate('additionalDetailsStep.ssnFull9Error'); } return errors; }; @@ -48,16 +48,17 @@ function SocialSecurityNumberUBO({onNext, onMove, isEditing, beneficialOwnerBein onNext={onNext} onMove={onMove} formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM} - formTitle={translate('beneficialOwnerInfoStep.enterTheLast4')} + formTitle={translate('beneficialOwnerInfoStep.enterTheSSN')} formDisclaimer={translate('beneficialOwnerInfoStep.dontWorry')} validate={validate} onSubmit={handleSubmit} - inputId={ssnLast4InputID} - inputLabel={translate('beneficialOwnerInfoStep.last4SSN')} + inputId={ssnInputID} + inputLabel={translate('common.ssnFull9')} inputMode={CONST.INPUT_MODE.NUMERIC} - defaultValue={defaultSsnLast4} + defaultValue={defaultSsn} shouldShowHelpLinks={false} - maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN} + maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.FULL_SSN} + forwardedFSClass={CONST.FULLSTORY.CLASS.MASK} shouldDelayAutoFocus /> ); diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx index 20aae6b7065d..1182a27dca55 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx @@ -1,5 +1,6 @@ import UserAvatar from '@components/Avatar/UserAvatar'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import MenuItem from '@components/MenuItem'; import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; @@ -7,7 +8,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -31,13 +31,8 @@ type CompanyOwnersListUBOProps = { /** Method called when user presses on one of UBOs to edit its data */ handleUBOEdit: (value: string) => void; - /** List of UBO keys */ beneficialOwnerKeys: string[]; - - /** Info is user UBO */ isUserUBO: boolean; - - /** Info about other existing UBOs */ isAnyoneElseUBO: boolean; }; @@ -46,7 +41,6 @@ const REQUESTOR_PERSONAL_INFO_KEYS = INPUT_IDS.PERSONAL_INFO_STEP; function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmation, beneficialOwnerKeys, handleUBOEdit}: CompanyOwnersListUBOProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const {paddingBottom: safeAreaInsetPaddingBottom} = useSafeAreaPaddings(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); @@ -108,16 +102,15 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio messages={{error}} /> )} - + ); diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx index 2b988f0a9114..3ae983c5b0f3 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx @@ -2,7 +2,7 @@ import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; @@ -68,83 +68,72 @@ function ConfirmationBusiness({onNext, onMove}: SubPageProps) { return ( {translate('businessInfoStep.letsDoubleCheck')} - { onMove(BUSINESS_INFO_STEP_INDEXES.BUSINESS_NAME); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_NAME]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.TAX_ID_NUMBER); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_TAX_ID]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_ADDRESS); }} + value={`${values[BUSINESS_INFO_STEP_KEYS.STREET]}, ${values[BUSINESS_INFO_STEP_KEYS.CITY]}, ${values[BUSINESS_INFO_STEP_KEYS.STATE]} ${values[BUSINESS_INFO_STEP_KEYS.ZIP_CODE]}`} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.PHONE_NUMBER); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_PHONE]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_WEBSITE); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_WEBSITE]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_TYPE); }} + value={ + values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE] + ? translate(`businessInfoStep.incorporationType.${values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE]}` as TranslationPaths) + : undefined + } /> - { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_DATE); }} + value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_DATE]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_STATE); }} + value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE] ? translate(`allStates.${values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE] as States}.stateName`) : undefined} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_CODE); }} + value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_CODE]} /> void; /** Function to call when the user closes the business type selector modal */ onClose: () => void; - /** Label to display on field */ label: string; }; diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx index 937632674418..147d036c1716 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx @@ -15,7 +15,6 @@ import type {BusinessTypeItemType, IncorporationType} from './types'; import BusinessTypeSelectorModal from './BusinessTypeSelectorModal'; type BusinessTypePickerProps = { - /** Error text to display */ errorText?: string; /** Business type to display */ @@ -24,10 +23,7 @@ type BusinessTypePickerProps = { /** Callback to call when the input changes */ onInputChange?: (value: string) => void; - /** Label to display on field */ label: string; - - /** Any additional styles to apply */ wrapperStyle: StyleProp; /** Callback to call when the picker modal is dismissed */ diff --git a/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx b/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx index 136294ac898c..2101519f4188 100644 --- a/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx +++ b/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx @@ -19,7 +19,6 @@ import React, {useCallback, useMemo} from 'react'; import ConfirmAgreements from './subSteps/ConfirmAgreements'; type CompleteVerificationProps = { - /** Handles back button press */ onBackButtonPress: () => void; /** Handles submit button press (URL-based navigation) */ diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx index 6d3faa285747..28afcf49fdd7 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx @@ -32,7 +32,6 @@ import BankAccountValidationForm from './components/BankAccountValidationForm'; import FinishChatCard from './components/FinishChatCard'; type ConnectBankAccountProps = { - /** Handles back button press */ onBackButtonPress: () => void; /** Method to set the state of shouldShowConnectedVerifiedBankAccount */ @@ -41,7 +40,6 @@ type ConnectBankAccountProps = { /** Method to set the state of shouldShowConnectedVerifiedBankAccount */ setUSDBankAccountStep?: (step: string | null) => void; - /** ID of current policy */ policyID?: string; /** Route to return to when navigating back out of the flow */ diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx index c7f0b0fcc7fa..e45710d00ee5 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/Enable2FACard.tsx @@ -1,23 +1,24 @@ import {loadIllustration} from '@components/Icon/IllustrationLoader'; import type {IllustrationName} from '@components/Icon/IllustrationLoader'; +import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; import Text from '@components/Text'; import {useMemoizedLazyAsset, useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; import Navigation from '@navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import React from 'react'; import {View} from 'react-native'; function Enable2FACard() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const {asset: ShieldYellow} = useMemoizedLazyAsset(() => loadIllustration('ShieldYellow' as IllustrationName)); const icons = useMemoizedLazyExpensifyIcons(['Shield']); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); @@ -28,19 +29,25 @@ function Enable2FACard() { icon={ShieldYellow} titleStyles={styles.mb4} containerStyles={styles.mh5} - menuItems={[ - { - title: translate('connectBankAccountStep.secureYourAccount'), - onPress: () => Navigation.navigate(getTwoFactorAuthRoute()), - icon: icons.Shield, - shouldShowRightIcon: true, - outerWrapperStyle: shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8, - }, - ]} > {translate('connectBankAccountStep.enable2FAText')} + + Navigation.navigate(getTwoFactorAuthRoute()))}> + + + + + + {translate('connectBankAccountStep.secureYourAccount')} + + + + + + + ); } diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx index 5cdfc4a2b20c..04d748ad31a7 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx @@ -1,4 +1,4 @@ -import MenuItem from '@components/MenuItem'; +import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import Text from '@components/Text'; @@ -26,6 +26,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; +import {View} from 'react-native'; import Enable2FACard from './Enable2FACard'; @@ -87,39 +88,35 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se titleStyles={[styles.mb3]} > {translate('connectBankAccountStep.letsChatText')} - - { - setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL).then(() => { - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR); - Navigation.navigate( - ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({ - policyID: policy?.id, - page: CONST.BANK_ACCOUNT.PAGE_NAMES.REQUESTOR, - subPage: CONST.BANK_ACCOUNT.PERSONAL_INFO_STEP.SUB_PAGE_NAMES.FULL_NAME, - backTo, - }), - ); - }); - }} - outerWrapperStyle={shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8} - shouldShowRightIcon - /> - + + + { + setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL).then(() => { + goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + Navigation.navigate( + ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({ + policyID: policy?.id, + page: CONST.BANK_ACCOUNT.PAGE_NAMES.REQUESTOR, + subPage: CONST.BANK_ACCOUNT.PERSONAL_INFO_STEP.SUB_PAGE_NAMES.FULL_NAME, + backTo, + }), + ); + }); + }} + /> + + {!requiresTwoFactorAuth && } diff --git a/src/pages/ReimbursementAccount/USD/Country/index.tsx b/src/pages/ReimbursementAccount/USD/Country/index.tsx index 0991dc522cd1..bcade381ab96 100644 --- a/src/pages/ReimbursementAccount/USD/Country/index.tsx +++ b/src/pages/ReimbursementAccount/USD/Country/index.tsx @@ -7,16 +7,12 @@ import CONST from '@src/CONST'; import React from 'react'; type CountryProps = { - /** Handles back button press */ onBackButtonPress: () => void; /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - /** Array of step names */ stepNames: readonly string[]; - - /** ID of current policy */ policyID: string | undefined; }; diff --git a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx index 067214d2f60b..1b117901028e 100644 --- a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx +++ b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; diff --git a/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/HelpLinks.tsx b/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/HelpLinks.tsx index 46f9978552bd..d90a22fc65bb 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/HelpLinks.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/HelpLinks.tsx @@ -15,7 +15,6 @@ import React from 'react'; import {View} from 'react-native'; type HelpLinkProps = { - /** Style for wrapping View */ containerStyles?: StyleProp; }; diff --git a/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx b/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx index 69a0f4bfde0c..ecb647dc88e2 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx @@ -39,7 +39,6 @@ type PersonalInfoProps = { /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - /** Reference to the outer element */ ref?: ForwardedRef; /** Back to URL for preserving navigation context */ diff --git a/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx b/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx index 0d84af8d58a4..7c01afee52e5 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx @@ -12,7 +12,6 @@ type RequestorStepProps = { /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - /** Reference to the outer element */ ref?: ForwardedRef; /** Back to URL for preserving navigation context */ diff --git a/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx b/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx index 4f82280c1bae..9c87c39bbff1 100644 --- a/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx +++ b/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx @@ -40,9 +40,30 @@ type PageEntry = { lastSubPage?: string; }; +function CountryPage({onBackButtonPress, onSubmit, stepNames, policyID}: USDPageProps) { + return ( + + ); +} + +function BankInfoPage({onBackButtonPress, onSubmit, policyID}: USDPageProps) { + return ( + + ); +} + const pages: PageEntry[] = [ - {pageName: PAGE_NAMES.COUNTRY, component: Country as React.ComponentType}, - {pageName: PAGE_NAMES.BANK_ACCOUNT, component: BankInfo as React.ComponentType, firstSubPage: BANK_INFO_SUB_PAGES.PLAID, lastSubPage: BANK_INFO_SUB_PAGES.PLAID}, + {pageName: PAGE_NAMES.COUNTRY, component: CountryPage}, + {pageName: PAGE_NAMES.BANK_ACCOUNT, component: BankInfoPage, firstSubPage: BANK_INFO_SUB_PAGES.PLAID, lastSubPage: BANK_INFO_SUB_PAGES.PLAID}, { pageName: PAGE_NAMES.REQUESTOR, component: RequestorStep as React.ComponentType, @@ -93,7 +114,7 @@ function USDVerifiedBankAccountFlowPage({route}: USDVerifiedBankAccountFlowPageP }, [currentPage]); const currentEntry = pages.at(currentPageIndex); - const CurrentPage = currentEntry?.component ?? (Country as React.ComponentType); + const CurrentPage = currentEntry?.component ?? CountryPage; const isRequestorStep = currentEntry?.pageName === PAGE_NAMES.REQUESTOR; const shouldSkipVerifyIdentity = useCallback((pageName?: string) => pageName === PAGE_NAMES.VERIFY_IDENTITY && isOnfidoSetupComplete, [isOnfidoSetupComplete]); @@ -132,9 +153,14 @@ function USDVerifiedBankAccountFlowPage({route}: USDVerifiedBankAccountFlowPageP const onBackButtonPress = useCallback(() => { // When the bank account is pending validation it has already been submitted, so stepping back through the - // setup pages doesn't make sense. Pop back to the entry point screen the user came from. + // setup pages doesn't make sense. Leave the flow entirely rather than popping to ReimbursementAccountPage: + // that page redirects a pending account straight back here, so returning to it would trap the user in a loop. if (currentEntry?.pageName === PAGE_NAMES.VALIDATION && reimbursementAccount?.achData?.state === CONST.BANK_ACCOUNT.STATE.PENDING) { - Navigation.goBack(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, backTo})); + if (backTo) { + Navigation.goBack(backTo); + } else { + Navigation.dismissModal(); + } return; } diff --git a/src/pages/ReimbursementAccount/USD/types.ts b/src/pages/ReimbursementAccount/USD/types.ts index d102987079ea..8a6a35e50895 100644 --- a/src/pages/ReimbursementAccount/USD/types.ts +++ b/src/pages/ReimbursementAccount/USD/types.ts @@ -7,13 +7,8 @@ type USDPageProps = { /** Handles submit button press */ onSubmit: () => void; - /** Handles back button press */ onBackButtonPress: () => void; - - /** ID of current policy */ policyID?: string; - - /** Name of the current sub page */ currentSubPage?: string; /** Array of step names for the progress indicator */ diff --git a/src/pages/ReimbursementAccount/USD/utils/getValuesForBeneficialOwner.ts b/src/pages/ReimbursementAccount/USD/utils/getValuesForBeneficialOwner.ts index f1a1166a5b0c..f1b73ef0cdad 100644 --- a/src/pages/ReimbursementAccount/USD/utils/getValuesForBeneficialOwner.ts +++ b/src/pages/ReimbursementAccount/USD/utils/getValuesForBeneficialOwner.ts @@ -9,7 +9,7 @@ type BeneficialOwnerValues = { firstName: string; lastName: string; dob: string; - ssnLast4: string; + ssn: string; street: string; city: string; state: string; @@ -22,7 +22,7 @@ function getValuesForBeneficialOwner(beneficialOwnerBeingModifiedID: string, rei firstName: '', lastName: '', dob: '', - ssnLast4: '', + ssn: '', street: '', city: '', state: '', @@ -36,7 +36,7 @@ function getValuesForBeneficialOwner(beneficialOwnerBeingModifiedID: string, rei firstName: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.FIRST_NAME}`, lastName: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.LAST_NAME}`, dob: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.DOB}`, - ssnLast4: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.SSN_LAST_4}`, + ssn: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.SSN}`, street: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.STREET}`, city: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.CITY}`, state: `${beneficialOwnerPrefix}_${beneficialOwnerBeingModifiedID}_${beneficialOwnerInfoKey.STATE}`, @@ -47,7 +47,7 @@ function getValuesForBeneficialOwner(beneficialOwnerBeingModifiedID: string, rei firstName: SafeString(reimbursementAccountDraft[INPUT_KEYS.firstName]), lastName: SafeString(reimbursementAccountDraft[INPUT_KEYS.lastName]), dob: SafeString(reimbursementAccountDraft[INPUT_KEYS.dob]), - ssnLast4: SafeString(reimbursementAccountDraft[INPUT_KEYS.ssnLast4]), + ssn: SafeString(reimbursementAccountDraft[INPUT_KEYS.ssn]), street: SafeString(reimbursementAccountDraft[INPUT_KEYS.street]), city: SafeString(reimbursementAccountDraft[INPUT_KEYS.city]), state: SafeString(reimbursementAccountDraft[INPUT_KEYS.state]), diff --git a/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx b/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx index 44579b585c41..3a492fc18097 100644 --- a/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx +++ b/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx @@ -1,7 +1,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import LottieAnimations from '@components/LottieAnimations'; -import MenuItem from '@components/MenuItem'; +import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -16,7 +16,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResetBankAccountModal from '@hooks/useResetBankAccountModal'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -68,7 +67,6 @@ type VerifiedBankAccountFlowEntryPointProps = { /** Back to url passed from page */ backTo?: Route; - /** Should show the continue setup button */ shouldShowContinueSetupButton: boolean | null; /** Whether the workspace currency is set to non USD currency */ @@ -80,7 +78,6 @@ type VerifiedBankAccountFlowEntryPointProps = { /** Method to set the state of shouldShowContinueSetupButton */ setShouldShowContinueSetupButton?: (shouldShowContinueSetupButton: boolean) => void; - /** Whether the user is coming from the expensify card */ isComingFromExpensifyCard?: boolean; /** Whether this instance is starting a fresh setup from a "change bank account" flow */ @@ -105,7 +102,6 @@ function VerifiedBankAccountFlowEntryPoint({ const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Bank', 'Connect', 'Lightbulb', 'Lock', 'RotateLeft']); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -289,55 +285,46 @@ function VerifiedBankAccountFlowEntryPoint({ } errorRowStyles={styles.mt2} shouldShowErrorMessages + contentContainerStyle={styles.mhn5} onClose={reimbursementAccount?.maxAttemptsReached ? undefined : resetReimbursementAccount} > - {shouldShowChangeBankAccount && ( - )} - ) : ( - <> + {!isNonUSDWorkspace && !shouldShowContinueSetupButton && ( - )} - - + )} diff --git a/src/pages/ReimbursementAccount/WhyLink.tsx b/src/pages/ReimbursementAccount/WhyLink.tsx index bf09f371a586..abf6f25e5bc7 100644 --- a/src/pages/ReimbursementAccount/WhyLink.tsx +++ b/src/pages/ReimbursementAccount/WhyLink.tsx @@ -14,7 +14,6 @@ import React from 'react'; import {View} from 'react-native'; type WhyLinkProps = { - /** Style for wrapping View */ containerStyles?: StyleProp; }; diff --git a/src/pages/RejectExpenseReportPage.tsx b/src/pages/RejectExpenseReportPage.tsx index 6db3b08efbad..0cca7de97553 100644 --- a/src/pages/RejectExpenseReportPage.tsx +++ b/src/pages/RejectExpenseReportPage.tsx @@ -95,6 +95,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { const [selectionError, setSelectionError] = useState(''); const isSubmitAttempt = useRef(false); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const lastForwardedActorDetails = getPersonalDetailByEmail(lastForwardedActorEmail); const previousApprover = !lastForwardedActorDetails?.accountID @@ -167,6 +168,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { currentUserPersonalDetails?.avatar, isTrackIntentUser, delegateAccountID, + rules, ); Navigation.goBack(); }; diff --git a/src/pages/ReportAddApproverPage.tsx b/src/pages/ReportAddApproverPage.tsx index 93ac2abcf072..2081633c8e6c 100644 --- a/src/pages/ReportAddApproverPage.tsx +++ b/src/pages/ReportAddApproverPage.tsx @@ -54,6 +54,7 @@ function ReportAddApproverPage({report, isLoadingReportData, policy}: ReportAddA const currentUserDetails = useCurrentUserPersonalDetails(); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const employeeList = policy?.employeeList; const allApprovers = (() => { @@ -107,6 +108,7 @@ function ReportAddApproverPage({report, isLoadingReportData, policy}: ReportAddA accountID: currentUserDetails.accountID, email: currentUserDetails.email ?? '', policy, + rules, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 0bfed8ec2d5e..e7a351b87d06 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -80,7 +80,7 @@ function ReportSubmitToContent({ }: ReportSubmitToContentProps) { const styles = useThemeStyles(); const {translate, localeCompare, dateFnsLocale} = useLocalize(); - const {getCurrencyDecimals} = useCurrencyListActions(); + const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); const isInLandscapeMode = useIsInLandscapeMode(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {keyboardActiveHeight, isKeyboardActive} = useKeyboardState(); @@ -99,16 +99,17 @@ function ReportSubmitToContent({ const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {isOffline} = useNetwork(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); - const prepopulatedEmail = getSubmitToEmail(policy, report, submitterLogin); + const prepopulatedEmail = getSubmitToEmail(policy, report, submitterLogin, rules); const [userSelectedManagerEmail, setUserSelectedManagerEmail] = useState(); const [extraSubmitToRecipients, setExtraSubmitToRecipients] = useState([]); @@ -211,6 +212,7 @@ function ReportSubmitToContent({ const inviteOption = getUserToInviteOption({ dateFnsLocale, + convertToDisplayString, searchValue: trimmed, personalDetails, loginList, @@ -218,6 +220,7 @@ function ReportSubmitToContent({ countryCode, selectedOptions: [], loginsToExclude: CONST.EXPENSIFY_EMAILS_OBJECT, + rules, }); if (!inviteOption?.login) { @@ -231,7 +234,7 @@ function ReportSubmitToContent({ keyForList: `nonWorkspace:${login}`, isSelected: managerEmail.trim().toLowerCase() === login.trim().toLowerCase(), }; - }, [countryCode, currentUserDetails.email, searchTerm, filteredWorkspaceMembers.length, loginList, managerEmail, personalDetails, dateFnsLocale]); + }, [countryCode, currentUserDetails.email, searchTerm, filteredWorkspaceMembers.length, loginList, managerEmail, personalDetails, dateFnsLocale, convertToDisplayString, rules]); const submitToSelectionData = useMemo(() => { if (!nonWorkspaceInviteRow) { @@ -303,6 +306,7 @@ function ReportSubmitToContent({ getCurrencyDecimals, expenseReport: report, policy, + rules, currentUserAccountIDParam: currentUserDetails.accountID, currentUserEmailParam: currentUserDetails.email ?? '', hasViolations, @@ -363,6 +367,7 @@ function ReportSubmitToContent({ shouldDismissRHPAfterSubmit, isTrackIntentUser, getCurrencyDecimals, + rules, ]); const onSelectMember = useCallback( diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index c17e46e73922..60015feb132d 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import Icon from '@components/Icon'; import Text from '@components/Text'; diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx index a39faf097f25..61c50de7800a 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx index 0908d73fb165..7e3a012ddbea 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx index 74623d6e8188..ddaeae063f4a 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx index 0af63d71a8a8..c6dc7dff5cbd 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx index 14b7bbc37a1c..fb3fdf279eb4 100644 --- a/src/pages/RoomDescriptionPage.tsx +++ b/src/pages/RoomDescriptionPage.tsx @@ -41,7 +41,6 @@ type RoomDescriptionPageProps = { /** Policy for the current report */ policy: OnyxEntry; - /** The report currently being looked at */ report: OnyxTypes.Report; }; diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index d0a2b1c85b60..3a1a6ded6c15 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -1,8 +1,9 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -137,10 +138,9 @@ function ScheduleCallConfirmationPage() { Navigation.goBack(ROUTES.SCHEDULE_CALL_BOOK.getRoute(route?.params?.reportID)); }} /> - diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index 57be980bc646..0bfe8247c4e0 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 54f69f29f8d3..2b803f25234a 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -27,8 +27,8 @@ import Navigation from '@libs/Navigation/Navigation'; import {canSendInvoice, getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated} from '@libs/PolicyUtils'; import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {getAllPolicyValues, getFilterFromQuery, isDefaultExpenseReportsQuery, isDefaultExpensesQuery, isSearchBeforeViolationsSnapshotStarted} from '@libs/SearchQueryUtils'; -import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import {TODO_SEARCH_KEYS} from '@libs/SearchUIUtils'; +import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -80,7 +80,7 @@ type EmptySearchViewItem = { function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, violationSnapshotStartedAt, onScroll, contentContainerStyle}: EmptySearchViewProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {typeMenuSections} = useSearchTypeMenuSections(); + const typeMenuSections = useSearchTypeMenuSections(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -156,6 +156,7 @@ function EmptySearchViewContent({ selector: hasExpenseReportsSelector, }); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy); @@ -180,6 +181,7 @@ function EmptySearchViewContent({ betas, isTrackIntentUser, getCurrencyDecimals, + rules, false, shouldDismissEmptyReportsConfirmation, ); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index a3c5e060fb56..e2866e62771f 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -1,6 +1,7 @@ import MenuItemList from '@components/MenuItemList'; import {useSearchSidebarCollapse} from '@components/Navigation/SearchSidebarCollapseStore'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; @@ -16,8 +17,16 @@ import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES} from '@libs/SearchUIUtils'; +import {getValidLastQuery} from '@libs/SearchQueryUtils'; +import type {SavedSearchMenuItem, SearchKey} from '@libs/SearchUIUtils'; +import { + createBaseSavedSearchMenuItem, + getLastSearchQuery, + getOverflowMenu as getOverflowMenuUtil, + savedSearchIDToSearchKey, + SAVED_SEARCH_FALLBACK_ICON_NAME, + SAVED_SEARCH_ICON_NAMES, +} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -35,24 +44,35 @@ import useSavedSearchTitles from './hooks/useSavedSearchTitles'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SearchTypeMenuItem from './SearchTypeMenuItem'; -type SavedSearchListProps = { - hash: number | undefined; -}; - type SavedSearchMenuItemBuilderParams = { item: SaveSearchItem; + itemQuery: string; key: string; index: number; - hash: number | undefined; + currentSearchKey: SearchKey | undefined; title: string; - getOverflowMenu: (itemName: string, itemHash: number, itemQuery: string) => ReturnType; + onPress: (searchKey: SearchKey) => void; + getOverflowMenu: (itemSavedSearchID: string, itemQuery: string) => ReturnType; itemStyle: SavedSearchMenuItem['style']; isCopied: boolean; icon: IconAsset; }; -function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMenu, itemStyle, isCopied, icon}: SavedSearchMenuItemBuilderParams): SavedSearchMenuItem & {icon: IconAsset} { - const isItemFocused = Number(key) === hash; +function buildSavedSearchMenuItem({ + item, + itemQuery, + key, + index, + currentSearchKey, + title, + onPress, + getOverflowMenu, + itemStyle, + isCopied, + icon, +}: SavedSearchMenuItemBuilderParams): SavedSearchMenuItem & {icon: IconAsset} { + const savedSearchKey = savedSearchIDToSearchKey(key); + const isItemFocused = savedSearchKey === currentSearchKey; const baseMenuItem: SavedSearchMenuItem = createBaseSavedSearchMenuItem(item, key, index, title, isItemFocused); return { @@ -62,11 +82,12 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); + onPress(savedSearchKey); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: itemQuery, name: item?.name})); }, rightComponent: ( @@ -75,13 +96,14 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen }; } -function SavedSearchList({hash}: SavedSearchListProps) { +function SavedSearchList() { const styles = useThemeStyles(); const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isVisuallyCollapsed} = useSearchSidebarCollapse(); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const personalDetails = usePersonalDetails(); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); @@ -92,11 +114,13 @@ function SavedSearchList({hash}: SavedSearchListProps) { const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); + const {currentSearchKey} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const {showDeleteModal} = useDeleteSavedSearch(); const expensifyIcons = useMemoizedLazyExpensifyIcons([...SAVED_SEARCH_ICON_NAMES, 'Pencil', 'Trashcan', 'LinkCopy', 'Checkmark']); - const {copiedHash, handleShare} = useShareSavedSearch(); + const {copiedID, handleShare} = useShareSavedSearch(); const taxRates = getAllTaxRates(allPolicies); const cardsForSavedSearchDisplay = mergeCardListWithWorkspaceFeeds(workspaceCardList ?? CONST.EMPTY_OBJECT, cardList); @@ -117,10 +141,10 @@ function SavedSearchList({hash}: SavedSearchListProps) { bankAccountList, }); - const getOverflowMenu = (itemName: string, itemHash: number, itemQuery: string) => - getOverflowMenuUtil(expensifyIcons, itemName, itemHash, itemQuery, translate, showDeleteModal, false, undefined, { - onShare: () => handleShare(itemHash, itemQuery), - isCopied: copiedHash === itemHash, + const getOverflowMenu = (itemID: string, itemQuery: string) => + getOverflowMenuUtil(expensifyIcons, itemID, translate, showDeleteModal, false, undefined, { + onShare: () => handleShare(itemID, itemQuery), + isCopied: copiedID === itemID, }); const itemStyle = [styles.alignItemsCenter]; @@ -130,19 +154,22 @@ function SavedSearchList({hash}: SavedSearchListProps) { const savedSearchesMenuItems = savedSearches ? Object.entries(savedSearches) - .map(([key, item], index) => - buildSavedSearchMenuItem({ + .map(([key, item], index) => { + const itemQuery = getValidLastQuery(getLastSearchQuery(searchFilters, savedSearchIDToSearchKey(key)), item.query); + return buildSavedSearchMenuItem({ item, + itemQuery, key, index, - hash, + currentSearchKey, title: item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name, + onPress: (savedSearchKey) => setCurrentSearchKey(savedSearchKey, itemQuery), getOverflowMenu, itemStyle, - isCopied: copiedHash === Number(key), + isCopied: copiedID === key, icon: expensifyIcons[savedSearchIconNames.get(item.query) ?? SAVED_SEARCH_FALLBACK_ICON_NAME], - }), - ) + }); + }) .sort((a, b) => localeCompare(a.title ?? '', b.title ?? '')) : []; diff --git a/src/pages/Search/SavedSearchRenamePage.tsx b/src/pages/Search/SavedSearchRenamePage.tsx index 0d06f2abbb6e..c4772e7f9822 100644 --- a/src/pages/Search/SavedSearchRenamePage.tsx +++ b/src/pages/Search/SavedSearchRenamePage.tsx @@ -1,3 +1,4 @@ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -8,27 +9,39 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {saveSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SearchSavedSearchParamList} from '@libs/Navigation/types'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/SearchSavedSearchRenameForm'; import React from 'react'; -function SavedSearchRenamePage({route}: {route: {params: {q: string; name: string}}}) { +type SavedSearchRenamePageProps = PlatformStackScreenProps; + +function SavedSearchRenamePage({route}: SavedSearchRenamePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {q, name} = route.params; + const {id} = route.params; + const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {selector: (savedSearches) => savedSearches?.[id]}); + const q = savedSearch?.query; const {inputCallbackRef} = useAutoFocusInput(); const applyFiltersAndNavigate = (newName: string) => { + if (!q) { + return; + } + Navigation.dismissModal(); Navigation.isNavigationReady().then(() => { Navigation.navigate( @@ -41,10 +54,15 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin }; const onSaveSearch = (values: FormOnyxValues) => { + if (!q) { + return; + } + const newName = values[INPUT_IDS.NAME].trim(); - const queryJSON = buildSearchQueryJSON(q || buildCannedSearchQuery()) ?? ({} as SearchQueryJSON); + const queryJSON = buildSearchQueryJSON(q) ?? ({} as SearchQueryJSON); saveSearch({ + id, queryJSON, newName, }); @@ -62,26 +80,28 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom > - - - - + + + + + + ); } diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index d2a0fac4ce82..4df14d266bfa 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -39,6 +39,7 @@ function SearchAddApproverPage() { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {clearSelectedTransactions} = useSearchSelectionActions(); const {selectedReports} = useSearchSelectionContext(); const {isLoading, startWithLoading} = usePressLoading(); @@ -145,6 +146,7 @@ function SearchAddApproverPage() { accountID: currentUserDetails.accountID, email: currentUserDetails.email ?? '', policy, + rules, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx index a2d58ff17f39..a9bbaf977a6e 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DateFilterContent from '@components/Search/FilterComponents/AdvancedFilters/DateFilterContent'; import type {DateFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx index aa5dd0075039..81fd05739b16 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {ListFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; import ListFilterContent from '@components/Search/FilterComponents/ListFilterContent'; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx index 7a67f2902cac..b22e7327bfa5 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ReportFieldFilterContent from '@components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent'; import type {ReportFieldFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 6c3c67642277..059d4ca30a14 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList'; diff --git a/src/pages/Search/SearchAdvancedFiltersProvider.tsx b/src/pages/Search/SearchAdvancedFiltersProvider.tsx index 109be7adb121..d352c9f07146 100644 --- a/src/pages/Search/SearchAdvancedFiltersProvider.tsx +++ b/src/pages/Search/SearchAdvancedFiltersProvider.tsx @@ -5,11 +5,13 @@ import useOnyx from '@hooks/useOnyx'; import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils'; +import {buildQueryStringWithResetFilters, hasFiltersChangedFromDefault} from '@libs/SearchQueryUtils'; +import {shouldShowFilter, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import React, {useState} from 'react'; @@ -41,24 +43,23 @@ type SearchAdvancedFiltersProviderProps = { function SearchAdvancedFiltersProvider({children}: SearchAdvancedFiltersProviderProps) { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const {currentSearchQueryJSON} = useSearchQueryContext(); + const {currentDefaultSearchQueryJSON, currentSearchQueryJSON} = useSearchQueryContext(); const {getUpdatedFilterFormValues, setFilterQueryParams} = useUpdateFilterQuery(currentSearchQueryJSON); const [values, setValues] = useState>(searchAdvancedFiltersForm ?? {}); - const advancedFiltersToReset = searchAdvancedFiltersForm ? getAdvancedFiltersToReset(searchAdvancedFiltersForm) : undefined; - const applyFilters = () => { Navigation.dismissModal({afterTransition: () => setFilterQueryParams(values)}); }; const resetFilters = () => { - if (!advancedFiltersToReset) { + if (!currentSearchQueryJSON) { return; } + Navigation.dismissModal({ afterTransition: () => { - setFilterQueryParams(advancedFiltersToReset); + Navigation.setParams({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); setSearchContext(false); }, }); @@ -70,7 +71,13 @@ function SearchAdvancedFiltersProvider({children}: SearchAdvancedFiltersProvider const searchAdvancedFiltersValue: SearchAdvancedFiltersValue = { currentDraftFilters: values, - shouldShowResetFilters: !isEmptyObject(advancedFiltersToReset), + shouldShowResetFilters: + currentDefaultSearchQueryJSON && currentSearchQueryJSON + ? hasFiltersChangedFromDefault(currentSearchQueryJSON, currentDefaultSearchQueryJSON) + : !!searchAdvancedFiltersForm && + ObjectUtils.typedKeys(searchAdvancedFiltersForm).filter((key) => + shouldShowFilter(SKIPPED_SEARCH_FILTERS, key, searchAdvancedFiltersForm?.[key], searchAdvancedFiltersForm?.type), + ).length > 0, }; const searchAdvancedFiltersActionValue: SearchAdvancedFiltersActionValue = { diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index 6d1905bfbf85..81e591651b14 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -101,6 +101,7 @@ function SearchChangeApproverPage() { }; const [onyxReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: getOnyxReports}); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const hasAutoAppliedRef = useRef(false); // Set when navigating to WORKSPACE_UPGRADE; prevents the auto-close in useLayoutEffect from @@ -173,7 +174,17 @@ function SearchChangeApproverPage() { if (report.managerID !== currentUserDetails.accountID) { const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); - assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber); + assignReportToMe( + report, + currentUserDetails.accountID, + currentUserDetails.email ?? '', + policy, + hasViolations, + isASAPSubmitBetaEnabled, + isTrackIntentUser, + formatPhoneNumber, + rules, + ); } } diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index d3f35d34a0e1..ce104c3865be 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -24,7 +24,8 @@ function SearchColumnsPage() { const allGroupCustomColumns = getCustomColumns(groupBy); const defaultGroupCustomColumns = getCustomColumnDefault(groupBy); const defaultTypeCustomColumns = [...getCustomColumnDefault(queryType)]; - const shouldRequireViolationsColumn = !!searchAdvancedFiltersForm?.has?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + const shouldRequireViolationsColumn = + !!searchAdvancedFiltersForm?.has?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION) || !!searchAdvancedFiltersForm?.has?.includes(CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION); const currentColumns = [...(searchAdvancedFiltersForm?.columns ?? [])]; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 15a112b5c972..cd4fbba5a071 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -85,6 +85,7 @@ function SearchEditMultiplePage() { }); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const snapshotData = currentSearchResults?.data; const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); @@ -108,7 +109,7 @@ function SearchEditMultiplePage() { if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { return false; } - return !canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report, policy: transactionPolicy, reportNameValuePairs}); + return !canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report, policy: transactionPolicy, reportNameValuePairs, rules}); }); const hasPartiallyEditableTransaction = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.AMOUNT); @@ -208,6 +209,7 @@ function SearchEditMultiplePage() { updateMultipleMoneyRequests({ transactionIDs: selectedTransactionIDs, changes, + bulkEditTagChanges: draftTransaction.bulkEditTagChanges, policy, reports: mergedReports, transactions: mergedTransactions, @@ -224,6 +226,7 @@ function SearchEditMultiplePage() { personalDetailsList, getCurrencyDecimals, getCurrencySymbol, + rules, }); // Bulk edit can start from report (ID-based selection) or search (map-based selection), // so clear both stores to keep deselection behavior consistent. diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx index 488bacc8f0bc..738c39c5012e 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx @@ -47,9 +47,17 @@ function SearchEditMultipleTagPage() { const headerTitle = tagListName || translate('common.tag'); const saveTag = (item: Partial) => { + const selectedTagName = item.searchText ?? ''; + // Tapping the value already committed in this draft means the user is clearing the level. + // getUpdatedTransactionTag resolves the same thing internally for the displayed tag, but the + // intent has to be resolved again here because apply time replays each recorded intent with an + // empty currentTag. A raw tag name would read as a fresh selection there and re-add the level + // the user just cleared, so record an empty value to carry the clear through. + const isDeselecting = selectedTagName === currentTag; + const updatedTag = getUpdatedTransactionTag({ transactionTag, - selectedTagName: item.searchText ?? '', + selectedTagName, currentTag, tagListIndex, policyTags, @@ -57,8 +65,26 @@ function SearchEditMultipleTagPage() { hasMultipleTagLists: policy?.hasMultipleTagLists ?? false, }); + // Record the per-level edit intent. For dependent tags, editing this level invalidates every + // deeper (child) level, so drop any child intents previously recorded in the same draft. The + // draft is merged, so without this a stale child edit would be replayed after this parent change + // at apply time and re-add a child that no longer belongs under the newly selected parent, even + // though the displayed updatedTag above already cleared it. Independent tags keep every level. + const bulkEditTagChanges: Record = {[tagListIndex]: isDeselecting ? '' : selectedTagName}; + if (hasDependentTags) { + for (const recordedIndex of Object.keys(draftTransaction?.bulkEditTagChanges ?? {})) { + if (Number(recordedIndex) <= tagListIndex) { + continue; + } + bulkEditTagChanges[recordedIndex] = null; + } + } + updateBulkEditDraftTransaction({ + // Keep the flattened tag for the summary display, and record the per-level edit intent so + // apply time can merge it into each transaction's own tag instead of overwriting all levels. tag: updatedTag, + bulkEditTagChanges, }); Navigation.goBack(); }; diff --git a/src/pages/Search/SearchHoldReasonPage.tsx b/src/pages/Search/SearchHoldReasonPage.tsx index 1303de3f79b0..72567d38b727 100644 --- a/src/pages/Search/SearchHoldReasonPage.tsx +++ b/src/pages/Search/SearchHoldReasonPage.tsx @@ -51,6 +51,7 @@ function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { selector: isTrackIntentUserSelector, }); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const selectedTransactionsList = Object.values(selectedTransactions); const isSubmitter = report ? report.ownerAccountID === currentUserAccountID : selectedTransactionsList.some((t) => t.ownerAccountID === currentUserAccountID); @@ -75,7 +76,7 @@ function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { selectedTransactionViolations, isTrackIntentUser, delegateAccountID, - ancestors, + {rules, ancestors}, ); clearSelectedTransactions(true); } else { @@ -93,7 +94,7 @@ function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { transactionViolations, isTrackIntentUser, delegateAccountID, - ancestors, + {rules, ancestors}, ); } clearSelectedTransactions(); @@ -116,6 +117,7 @@ function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { selectedTransactionViolations, isTrackIntentUser, delegateAccountID, + rules, ], ); diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 2b379809498f..3463e67702b2 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -32,7 +32,7 @@ import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getIOUActionForTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@libs/ReportUtils'; -import {cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; +import {cancelAllSendMessageSpans} from '@libs/telemetry/sendMessageSpans'; import {doesDeleteNavigateBackUrlIncludeDuplicatesReview, getParentReportActionDeletionStatus, hasLoadedReportActions, isThreadReportDeleted} from '@libs/TransactionNavigationUtils'; import Navigation from '@navigation/Navigation'; @@ -222,6 +222,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { introSelected, conciergeChat, betas, + personalDetails, hasReportActions, currentUserAccountID, isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, @@ -242,7 +243,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { return () => { // Cancel any pending send-message spans to prevent orphaned spans when navigating away - cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); + cancelAllSendMessageSpans(); }; }, [reportIDFromRoute]); @@ -293,6 +294,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { createTransactionThreadReport({ introSelected, conciergeChat, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, currentUserLogin: currentUserEmail ?? '', currentUserAccountID, betas, @@ -305,6 +308,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { allReportTransactions, allReportViolations, introSelected, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, currentUserEmail, currentUserAccountID, betas, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 77a6575c9e0e..2e27ea6b4e23 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -10,6 +10,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useOnyx from '@hooks/useOnyx'; import {PaymentContextProvider} from '@hooks/usePaymentContext'; import usePrevious from '@hooks/usePrevious'; +import useReleaseOptionListCaches from '@hooks/useReleaseOptionListCaches'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchOverlay from '@hooks/useSearchOverlay'; import useSearchPageSetup from '@hooks/useSearchPageSetup'; @@ -52,6 +53,7 @@ function SearchPage({route}: SearchPageProps) { useSearchPageSetup(currentSearchQueryJSON); useSeedMyExpensesSearch(); + useReleaseOptionListCaches(); // Adjust state during rendering rather than in a useEffect: the value is consumed in the same // render below (`searchResults = lastNonEmptySearchResults` when sorting), so a useEffect would diff --git a/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx b/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx index 25af4cd9ff2b..8dede1a36f5e 100644 --- a/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx +++ b/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx @@ -1,10 +1,14 @@ +// Static twin of SearchTypeMenuNarrow - used for fast perceived performance. +// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only +// when strictly necessary. UI must stay visually identical to the interactive version. import {useSession} from '@components/OnyxListItemProvider'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import type {TabSelectorBaseItem} from '@components/TabSelector/types'; +import useActiveSavedSearch from '@hooks/useActiveSavedSearch'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import type {SearchKey, SearchTypeMenuItem} from '@libs/SearchUIUtils'; @@ -14,13 +18,7 @@ import {SearchTypeMenuNarrowContent} from '@pages/Search/SearchTypeMenuNarrow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SaveSearch} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; - -// Static twin of SearchTypeMenuNarrow - used for fast perceived performance. -// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only -// when strictly necessary. UI must stay visually identical to the interactive version. import React from 'react'; import staticPolicyInfoSelector from './staticPolicyInfoSelector'; @@ -33,32 +31,12 @@ function getActiveKey(similarSearchHash: number, hasGroupPolicy: boolean, search return candidates.find((entry) => similarSearchHash === entry.similarSearchHash)?.key ?? reportsSearch.key; } -function getActiveSavedSearch(savedSearches: OnyxEntry, hash: number, isOffline: boolean): {key: string; title: string; query: string} | undefined { - if (!savedSearches) { - return undefined; - } - const entry = Object.entries(savedSearches).find(([key, item]) => { - if (Number(key) !== hash) { - return false; - } - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) { - return false; - } - return true; - }); - if (!entry) { - return undefined; - } - const [key, item] = entry; - return {key, title: item.name || item.query || key, query: item.query}; -} - function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) { const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'Document', 'Pencil', ...SAVED_SEARCH_ICON_NAMES]); + const {currentSearchKey} = useSearchQueryContext(); const [policyInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: staticPolicyInfoSelector}); - const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const activeSavedSearch = useActiveSavedSearch(); const hasGroupPolicy = policyInfo?.hasGroupPolicy ?? false; const session = useSession(); const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; @@ -68,8 +46,7 @@ function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) { const expensesSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.EXPENSES]; const submitSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SUBMIT]; - // Saved searches are keyed by their raw hash rather than by a SearchKey, so the tab keys widen to string. - const tabs: TabSelectorBaseItem[] = [ + const tabs: Array> = [ {key: reportsSearch.key, icon: expensifyIcons.Document, title: translate(reportsSearch.translationPath)}, {key: expensesSearch.key, icon: expensifyIcons.Receipt, title: translate(expensesSearch.translationPath)}, ]; @@ -78,12 +55,11 @@ function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) { tabs.push({key: submitSearch.key, icon: expensifyIcons.Pencil, title: translate(submitSearch.translationPath)}); } - const activeSavedSearch = getActiveSavedSearch(savedSearches, queryJSON.hash, isOffline); - if (activeSavedSearch) { - tabs.push({key: activeSavedSearch.key, icon: expensifyIcons[getSavedSearchIconName(activeSavedSearch.query)], title: activeSavedSearch.title}); + if (activeSavedSearch && currentSearchKey) { + tabs.push({key: currentSearchKey, icon: expensifyIcons[getSavedSearchIconName(activeSavedSearch.query)], title: activeSavedSearch.name}); } - const activeKey = activeSavedSearch?.key ?? getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches); + const activeKey = activeSavedSearch ? currentSearchKey : getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches); return ( - - - {!isMobileSelectionModeEnabled ? ( - - - - - - - - - - + + {!isMobileSelectionModeEnabled ? ( + + + + + + + + topBarOffset.set(StyleUtils.searchHeaderDefaultOffset)} /> - + topBarOffset.set(StyleUtils.searchHeaderDefaultOffset)} + /> + + + - - - - + + + - - ) : ( - <> - { - topBarOffset.set(StyleUtils.searchHeaderDefaultOffset); - clearSelectedTransactions(); - turnOffMobileSelectionMode(); - }} - /> - - - )} - - {useStaticRendering && ( - <> - {isInteractive && ( - - )} - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} - - )} - {!useStaticRendering && ( + ) : ( <> - {shouldShowLoadingSkeleton ? ( - - ) : ( - - )} - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} + { + topBarOffset.set(StyleUtils.searchHeaderDefaultOffset); + clearSelectedTransactions(); + turnOffMobileSelectionMode(); + }} + /> + )} + + {useStaticRendering && ( + <> + {isInteractive && ( + + )} + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + {!useStaticRendering && ( + <> + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + + - - - - {(!useStaticRendering || isHeaderInteractive) && ( - - )} + + ); } diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 8b2f97dbb86e..6e1e0647c7ef 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -66,10 +66,9 @@ function SearchPageWide({ // the indicator unreserved and it drops onto its own line. Reading `hasSelectedTransactions` re-renders only // this component on selection changes (its memoized JSX keeps the subtree from re-rendering; // verified via profiling), so the heavy list is unaffected. - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, queryJSON?.hash, true); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const shouldReserveFooterSpace = hasSelectedTransactions || (shouldAllowFooterTotals && !!searchResults?.search?.count); const {saveScrollOffset} = useContext(ScrollOffsetContext); - const receiptDropTargetRef = useRef(null); const endSubmitNavigationSpans = useEndSubmitNavigationSpans({requireLayout: false}); @@ -94,56 +93,61 @@ function SearchPageWide({ const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()})); const splitContainerAnimatedStyle = useSearchSidebarContentOffsetStyle(); + const receiptDropTargetRef = useRef(null); return ( - - - {!!queryJSON && ( - <> - - - - {shouldShowLoadingSkeleton ? ( - - ) : ( - - )} - {!!searchOverlayContent && {searchOverlayContent}} - - - - )} - - - {!!queryJSON && } + + {!!queryJSON && ( + <> + + + + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + {!!searchOverlayContent && {searchOverlayContent}} + + + + )} + + + ); } diff --git a/src/pages/Search/SearchRejectReasonPage.tsx b/src/pages/Search/SearchRejectReasonPage.tsx index cc97433922a3..261b94418435 100644 --- a/src/pages/Search/SearchRejectReasonPage.tsx +++ b/src/pages/Search/SearchRejectReasonPage.tsx @@ -40,6 +40,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { const {getCurrencyDecimals} = useCurrencyListActions(); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); // When coming from the report view, selectedTransactions is empty, build it from selectedTransactionIDs @@ -73,6 +74,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { betas, delegateAccountID, getCurrencyDecimals, + rules, ); if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS) { clearSelectedTransactions(true); @@ -95,6 +97,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { betas, delegateAccountID, getCurrencyDecimals, + rules, route.name, showDelegateNoAccessModal, clearSelectedTransactions, diff --git a/src/pages/Search/SearchReportsMergeReports.tsx b/src/pages/Search/SearchReportsMergeReports.tsx new file mode 100644 index 000000000000..040c75dc9c85 --- /dev/null +++ b/src/pages/Search/SearchReportsMergeReports.tsx @@ -0,0 +1,236 @@ +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; +import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import SearchMergeReportsListItem from '@components/Search/SearchList/ListItem/SearchMergeReportsListItem'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useHydrateReportsFromSnapshot from '@hooks/useHydrateReportsFromSnapshot'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {mergeReports} from '@libs/actions/Report'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; +import {canMergeReports, getMoneyRequestSpendBreakdown, getPersonalDetailsForAccountID} from '@libs/ReportUtils'; + +import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; +import type {Transaction} from '@src/types/onyx'; + +import React, {useMemo, useState} from 'react'; + +function SearchMergeReports() { + const {selectedReports} = useSearchSelectionContext(); + const {clearSelectedTransactions} = useSearchSelectionActions(); + const {currentSearchResults} = useSearchResultsContext(); + const {currentSearchHash, currentSearchQueryJSON} = useSearchQueryContext(); + + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); + + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isReportMergeBetaEnabled = isBetaEnabled(CONST.BETAS.REPORT_MERGE); + const session = useSession(); + const personalDetails = usePersonalDetails(); + const personalPolicy = usePersonalPolicy(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const delegateAccountID = useDelegateAccountID(); + const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const [destinationReportID, setDestinationReportID] = useState(); + + useHydrateReportsFromSnapshot(currentSearchResults, allReports, allTransactions, selectedReports); + + const allReportsTransactions: Record = useMemo(() => { + const selectedReportIDSet = new Set(selectedReports.map((report) => report.reportID)); + const addedTransactionIDSet = new Set(); + + const isTransaction = (key: string, value: unknown): value is Transaction => + key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION) && typeof value === 'object' && value !== null && 'transactionID' in value; + const result: Record = {}; + for (const transaction of Object.values(allTransactions ?? {})) { + if (!transaction?.reportID || transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !selectedReportIDSet.has(transaction.reportID)) { + continue; + } + addedTransactionIDSet.add(transaction.transactionID); + if (!result[transaction.reportID]) { + result[transaction.reportID] = []; + } + result[transaction.reportID].push(transaction); + } + // We iterate through `snapshotData` because some transactions may not yet be available in `allTransactions` immediately after the user logs in. + for (const [key, value] of Object.entries(currentSearchResults?.data ?? {})) { + if (!isTransaction(key, value)) { + continue; + } + const transaction = value; + if ( + !transaction.reportID || + transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + !selectedReportIDSet.has(transaction.reportID) || + addedTransactionIDSet.has(transaction.transactionID) + ) { + continue; + } + if (!result[transaction.reportID]) { + result[transaction.reportID] = []; + } + result[transaction.reportID].push(transaction); + } + return result; + }, [currentSearchResults?.data, selectedReports, allTransactions]); + + const reportItems = useMemo(() => { + if (!selectedReports || currentSearchQueryJSON?.type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + return []; + } + return selectedReports + .map(({reportID}) => { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!reportID || !report?.reportID || report?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return undefined; + } + const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = getMoneyRequestSpendBreakdown(report); + return { + ...report, + groupedBy: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + keyForList: reportID, + text: report.reportName ?? '', + isSelected: reportID === destinationReportID, + from: getPersonalDetailsForAccountID(report.ownerAccountID, personalDetails) ?? {}, + to: getPersonalDetailsForAccountID(report.managerID, personalDetails) ?? {}, + totalDisplaySpend, + nonReimbursableSpend, + reimbursableSpend, + }; + }) + .filter((item) => !!item); + }, [selectedReports, allReports, destinationReportID, personalDetails, currentSearchQueryJSON?.type]); + + const destinationReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${destinationReportID}`]; + const sourceReportIDs = destinationReport + ? reportItems.reduce((acc, report) => { + const reportID = report.reportID; + if (reportID !== destinationReportID) { + acc.push(reportID); + } + return acc; + }, [] as string[]) + : []; + + // Ensure `allReports` and `allReportActions` are fully hydrated before merging reports. + const isValidForMerge = + !!allReports && + !!allReportActions && + !!destinationReportID && + !!destinationReport && + sourceReportIDs.length > 0 && + canMergeReports(reportItems, currentUserPersonalDetails.accountID, rules); + + const mergeSelectedReports = () => { + if (!destinationReportID || !destinationReport || !isValidForMerge) { + return; + } + + const policyID = destinationReport.policyID; + + mergeReports({ + destinationReportID, + sourceReportIDs, + isASAPSubmitBetaEnabled, + accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + email: session?.email ?? '', + policy: policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined, + policyCategories: policyID ? allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] : undefined, + policyTagList: policyID ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}) : {}, + allTransactionViolation: transactionViolations, + allReports, + allReportActions, + allReportsTransactions, + bankAccountList, + rules, + hash: currentSearchHash, + isTrackIntentUser, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + selfDMReportActions, + delegateAccountID, + getCurrencyDecimals, + getCurrencySymbol, + }); + + Navigation.dismissModal({ + afterTransition: () => { + clearSelectedTransactions(undefined, true); + // Wrap navigation in a microtask to avoid a visual glitch on Android. + // If we navigate before selection mode has fully exited and the UI has finished rendering, + // the report header may briefly display incorrectly. + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: destinationReportID})); + }); + }, + }); + }; + + const onSelection = (item: ListItem) => { + setDestinationReportID(item.reportID); + }; + + if (!isReportMergeBetaEnabled) { + return null; + } + + return ( + Navigation.goBack()} + shouldShowWrapper + testID="SearchMergeReports" + includeSafeAreaPaddingBottom + > + {translate('search.mergeReports.description')} + + } + /> + + ); +} + +export default SearchMergeReports; diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index 9aabd5ca1c7d..b91b2d5210ea 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -11,7 +11,7 @@ import useFilterReportValue from '@components/Search/hooks/useFilterReportValue' import useFilterTaxRateValue from '@components/Search/hooks/useFilterTaxRateValue'; import useFilterUserValue from '@components/Search/hooks/useFilterUserValue'; import useFilterWorkspaceValue from '@components/Search/hooks/useFilterWorkspaceValue'; -import {useSearchQueryContext} from '@components/Search/SearchContext'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -24,7 +24,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {saveSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {getCustomColumnDefault, getSearchColumnTranslationKey, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils'; +import {rand64} from '@libs/NumberUtils'; +import {getCustomColumnDefault, getSearchColumnTranslationKey, mapFiltersFormToLabelValueList, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -41,6 +42,10 @@ type FilterValueProps = { value: SearchFilter['value']; }; +type ArrayFilterValueProps = { + value: Extract; +}; + type FilterValueWithKeyProps = FilterValueProps & { filterKey: SearchFilter['key']; }; @@ -53,16 +58,16 @@ function FilterWorkspaceValue({value}: FilterValueProps) { return useFilterWorkspaceValue(value); } -function FilterFeedValue({value}: FilterValueProps) { - return useFilterFeedValue(value as string[]); +function FilterFeedValue({value}: ArrayFilterValueProps) { + return useFilterFeedValue(value); } function FilterCardValue({value}: FilterValueProps) { - return useFilterCardValue(value as string[]); + return useFilterCardValue(Array.isArray(value) ? value : value.split(', ')); } -function FilterTaxRateValue({value}: FilterValueProps) { - return useFilterTaxRateValue(value as string[]); +function FilterTaxRateValue({value}: ArrayFilterValueProps) { + return useFilterTaxRateValue(value); } function FilterReportValue({value}: FilterValueProps) { @@ -88,7 +93,7 @@ function FilterValue({filterKey, value}: FilterValueWithKeyProps) { return ; } - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED && Array.isArray(value)) { return ; } @@ -96,7 +101,7 @@ function FilterValue({filterKey, value}: FilterValueWithKeyProps) { return ; } - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE && Array.isArray(value)) { return ; } @@ -158,7 +163,8 @@ function SearchSavePage() { const {convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const {currentSearchQueryJSON} = useSearchQueryContext(); + const {currentDefaultSearchQueryFilterKeys, currentSearchQueryJSON} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const onSaveSearch = (values: FormOnyxValues) => { if (!currentSearchQueryJSON) { @@ -166,14 +172,24 @@ function SearchSavePage() { return; } - saveSearch({queryJSON: currentSearchQueryJSON, newName: values[INPUT_IDS.NAME].trim()}); + const id = rand64(); + setCurrentSearchKey(savedSearchIDToSearchKey(id)); + saveSearch({id, queryJSON: currentSearchQueryJSON, newName: values[INPUT_IDS.NAME].trim()}); Navigation.goBack(); }; const validate = (values: FormOnyxValues): FormInputErrors => getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate); - const appliedFilters = mapFiltersFormToLabelValueList(searchAdvancedFiltersForm, undefined, translate, dateFnsLocale, localeCompare, convertToDisplayStringWithoutCurrency); + const appliedFilters = mapFiltersFormToLabelValueList( + searchAdvancedFiltersForm, + currentDefaultSearchQueryFilterKeys, + undefined, + translate, + dateFnsLocale, + localeCompare, + convertToDisplayStringWithoutCurrency, + ); const appliedDisplays = getAppliedDisplays(searchAdvancedFiltersForm, currentSearchQueryJSON, translate); const {inputCallbackRef} = useAutoFocusInput(); diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 83484a518d6e..8e67c9f3248c 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -7,6 +7,7 @@ import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCr import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; +import useHydrateReportsFromSnapshot from '@hooks/useHydrateReportsFromSnapshot'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; @@ -20,18 +21,22 @@ import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNav import Navigation from '@libs/Navigation/Navigation'; import {generateReportID, getPersonalDetailsForAccountID, getReportOrDraftReport, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {isManualDistanceRequest as isManualDistanceRequestUtil, isOdometerDistanceRequest as isOdometerDistanceRequestUtil, isUnreportedManagedCardTransaction} from '@libs/TransactionUtils'; +import { + isDistanceRequest as isDistanceRequestUtil, + isManualDistanceRequest as isManualDistanceRequestUtil, + isOdometerDistanceRequest as isOdometerDistanceRequestUtil, + isUnreportedManagedCardTransaction, +} from '@libs/TransactionUtils'; import IOURequestEditReportCommon from '@pages/iou/request/step/IOURequestEditReportCommon'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {PersonalDetails, Report, Transaction} from '@src/types/onyx'; +import type {PersonalDetails, Transaction} from '@src/types/onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useEffect, useMemo} from 'react'; -import Onyx from 'react-native-onyx'; +import React, {useMemo} from 'react'; type TransactionGroupListItem = ListItem & { /** reportID of the report */ @@ -66,6 +71,7 @@ function SearchTransactionsChangeReport() { const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const reports = useChangeTransactionsReportReports(transactions, undefined); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const session = useSession(); @@ -114,44 +120,7 @@ function SearchTransactionsChangeReport() { }, [selectedTransactions, selectedTransactionsKeys, allReports]); const targetOwnerPersonalDetails = useMemo(() => getPersonalDetailsForAccountID(targetOwnerAccountID, personalDetails) as PersonalDetails, [personalDetails, targetOwnerAccountID]); - useEffect(() => { - const snapshotData = currentSearchResults?.data; - if (!snapshotData) { - return; - } - - const onyxUpdates: Array<{ - onyxMethod: typeof Onyx.METHOD.MERGE; - key: `${typeof ONYXKEYS.COLLECTION.REPORT}${string}`; - value: Report; - }> = []; - - for (const key of Object.keys(snapshotData)) { - if (!key.startsWith(ONYXKEYS.COLLECTION.REPORT) || key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS) || key.startsWith(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS)) { - continue; - } - - const typedKey = key as `${typeof ONYXKEYS.COLLECTION.REPORT}${string}`; - if (allReports?.[typedKey]) { - continue; - } - - const report = snapshotData[typedKey]; - if (report) { - onyxUpdates.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: typedKey, - value: report, - }); - } - } - - if (onyxUpdates.length > 0) { - Onyx.update(onyxUpdates); - } - // Hydration should only run once on mount using the initial snapshot data - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useHydrateReportsFromSnapshot(currentSearchResults, allReports); const createReportForPolicy = (shouldDismissEmptyReportsConfirmation?: boolean) => { const optimisticReport = createNewReport( @@ -162,6 +131,7 @@ function SearchTransactionsChangeReport() { betas, isTrackIntentUser, getCurrencyDecimals, + rules, false, shouldDismissEmptyReportsConfirmation, {managedCardTransactionID}, @@ -184,6 +154,7 @@ function SearchTransactionsChangeReport() { transactions, allTransactionViolation: transactionViolations, reports: reportsForCall, + rules, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, @@ -276,6 +247,7 @@ function SearchTransactionsChangeReport() { transactions, allTransactionViolation: transactionViolations, reports: reportsForCall, + rules, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, @@ -301,6 +273,7 @@ function SearchTransactionsChangeReport() { transactions, allTransactionViolation: transactionViolations, reports, + rules, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, @@ -318,6 +291,7 @@ function SearchTransactionsChangeReport() { transactionIDs={selectedTransactionsKeys} isManualDistanceRequest={transactions.some(isManualDistanceRequestUtil)} isOdometerDistanceRequest={transactions.some(isOdometerDistanceRequestUtil)} + isDistanceRequest={transactions.some(isDistanceRequestUtil)} selectedReportID={selectedReportID} selectReport={selectReport} removeFromReport={removeFromReport} diff --git a/src/pages/Search/SearchTypeMenuItem.tsx b/src/pages/Search/SearchTypeMenuItem.tsx index 1071dbf42d21..3e747efe9162 100644 --- a/src/pages/Search/SearchTypeMenuItem.tsx +++ b/src/pages/Search/SearchTypeMenuItem.tsx @@ -27,7 +27,6 @@ type SearchTypeMenuItemProps = { /** Translated title */ title: string; - /** Icon component or asset */ icon: IconAsset | undefined; /** Optional badge text (e.g. count) */ @@ -36,7 +35,6 @@ type SearchTypeMenuItemProps = { /** Whether the item is focused (keyboard nav) */ focused?: boolean; - /** Press handler */ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; }; diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 5dfe69dbbc7e..83cda8a966f6 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -2,6 +2,7 @@ import type BaseModalProps from '@components/Modal/types'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PopoverMenu from '@components/PopoverMenu'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; @@ -22,7 +23,17 @@ import useTodoCounts from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {getItemBadgeText, getOverflowMenu, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; +import {getValidLastQuery} from '@libs/SearchQueryUtils'; +import { + getItemBadgeText, + getLastSearchQuery, + getOverflowMenu, + savedSearchIDToSearchKey, + SAVED_SEARCH_FALLBACK_ICON_NAME, + SAVED_SEARCH_ICON_NAMES, + SEARCH_TYPE_MENU_ICON_NAMES, +} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -39,18 +50,16 @@ import useSavedSearchIcons from './hooks/useSavedSearchIcons'; import useSavedSearchTitles from './hooks/useSavedSearchTitles'; type SearchTypeMenuNarrowProps = { - /** Search query JSON */ queryJSON?: SearchQueryJSON; - /** Function to call when a tab is pressed */ onTabPress?: () => void; }; type SearchTypeMenuNarrowContentProps = { - tabs: TabSelectorBaseItem[]; - activeTabKey: string; - onActiveTabPress?: (key: string) => void; - onTabPress?: (key: string) => void; - onLongTabPress?: (key: string) => void; + tabs: Array>; + activeTabKey: SearchKey | undefined; + onActiveTabPress?: (key: SearchKey) => void; + onTabPress?: (key: SearchKey) => void; + onLongTabPress?: (key: SearchKey) => void; containerRef?: React.RefObject; children?: React.ReactNode; }; @@ -83,16 +92,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); - const {typeMenuSections, activeKey: activeTypeMenuKey} = useSearchTypeMenuSections( - { - hash: queryJSON?.hash, - similarSearchHash: queryJSON?.similarSearchHash, - sortBy: queryJSON?.sortBy, - sortOrder: queryJSON?.sortOrder, - type: queryJSON?.type, - }, - isFocused, - ); + const typeMenuSections = useSearchTypeMenuSections(isFocused); const personalDetails = usePersonalDetails(); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [restoreFocusType, setRestoreFocusType] = useState(); @@ -104,9 +104,12 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [workspaceCardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const {counts: reportCounts} = useTodoCounts(isFocused); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); + const {currentSearchKey} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const taxRates = getAllTaxRates(allPolicies); const cardsForSavedSearchDisplay = mergeCardListWithWorkspaceFeeds(workspaceCardList ?? CONST.EMPTY_OBJECT, cardList); @@ -127,47 +130,42 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps enabled: !!queryJSON, }); - const [savedSearchToModifyKey, setSavedSearchToModifyKey] = useState(null); + const [savedSearchToModifyKey, setSavedSearchToModifyKey] = useState(null); const menuAnchorRef = useRef(null); const {showDeleteModal} = useDeleteSavedSearch(); - const {copiedHash, handleShare} = useShareSavedSearch(); + const {copiedID, handleShare} = useShareSavedSearch(); const expensifyIcons = useMemoizedLazyExpensifyIcons([...SEARCH_TYPE_MENU_ICON_NAMES, ...SAVED_SEARCH_ICON_NAMES, 'Trashcan', 'LinkCopy', 'Checkmark']); // Resolve each saved search's icon once per collection change (see useSavedSearchIcons for why). const savedSearchIconNames = useSavedSearchIcons(savedSearches); - const queryMap = new Map(); - const tabItems: TabSelectorBaseItem[] = []; - const savedSearchesPopoverMenuItems: Record = {}; - let activeKey = ''; + const queryMap = new Map(); + const tabItems: Array> = []; + const savedSearchesPopoverMenuItems: Partial> = {}; - const savedSearchesTabItems: TabSelectorBaseItem[] = savedSearches + const savedSearchesTabItems: Array> = savedSearches ? Object.entries(savedSearches) - .map(([key, item]): TabSelectorBaseItem | null => { + .map(([key, item]): TabSelectorBaseItem | null => { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) { return null; } const title = item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name; - queryMap.set(key, {query: item.query ?? '', name: item.name}); - const itemHash = Number(key); - savedSearchesPopoverMenuItems[key] = getOverflowMenu(expensifyIcons, title, itemHash, item.query, translate, showDeleteModal, true, () => setSavedSearchToModifyKey(null), { + const savedSearchKey = savedSearchIDToSearchKey(key); + queryMap.set(savedSearchKey, {query: item.query ?? '', name: item.name}); + savedSearchesPopoverMenuItems[savedSearchKey] = getOverflowMenu(expensifyIcons, key, translate, showDeleteModal, true, () => setSavedSearchToModifyKey(null), { onShare: () => { - handleShare(itemHash, item.query); + handleShare(key, item.query); setTimeout(() => setSavedSearchToModifyKey(null), MENU_CLOSE_DELAY_MS); }, - isCopied: copiedHash === itemHash, + isCopied: copiedID === key, }); - if (Number(key) === queryJSON?.hash) { - activeKey = key; - } - return { - key, + key: savedSearchKey, icon: expensifyIcons[savedSearchIconNames.get(item.query) ?? SAVED_SEARCH_FALLBACK_ICON_NAME], title, isDisabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, @@ -197,41 +195,41 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps badgeStyles: styles.tabSelectorBadge, }); queryMap.set(item.key, {query: item.searchQuery}); - if (item.key === activeTypeMenuKey) { - activeKey = item.key; - } } } } - const popoverMenuItems = savedSearchToModifyKey ? savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] : []; + const popoverMenuItems = savedSearchToModifyKey ? (savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] ?? []) : []; const shouldShowSavedSearchPopover = savedSearchToModifyKey && popoverMenuItems.length > 0; - const handleActiveTabPress = (tabKey: string) => { + const handleActiveTabPress = (tabKey: SearchKey) => { const searchData = queryMap.get(tabKey); if (!searchData) { return; } onTabPress?.(); + setCurrentSearchKey(tabKey); setSearchContext(false); }; - const handleTabPress = (tabKey: string) => { + const handleTabPress = (tabKey: SearchKey) => { const searchData = queryMap.get(tabKey); if (!searchData) { return; } onTabPress?.(); + const query = getValidLastQuery(getLastSearchQuery(searchFilters, tabKey), searchData.query); + setCurrentSearchKey(tabKey, query); setSearchContext(false); navigation.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, payload: { - params: {q: searchData.query, name: searchData.name, rawQuery: undefined}, + params: {q: query, name: searchData.name, rawQuery: undefined}, }, }); }; - const handleLongTabPress = (tabKey: string) => { + const handleLongTabPress = (tabKey: SearchKey) => { if (!savedSearchesPopoverMenuItems?.[tabKey]) { return; } @@ -242,7 +240,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps return ( void; + onItemPress: (key: SearchKey, query: string) => void; }; -function Section({section, hash, activeItemIndex, sectionStartIndex, reportCounts, onItemPress}: SectionParams) { +function Section({section, reportCounts, onItemPress}: SectionParams) { const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(SEARCH_TYPE_MENU_ICON_NAMES); + const {currentSearchKey} = useSearchQueryContext(); + const [isExpanded, setIsExpanded] = useState(true); const isSavedSearchesSection = section.translationPath === 'search.savedSearchesMenuItemTitle'; @@ -56,11 +53,10 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount title={translate(section.translationPath)} badgeText={getSectionBadgeText(section.translationPath, reportCounts)} > - {isSavedSearchesSection && } + {isSavedSearchesSection && } {!isSavedSearchesSection && - section.menuItems.map((item, itemIndex) => { - const flattenedIndex = sectionStartIndex + itemIndex; - const focused = activeItemIndex === flattenedIndex; + section.menuItems.map((item) => { + const focused = item.key === currentSearchKey; const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon; return ( @@ -70,7 +66,7 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount icon={icon} badgeText={getItemBadgeText(item.key, reportCounts)} focused={focused} - onPress={() => onItemPress(item.searchQuery)} + onPress={() => onItemPress(item.key, item.searchQuery)} /> ); })} @@ -78,13 +74,13 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount ); } -function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { - const {hash, similarSearchHash, sortBy, sortOrder, type} = queryJSON ?? {}; - +function SearchTypeMenuWide() { const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections({hash, similarSearchHash, sortBy, sortOrder, type}); + const typeMenuSections = useSearchTypeMenuSections(); + const {setCurrentSearchKey} = useSearchQueryActions(); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent // slot, where useIsFocused() does not track visibility, so freezing on it would be unreliable. const {counts: reportCounts} = useTodoCounts(); @@ -101,7 +97,9 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { saveScrollOffset(route, e.nativeEvent.contentOffset.y); }; - const handleTypeMenuItemPress = singleExecution((searchQuery: string) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions)); + const handleTypeMenuItemPress = singleExecution((searchKey: SearchKey, searchQuery: string) => { + navigateToCannedSpendSearch(searchKey, searchQuery, getLastSearchQuery(searchFilters, searchKey), clearSelectedTransactions, setCurrentSearchKey); + }); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); @@ -111,10 +109,6 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { scrollViewRef.current.scrollTo({y: scrollOffset, animated: false}); }, [getScrollOffset, route]); - const sectionStartIndices = [0]; - for (const section of typeMenuSections) { - sectionStartIndices.push((sectionStartIndices.at(-1) ?? 0) + section.menuItems.length); - } const expenseReportsSection = typeMenuSections.find((section) => section.translationPath === 'search.tabs.expenseReports'); const nonExpenseReportsSections = typeMenuSections.filter((section) => section.translationPath !== 'search.tabs.expenseReports'); @@ -129,21 +123,15 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) {
)} - {nonExpenseReportsSections.map((section, index) => ( + {nonExpenseReportsSections.map((section) => (
))} diff --git a/src/pages/Share/ShareButton.tsx b/src/pages/Share/ShareButton.tsx index 4b6db3da819f..9928feb0a171 100644 --- a/src/pages/Share/ShareButton.tsx +++ b/src/pages/Share/ShareButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/pages/Share/ShareDetailsPage.tsx b/src/pages/Share/ShareDetailsPage.tsx index 6f132d8769b1..d649206f1025 100644 --- a/src/pages/Share/ShareDetailsPage.tsx +++ b/src/pages/Share/ShareDetailsPage.tsx @@ -9,6 +9,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useAncestors from '@hooks/useAncestors'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -60,6 +61,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); const [unknownUserDetails] = useOnyx(ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS); const [currentAttachment] = useOnyx(ONYXKEYS.SHARE_TEMP_FILE); const [validatedFile] = useOnyx(ONYXKEYS.VALIDATED_FILE_OBJECT); @@ -69,6 +71,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const delegateAccountID = useDelegateAccountID(); const reportAttributesDerived = useReportAttributes(); @@ -89,6 +92,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { () => getReportDisplayOption({ dateFnsLocale, + convertToDisplayString, report, unknownUserDetails, personalDetails, @@ -98,8 +102,22 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { translate, currentUserAccountID, reportAttributesDerived, + rules, }), - [report, unknownUserDetails, personalDetails, privateIsArchived, policy, conciergeReportID, translate, currentUserAccountID, reportAttributesDerived, dateFnsLocale], + [ + report, + unknownUserDetails, + personalDetails, + privateIsArchived, + policy, + conciergeReportID, + translate, + currentUserAccountID, + reportAttributesDerived, + dateFnsLocale, + convertToDisplayString, + rules, + ], ); const shouldShowAttachment = !isTextShared; diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index ea980e7fad8c..5314960181c8 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -3,6 +3,7 @@ import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import Text from '@components/Text'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useFilteredOptions from '@hooks/useFilteredOptions'; @@ -10,7 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; -import useSortedActions from '@hooks/useSortedActions'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import useThemeStyles from '@hooks/useThemeStyles'; import {getOptimisticChatReport, saveReportDraft, searchInServer} from '@libs/actions/Report'; @@ -40,6 +41,7 @@ const defaultListOptions = { function ShareTab() { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const {isOffline} = useNetwork(); const [textInputValue, debouncedTextInputValue, setTextInputValue] = useDebouncedState(''); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -49,8 +51,10 @@ function ShareTab() { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const sortedActions = useSortedActions(); + const sortedReportActionsData = useSortedReportActionsData(); + const sortedActions = sortedReportActionsData?.sortedActions; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -72,6 +76,8 @@ function ShareTab() { const searchOptions = areOptionsInitialized ? getSearchOptions({ dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, options: listOptions ?? {reports: [], personalDetails: []}, draftComments, betas: betas ?? [], @@ -91,6 +97,7 @@ function ShareTab() { conciergeReportID, isTrackIntentUser, translate, + rules, }).options : defaultListOptions; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 9b3825bfc041..d959eaeb6752 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -85,7 +85,7 @@ function SubmitDetailsPage({ }: ShareDetailsPageProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); - const {getCurrencyDecimals} = useCurrencyListActions(); + const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); const delegateAccountID = useDelegateAccountID(); const [unknownUserDetails] = useOnyx(ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS); const [personalDetails] = useOnyx(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`); @@ -112,6 +112,7 @@ function SubmitDetailsPage({ const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportAttributesDerived = useReportAttributes(); const privateIsArchivedMap = usePrivateIsArchivedMap(); const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); @@ -228,10 +229,22 @@ function SubmitDetailsPage({ const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]; return participant?.accountID ? getParticipantsOption(participant, personalDetails, translate) - : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived, reportDraft, currentUserPersonalDetails.accountID, { - translate, - dateFnsLocale, - }); + : getReportOption( + participant, + privateIsArchived, + policy, + personalDetails, + conciergeReportID, + reportAttributesDerived, + reportDraft, + currentUserPersonalDetails.accountID, + { + translate, + dateFnsLocale, + convertToDisplayString, + }, + rules, + ); }); const isPolicyExpenseChat = participants?.some((participant) => participant.isPolicyExpenseChat); @@ -270,7 +283,7 @@ function SubmitDetailsPage({ const [storedTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(existingTransactionID)}`); const listOfParticipants = participants.filter((participant) => participant.selected); const participant = listOfParticipants.at(0) ?? selectedParticipants.at(0); - const reportToSubmit = resolveReportForMoneyRequest({transaction, transactionReport, routeReport: report, policy, reportNameValuePair}); + const reportToSubmit = resolveReportForMoneyRequest({transaction, transactionReport, routeReport: report, reportNameValuePair, rules}); const postSubmitNavigationReportID = (isSelfDM(report) ? report : reportToSubmit)?.reportID ?? reportOrAccountID; const isIouReport = isMoneyRequestReport(reportToSubmit); const policyTagsForRequestMoney = useMoneyRequestPolicyTags({ @@ -297,6 +310,7 @@ function SubmitDetailsPage({ iouType, isCreatingTrackExpense, isSelfDMDestination: isSelfDM(report), + isOptimisticNewChatDestination: false, isLookingAroundUser, isMovingTransactionFromTrackExpense: false, }); @@ -415,6 +429,7 @@ function SubmitDetailsPage({ currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, delegateAccountID, reportActionsList: undefined, + rules, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -465,6 +480,7 @@ function SubmitDetailsPage({ delegateAccountID, formatPhoneNumber, optimisticChatReportID: routeReportID, + rules, }); } }; @@ -669,6 +685,8 @@ function SubmitDetailsPage({ {}} iouType={iouType} onToggleBillable={setBillable} onToggleReimbursable={setReimbursable} diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 94bb99830715..781302e68c2d 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -54,7 +54,6 @@ import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; type ShareCodePageOnyxProps = { - /** The report currently being looked at */ report?: OnyxEntry; /** The policy for the report currently being looked at */ @@ -109,6 +108,7 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const reportForTitle = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForTitle?.reportID]); @@ -132,12 +132,24 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { return ( getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived).workspaceName ?? - getChatRoomSubtitle(report, policy, conciergeReportID, translate, false, isReportArchived) + getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived) ); } return currentUserPersonalDetails.login; - }, [report, policy, currentUserPersonalDetails.login, isReport, isReportArchived, isParentReportArchived, formatPhoneNumber, conciergeReportID, translate, derivedParentReportName]); + }, [ + report, + policy, + currentUserPersonalDetails.login, + isReport, + isReportArchived, + isParentReportArchived, + formatPhoneNumber, + conciergeReportID, + translate, + derivedParentReportName, + rules, + ]); const title = isReport ? getReportName(reportForTitle, derivedTitleReportName) : (currentUserPersonalDetails.displayName ?? ''); const urlWithTrailingSlash = addTrailingForwardSlash(environmentURL); diff --git a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx index 89a64a18a0ab..9217e424b1b8 100644 --- a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx +++ b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx @@ -1,5 +1,5 @@ import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index a2d2329feb74..ee5e8bb9b6bb 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; @@ -23,6 +22,7 @@ import type {Transaction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -46,6 +46,7 @@ function DuplicateTransactionItem({transaction, isLastItem, isSelected, shouldSh const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const action = Object.values(reportActions ?? {})?.find((reportAction) => { const iouTransactionID = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction)?.IOUTransactionID : CONST.DEFAULT_NUMBER_ID; @@ -74,6 +75,8 @@ function DuplicateTransactionItem({transaction, isLastItem, isSelected, shouldSh const transactionThreadReport = createTransactionThreadReport({ introSelected, conciergeChat, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, currentUserLogin: currentUserPersonalDetails.login ?? '', currentUserAccountID: currentUserPersonalDetails.accountID, betas, @@ -99,7 +102,7 @@ function DuplicateTransactionItem({transaction, isLastItem, isSelected, shouldSh sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM} onPress={handlePreviewPress} accessibilityLabel={transaction.comment?.comment ?? ''} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} isNested hoverStyle={styles.hoveredComponentBG} style={[!isLastItem && styles.borderBottom, styles.pt4, styles.pb4, styles.pl4, shouldShowSelection ? styles.pr0 : styles.pr4]} diff --git a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx index 45e5fc66d940..12228893d7f0 100644 --- a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -29,7 +29,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import {resolveCurrentTaxCode} from '@libs/PolicyUtils'; -import variables from '@styles/variables'; +import {fontScale} from '@styles/typography'; import CONST from '@src/CONST'; import * as ReportActionsUtils from '@src/libs/ReportActionsUtils'; @@ -180,7 +180,7 @@ function DynamicConfirmationPage() { {translate('violations.confirmDetails')} diff --git a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx index 58e0254f91e8..17032027afc1 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ConfirmationPage from '@components/ConfirmationPage'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -69,6 +69,7 @@ function DynamicReviewPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const originalTransactionIDsListRef = useRef(null); @@ -203,6 +204,7 @@ function DynamicReviewPage() { policy, isASAPSubmitBetaEnabled, allTransactions, + rules, currentTransactionViolations, isTrackIntentUser, }); diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx index e031db5322cc..5e10f42e3896 100644 --- a/src/pages/TransactionDuplicate/ReviewFields.tsx +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -6,7 +6,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import {fontScale} from '@styles/typography'; import CONST from '@src/CONST'; import type {ReviewDuplicates} from '@src/types/onyx'; @@ -21,22 +21,14 @@ type FieldItemType = { }; type ReviewFieldsProps = { - /* Step Names which are displayed in stepper */ stepNames: string[]; - /* Label which is displayed to describe current step */ + /** Label which is displayed to describe current step */ label: string; - /* Values to choose from */ options: Array<{text: string; value: ReviewDuplicates[K]}> | undefined; - - /* Current index */ index: number; - - /* Callback to what should happen after selecting row */ onSelectRow: (item: FieldItemType) => void; - - /* Currently selected value */ selectedValue?: ReviewDuplicates[K]; }; @@ -81,7 +73,7 @@ function ReviewFields({stepNames, label, optio {label} diff --git a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx index a5f555224f1e..daee861f3cda 100644 --- a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -29,7 +29,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; import {getFilteredReportActionsForReportView, getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; -import {findSelfDMReportID, getReportTransactions} from '@libs/ReportUtils'; +import {findSelfDMReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -85,6 +85,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { selector: isTrackIntentUserSelector, }); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const selfDMReport = useSelfDMReport(); const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReport?.reportID)}`); @@ -106,7 +107,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { // mergeTransactionRequest optimistically deletes the report. Capture that here (before the optimistic update runs) // so we can replace the now-deleted report screen instead of pushing on top of it — otherwise the stale screen // lingers in the stack and briefly flashes the "not found" page when the user taps back. Must be read pre-merge. - const willDeleteTargetTransactionReport = getReportTransactions(targetTransaction.reportID).length === 1; + const willDeleteTargetTransactionReport = Object.keys(targetReportTransactionsCollection ?? {}).length === 1; setIsMergingExpenses(true); @@ -134,6 +135,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { reportPolicyTags, sourceTransactionThreadReportActions, sourceIOUAction, + rules, }); const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : undefined; diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index d120d24726ec..9760acfbb2dd 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; diff --git a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx index 7e6ccb8437b6..e4d2a4b7df9c 100644 --- a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/TransactionMerge/MergeTransactionItem.tsx b/src/pages/TransactionMerge/MergeTransactionItem.tsx index 133fffab5b34..7edbaf669152 100644 --- a/src/pages/TransactionMerge/MergeTransactionItem.tsx +++ b/src/pages/TransactionMerge/MergeTransactionItem.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; @@ -48,7 +47,7 @@ function MergeTransactionItem({item, isFocused, showTool }} disabled={isDisabled && !item.isSelected} accessibilityLabel={item.text ?? ''} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} isNested onMouseDown={(e) => e.preventDefault()} hoverStyle={[!item.isDisabled && styles.hoveredComponentBG]} diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index a7485fc6f56f..394d3cdba6a8 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -50,6 +50,7 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const currentUserLogin = session?.email; const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {isOffline} = useNetwork(); const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -71,8 +72,9 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr policy: targetTransactionPolicy, report: targetTransactionReport, currentUserLogin, + rules, }); - }, [transactions, isOffline, mergeTransaction?.eligibleTransactions, targetTransactionPolicy, targetTransactionReport, currentUserLogin, targetTransaction]); + }, [transactions, isOffline, mergeTransaction?.eligibleTransactions, targetTransactionPolicy, targetTransactionReport, currentUserLogin, targetTransaction, rules]); const data = !eligibleTransactions ? [] diff --git a/src/pages/TransactionMerge/TransactionMergeReceipts.tsx b/src/pages/TransactionMerge/TransactionMergeReceipts.tsx index 27376e9d31bc..200f0f3a80ed 100644 --- a/src/pages/TransactionMerge/TransactionMergeReceipts.tsx +++ b/src/pages/TransactionMerge/TransactionMergeReceipts.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import RadioButton from '@components/RadioButton'; import ReportActionItemImage from '@components/ReportActionItem/ReportActionItemImage'; diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 85e7280a94d7..175d2b908d79 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -1,3 +1,4 @@ +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -64,10 +65,9 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { helperTextStyle={[styles.pb3, styles.mtn2]} /> {!!reservation.carInfo?.name && ( - )} {!!cancellationText && ( diff --git a/src/pages/Travel/DynamicDomainPermissionInfoPage.tsx b/src/pages/Travel/DynamicDomainPermissionInfoPage.tsx index d109be0144b9..67cae103f7a4 100644 --- a/src/pages/Travel/DynamicDomainPermissionInfoPage.tsx +++ b/src/pages/Travel/DynamicDomainPermissionInfoPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; diff --git a/src/pages/Travel/DynamicPublicDomainErrorPage.tsx b/src/pages/Travel/DynamicPublicDomainErrorPage.tsx index 460b1be3607e..38c49431bd65 100644 --- a/src/pages/Travel/DynamicPublicDomainErrorPage.tsx +++ b/src/pages/Travel/DynamicPublicDomainErrorPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/Travel/DynamicTripDetailsPage.tsx b/src/pages/Travel/DynamicTripDetailsPage.tsx index a571ee400493..09b2b1e6282d 100644 --- a/src/pages/Travel/DynamicTripDetailsPage.tsx +++ b/src/pages/Travel/DynamicTripDetailsPage.tsx @@ -17,7 +17,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; -import {getTripIDFromTransactionParentReportID} from '@libs/ReportUtils'; import {formatCancelledDescription, getReservationDetailsFromSequence, getReservationsFromTripReport} from '@libs/TripReservationUtils'; import {openTravelDotLink} from '@userActions/Link'; @@ -78,10 +77,11 @@ function DynamicTripDetailsPage({route}: DynamicTripDetailsPageProps) { const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transaction?.reportID)}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? reportID}`); + const [parentReportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${parentReport?.reportID ?? reportID}`); - const tripID = getTripIDFromTransactionParentReportID(parentReport?.reportID); + const tripID = parentReportNameValuePairs?.tripData?.tripID; // If pnr is not passed and transaction is present, we want to use transaction to get the trip reservations as the provided sequenceIndex now refers to the position of trip reservation in transaction's reservation list - const tripReservations = getReservationsFromTripReport(!Number(pnr) && transaction ? undefined : parentReport, transaction ? [transaction] : []); + const tripReservations = getReservationsFromTripReport(!Number(pnr) && transaction ? undefined : parentReport, parentReportNameValuePairs, transaction ? [transaction] : []); const {reservation, prevReservation, reservationType, reservationIcon, isCancelled} = getReservationDetailsFromSequence(icons, tripReservations, Number(sequenceIndex)); const travelerEmail = reservation?.travelerPersonalInfo?.email; diff --git a/src/pages/Travel/DynamicTripSummaryPage.tsx b/src/pages/Travel/DynamicTripSummaryPage.tsx index aa80d2b01452..7d72a82f1d84 100644 --- a/src/pages/Travel/DynamicTripSummaryPage.tsx +++ b/src/pages/Travel/DynamicTripSummaryPage.tsx @@ -30,8 +30,9 @@ function DynamicTripSummaryPage({route}: DynamicTripSummaryPageProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.path); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${route.params.reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(route.params.transactionID)}`); - const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripReport(report, transaction ? [transaction] : []); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripReport(report, reportNameValuePairs, transaction ? [transaction] : []); return ( ; @@ -38,20 +37,10 @@ function DynamicVerifyAccountPage({route}: DynamicVerifyAccountPageProps) { const defaultForwardRoute = policyID ? getTravelAcceptTermsRoute(policyID) : undefined; const navigateForwardTo = isTravelVerifiedBetaEnabled ? (travelProvisioning?.nextStepRoute ?? defaultForwardRoute) : undefined; - const handleValidationSuccess = useCallback(() => { - requestTravelAccess(); - }, []); - - const handleClose = useCallback(() => { - Navigation.goBack(backPath); - }, [backPath]); - return ( ); } diff --git a/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx b/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx index 42a618d9599b..ad9a9ba566c7 100644 --- a/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx +++ b/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx @@ -6,6 +6,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useOnyx from '@hooks/useOnyx'; import {createDraftWorkspace, createWorkspace} from '@libs/actions/Policy/Policy'; @@ -28,6 +29,7 @@ function DynamicWorkspaceConfirmationForTravelPage() { const activePolicy = useActivePolicy(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path); const goBack = () => { @@ -58,6 +60,7 @@ function DynamicWorkspaceConfirmationForTravelPage() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); goBack(); }; diff --git a/src/pages/Travel/EnableTravel/subPages/DomainSelectorStep.tsx b/src/pages/Travel/EnableTravel/subPages/DomainSelectorStep.tsx index 55d94465b634..72cbf1b22f0e 100644 --- a/src/pages/Travel/EnableTravel/subPages/DomainSelectorStep.tsx +++ b/src/pages/Travel/EnableTravel/subPages/DomainSelectorStep.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import SelectionList from '@components/SelectionList'; import TravelDomainListItem from '@components/SelectionList/ListItem/TravelDomainListItem'; import type {ListItem} from '@components/SelectionList/types'; diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx index ec618c5e29e2..3bf887816228 100644 --- a/src/pages/Travel/HotelTripDetails.tsx +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -1,3 +1,4 @@ +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -65,10 +66,9 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) /> {!!reservation.roomClass && ( - )} {!!cancellationText && ( diff --git a/src/pages/Travel/ManageTrips.tsx b/src/pages/Travel/ManageTrips.tsx index a9748821914f..27788b6cf0f0 100644 --- a/src/pages/Travel/ManageTrips.tsx +++ b/src/pages/Travel/ManageTrips.tsx @@ -1,5 +1,5 @@ import BookTravelButton from '@components/BookTravelButton'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {FeatureListItem} from '@components/FeatureList'; import FeatureList from '@components/FeatureList'; import LottieAnimations from '@components/LottieAnimations'; diff --git a/src/pages/Travel/MyTripsPage.tsx b/src/pages/Travel/MyTripsPage.tsx index 6e875810aa40..bbcb9fe7b783 100644 --- a/src/pages/Travel/MyTripsPage.tsx +++ b/src/pages/Travel/MyTripsPage.tsx @@ -2,25 +2,50 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import Navigation from '@libs/Navigation/Navigation'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {StackScreenProps} from '@react-navigation/stack'; -import React from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {useEffect} from 'react'; import ManageTrips from './ManageTrips'; type MyTripsPageProps = StackScreenProps; function MyTripsPage({route}: MyTripsPageProps) { - const policyID = route.params?.policyID; + const routePolicyID = route.params?.policyID; + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const routePolicy = usePolicy(routePolicyID); + const isFocused = useIsFocused(); const {translate} = useLocalize(); + // A personal workspace can never book travel and a workspace the backend rejected is left in Onyx as pending, so + // either one in the params means the default has moved on. Unknown policies keep resolving to not found. + const isStalePolicyInParams = routePolicy?.type === CONST.POLICY.TYPE.PERSONAL || routePolicy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + const shouldUseDefaultPolicy = !!activePolicyID && activePolicyID !== routePolicyID && isStalePolicyInParams; + const policyID = shouldUseDefaultPolicy ? activePolicyID : routePolicyID; + + // Rewrite the params too: BookTravelButton builds its sub-routes from the active URL (hasPolicyIDInActiveRoute), + // so a URL disagreeing with the screen would pass the stale workspace on. SET_PARAMS needs this screen's own key. + useEffect(() => { + if (!shouldUseDefaultPolicy || !isFocused) { + return; + } + + Navigation.setParams({policyID: activePolicyID}, route.key); + }, [shouldUseDefaultPolicy, isFocused, activePolicyID, route.key]); + return ( - {!!reservation.coachNumber && ( - )} {!!reservation.seatNumber && ( - )} diff --git a/src/pages/UnreportedExpenseListItem.tsx b/src/pages/UnreportedExpenseListItem.tsx index f03d3066e2a6..ed3cf4b9bace 100644 --- a/src/pages/UnreportedExpenseListItem.tsx +++ b/src/pages/UnreportedExpenseListItem.tsx @@ -1,4 +1,3 @@ -import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; @@ -75,7 +74,7 @@ function UnreportedExpenseListItem({ }} disabled={isItemDisabled} accessibilityLabel={item.text ?? ''} - role={getButtonRole(true)} + role={CONST.ROLE.BUTTON} isNested onMouseDown={(e) => e.preventDefault()} hoverStyle={[!item.isDisabled && !readOnly && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} diff --git a/src/pages/domain/Admins/DomainAddAdminPage.tsx b/src/pages/domain/Admins/DomainAddAdminPage.tsx index f8fa6e1a73a0..74e531170663 100644 --- a/src/pages/domain/Admins/DomainAddAdminPage.tsx +++ b/src/pages/domain/Admins/DomainAddAdminPage.tsx @@ -13,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {searchUserInServer} from '@libs/actions/Report'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -48,7 +49,7 @@ function DomainAddAdminPage({route}: DomainAddAdminProps) { const {translate} = useLocalize(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const [domainEmail] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: domainEmailSelector, }); @@ -180,11 +181,13 @@ function DomainAddAdminPage({route}: DomainAddAdminProps) { textInputOptions={textInputOptions} confirmButtonOptions={{ onConfirm: inviteUser, + isFooterConfirmEnabled: selectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldShowLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} footerContent={footerContent} - isLoadingNewOptions={!!isSearchingForReports} + isLoadingNewOptions={!!isSearchingForUsers} addBottomSafeAreaPadding shouldShowTextInput disableMaintainingScrollPosition diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx index 885922570e4b..4b702d4b022a 100644 --- a/src/pages/domain/Admins/DomainAdminsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsPage.tsx @@ -1,7 +1,7 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import type {DomainAdminRowData} from '@components/Tables/DomainAdminsTable'; +import type {DomainAdminRequestRowData, DomainAdminRowData} from '@components/Tables/DomainAdminsTable'; import DomainAdminsTable from '@components/Tables/DomainAdminsTable'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -25,20 +25,44 @@ import type {DomainSplitNavigatorParamList} from '@navigation/types'; import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; -import {clearAdminError} from '@userActions/Domain'; +import {approveDomainAdminshipRequest, clearAdminError, clearAdminshipRequesterError, declineDomainAdminshipRequest} from '@userActions/Domain'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {PersonalDetails} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {adminAccountIDsSelector, adminPendingActionSelector, domainNameSelector, technicalContactSettingsSelector} from '@selectors/Domain'; +import { + adminAccountIDsSelector, + adminPendingActionSelector, + adminshipRequesterPendingActionSelector, + domainNameSelector, + pendingAdminRequesterAccountIDsSelector, + technicalContactSettingsSelector, +} from '@selectors/Domain'; import React from 'react'; import {View} from 'react-native'; type DomainAdminsPageProps = PlatformStackScreenProps; +/** Whether a pending admin/requester row should still be shown, given its personal details, pending action, and errors. */ +function shouldShowPendingRow( + details: PersonalDetails | null | undefined, + pendingAction: OnyxCommon.PendingAction | undefined, + errors: OnyxCommon.Errors | undefined, + isOffline: boolean, +): boolean { + if (!details?.login && !details?.displayName) { + return false; + } + + const isPendingDelete = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + return isOffline || !isPendingDelete || !isEmptyObject(errors); +} + function DomainAdminsPage({route}: DomainAdminsPageProps) { const {domainAccountID} = route.params; const [domainName] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: domainNameSelector}); @@ -59,6 +83,14 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { selector: adminPendingActionSelector, }); + const [requesterAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + selector: pendingAdminRequesterAccountIDsSelector, + }); + + const [adminshipRequesterPendingAction] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { + selector: adminshipRequesterPendingActionSelector, + }); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [technicalContactSettings] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { selector: technicalContactSettingsSelector, @@ -73,15 +105,10 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { const admins: DomainAdminRowData[] = (adminAccountIDs ?? []) .filter((accountID) => { const details = personalDetails?.[accountID]; - if (!details?.login && !details?.displayName) { - return false; - } - const pendingAction = domainPendingAction?.[accountID]?.pendingAction; const errors = domainErrors?.adminErrors?.[accountID]?.errors; - const isPendingDelete = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - return isOffline || !isPendingDelete || !isEmptyObject(errors); + return shouldShowPendingRow(details, pendingAction, errors, isOffline); }) .map((accountID) => { const details = personalDetails?.[accountID]; @@ -92,6 +119,8 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { return { keyForList: String(accountID), + rowType: CONST.DOMAIN.ADMINS.ROW_TYPE.ADMIN, + groupOrder: CONST.DOMAIN.ADMINS.GROUP_ORDER.ADMINS, accountID, name: temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}), email: formatPhoneNumber(login), @@ -100,10 +129,41 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { pendingAction, disabled: isPendingActionDelete || !!details?.isOptimisticPersonalDetail, action: () => Navigation.navigate(ROUTES.DOMAIN_ADMIN_DETAILS.getRoute(domainAccountID, accountID)), - dismissError: () => clearAdminError(domainAccountID, accountID), + dismissError: () => clearAdminError(domainAccountID, accountID, !!details?.isOptimisticPersonalDetail), }; }); + const requests: DomainAdminRequestRowData[] = isAdmin + ? (requesterAccountIDs ?? []).reduce((acc, accountID) => { + const details = personalDetails?.[accountID]; + const pendingAction = adminshipRequesterPendingAction?.[accountID]?.pendingAction; + const errors = domainErrors?.adminshipRequesterErrors?.[accountID]?.errors; + + if (!shouldShowPendingRow(details, pendingAction, errors, isOffline)) { + return acc; + } + + const login = details?.login ?? ''; + + acc.push({ + keyForList: `request-${accountID}`, + rowType: CONST.DOMAIN.ADMINS.ROW_TYPE.REQUEST, + groupOrder: CONST.DOMAIN.ADMINS.GROUP_ORDER.REQUESTS, + accountID, + name: temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}), + email: formatPhoneNumber(login), + canApprove: !!login, + errors: getLatestError(errors), + pendingAction, + approve: () => approveDomainAdminshipRequest(domainAccountID, accountID, login, domainName ?? ''), + deny: () => declineDomainAdminshipRequest(domainAccountID, accountID), + dismissError: () => clearAdminshipRequesterError(domainAccountID, accountID), + }); + + return acc; + }, []) + : []; + const hasSettingsErrors = hasDomainAdminsSettingsErrors(domainErrors); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -154,6 +214,7 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { diff --git a/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx b/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx index 5af234c5a363..8c289b5b6676 100644 --- a/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; @@ -48,11 +48,10 @@ function DomainAdminsSettingsPage({route}: DomainAdminsSettingsPageProps) { errors={getLatestError(domainErrors?.technicalContactEmailErrors)} onClose={() => clearSetPrimaryContactError(domainAccountID)} > - Navigation.navigate(ROUTES.DOMAIN_ADD_PRIMARY_CONTACT.getRoute(domainAccountID))} + value={technicalContactSettings?.technicalContactEmail} /> void; /** Filter configuration for the group filter dropdown */ @@ -47,7 +43,6 @@ type BaseDomainMembersPageProps = { /** Callback to determine whether a member matches the active group filter */ isItemInFilter?: IsItemInFilterCallback; - /** Whether the group filter should be shown */ shouldShowGroupFilter: boolean; /** Whether the group column should be shown in the table */ diff --git a/src/pages/domain/BaseDomainRequireTwoFactorAuthPage.tsx b/src/pages/domain/BaseDomainRequireTwoFactorAuthPage.tsx index 08d0053003a2..efdb185edf28 100644 --- a/src/pages/domain/BaseDomainRequireTwoFactorAuthPage.tsx +++ b/src/pages/domain/BaseDomainRequireTwoFactorAuthPage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/domain/BaseDomainSettingsPage.tsx b/src/pages/domain/BaseDomainSettingsPage.tsx index 80cd4a030f2b..db9fb18abbdb 100644 --- a/src/pages/domain/BaseDomainSettingsPage.tsx +++ b/src/pages/domain/BaseDomainSettingsPage.tsx @@ -14,7 +14,6 @@ import {View} from 'react-native'; import DomainNotFoundPageWrapper from './DomainNotFoundPageWrapper'; type BaseDomainSettingsPageProps = { - /** The ID of the domain used for the not found wrapper */ domainAccountID: number; /** Settings page content */ diff --git a/src/pages/domain/BaseDomainVerifiedPage.tsx b/src/pages/domain/BaseDomainVerifiedPage.tsx index ea5c7d81130d..a772a7d7fd24 100644 --- a/src/pages/domain/BaseDomainVerifiedPage.tsx +++ b/src/pages/domain/BaseDomainVerifiedPage.tsx @@ -25,7 +25,6 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; type BaseDomainVerifiedPageProps = { - /** The accountID of the domain */ domainAccountID: number; /** Route to redirect to when trying to access the page for an unverified domain */ diff --git a/src/pages/domain/BaseVerifyDomainPage.tsx b/src/pages/domain/BaseVerifyDomainPage.tsx index 3513a270b5ef..58e81f74e1c2 100644 --- a/src/pages/domain/BaseVerifyDomainPage.tsx +++ b/src/pages/domain/BaseVerifyDomainPage.tsx @@ -47,7 +47,6 @@ function OrderedListRow({index, children}: PropsWithChildren<{index: number}>) { } type BaseVerifyDomainPageProps = { - /** The accountID of the domain */ domainAccountID: number; /** Route to navigate to after successful verification */ diff --git a/src/pages/domain/DomainAccessRestrictedPage.tsx b/src/pages/domain/DomainAccessRestrictedPage.tsx index 0503884d4d97..854be174472e 100644 --- a/src/pages/domain/DomainAccessRestrictedPage.tsx +++ b/src/pages/domain/DomainAccessRestrictedPage.tsx @@ -1,48 +1,49 @@ -import Button from '@components/ButtonComposed'; -import FixedFooter from '@components/FixedFooter'; +import ConfirmationPage from '@components/ConfirmationPage'; +import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Icon from '@components/Icon'; -import {loadExpensifyIcon} from '@components/Icon/ExpensifyIconLoader'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; -import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; 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'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import {hasPendingAdminshipRequestSelector} from '@src/selectors/Domain'; +import {accountIDSelector} from '@src/selectors/Session'; -import React from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import DomainNameOrNotFoundWrapper from './DomainNameOrNotFoundWrapper'; type DomainAccessRestrictedPageProps = PlatformStackScreenProps; -const FEATURES: TranslationPaths[] = [ - 'domain.accessRestricted.companyCardManagement', - 'domain.accessRestricted.accountCreationAndDeletion', - 'domain.accessRestricted.workspaceCreation', - 'domain.accessRestricted.samlSSO', -]; - function DomainAccessRestrictedPage({route}: DomainAccessRestrictedPageProps) { - const {asset: Checkmark} = useMemoizedLazyAsset(() => loadExpensifyIcon('Checkmark')); + const {domainAccountID} = route.params; + const icons = useMemoizedLazyExpensifyIcons(['EmptyStateSpyPigeon']); const styles = useThemeStyles(); - const theme = useTheme(); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); - const {domainAccountID} = route.params; + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [hasPendingRequest] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: hasPendingAdminshipRequestSelector(currentUserAccountID)}); + const [isRequestPending] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, {selector: (pendingActions) => !!pendingActions?.requestAdminship}); + const [requestError] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, {selector: (errors) => errors?.requestAdminshipError}); + + useEffect(() => { + return () => clearRequestAdminshipError(domainAccountID); + }, [domainAccountID]); return ( ( - - - - - - - {FEATURES.map((featureTranslationPath) => ( - - - {translate(featureTranslationPath)} - - ))} - - - - - + + + + } + 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))} + /> )} diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 95fd15c24fb8..8780238ea019 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -12,12 +12,14 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; -import {openDomainPage} from '@libs/actions/Domain'; +import {clearStaleAdminshipRequesterErrors, openDomainPage} from '@libs/actions/Domain'; +import {getStaleAdminshipRequesterErrorAccountIDs, hasPendingDomainAdminRequestsToReview} from '@libs/DomainUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -26,7 +28,7 @@ import type {DomainSplitNavigatorParamList} from '@navigation/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {isAdminSelector} from '@src/selectors/Domain'; +import {isAdminSelector, pendingAdminRequesterAccountIDsSelector} from '@src/selectors/Domain'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {findFocusedRoute, useNavigationState} from '@react-navigation/native'; @@ -53,10 +55,20 @@ function DomainInitialPage({route}: DomainInitialPageProps) { useDocumentTitle(domainName ?? ''); const isAdmin = isAdminSelector(currentUserAccountID)(domain); const [domainErrors] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`); + const [domainPendingActions] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`); + const requesterAccountIDs = pendingAdminRequesterAccountIDsSelector(domain); + const previousRequesterAccountIDs = usePrevious(requesterAccountIDs); + const hasPendingAdminRequests = hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, domainPendingActions); + + useEffect(() => { + clearStaleAdminshipRequesterErrors(domainAccountID, getStaleAdminshipRequesterErrorAccountIDs(previousRequesterAccountIDs, requesterAccountIDs, domainErrors)); + }, [domainAccountID, previousRequesterAccountIDs, requesterAccountIDs, domainErrors]); const domainMenuItems = getDomainMenuItems({ domainAccountID, domainErrors, + hasPendingAdminRequests, + domain, icons, }).map((item) => ({ ...item, diff --git a/src/pages/domain/DomainNameOrNotFoundWrapper.tsx b/src/pages/domain/DomainNameOrNotFoundWrapper.tsx index 53ad393e7218..d026028e6aac 100644 --- a/src/pages/domain/DomainNameOrNotFoundWrapper.tsx +++ b/src/pages/domain/DomainNameOrNotFoundWrapper.tsx @@ -13,7 +13,6 @@ import type {ReactNode} from 'react'; import React from 'react'; type DomainNameOrNotFoundWrapperProps = { - /** AccountID of the domain */ domainAccountID: number; /** Called when the not found page's link is pressed */ diff --git a/src/pages/domain/DomainNotFoundPageWrapper.tsx b/src/pages/domain/DomainNotFoundPageWrapper.tsx index d9854d36007c..fcba0d3856ff 100644 --- a/src/pages/domain/DomainNotFoundPageWrapper.tsx +++ b/src/pages/domain/DomainNotFoundPageWrapper.tsx @@ -16,10 +16,7 @@ import {adminAccountIDsSelector} from '@selectors/Domain'; import React, {useEffect} from 'react'; type DomainNotFoundPageWrapperProps = { - /** AccountID of the domain */ domainAccountID: number; - - /** The children to render */ children: React.ReactNode; /** Props for customizing fallback pages */ diff --git a/src/pages/domain/DomainsListPage.tsx b/src/pages/domain/DomainsListPage.tsx index 7cef9f6da787..af8ee6448ced 100644 --- a/src/pages/domain/DomainsListPage.tsx +++ b/src/pages/domain/DomainsListPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {DomainRowData} from '@components/Tables/DomainListTable'; import DomainListTable from '@components/Tables/DomainListTable'; import WorkspaceListLayout, {WorkspaceListHeaderContent} from '@components/WorkspaceListLayout'; @@ -13,7 +13,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearStaleDomainFromFailedCreation} from '@libs/actions/Domain'; -import {hasDomainErrors} from '@libs/DomainUtils'; +import {getDomainBrickRoadIndicator, hasDomainErrors, hasPendingDomainAdminRequestsToReview} from '@libs/DomainUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; @@ -42,6 +42,7 @@ function DomainsListPage() { const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [allDomainErrors] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_ERRORS); + const [allDomainPendingActions] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS); const [createDomainForm] = useOnyx(ONYXKEYS.FORMS.CREATE_DOMAIN_FORM); const failedDomainAccountID = createDomainForm?.domainAccountID; @@ -90,7 +91,10 @@ function DomainsListPage() { errors: domainErrors?.errors, pendingAction: domain.pendingAction, disabled: domain.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - brickRoadIndicator: hasDomainErrors(domainErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + brickRoadIndicator: getDomainBrickRoadIndicator( + hasDomainErrors(domainErrors, domain), + hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, allDomainPendingActions?.[`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domain.accountID}`]), + ), action: () => navigateToDomain({domainAccountID: domain.accountID, isAdmin: isDomainAdmin}), }); } diff --git a/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx b/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx new file mode 100644 index 000000000000..9029e7ec1bac --- /dev/null +++ b/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx @@ -0,0 +1,131 @@ +/** + * Shared preferred-workspace selector for domain groups. Both the group-create and group-edit + * pages delegate to this component; it renders the admin workspace list with a search field and + * gates access behind DomainNotFoundPageWrapper. + */ +import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/ListItem/UserListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import tokenizedSearch from '@libs/tokenizedSearch'; + +import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {createAdminPoliciesSelector} from '@selectors/Policy'; +import React from 'react'; + +type WorkspaceListItem = { + policyID: string; + + /** The timestamp of when the policy was created */ + created?: string; +} & ListItem; + +type BaseDomainGroupPreferredWorkspacePageProps = { + /** AccountID of the domain */ + domainAccountID: number; + + /** The policy ID of the currently selected preferred workspace */ + selectedPolicyID: string | undefined; + + /** Called with the policy ID of the workspace the user picked */ + onSelectWorkspace: (policyID: string) => void; + + /** Called when the back button is pressed */ + onBackButtonPress: () => void; + + /** Used to locate the page in the tests */ + testID: string; + + /** Whether or not to block user from accessing the page */ + shouldBeBlocked?: boolean; + + /** Props for customizing fallback pages */ + fullPageNotFoundViewProps?: FullPageNotFoundViewProps; +}; + +function BaseDomainGroupPreferredWorkspacePage({ + domainAccountID, + selectedPolicyID, + onSelectWorkspace, + onBackButtonPress, + testID, + shouldBeBlocked, + fullPageNotFoundViewProps, +}: BaseDomainGroupPreferredWorkspacePageProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(selectedPolicyID)}); + + const workspaceOptions: WorkspaceListItem[] = []; + for (const policy of Object.values(policies ?? {})) { + if (!policy?.name || !policy?.id) { + continue; + } + + workspaceOptions.push({ + text: policy.name, + policyID: policy.id, + created: policy.created, + keyForList: policy.id, + isSelected: selectedPolicyID === policy.id, + }); + } + workspaceOptions.sort((a, b) => localeCompare(a.created ?? '', b.created ?? '')); + + const [searchTerm, setSearchTerm, filteredWorkspaceOptions] = useSearchResults( + workspaceOptions, + (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 shouldShowSearchInput = workspaceOptions.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + + return ( + + + + {translate('domain.groups.preferredWorkspaceSelectDescription')} + + data={filteredWorkspaceOptions} + ListItem={UserListItem} + textInputOptions={{ + label: shouldShowSearchInput ? translate('common.search') : undefined, + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage: workspaceOptions.length > 0 && filteredWorkspaceOptions.length === 0 ? translate('common.noResultsFound') : '', + }} + onSelectRow={(item: WorkspaceListItem) => onSelectWorkspace(item.policyID)} + initiallyFocusedItemKey={selectedPolicyID} + shouldUpdateFocusedIndex + /> + + + ); +} + +export default BaseDomainGroupPreferredWorkspacePage; diff --git a/src/pages/domain/Groups/DeleteGroupRow.tsx b/src/pages/domain/Groups/DeleteGroupRow.tsx index 6d2d9f10a22c..5bb418ac7f42 100644 --- a/src/pages/domain/Groups/DeleteGroupRow.tsx +++ b/src/pages/domain/Groups/DeleteGroupRow.tsx @@ -18,7 +18,6 @@ import {defaultSecurityGroupIDSelector, selectGroupByID} from '@selectors/Domain import React from 'react'; type DeleteGroupRowProps = { - /** The account ID of the domain */ domainAccountID: number; /** The ID of the security group */ diff --git a/src/pages/domain/Groups/DomainGroupCreatePage.tsx b/src/pages/domain/Groups/DomainGroupCreatePage.tsx index fe1aa0cbc6a4..5b225f69d5e8 100644 --- a/src/pages/domain/Groups/DomainGroupCreatePage.tsx +++ b/src/pages/domain/Groups/DomainGroupCreatePage.tsx @@ -2,7 +2,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -68,6 +68,7 @@ function DomainGroupCreatePage({route}: DomainGroupCreatePageProps) { .sort((a, b) => localeCompare(a?.created ?? '', b?.created ?? '')) .at(0); const hasAdminPolicies = !!firstAdminPolicy; + const preferredWorkspaceName = preferredPolicyName ?? firstAdminPolicy?.name; useEffect(() => { return () => { @@ -203,12 +204,11 @@ function DomainGroupCreatePage({route}: DomainGroupCreatePageProps) { shouldPlaceSubtitleBelowSwitch /> {hasAdminPolicies && ( - Navigation.navigate(ROUTES.DOMAIN_GROUP_CREATE_PREFERRED_WORKSPACE.getRoute(domainAccountID))} - disabled={!preferredWorkspace} + isDisabled={!preferredWorkspace} + value={preferredWorkspaceName} /> )} ; function DomainGroupCreatePreferredWorkspacePage({route}: DomainGroupCreatePreferredWorkspacePageProps) { const {domainAccountID} = route.params; - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const [currentPolicyID] = useOnyx(ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(currentPolicyID)}); - - const workspaceOptions: WorkspaceListItem[] = []; - for (const policy of Object.values(policies ?? {})) { - if (!policy?.name || !policy?.id) { - continue; - } - - workspaceOptions.push({ - text: policy.name, - policyID: policy.id, - created: policy.created, - keyForList: policy.id, - isSelected: currentPolicyID === policy.id, - }); - } return ( - - - Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID))} - /> - {translate('domain.groups.preferredWorkspaceSelectDescription')} - - data={workspaceOptions.sort((a, b) => localeCompare(a.created ?? '', b.created ?? ''))} - ListItem={UserListItem} - onSelectRow={(item: WorkspaceListItem) => { - setDomainGroupCreatePreferredPolicyID(item.policyID); - Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID)); - }} - initiallyFocusedItemKey={currentPolicyID} - shouldUpdateFocusedIndex - /> - - + Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID))} + onSelectWorkspace={(policyID: string) => { + setDomainGroupCreatePreferredPolicyID(policyID); + Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID)); + }} + /> ); } diff --git a/src/pages/domain/Groups/DomainGroupDetailsPage.tsx b/src/pages/domain/Groups/DomainGroupDetailsPage.tsx index 375202bdf991..bc3055082f89 100644 --- a/src/pages/domain/Groups/DomainGroupDetailsPage.tsx +++ b/src/pages/domain/Groups/DomainGroupDetailsPage.tsx @@ -1,5 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -75,11 +75,10 @@ function DomainGroupDetailsPage({route}: DomainGroupDetailsPageProps) { onClose={() => clearDomainSecurityGroupSettingError(domainAccountID, groupID, 'nameErrors')} errorRowStyles={[styles.mh5]} > - Navigation.navigate(ROUTES.DOMAIN_GROUP_EDIT_NAME.getRoute(domainAccountID, groupID))} + value={group?.name} /> ; function DomainGroupPreferredWorkspacePage({route}: DomainGroupPreferredWorkspacePageProps) { const {domainAccountID, groupID} = route.params; - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const [group] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: selectGroupByID(groupID), }); @@ -46,58 +28,24 @@ function DomainGroupPreferredWorkspacePage({route}: DomainGroupPreferredWorkspac selector: domainSecurityGroupSettingPendingActionSelector('deleteGroup', groupID), }); - const currentPolicyID = group?.restrictedPrimaryPolicyID; - - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(currentPolicyID)}); - - const workspaceOptions: WorkspaceListItem[] = []; - for (const policy of Object.values(policies ?? {})) { - if (!policy?.name || !policy?.id) { - continue; - } - - workspaceOptions.push({ - text: policy.name, - policyID: policy.id, - created: policy.created, - keyForList: policy.id, - isSelected: currentPolicyID === policy.id, - }); - } - return ( - Navigation.goBack(ROUTES.DOMAIN_GROUPS.getRoute(domainAccountID)), }} - > - - Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID))} - /> - {translate('domain.groups.preferredWorkspaceSelectDescription')} - - data={workspaceOptions.sort((a, b) => localeCompare(a.created ?? '', b.created ?? ''))} - ListItem={UserListItem} - onSelectRow={(item: WorkspaceListItem) => { - if (!group) { - return; - } - updateDomainSecurityGroup(domainAccountID, groupID, group, {restrictedPrimaryPolicyID: item.policyID}, 'restrictedPrimaryPolicyID'); - Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID)); - }} - initiallyFocusedItemKey={currentPolicyID} - shouldUpdateFocusedIndex - /> - - + onBackButtonPress={() => Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID))} + onSelectWorkspace={(policyID: string) => { + if (!group) { + return; + } + updateDomainSecurityGroup(domainAccountID, groupID, group, {restrictedPrimaryPolicyID: policyID}, 'restrictedPrimaryPolicyID'); + Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID)); + }} + /> ); } diff --git a/src/pages/domain/Groups/DomainGroupsPage.tsx b/src/pages/domain/Groups/DomainGroupsPage.tsx index f83ccd18a6be..1744d3f3e9d0 100644 --- a/src/pages/domain/Groups/DomainGroupsPage.tsx +++ b/src/pages/domain/Groups/DomainGroupsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import type {DomainGroupRowData} from '@components/Tables/DomainGroupsTable'; diff --git a/src/pages/domain/Groups/ExpensifyCardPreferredWorkspaceToggle.tsx b/src/pages/domain/Groups/ExpensifyCardPreferredWorkspaceToggle.tsx index 90e820ccb62e..b5c3bdd5b85c 100644 --- a/src/pages/domain/Groups/ExpensifyCardPreferredWorkspaceToggle.tsx +++ b/src/pages/domain/Groups/ExpensifyCardPreferredWorkspaceToggle.tsx @@ -14,7 +14,6 @@ import React from 'react'; import {View} from 'react-native'; type ExpensifyCardPreferredWorkspaceToggleProps = { - /** The account ID of the domain */ domainAccountID: number; /** The ID of the security group */ diff --git a/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx b/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx index 43c47e3cb1d4..6f7756df7c9d 100644 --- a/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx +++ b/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx @@ -1,7 +1,7 @@ -import ConfirmModal from '@components/ConfirmModal'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,11 +17,10 @@ import ROUTES from '@src/ROUTES'; import {domainSecurityGroupSettingErrorsSelector, domainSecurityGroupSettingPendingActionSelector, selectGroupByID} from '@selectors/Domain'; import {createAdminPoliciesSelector, policyNameSelector} from '@selectors/Policy'; -import React, {useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; type PreferredWorkspaceToggleProps = { - /** The account ID of the domain */ domainAccountID: number; /** The ID of the security group */ @@ -31,7 +30,7 @@ type PreferredWorkspaceToggleProps = { function PreferredWorkspaceToggle({domainAccountID, groupID}: PreferredWorkspaceToggleProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const [isNoWorkspacesModalVisible, setIsNoWorkspacesModalVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); const [group] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: selectGroupByID(groupID), @@ -48,6 +47,7 @@ function PreferredWorkspaceToggle({domainAccountID, groupID}: PreferredWorkspace // When the requester is not a member of the preferred policy, BE adds a minimal {avatarURL, id, name} policy Onyx data for the policy to the policy collection, so this resolves to the configured workspace's name even for domain admins without access to that policy. const [preferredPolicyName] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`, {selector: policyNameSelector}); + const preferredWorkspaceName = preferredPolicyName ?? firstAdminPolicy?.name; const [enableRestrictedPrimaryPolicyPendingAction] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { selector: domainSecurityGroupSettingPendingActionSelector('enableRestrictedPrimaryPolicy', groupID), @@ -73,7 +73,14 @@ function PreferredWorkspaceToggle({domainAccountID, groupID}: PreferredWorkspace shouldPlaceSubtitleBelowSwitch isActive={isEnabled} disabled={!hasAdminPolicies && !isEnabled} - disabledAction={() => setIsNoWorkspacesModalVisible(true)} + disabledAction={() => { + showConfirmModal({ + title: translate('workspace.distanceRates.oopsNotSoFast'), + prompt: translate('domain.groups.noWorkspacesMessage'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }} onToggle={(enabled) => { if (!group?.name) { return; @@ -103,15 +110,6 @@ function PreferredWorkspaceToggle({domainAccountID, groupID}: PreferredWorkspace onCloseError={() => clearDomainSecurityGroupSettingError(domainAccountID, groupID, 'enableRestrictedPrimaryPolicyErrors')} /> - setIsNoWorkspacesModalVisible(false)} - onCancel={() => setIsNoWorkspacesModalVisible(false)} - isVisible={isNoWorkspacesModalVisible} - title={translate('workspace.distanceRates.oopsNotSoFast')} - prompt={translate('domain.groups.noWorkspacesMessage')} - confirmText={translate('common.buttonConfirm')} - shouldShowCancelButton={false} - /> {(hasAdminPolicies || !!preferredPolicyName) && ( clearDomainSecurityGroupSettingError(domainAccountID, groupID, 'restrictedPrimaryPolicyIDErrors')} errorRowStyles={[styles.mh5]} > - Navigation.navigate(ROUTES.DOMAIN_SECURITY_GROUPS_PREFERRED_WORKSPACE.getRoute(domainAccountID, groupID))} - disabled={!isEnabled || (!hasAdminPolicies && !!preferredPolicyName)} + isDisabled={!isEnabled || (!hasAdminPolicies && !!preferredPolicyName)} + value={preferredWorkspaceName} /> )} diff --git a/src/pages/domain/Groups/RestrictDefaultLoginSelectionToggle.tsx b/src/pages/domain/Groups/RestrictDefaultLoginSelectionToggle.tsx index 9e891a459d96..02c4b329673e 100644 --- a/src/pages/domain/Groups/RestrictDefaultLoginSelectionToggle.tsx +++ b/src/pages/domain/Groups/RestrictDefaultLoginSelectionToggle.tsx @@ -13,7 +13,6 @@ import React from 'react'; import {View} from 'react-native'; type RestrictDefaultLoginSelectionToggleProps = { - /** The account ID of the domain */ domainAccountID: number; /** The ID of the security group */ diff --git a/src/pages/domain/Groups/RestrictExpenseWorkspaceCreationToggle.tsx b/src/pages/domain/Groups/RestrictExpenseWorkspaceCreationToggle.tsx index edc427e4867b..5b7feee7dfb7 100644 --- a/src/pages/domain/Groups/RestrictExpenseWorkspaceCreationToggle.tsx +++ b/src/pages/domain/Groups/RestrictExpenseWorkspaceCreationToggle.tsx @@ -13,7 +13,6 @@ import React from 'react'; import {View} from 'react-native'; type RestrictExpenseWorkspaceCreationToggleProps = { - /** The account ID of the domain */ domainAccountID: number; /** The ID of the security group */ diff --git a/src/pages/domain/Groups/StrictlyEnforceWorkspaceRulesToggle.tsx b/src/pages/domain/Groups/StrictlyEnforceWorkspaceRulesToggle.tsx index 8c606160f774..850f821a28f1 100644 --- a/src/pages/domain/Groups/StrictlyEnforceWorkspaceRulesToggle.tsx +++ b/src/pages/domain/Groups/StrictlyEnforceWorkspaceRulesToggle.tsx @@ -13,7 +13,6 @@ import React from 'react'; import {View} from 'react-native'; type StrictlyEnforceWorkspaceRulesToggleProps = { - /** The account ID of the domain */ domainAccountID: number; /** The ID of the security group */ diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 5d6a3e241289..96eace018ff2 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -1,8 +1,8 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DecisionModal from '@components/DecisionModal'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; @@ -159,11 +159,10 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { errors={getLatestError(domainErrors?.memberErrors?.[memberLogin]?.changeDomainSecurityGroupErrors)} onClose={() => clearChangeDomainSecurityGroupError(domainAccountID, memberLogin)} > - Navigation.navigate(ROUTES.DOMAIN_MEMBER_MOVE_TO_GROUP.getRoute(domainAccountID, accountID))} - shouldShowRightIcon + value={userSecurityGroup?.securityGroup?.name} /> (); const {showConfirmModal} = useConfirmModal(); @@ -128,12 +127,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) { pendingAction: customProps?.pendingAction, disabled: isPendingActionDelete || !!details?.isOptimisticPersonalDetail, action: () => Navigation.navigate(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)), - dismissError: () => { - if (!defaultSecurityGroupID) { - return; - } - clearDomainMemberError(domainAccountID, accountID, login, defaultSecurityGroupID, customProps?.pendingAction); - }, + dismissError: () => clearDomainMemberError(domainAccountID, accountID, login, group?.id, !!details?.isOptimisticPersonalDetail), }; }); diff --git a/src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx b/src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx index 0ca55cd68044..ff3bc7a6c598 100644 --- a/src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx +++ b/src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx b/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx index 496d8fd81d27..ed8da0e8e356 100644 --- a/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx +++ b/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/domain/Saml/SamlLoginSectionContent.tsx b/src/pages/domain/Saml/SamlLoginSectionContent.tsx index c575eca782ad..7ae4756d02cc 100644 --- a/src/pages/domain/Saml/SamlLoginSectionContent.tsx +++ b/src/pages/domain/Saml/SamlLoginSectionContent.tsx @@ -1,9 +1,10 @@ -import ConfirmModal from '@components/ConfirmModal'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; import Switch from '@components/Switch'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -18,7 +19,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {domainMemberSettingsSelector, domainSamlSettingsStateSelector, metaIdentitySelector} from '@src/selectors/Domain'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type SamlLoginSectionContentProps = { @@ -34,7 +35,6 @@ type SamlLoginSectionContentProps = { /** Whether SAML authentication is required for the domain. */ isSamlRequired: boolean; - /** Whether Okta SCIM is enabled for the domain. */ isOktaScimEnabled: boolean; }; @@ -49,7 +49,7 @@ function SamlLoginSectionContent({accountID, domainName, isSamlEnabled, isSamlRe const [domainSettings] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${accountID}`, { selector: domainMemberSettingsSelector, }); - const [isOktaScimConfirmModalVisible, setIsScimConfirmModalVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); useEffect(() => { // Auto dismiss the saml enabled/required errors when first opening the page @@ -115,7 +115,19 @@ function SamlLoginSectionContent({accountID, domainName, isSamlEnabled, isSamlRe isOn={isSamlRequired} onToggle={() => { if (isSamlRequired && isOktaScimEnabled) { - setIsScimConfirmModalVisible(true); + showConfirmModal({ + title: translate('domain.samlLogin.disableSamlRequired'), + prompt: translate('domain.samlLogin.oktaWarningPrompt'), + confirmText: translate('common.disable'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldHandleNavigationBack: true, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + setSamlRequired({required: false, accountID, domainName, metaIdentity}); + }); return; } setSamlRequired({required: !isSamlRequired, accountID, domainName, metaIdentity}); @@ -127,21 +139,6 @@ function SamlLoginSectionContent({accountID, domainName, isSamlEnabled, isSamlRe )} - - { - setSamlRequired({required: false, accountID, domainName, metaIdentity}); - setIsScimConfirmModalVisible(false); - }} - title={translate('domain.samlLogin.disableSamlRequired')} - prompt={translate('domain.samlLogin.oktaWarningPrompt')} - confirmText={translate('common.disable')} - cancelText={translate('common.cancel')} - onCancel={() => setIsScimConfirmModalVisible(false)} - buttonVariant={CONST.BUTTON_VARIANT.DANGER} - shouldHandleNavigationBack - /> ); } diff --git a/src/pages/domain/Saml/ScimTokenContent.tsx b/src/pages/domain/Saml/ScimTokenContent.tsx index 7e2853e4bbea..58dd91c7392b 100644 --- a/src/pages/domain/Saml/ScimTokenContent.tsx +++ b/src/pages/domain/Saml/ScimTokenContent.tsx @@ -1,9 +1,9 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CopyableTextField from '@components/Domain/CopyableTextField'; import FormHelpMessageRowWithRetryButton from '@components/Domain/FormHelpMessageRowWithRetryButton'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import {getScimToken} from '@libs/actions/Domain'; @@ -22,7 +22,6 @@ type ScimTokenContentProps = { function ScimTokenContent({domainName}: ScimTokenContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const [oktaScimToken, setOktaScimToken] = useState(undefined); @@ -34,14 +33,13 @@ function ScimTokenContent({domainName}: ScimTokenContentProps) { // token not fetched yet if (!oktaScimToken || oktaScimToken.state === ScimTokenState.LOADING) { return ( - + ); } diff --git a/src/pages/domain/getDomainMenuItems.ts b/src/pages/domain/getDomainMenuItems.ts index 3cdd0a92581f..867ff81e2908 100644 --- a/src/pages/domain/getDomainMenuItems.ts +++ b/src/pages/domain/getDomainMenuItems.ts @@ -1,4 +1,4 @@ -import {hasDomainAdminsErrors, hasDomainGroupsErrors, hasDomainMembersErrors} from '@libs/DomainUtils'; +import {getDomainBrickRoadIndicator, hasDomainAdminsErrors, hasDomainGroupsErrors, hasDomainMembersErrors} from '@libs/DomainUtils'; import type DOMAIN_TO_RHP from '@navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP'; @@ -7,9 +7,11 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Domain} from '@src/types/onyx'; import type DomainErrors from '@src/types/onyx/DomainErrors'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; const DOMAIN_MENU_ICON_NAMES = ['UserLock', 'UserShield', 'User', 'Users'] as const; @@ -42,14 +44,19 @@ type GetDomainMenuItemsParams = { /** Errors used to show indicators on the affected Domain sections. */ domainErrors?: DomainErrors; - /** Icons used by the Domain menu items. */ + /** Whether the domain has pending adminship requests awaiting review. */ + hasPendingAdminRequests?: boolean; + + /** The domain itself, used to tell which adminship request errors still have a row to be dismissed from. */ + domain?: OnyxEntry; + icons: DomainMenuIconMap; }; /** * Menu order is significant because it controls the on-screen layout. Callers attach navigation actions so those actions can retain page-specific lifecycle handling. */ -function getDomainMenuItems({domainAccountID, domainErrors, icons}: GetDomainMenuItemsParams): DomainMenuItem[] { +function getDomainMenuItems({domainAccountID, domainErrors, hasPendingAdminRequests, domain, icons}: GetDomainMenuItemsParams): DomainMenuItem[] { return [ { translationKey: 'domain.domainMembers', @@ -63,7 +70,7 @@ function getDomainMenuItems({domainAccountID, domainErrors, icons}: GetDomainMen icon: icons.UserShield, route: ROUTES.DOMAIN_ADMINS.getRoute(domainAccountID), screenName: SCREENS.DOMAIN.ADMINS, - brickRoadIndicator: hasDomainAdminsErrors(domainErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + brickRoadIndicator: getDomainBrickRoadIndicator(hasDomainAdminsErrors(domainErrors, domain), hasPendingAdminRequests), }, { translationKey: 'domain.groups.title', diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 41049c349e18..1f2adeba6312 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,6 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import Composer from '@components/Composer'; -import type {ComposerRef} from '@components/Composer/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; @@ -28,6 +28,8 @@ import getButtonState from '@libs/getButtonState'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; +import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; +import useComposerSuggestions from '@pages/inbox/report/ReportActionCompose/useComposerSuggestions'; import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; @@ -43,6 +45,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {NativeMethods, TextInputKeyPressEvent} from 'react-native'; +import {useIsFocused} from '@react-navigation/core'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useAnimatedRef} from 'react-native-reanimated'; @@ -79,12 +82,13 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const {calculatePopoverPosition} = usePopoverPosition(); const [draft] = useOnyx(ONYXKEYS.CONCIERGE_PROMPT_DRAFT); const [value, setValue] = useState(draft ?? ''); + const isScreenFocused = useIsFocused(); const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID: conciergeTargetReportID}); // Composer is a controlled input: the caret position must be tracked and fed back in (with // shouldCalculateCaretPosition), otherwise every value update re-renders it with the caret at the start. - const [selection, setSelection] = useState({start: value.length, end: value.length}); + const [selection, setSelection] = useState({start: value.length, end: value.length}); const [lastSyncedDraft, setLastSyncedDraft] = useState(draft); const {saveDraft: debouncedSaveDraft, cancelSaveDraft} = useDebouncedSaveDraft( @@ -112,14 +116,29 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const actionButtonRef = useRef(null); const animatedRef = useAnimatedRef(); + const containerRef = useRef(null); + // The native Composer only forwards its underlying input to a callback ref, so an object ref would never be populated. const composerRef = useRef(null); + const {suggestionsRef, measureParentContainerAndReportCursor, hideSuggestionMenu, onSaveScrollAndHideSuggestionMenu, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer: (callback) => containerRef.current?.measureInWindow(callback), + }); + const setComposerRef = (element: ComposerRef) => { animatedRef(element); composerRef.current = element; }; + // Shared by typing and by inserting a mention, so a mention takes the same validation and draft-save path as typed text. + const updateComment = (text: string) => { + setValue(text); + debouncedCommentMaxLengthValidation(text); + debouncedSaveDraft(text); + }; + const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); @@ -182,6 +201,11 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo if (canSkipTriggerHotkeys(shouldUseNarrowLayout, isKeyboardShown)) { return; } + + if (suggestionsRef.current?.triggerHotkeyActions(event as unknown as KeyboardEvent)) { + return; + } + const {nativeEvent} = event; const hasShiftModifier = 'shiftKey' in nativeEvent && !!nativeEvent.shiftKey; if (nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || hasShiftModifier) { @@ -191,6 +215,14 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo submit(); }; + useEffect(() => { + if (isScreenFocused) { + return; + } + + hideSuggestionMenu(); + }, [isScreenFocused, hideSuggestionMenu]); + return ( @@ -199,6 +231,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo { - setValue(text); - debouncedCommentMaxLengthValidation(text); - debouncedSaveDraft(text); + raiseIsScrollLayoutTriggered(); + updateComment(text); }} + onScroll={onSaveScrollAndHideSuggestionMenu} selection={selection} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} shouldCalculateCaretPosition onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onBlur={() => { + setIsFocused(false); + hideSuggestionMenu(); + }} onKeyPress={handleKeyPress} onPasteFile={(files) => { // Concierge isn't reachable yet, so there is nowhere to send the paste. Mirrors the disabled "+" button. @@ -335,6 +371,16 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo + {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} ( - - {todoItems.map(({key, count, icon, iconBackgroundColor, iconFill, translationKey, handler, buttonVariant}) => ( - - ))} - - ); + const forYouRows: React.ReactNode[] = todoItems.map(({key, count, icon, translationKey, handler, buttonVariant}) => ( + + )); // Persist a one-time flag the first time a to-do appears so the section stays visible even when later empty. useEffect(() => { @@ -195,14 +180,6 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY setHasSeenForYouTodo(); }, [isInitialLoad, hasAnyTodos, hasSeenForYouTodo]); - const renderContent = () => { - if (isInitialLoad) { - return ; - } - - return hasAnyTodos ? renderTodoItems() : ; - }; - const hideForYou = shouldHideForYouSection({ isInitialLoad, hasAnyTodos, @@ -213,10 +190,23 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY isOnboardingStatusKnown, }); - const willOnlyShowConciergePromptBox = timeSensitiveItems.length === 0 && hideForYou; + const visibleForYouRows = hideForYou ? [] : forYouRows; + + // Show the skeleton while the to-dos load. Show the empty state only when both groups are empty. + const showSkeleton = isInitialLoad && !hideForYou; + const showEmptyState = !isInitialLoad && !hideForYou && visibleForYouRows.length === 0 && timeSensitiveItems.length === 0; + const willOnlyShowConciergePromptBox = timeSensitiveItems.length === 0 && visibleForYouRows.length === 0 && !showSkeleton && !showEmptyState; + + const getForYouFallback = () => { + if (showSkeleton) { + return ; + } + if (showEmptyState) { + return ; + } + return null; + }; - // The card always renders so the Concierge input stays on the home page. `hideForYou` only gates the "For you" - // heading and todos or empty-state below it. When hidden with no time-sensitive content, the card is just the box. return ( } > - - {!hideForYou && ( - <> - - {translate('homePage.forYou')} - - {renderContent()} - - )} + + 0} + > + {getForYouFallback()} + ); } diff --git a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts index a21d36838170..826181772b36 100644 --- a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts +++ b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts @@ -33,7 +33,6 @@ type ReviewFlaggedExpenses = { /** A transaction that should surface in the "Review X expenses" row, paired with its parent report. */ type FlaggedExpense = { - /** ID of the flagged transaction */ transactionID: string; /** ID of the parent expense report */ reportID: string; diff --git a/src/pages/home/FreeTrialSection/index.tsx b/src/pages/home/FreeTrialSection/index.tsx index 8e24b719b3aa..8939d41cfbb7 100644 --- a/src/pages/home/FreeTrialSection/index.tsx +++ b/src/pages/home/FreeTrialSection/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithoutFeedback} from '@components/Pressable'; import Text from '@components/Text'; diff --git a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx index 39ece622d18a..2c1f30d6e113 100644 --- a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import {PressableWithoutFeedback} from '@components/Pressable'; import Text from '@components/Text'; diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index d5a5f143f8c5..0ff73a1d9efd 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -20,6 +20,8 @@ import { isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, + isPolicyOwner, + isSubmitPolicy, isWorkspaceProvisionedForTravel, } from '@libs/PolicyUtils'; import {generateReportID} from '@libs/ReportUtils'; @@ -33,6 +35,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import {hasIssuedExpensifyCardSelector} from '@selectors/Card'; +import {accountIDSelector} from '@selectors/Session'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; const MIN_MEMBERS_FOR_ACCOUNTANT_INVITED = 2; @@ -65,6 +68,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { const {shouldUseNarrowLayout} = useResponsiveLayout(); const intent = useOnboardingIntent(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const [reportedIntegration] = useOnyx(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); @@ -96,17 +100,37 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { return {shouldShowSection: true, items: builtItems}; }; - if (intent !== CONST.ONBOARDING_CHOICES.MANAGE_TEAM && intent !== CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE && intent !== CONST.ONBOARDING_CHOICES.TRACK_PERSONAL) { + // "Submit expenses to my employer" writes EMPLOYER when the user picks it themselves and SUBMIT when they were + // invited to someone else's workspace. Only the former should see this section, which the ownership gate below enforces. + const isSubmitIntent = intent === CONST.ONBOARDING_CHOICES.EMPLOYER || intent === CONST.ONBOARDING_CHOICES.SUBMIT; + + if (intent !== CONST.ONBOARDING_CHOICES.MANAGE_TEAM && intent !== CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE && intent !== CONST.ONBOARDING_CHOICES.TRACK_PERSONAL && !isSubmitIntent) { return emptyResult; } - if (!isWithinGettingStartedPeriod(firstDayFreeTrial)) { + // Submit is a free plan, so nvp_private_firstDayFreeTrial is only ever written if the user later upgrades, and by then the + // workspace is a paid one that the isSubmitPolicy gate below excludes. nvp_private_firstPolicyCreatedDate is no good + // either: it points at the account's very first workspace, which can be an older one the user has since deleted, so a + // brand-new Submit workspace would inherit an expired window. Anchor on the Submit workspace's own creation time, which + // createWorkspace also writes optimistically so the section still renders while the user is offline. + if (!isWithinGettingStartedPeriod(isSubmitIntent ? policy?.created : firstDayFreeTrial)) { return emptyResult; } - // When there is no active paid workspace to run onboarding against (e.g. the user just deleted their only + const hasUsableWorkspace = !!activePolicyID && !!policy && !isPendingDeletePolicy(policy); + + // These steps describe a Submit workspace, and the one for this intent is auto-created by useAutoCreateSubmitWorkspace, so + // there is no "Create a workspace" step to offer. That step would also send the user to WORKSPACE_CONFIRMATION, which + // creates a paid workspace, and completing it would make the section disappear rather than advance. Hide instead. This + // also covers the user who already owns an editable Team or Corporate workspace, for whom useAutoCreateSubmitWorkspace + // deliberately creates nothing, so these steps would otherwise run against a workspace they do not describe. + if (isSubmitIntent && (!hasUsableWorkspace || !isSubmitPolicy(policy))) { + return emptyResult; + } + + // When there is no active workspace to run onboarding against (e.g. the user just deleted their only // workspace), keep the section visible with a single actionable step to create one instead of hiding it. - if (!activePolicyID || !policy || isPendingDeletePolicy(policy) || !isPaidGroupPolicy(policy)) { + if (!isSubmitIntent && (!hasUsableWorkspace || !isPaidGroupPolicy(policy))) { return { shouldShowSection: true, items: [ @@ -121,10 +145,46 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { }; } - if (!isPolicyAdmin(policy)) { + // Both guards above already return when there is no usable workspace. This repeats the check so TypeScript narrows + // activePolicyID and policy for the rest of the hook. + if (!activePolicyID || !policy) { return emptyResult; } + // Submit workspaces use a flat role model. `getRoleForCallerOnNewPolicy` hands even the creator the `editor` role, and + // `updateWorkspaceMembersRole` refuses to change it, so `isPolicyAdmin` is never true for them. Gate the Submit intent on + // ownership instead: true for the workspace auto-created during onboarding, false for members invited to someone else's. + if (isSubmitIntent ? !isPolicyOwner(policy, currentUserAccountID) : !isPolicyAdmin(policy)) { + return emptyResult; + } + + // The Submit intent has exactly two to-dos and no "Create a workspace" step, because the workspace already exists here. + if (isSubmitIntent) { + const submitItems: GettingStartedItem[] = []; + + // Only surface the categories step while the Categories feature is enabled. Disabling it from the + // More features menu hides this step, and re-enabling it brings the step back. + if (policy.areCategoriesEnabled) { + submitItems.push({ + key: 'customizeExpenseCategories', + label: translate('homePage.gettingStartedSection.customizeExpenseCategories'), + subText: translate('homePage.gettingStartedSection.customizeExpenseCategoriesSubText'), + isComplete: hasCustomCategories(policyCategories), + route: ROUTES.WORKSPACE_CATEGORIES.getRoute(activePolicyID), + }); + } + + submitItems.push({ + key: 'linkPersonalCard', + label: translate('homePage.gettingStartedSection.linkPersonalCard'), + subText: translate('homePage.gettingStartedSection.linkPersonalCardSubText'), + isComplete: Object.keys(personalCards).length > 0, + route: ROUTES.SETTINGS_WALLET, + }); + + return buildResult(submitItems); + } + const items: GettingStartedItem[] = []; items.push({ diff --git a/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts index aa0eb3e55769..00e77a1dea77 100644 --- a/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts +++ b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts @@ -5,21 +5,22 @@ import {fromZonedTime} from 'date-fns-tz'; const SIXTY_DAYS_MS = 60 * CONST.DATE.SECONDS_PER_DAY * CONST.MILLISECONDS_PER_SECOND; /** - * Checks if the current date is within 60 days of the trial start date. - * Returns false if no trial start date is provided. + * Checks if the current date is within 60 days of the onboarding start date. Paid plans pass their trial start date. + * Free plans such as Submit never start a trial, so they pass their workspace creation time instead. + * Returns false if no start date is provided. */ -function isWithinGettingStartedPeriod(firstDayFreeTrial: string | undefined): boolean { - if (!firstDayFreeTrial) { +function isWithinGettingStartedPeriod(onboardingStartDate: string | undefined): boolean { + if (!onboardingStartDate) { return false; } - // Trial dates are UTC DB timestamps without a timezone suffix, so parse them as UTC. - const trialStartMs = fromZonedTime(firstDayFreeTrial, 'UTC').getTime(); - if (Number.isNaN(trialStartMs)) { + // These are UTC DB timestamps without a timezone suffix, so parse them as UTC. + const startMs = fromZonedTime(onboardingStartDate, 'UTC').getTime(); + if (Number.isNaN(startMs)) { return false; } - const elapsed = Date.now() - trialStartMs; + const elapsed = Date.now() - startMs; return elapsed >= 0 && elapsed <= SIXTY_DAYS_MS; } diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 8ed189ae7519..b8b991bf9aef 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -8,6 +8,7 @@ import ScrollView from '@components/ScrollView'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,6 +17,7 @@ import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; +import {PortalHost} from '@gorhom/portal'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -33,9 +35,11 @@ function HomePage() { const styles = useThemeStyles(); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); + const {isOffline} = useNetwork(); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [isLoadingReportData = false] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); - const isForYouLoading = !!(isLoadingApp || isLoadingReportData); + // Offline the underlying commands never send, so the loading flags can stay true forever. Match useLoadingBarVisibility and hide the bar when offline. + const isForYouLoading = !isOffline && !!(isLoadingApp || isLoadingReportData); const receiptDropTargetRef = useRef(null); // Owned here (above the narrow/wide layout branch) so the Concierge "+" menu survives the ForYouSection remount that @@ -43,10 +47,13 @@ function HomePage() { const [isConciergeMenuVisible, setIsConciergeMenuVisible] = useState(false); return ( - - + + - - + ); } diff --git a/src/pages/home/HomeSectionExpandToggle.tsx b/src/pages/home/HomeSectionExpandToggle.tsx index 484d34abbd7f..25b2823b4cda 100644 --- a/src/pages/home/HomeSectionExpandToggle.tsx +++ b/src/pages/home/HomeSectionExpandToggle.tsx @@ -18,7 +18,6 @@ import React from 'react'; import {View} from 'react-native'; type HomeSectionExpandToggleProps = { - /** Whether the section is currently expanded */ isExpanded: boolean; /** Callback to toggle the expanded state */ diff --git a/src/pages/home/HomeTaskGroup.tsx b/src/pages/home/HomeTaskGroup.tsx new file mode 100644 index 000000000000..eed3a0883afe --- /dev/null +++ b/src/pages/home/HomeTaskGroup.tsx @@ -0,0 +1,72 @@ +/** + * Renders a Home task group. Heading above a set of prebuilt rows (urgent/error rows first), + * with an expand/collapse toggle once past the visible limit. `children` is the slot for the loading + * skeleton and empty state. Collapses again whenever the user leaves and returns to Home. + */ +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; + +import HomeSectionExpandToggle from './HomeSectionExpandToggle'; + +type HomeTaskGroupProps = { + /** Heading rendered above the rows */ + title: string; + + /** Rows in the order they should render (urgent/error rows first) */ + rows: React.ReactNode[]; + + /** Rendered below the rows, for the loading skeleton and the empty state */ + children?: React.ReactNode; + + /** Tightens the gap above this group's title (used for the To-dos group, which follows Time sensitive) */ + reducedTopGap?: boolean; +}; + +function HomeTaskGroup({title, rows, children, reducedTopGap = false}: HomeTaskGroupProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [isExpanded, setIsExpanded] = useState(false); + + // Collapse again whenever the user leaves and returns to Home. + useFocusEffect(useCallback(() => () => setIsExpanded(false), [])); + + if (rows.length === 0 && !children) { + return null; + } + + const hiddenCount = Math.max(0, rows.length - CONST.HOME.SECTION_VISIBLE_LIMIT); + const visibleRows = isExpanded ? rows : rows.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT); + + return ( + <> + + {title} + + {rows.length > 0 && ( + + {visibleRows} + {hiddenCount > 0 && ( + setIsExpanded((prev) => !prev)} + collapsedLabel={translate('homePage.seeMore', {count: hiddenCount})} + /> + )} + + )} + {children} + + ); +} + +export default HomeTaskGroup; diff --git a/src/pages/home/InsightsSection/InsightTitleDropdown.tsx b/src/pages/home/InsightsSection/InsightTitleDropdown.tsx index bda66d3b6b09..0207c7c770d6 100644 --- a/src/pages/home/InsightsSection/InsightTitleDropdown.tsx +++ b/src/pages/home/InsightsSection/InsightTitleDropdown.tsx @@ -26,7 +26,6 @@ type InsightTitleDropdownProps = { /** The insight options to list in the dropdown, in display order */ configs: SearchTypeMenuItem[]; - /** The currently selected insight key */ selectedKey: SearchKey; /** Called with the newly selected insight key */ diff --git a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts index 33c0b2ef65c4..e90f14817578 100644 --- a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts +++ b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts @@ -28,15 +28,8 @@ function useHomeInsightConfigs(): SearchTypeMenuItem[] { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const {defaultCardFeed, cardFeedsByPolicy} = useCardFeedsForDisplay(); - const {visibility, shouldShowExpensifyCard, topSpendersPolicyIDs} = getSuggestedSearchesVisibility( - session?.email, - cardFeedsByPolicy, - policies, - defaultExpensifyCard, - false, - !!isTrackIntentUser, - ); - const suggestedSearches = getSuggestedSearches(session?.accountID, (defaultCardFeed ?? defaultExpensifyCard)?.id, shouldShowExpensifyCard, topSpendersPolicyIDs); + const {visibility, shouldShowExpensifyCard} = getSuggestedSearchesVisibility(session?.email, cardFeedsByPolicy, policies, defaultExpensifyCard, false, !!isTrackIntentUser); + const suggestedSearches = getSuggestedSearches(session?.accountID, (defaultCardFeed ?? defaultExpensifyCard)?.id, shouldShowExpensifyCard); return HOME_INSIGHT_KEYS.filter((key) => visibility[key]).map((key) => suggestedSearches[key]); } diff --git a/src/pages/home/InsightsSection/useInsightData.ts b/src/pages/home/InsightsSection/useInsightData.ts index 2e7bff763120..45bc77376840 100644 --- a/src/pages/home/InsightsSection/useInsightData.ts +++ b/src/pages/home/InsightsSection/useInsightData.ts @@ -66,6 +66,7 @@ function useInsightData(config: SearchTypeMenuItem | undefined) { const {accountID, login} = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); @@ -113,6 +114,7 @@ function useInsightData(config: SearchTypeMenuItem | undefined) { translate, formatPhoneNumber, bankAccountList: undefined, + rules, conciergeReportID, convertToDisplayString, reportAttributesDerivedValue: undefined, diff --git a/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx b/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx index bb3c3ed5fcc5..dff2062ccb06 100644 --- a/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx +++ b/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx @@ -20,7 +20,6 @@ import {View} from 'react-native'; import type {RecentlyAddedExpense} from './useRecentlyAddedData'; type RecentlyAddedRowProps = { - /** The expense to render */ expense: RecentlyAddedExpense; /** Called when the row is pressed */ diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 3c72599e80ce..a660e327811c 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -23,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {useIsFocused} from '@react-navigation/native'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import type {RecentlyAddedExpense} from './useRecentlyAddedData'; @@ -47,6 +48,7 @@ function RecentlyAddedSection() { const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const hasExpenses = transactions.length > 0; @@ -57,7 +59,16 @@ function RecentlyAddedSection() { // resolving every sibling up front would create a thread for each multi-expense sibling on a single tap. // Instead, seed the cheap snapshot-derived descriptors and let the carousel resolve each sibling lazily, // one at a time, only when the user actually navigates to it. - const resolveContext = {introSelected, betas, conciergeChat, currentUserEmail, currentUserAccountID, personalDetails}; + const resolveContext = { + introSelected, + betas, + conciergeChat, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + currentUserEmail, + currentUserAccountID, + personalDetails, + }; const reportID = getReportIDToOpenForExpense(expense, resolveContext); const siblingTransactionIDs = transactions.map((sibling) => sibling.transactionID); diff --git a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts index 8c327ec0e528..4b8f82966c7e 100644 --- a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts +++ b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts @@ -20,7 +20,6 @@ import {useEffect, useEffectEvent, useMemo, useState} from 'react'; /** A single expense row surfaced by the Recently added slot. */ type RecentlyAddedExpense = { - /** The transaction's ID */ transactionID: string; /** The report to open when the row is pressed */ diff --git a/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx b/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx deleted file mode 100644 index 4c866231c9ae..000000000000 --- a/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Text from '@components/Text'; - -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import HomeSectionExpandToggle from '@pages/home/HomeSectionExpandToggle'; - -import CONST from '@src/CONST'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; -import {View} from 'react-native'; - -type TimeSensitiveGroupProps = { - /** The prebuilt time-sensitive item rows (from useTimeSensitiveItems). */ - items: React.ReactNode[]; -}; - -/** - * Renders the "Time sensitive" heading and item rows as a group inside another card (the Home "For you" card). - * Returns null when there are no items so the group leaves no trace. It's the card owner's job to keep the card - * visible whenever this group has content. - */ -function TimeSensitiveGroup({items}: TimeSensitiveGroupProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [isExpanded, setIsExpanded] = useState(false); - - // Collapse again whenever the user leaves and returns to Home. - useFocusEffect(useCallback(() => () => setIsExpanded(false), [])); - - if (items.length === 0) { - return null; - } - - const hiddenCount = Math.max(0, items.length - CONST.HOME.SECTION_VISIBLE_LIMIT); - const visibleItems = isExpanded ? items : items.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT); - - return ( - <> - - {translate('homePage.timeSensitiveSection.title')} - - - {visibleItems} - {hiddenCount > 0 && ( - setIsExpanded((prev) => !prev)} - collapsedLabel={translate('homePage.seeMore', {count: hiddenCount})} - /> - )} - - - ); -} - -export default TimeSensitiveGroup; diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts index 124f55b0548a..aec04075bf63 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts @@ -4,6 +4,7 @@ import { isActionableVirtualExpensifyCard, isCard, isCardPendingActivate, + isCardPendingDigitalWalletApproval, isCardPendingIssue, isCardPendingReplace, isCardWithCustomZeroLimit, @@ -30,12 +31,20 @@ function useTimeSensitiveCards() { const cardsNeedingActivation: Card[] = []; const cardsWithFraud: Card[] = []; const virtualCardsNeedingPersonalDetails: Card[] = []; + const cardsPendingDigitalWalletApproval: Card[] = []; + let hasActiveExpensifyCard = false; for (const card of Object.values(cards ?? {})) { if (!isCard(card) || !isExpensifyCard(card) || !CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state)) { continue; } + hasActiveExpensifyCard = true; + + if (isCardPendingDigitalWalletApproval(card)) { + cardsPendingDigitalWalletApproval.push(card); + } + const fraudAlertReportID = card.nameValuePairs?.possibleFraud?.fraudAlertReportID; const reportActions = fraudAlertReportID ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fraudAlertReportID}`] : undefined; const hasUnresolvedFraudAction = !!fraudAlertReportID && !!getUnresolvedCardFraudAlertAction(String(fraudAlertReportID), reportActions); @@ -73,16 +82,20 @@ function useTimeSensitiveCards() { const shouldShowActivateCard = cardsNeedingActivation.length > 0; const shouldShowReviewCardFraud = cardsWithFraud.length > 0; const shouldShowAddVirtualCardPersonalDetails = virtualCardsNeedingPersonalDetails.length > 0; + const shouldShowConfirmDigitalWalletAddition = cardsPendingDigitalWalletApproval.length > 0; return { shouldShowAddShippingAddress, shouldShowActivateCard, shouldShowReviewCardFraud, shouldShowAddVirtualCardPersonalDetails, + shouldShowConfirmDigitalWalletAddition, + hasActiveExpensifyCard, cardsNeedingShippingAddress, cardsNeedingActivation, cardsWithFraud, virtualCardsNeedingPersonalDetails, + cardsPendingDigitalWalletApproval, }; } diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts index 7b99fdef0e29..626bb8f62819 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts @@ -13,7 +13,6 @@ type LockedBankAccount = { /** Stable key used to render this account widget */ key: string; - /** The ID of the locked bank account */ bankAccountID: number; /** The policy name — undefined means personal account */ diff --git a/src/pages/home/TimeSensitiveSection/items/ActivateCard.tsx b/src/pages/home/TimeSensitiveSection/items/ActivateCard.tsx index 46c7375a0e94..cd4ce2cad00f 100644 --- a/src/pages/home/TimeSensitiveSection/items/ActivateCard.tsx +++ b/src/pages/home/TimeSensitiveSection/items/ActivateCard.tsx @@ -3,7 +3,6 @@ import ExpensifyCardIcon from '@assets/images/expensify-card-icon.svg'; import BaseWidgetItem from '@components/BaseWidgetItem'; import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; import Navigation from '@libs/Navigation/Navigation'; @@ -18,14 +17,11 @@ type ActivateCardProps = { }; function ActivateCard({card}: ActivateCardProps) { - const theme = useTheme(); const {translate} = useLocalize(); return ( Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_ADD_TO_DIGITAL_WALLET.getRoute(String(card.cardID)))} + buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} + /> + ); +} + +export default ConfirmDigitalWalletAddition; diff --git a/src/pages/home/TimeSensitiveSection/items/EnterSignerInfo.tsx b/src/pages/home/TimeSensitiveSection/items/EnterSignerInfo.tsx index 28a49c92e27c..67f30ca79c9c 100644 --- a/src/pages/home/TimeSensitiveSection/items/EnterSignerInfo.tsx +++ b/src/pages/home/TimeSensitiveSection/items/EnterSignerInfo.tsx @@ -5,8 +5,6 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; -import colors from '@styles/theme/colors'; - import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -34,8 +32,6 @@ function EnterSignerInfo({policyID, bankAccountID, bankAccountLastFour}: EnterSi return ( { + if (!hasActiveExpensifyCard) { + return; + } + getExpensifyCardPendingWalletApproval(); + }); const {shouldShowFixFailedBilling} = useTimeSensitiveBilling(); const {shouldShowAddHomeAddress} = useTimeSensitiveHomeAddress(); @@ -98,35 +112,29 @@ function useTimeSensitiveItems(): React.ReactNode[] { const isCurrentLoginValidated = isCurrentUserValidated(loginList, sessionEmail ?? login); const shouldShowValidateAccount = isUserValidated === false && !isAnonymous && !isCurrentLoginValidated; - // Priority order: - // 1. Validate account - // 2. Fix failed billing (existing customers with declined cards) - // 3. Potential card fraud - // 4. Add payment card (trial ended, no payment card) - // 5. Add bank account for a queued reimbursement - // 6. Broken bank connections (company cards) - // 7. Broken bank connections (personal cards) - // 8. Locked bank accounts (workspace VBAs and personal) - // 9. Enter signer info for global bank accounts - // 10. Broken policy connections (accounting + HR) - // 11. Expensify card shipping - // 12. Expensify card activation - // 13. Virtual Expensify card needs personal details + // Priority order (RBR / urgent-error rows first, then GBR / setup nudges): + // 1. Fix failed billing (existing customers with declined cards) + // 2. Potential card fraud + // 3. Broken bank connections (company cards) + // 4. Broken bank connections (personal cards) + // 5. Locked bank accounts (workspace VBAs and personal) + // 6. Broken policy connections (accounting + HR) + // 7. Validate account + // 8. Add home address (commuter exclusions, homeAndOffice method) + // 9. Add payment card (trial ended, no payment card) + // 10. Add bank account for a queued reimbursement + // 11. Enter signer info for global bank accounts + // 12. Expensify card shipping + // 13. Expensify card activation + // 14. Virtual Expensify card needs personal details + // 15. Digital wallet addition needs confirming const items: React.ReactNode[] = []; - // Priority 1: Validate account - if (shouldShowValidateAccount) { - items.push(); - } - // Priority 1b: Add home address (commuter exclusions, homeAndOffice method) - if (shouldShowAddHomeAddress) { - items.push(); - } - // Priority 2: Failed billing for existing customers + // Priority 1: Failed billing for existing customers if (shouldShowFixFailedBilling) { items.push(); } - // Priority 3: Card fraud alerts + // Priority 2: Card fraud alerts if (shouldShowReviewCardFraud) { for (const card of cardsWithFraud) { if (!card.nameValuePairs?.possibleFraud) { @@ -140,15 +148,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } - // Priority 4: Add payment card (trial ended, no payment card) - if (shouldShowAddPaymentCard) { - items.push(); - } - // Priority 5: Add bank account for a queued reimbursement - if (shouldShowAddBankAccount) { - items.push(); - } - // Priority 6: Broken company card connections + // Priority 3: Broken company card connections for (const connection of brokenCompanyCardConnections) { const card = cardFeedErrors.cardsWithBrokenFeedConnection[connection.cardID]; if (!card) { @@ -163,7 +163,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { />, ); } - // Priority 7: Broken personal card connections + // Priority 4: Broken personal card connections for (const connection of brokenPersonalCardConnections) { const card = cardFeedErrors.personalCardsWithBrokenConnection[connection.cardID]; if (!card) { @@ -176,7 +176,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { />, ); } - // Priority 8: Locked bank accounts + // Priority 5: Locked bank accounts for (const lockedBankAccount of lockedBankAccounts) { items.push( , ); } - // Priority 9: Enter signer info for global bank accounts - for (const item of pendingSignerInfo) { - items.push( - , - ); - } - // Priority 10: Broken policy connections (accounting + HR) + // Priority 6: Broken policy connections (accounting + HR) for (const connection of brokenPolicyConnections) { items.push( , ); } - // Priority 11: Expensify card shipping + // Priority 7: Validate account + if (shouldShowValidateAccount) { + items.push(); + } + // Priority 8: Add home address (commuter exclusions, homeAndOffice method) + if (shouldShowAddHomeAddress) { + items.push(); + } + // Priority 9: Add payment card (trial ended, no payment card) + if (shouldShowAddPaymentCard) { + items.push(); + } + // Priority 10: Add bank account for a queued reimbursement + if (shouldShowAddBankAccount) { + items.push(); + } + // Priority 11: Enter signer info for global bank accounts + for (const item of pendingSignerInfo) { + items.push( + , + ); + } + // Priority 12: Expensify card shipping if (shouldShowAddShippingAddress) { for (const card of cardsNeedingShippingAddress) { items.push( @@ -220,7 +236,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } - // Priority 12: Expensify card activation + // Priority 13: Expensify card activation if (shouldShowActivateCard) { for (const card of cardsNeedingActivation) { items.push( @@ -231,7 +247,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } - // Priority 13: Virtual Expensify card needs personal details before reveal + // Priority 14: Virtual Expensify card needs personal details before reveal if (shouldShowAddVirtualCardPersonalDetails) { for (const card of virtualCardsNeedingPersonalDetails) { items.push( @@ -242,6 +258,17 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } + // Priority 15: Confirm a digital wallet addition + if (shouldShowConfirmDigitalWalletAddition) { + for (const card of cardsPendingDigitalWalletApproval) { + items.push( + , + ); + } + } return items; } diff --git a/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx b/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx index 45c58e048c30..707b503bac41 100644 --- a/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx +++ b/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx @@ -4,7 +4,6 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -84,7 +83,6 @@ function getTypeIdentifier(reservation: Reservation): string { function UpcomingTravelItem({reservation: upcomingReservation}: UpcomingTravelItemProps) { const theme = useTheme(); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ @@ -120,7 +118,7 @@ function UpcomingTravelItem({reservation: upcomingReservation}: UpcomingTravelIt onPress={handlePress} shouldShowRightIcon leftComponent={ - + | undefined, currentUserEmail: string): boolean { + return reportNameValuePairs?.tripData?.payload?.pnrs.some((pnr) => pnr.data.travelers.some((traveler) => traveler.user.email === currentUserEmail)) ?? false; +} + function useUpcomingTravelReservations(): UpcomingReservation[] { const tripRoomReports = useTripRoomReports(); - const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); return useMemo(() => { + if (!currentUserEmail) { + return []; + } + const now = new Date(); const windowEnd = new Date(now); windowEnd.setDate(windowEnd.getDate() + CONST.UPCOMING_TRAVEL_WINDOW_DAYS); @@ -27,11 +37,11 @@ function useUpcomingTravelReservations(): UpcomingReservation[] { const upcoming: UpcomingReservation[] = []; for (const report of tripRoomReports) { - // Only include reservations where the current user is the traveler - if (report.ownerAccountID !== accountID) { + const tripReportNameValuePairs = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; + if (!isCurrentUserTraveler(tripReportNameValuePairs, currentUserEmail)) { continue; } - const reservations = getReservationsFromTripReport(report); + const reservations = getReservationsFromTripReport(report, tripReportNameValuePairs); for (const resData of reservations) { const startDate = new Date(resData.reservation.start.date); if (Number.isNaN(startDate.getTime())) { @@ -44,7 +54,7 @@ function useUpcomingTravelReservations(): UpcomingReservation[] { } return upcoming.sort((a, b) => new Date(a.reservation.start.date).getTime() - new Date(b.reservation.start.date).getTime()); - }, [tripRoomReports, accountID]); + }, [tripRoomReports, currentUserEmail, reportNameValuePairs]); } export default useUpcomingTravelReservations; diff --git a/src/pages/home/YourSpendSection/CardRow.tsx b/src/pages/home/YourSpendSection/CardRow.tsx index a1a701cff73a..716a6b03e8cc 100644 --- a/src/pages/home/YourSpendSection/CardRow.tsx +++ b/src/pages/home/YourSpendSection/CardRow.tsx @@ -5,7 +5,6 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useHover from '@hooks/useHover'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -34,7 +33,6 @@ type CardRowProps = { function CardRow({cardRow, wrapperStyle}: CardRowProps) { const styles = useThemeStyles(); const theme = useTheme(); - const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); const {hovered, bind} = useHover(); @@ -84,7 +82,7 @@ function CardRow({cardRow, wrapperStyle}: CardRowProps) { onMouseLeave={onMouseLeave} > Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: cardRow.query}))} diff --git a/src/pages/home/YourSpendSection/SpendSummaryRow.tsx b/src/pages/home/YourSpendSection/SpendSummaryRow.tsx index aa7e8e5d0f03..f7ac7b83f8c5 100644 --- a/src/pages/home/YourSpendSection/SpendSummaryRow.tsx +++ b/src/pages/home/YourSpendSection/SpendSummaryRow.tsx @@ -58,8 +58,10 @@ type SpendSummaryRowProps = { iconSrc: IconAsset; onPress: () => void; wrapperStyle: StyleProp; - // Position of this row within the Your spend list. Used to vary the skeleton - // title width across stacked rows, mirroring `ForYouSkeleton`. + /** + * Position of this row within the Your spend list. Used to vary the skeleton + * title width across stacked rows, mirroring `ForYouSkeleton`. + */ skeletonRowIndex: number; /** Greys the total when a queued offline change may have made it stale. */ isStale?: boolean; @@ -114,7 +116,7 @@ function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onP onPress={onPress} shouldShowRightIcon leftComponent={ - + ; type YourSpendCardKind = ValueOf; @@ -338,6 +339,24 @@ function useOfflineFrozenSpendRow(isOffline: boolean, isApplicable: boolean, sta return frozen; } +function getCardTotalsByCardID(snapshot: OnyxEntry): Record | undefined { + if (!snapshot?.data) { + return undefined; + } + const totalsByCardID: Record = {}; + for (const key of Object.keys(snapshot.data)) { + if (!isGroupEntry(key)) { + continue; + } + const group = snapshot.data[key]; + if (!('cardID' in group) || !group.count) { + continue; + } + totalsByCardID[group.cardID] = {total: group.total, currency: group.currency}; + } + return totalsByCardID; +} + function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourSpendRowStateParams): YourSpendRowState { if (!isApplicable) { return YOUR_SPEND_ROW_STATE.HIDDEN; @@ -416,99 +435,17 @@ function useYourSpendData(): UseYourSpendDataReturn { [expensifyCards, thirdPartyCards], ); - // Stable signature for the search-firing effect. Re-fires on card-set changes - // but not on unrelated `cardList` mutations. - const displayableCardIDsKey = displayableCards - .map(({card}) => card.cardID) - .sort((a, b) => a - b) - .join(','); - - const cardQueryByCardID = useMemo( - () => - displayableCards.reduce}>>((acc, {card}) => { - const query = buildRecentCardTransactionsQuery(accountID, card.cardID); - acc[card.cardID] = {query, queryJSON: buildSearchQueryJSON(query)}; - return acc; - }, {}), - [displayableCards, accountID], - ); - - const cardSnapshotKeys = useMemo( - () => - Object.values(cardQueryByCardID) - .map((entry) => entry.queryJSON?.hash) - .filter((hash): hash is number => hash !== undefined) - .map((hash) => `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`), - [cardQueryByCardID], - ); - - type CardSnapshotSummary = { - count: number | undefined; - total: number | undefined; - currency: string | undefined; - }; - - // Project snapshots down to {count, total, currency} so unrelated snapshot - // mutations don't re-render us (useOnyx deep-equals selector output). - const cardSnapshotsSelector = (snapshots: OnyxCollection | undefined): Record | undefined => { - if (!snapshots || cardSnapshotKeys.length === 0) { - return undefined; - } - const projected: Record = {}; - for (const key of cardSnapshotKeys) { - const s = snapshots[key]; - projected[key] = s ? {count: s.search.count, total: s.search.total, currency: s.search.currency} : undefined; - } - return projected; - }; - const [cardSnapshots] = useOnyx(ONYXKEYS.COLLECTION.SNAPSHOT, {selector: cardSnapshotsSelector}); - - // Per-card READY totals cache. See the approval/payment cache below for the mechanic. - const [cachedCardTotals, setCachedCardTotals] = useState>({}); - - const cardCacheUpdates: Record = {}; - let hasCardCacheUpdates = false; - for (const {card} of displayableCards) { - const entry = cardQueryByCardID[card.cardID]; - const hash = entry?.queryJSON?.hash; - const snapshotKey = hash !== undefined ? `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` : undefined; - const snapshot = snapshotKey ? cardSnapshots?.[snapshotKey] : undefined; - if (!snapshot?.count) { - continue; - } - const cached = cachedCardTotals[card.cardID]; - if (!cached || cached.total !== snapshot.total || cached.currency !== snapshot.currency) { - cardCacheUpdates[card.cardID] = {total: snapshot.total, currency: snapshot.currency}; - hasCardCacheUpdates = true; - } - } - if (hasCardCacheUpdates) { - setCachedCardTotals((prev) => ({...prev, ...cardCacheUpdates})); - } + const cardGroupQueryJSON = displayableCards.length > 0 ? buildSearchQueryJSON(buildCardGroupQuery(accountID)) : undefined; + const [cardTotalsByCardID] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${cardGroupQueryJSON?.hash}`, {selector: getCardTotalsByCardID}); const cardRows: YourSpendCardRow[] = useMemo( () => displayableCards.reduce((acc, {card, kind}) => { - const entry = cardQueryByCardID[card.cardID]; - if (!entry) { - return acc; - } - const hash = entry.queryJSON?.hash; - const snapshotKey = hash !== undefined ? `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` : undefined; - const snapshot = snapshotKey ? cardSnapshots?.[snapshotKey] : undefined; - - // Snapshot loaded but count wiped by the Search screen, so fall back to cached READY totals. - const countIsMissing = snapshot !== undefined && (snapshot.count === undefined || snapshot.count === null); - const cached = cachedCardTotals[card.cardID]; - const shouldUseCached = countIsMissing && cached !== undefined; - - if (!snapshot?.count && !shouldUseCached) { + const totals = cardTotalsByCardID?.[card.cardID]; + if (!totals) { return acc; } - const total = snapshot?.count ? snapshot.total : cached?.total; - const currency = snapshot?.count ? snapshot.currency : cached?.currency; - // Fallback for third-party cards with empty `lastFourPAN` and digits in `cardName` // (e.g. "CREDIT CARD...1234". No-space names fall through to ""). Ternary so // empty-string `lastFourPAN` also falls through. @@ -525,9 +462,9 @@ function useYourSpendData(): UseYourSpendDataReturn { acc.push({ cardID: card.cardID, lastFour, - query: entry.query, - total, - currency, + query: buildRecentCardTransactionsQuery(accountID, card.cardID), + total: totals.total, + currency: totals.currency, spentFraction, kind, bank: card.bank, @@ -536,7 +473,7 @@ function useYourSpendData(): UseYourSpendDataReturn { }); return acc; }, []), - [displayableCards, cardQueryByCardID, cardSnapshots, cachedCardTotals], + [displayableCards, cardTotalsByCardID, accountID], ); const approvalRowStateRaw = getYourSpendRowState({isApplicable: isApprovalApplicable, isOffline, searchResults: approvalSearchResults}); @@ -610,13 +547,9 @@ function useYourSpendData(): UseYourSpendDataReturn { if (isOffline) { return; } - for (const {card} of displayableCards) { - const cardQueryJSON = cardQueryByCardID[card.cardID]?.queryJSON; - if (!cardQueryJSON) { - continue; - } + if (cardGroupQueryJSON) { search({ - queryJSON: cardQueryJSON, + queryJSON: cardGroupQueryJSON, searchKey: undefined, offset: 0, isLoading: false, @@ -651,7 +584,7 @@ function useYourSpendData(): UseYourSpendDataReturn { return; } fireSearches(); - }, [isFocused, isOffline, displayableCardIDsKey, applicabilityKey, accountID]); + }, [isFocused, isOffline, cardGroupQueryJSON?.hash, applicabilityKey, accountID]); return { approvalRowState, diff --git a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx index dd9836d90878..9036991e7b94 100644 --- a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx +++ b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx @@ -18,10 +18,7 @@ type WidgetHeaderMenuProps = { /** Items shown in the popover opened by the three-dots trigger */ menuItems: PopoverMenuItem[]; - /** Test ID for the trigger */ testID?: string; - - /** Sentry label for the trigger */ sentryLabel?: string; }; diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 3b822f72ab62..b09bfc4422f4 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -1,6 +1,6 @@ import ReportAvatar from '@components/Avatar/connected/ReportAvatar'; import BookCallButton from '@components/BookCallButton'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CaretWrapper from '@components/CaretWrapper'; import ChronosTimerHeaderButton from '@components/ChronosTimerHeaderButton'; import DisplayNames from '@components/DisplayNames'; @@ -36,7 +36,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; import {getPersonalDetailByEmail, getPersonalDetailsForAccountIDs} from '@libs/PersonalDetailsUtils'; -import {getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName} from '@libs/ReportActionsUtils'; +import {getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName, isTransactionThread} from '@libs/ReportActionsUtils'; import {getReportNameFromNames} from '@libs/ReportAttributesUtils'; import {getReportName} from '@libs/ReportNameUtils'; import { @@ -101,7 +101,6 @@ type HeaderViewProps = { /** Toggles the navigationMenu open and closed */ onNavigationMenuButtonClicked: () => void; - /** The reportID of the current report */ reportID: string | undefined; }; @@ -137,6 +136,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isReportArchived = isArchivedReport(reportNameValuePairs); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const theme = useTheme(); @@ -161,9 +161,9 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isChatRoom = isChatRoomReportUtils(report); const isPolicyExpenseChat = isPolicyExpenseChatReportUtils(report); const isTaskReport = isTaskReportReportUtils(report); - // This is used to check if the report is a chat thread and has a parent invoice report. - const isParentInvoiceAndIsChatThread = isChatThread && !!parentReport && isInvoiceReport(parentReport); - const reportHeaderData = (!isTaskReport && !isChatThread && report?.parentReportID) || isParentInvoiceAndIsChatThread ? parentReport : report; + // 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 && !isChatThread && report?.parentReportID) || isParentInvoiceAndIsTransactionThread ? parentReport : report; const isParentOneTransactionThread = isOneTransactionThread(parentReport, grandParentReport, grandParentReportAction); const parentNavigationReport = isParentOneTransactionThread ? parentReport : reportHeaderData; const derivedNames = useDerivedReportNamesByReportIDs([parentNavigationReport?.parentReportID, reportHeaderData?.reportID]); @@ -174,13 +174,13 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); // Use sorted display names for the title for group chats on native small screen widths const title = getReportName(reportHeaderData, derivedReportHeaderName); - const subtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, false, isReportHeaderDataArchived); + const subtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, rules, false, isReportHeaderDataArchived); // This is used to get the status badge for invoice report subtitle. - const statusTextForInvoiceReport = isParentInvoiceAndIsChatThread + const statusTextForInvoiceReport = isParentInvoiceAndIsTransactionThread ? getReportStatusTranslation({stateNum: reportHeaderData?.stateNum, statusNum: reportHeaderData?.statusNum, translate}) : undefined; - const statusColorForInvoiceReport = isParentInvoiceAndIsChatThread ? getReportStatusColorStyle(theme, reportHeaderData?.stateNum, reportHeaderData?.statusNum) : {}; - const statusTooltipForInvoiceReport = isParentInvoiceAndIsChatThread + const statusColorForInvoiceReport = isParentInvoiceAndIsTransactionThread ? getReportStatusColorStyle(theme, reportHeaderData?.stateNum, reportHeaderData?.statusNum) : {}; + const statusTooltipForInvoiceReport = isParentInvoiceAndIsTransactionThread ? getReportStatusTooltipTranslation({stateNum: reportHeaderData?.stateNum, statusNum: reportHeaderData?.statusNum, translate}) : undefined; const isParentReportHeaderDataArchived = useReportIsArchived(reportHeaderData?.parentReportID); diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index a5ec94c25c3c..2c6956399d82 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -92,6 +92,7 @@ function ReportFetchHandler() { // Only the main report route carries a Submit-via-PDF secure access key. const secureKeyFromRoute = route.name === SCREENS.REPORT ? route.params?.secureKey : undefined; + const isPendingCreationFromRoute = route.name === SCREENS.REPORT ? route.params?.isPendingCreation === 'true' : false; const shouldReplaceWithExpenseReportRHP = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT && route.params?.[REPORT_LINK_ROUTE_PARAMS.SHOULD_REPLACE_WITH_EXPENSE_REPORT_RHP] === 'true'; const navigation = useNavigation>(); @@ -111,6 +112,7 @@ function ReportFetchHandler() { const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`, {selector: Boolean}); const [reportDraftOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDFromRoute}`); + const [isPreMountedDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportIDFromRoute}`); 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}`); @@ -174,6 +176,14 @@ function ReportFetchHandler() { return; } + // 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) { + return; + } + if (reportMetadata.isOptimisticReport && report?.type === CONST.REPORT.TYPE.CHAT && !isPolicyExpenseChat(report)) { // openReport is intentionally never called for an optimistic chat report, so nothing else can settle its // initial-load state. The stamp written at creation lives in a RAM-only key and is lost on an app restart, @@ -194,6 +204,13 @@ function ReportFetchHandler() { return; } + // A pre-mounted draft gets copied into the real report collection so it can render immediately, + // which makes reportOnyx?.reportID truthy even though the row is still speculative - the guard + // above no longer catches it, so check the pre-mount marker directly instead. + if (isPreMountedDraft) { + return; + } + if (report?.errorFields?.notFound && isOffline) { return; } @@ -216,6 +233,7 @@ function ReportFetchHandler() { reportActionID: reportActionIDFromRoute, participants: dmParticipants, betas, + personalDetails, hasReportActions, currentUserAccountID, isSelfTourViewed, @@ -232,6 +250,8 @@ function ReportFetchHandler() { createTransactionThreadReport({ introSelected, conciergeChat, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, currentUserLogin: currentUserEmail ?? '', currentUserAccountID, betas, @@ -254,7 +274,7 @@ function ReportFetchHandler() { if (!shouldUseNarrowLayout || !isChatThread(report) || !isHiddenForCurrentUser(report) || isTransactionThreadView) { return; } - openReport({reportID, introSelected, conciergeChat, betas, hasReportActions, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow}); + openReport({reportID, introSelected, conciergeChat, betas, personalDetails, hasReportActions, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow}); }); const joinPublicRoomIfNeeded = useEffectEvent(() => { @@ -267,6 +287,7 @@ function ReportFetchHandler() { introSelected, conciergeChat, betas, + personalDetails, hasReportActions: hasViewingPublicRoomReportActions, currentUserAccountID, isSelfTourViewed, @@ -323,6 +344,28 @@ 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 + // does, so a copied URL, restored navigation state, or reload doesn't carry the stale flag and skip + // fetching again later. + useEffect(() => { + if (!isPendingCreationFromRoute || !reportOnyx?.reportID) { + return; + } + navigation.setParams({isPendingCreation: undefined}); + }, [isPendingCreationFromRoute, reportOnyx?.reportID, 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 + // still without a row after the much longer grace period holds a stale flag. Expire it, or the screen would skip + // fetching and never resolve to content or not-found. + useEffect(() => { + if (!isPendingCreationFromRoute || !isFocused || !!reportOnyx?.reportID) { + return; + } + const timeout = setTimeout(() => navigation.setParams({isPendingCreation: undefined}), CONST.TIMING.STALE_PENDING_CREATION_ROUTE_TIMEOUT); + return () => clearTimeout(timeout); + }, [isPendingCreationFromRoute, isFocused, reportOnyx?.reportID, navigation]); + useEffect(() => { if (!isAnonymousUser) { return; @@ -539,6 +582,8 @@ function ReportFetchHandler() { createTransactionThreadReport({ introSelected, conciergeChat, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, currentUserLogin: currentUserEmail ?? '', currentUserAccountID, betas, @@ -548,6 +593,8 @@ function ReportFetchHandler() { }); }, [ introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, currentUserEmail, currentUserAccountID, betas, diff --git a/src/pages/inbox/ReportLifecycleHandler.tsx b/src/pages/inbox/ReportLifecycleHandler.tsx index 87a267c92ab9..541ae22df9bd 100644 --- a/src/pages/inbox/ReportLifecycleHandler.tsx +++ b/src/pages/inbox/ReportLifecycleHandler.tsx @@ -7,7 +7,8 @@ import usePrevious from '@hooks/usePrevious'; import {hideEmojiPicker} from '@libs/actions/EmojiPickerAction'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import clearReportNotifications from '@libs/Notification/clearReportNotifications'; -import {cancelSpan, cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; +import {cancelSpan} from '@libs/telemetry/activeSpans'; +import {cancelAllSendMessageSpans} from '@libs/telemetry/sendMessageSpans'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,7 +52,7 @@ function ReportLifecycleHandler({reportID}: ReportLifecycleHandlerProps) { cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${onyxReportID}`); // Cancel any pending send-message spans to prevent orphaned spans when navigating away - cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); + cancelAllSendMessageSpans(); }; }, [onyxReportID]); diff --git a/src/pages/inbox/ReportNotFoundGuard.tsx b/src/pages/inbox/ReportNotFoundGuard.tsx index c6916cc4b02b..376e8c6a5893 100644 --- a/src/pages/inbox/ReportNotFoundGuard.tsx +++ b/src/pages/inbox/ReportNotFoundGuard.tsx @@ -14,6 +14,7 @@ import {getParentReportActionDeletionStatus} from '@libs/TransactionNavigationUt import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import {isLoadingInitialReportActionsSelector} from '@src/selectors/ReportMetaData'; import type {ReactNode} from 'react'; @@ -35,7 +36,7 @@ function ReportNotFoundGuard({children}: ReportNotFoundGuardProps) { const route = useRoute(); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const routeParams = route.params as {reportID?: string} | undefined; + const routeParams = route.params as {reportID?: string; isPendingCreation?: string} | undefined; const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID); const {isOffline} = useNetwork(); @@ -54,6 +55,10 @@ function ReportNotFoundGuard({children}: ReportNotFoundGuardProps) { const isInvalidReportPath = !!routeParams?.reportID && !isValidReportIDFromPath(routeParams.reportID); const isLoading = isLoadingApp !== false || isLoadingReportData || (!isOffline && !!isLoadingInitialReportActions); const reportExists = !!reportID || isOptimisticDelete || userLeavingStatus; + // A pre-mounted destination for a report the submit has not created yet: offline drops the actions-loading + // term above, so the guard would otherwise flash not-found until the optimistic report row lands. Only + // SCREENS.REPORT sets and clears this flag, so ignore it on the RHP report screen where it could never expire. + const isPendingCreation = route.name === SCREENS.REPORT && routeParams?.isPendingCreation === 'true'; // `isLoadingInitialReportActions` lives in a memory-only key that is not reset between navigations. // Returning to a previously visited report (e.g. via direct URL) can leave a stale `false` here, so we @@ -70,7 +75,8 @@ function ReportNotFoundGuard({children}: ReportNotFoundGuardProps) { setHasSeenLoadingForCurrentReportID(true); } - const shouldShowNotFoundPage = !deleteTransactionNavigateBackUrl && (isInvalidReportPath || (!isLoading && hasSeenLoadingForCurrentReportID && !reportExists)); + const isReportMissingAfterLoad = !isLoading && hasSeenLoadingForCurrentReportID && !reportExists && !isPendingCreation; + const shouldShowNotFoundPage = !deleteTransactionNavigateBackUrl && (isInvalidReportPath || isReportMissingAfterLoad); useEffect(() => { if (!shouldShowNotFoundPage) { @@ -85,10 +91,12 @@ function ReportNotFoundGuard({children}: ReportNotFoundGuardProps) { isOptimisticDelete, userLeavingStatus, reportIDFromPath: routeParams?.reportID, + isPendingCreation, deleteTransactionNavigateBackUrl, }); }, [ shouldShowNotFoundPage, + isPendingCreation, isLoadingApp, isLoadingReportData, isOffline, diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 10ea89edbc72..4658cc751d9d 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -56,9 +56,7 @@ type ReportScreenProps = ReportScreenNavigationProps & { }; type ReportScreenEditMessageProviderProps = { - /** The report ID */ reportID: string | undefined; - /** The children */ children: React.ReactNode; }; diff --git a/src/pages/inbox/report/AncestorReportActionItem.tsx b/src/pages/inbox/report/AncestorReportActionItem.tsx index 1de9a02b40d2..d153046c5c6c 100644 --- a/src/pages/inbox/report/AncestorReportActionItem.tsx +++ b/src/pages/inbox/report/AncestorReportActionItem.tsx @@ -35,49 +35,32 @@ type AncestorReportActionItemProps = { /** Report for this action */ report: Report; - /** All the data of the action item */ reportAction: ReportAction; /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: boolean; - /** Report name value pairs for the ancestor reports */ reportNameValuePairs: OnyxCollection; /** Beta features list */ allBetas: OnyxEntry; - /** Concierge personal details */ conciergePersonalDetail: OnyxEntry; - - /** The user's Concierge reportID */ conciergeReportID: string | undefined; - - /** Account ID of the current user */ currentUserAccountID: number; /** Model of onboarding */ introSelected: OnyxEntry; - /** If this is the first visible report action */ isFirstVisibleReportAction: boolean; - - /** Whether the current report is archived */ isReportArchived: boolean; /** Whether the user has viewed the self-guided tour */ isSelfTourViewed: boolean | undefined; - /** Linked transaction route error */ linkedTransactionRouteError: Errors | undefined; - - /** Report action belonging to the report's parent */ parentReportAction: OnyxEntry; - - /** If the thread divider line will be used */ shouldUseThreadDividerLine: boolean; - - /** The transaction thread report associated with the current report, if any */ transactionThreadReport: OnyxEntry; }; diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index a9abc0e3af29..d67176425c8b 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -110,16 +110,9 @@ type BaseReportActionContextMenuProps = { */ isThreadReportParentAction?: boolean; - /** Content Ref */ contentRef?: RefObject; - - /** Function to check if context menu is active */ checkIfContextMenuActive?: () => void; - - /** List of disabled actions */ disabledActions?: ContextMenuAction[]; - - /** Function to update emoji picker state */ setIsEmojiPickerActive?: (state: boolean) => void; /** Whether to add bottom safe area padding for edge-to-edge modal content */ @@ -167,7 +160,7 @@ function BaseReportActionContextMenu({ ]); const StyleUtils = useStyleUtils(); const {translate, getLocalDateFromDatetime, formatPhoneNumber, dateFnsLocale} = useLocalize(); - const {convertToDisplayString} = useCurrencyListActions(); + const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const [shouldKeepOpen, setShouldKeepOpen] = useState(false); @@ -250,6 +243,7 @@ function BaseReportActionContextMenu({ const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const personalDetails = usePersonalDetails(); const reportAttributes = useReportAttributes(); @@ -310,6 +304,7 @@ function BaseReportActionContextMenu({ transactions, isHarvestReport, currentUserAccountID: currentUserPersonalDetails?.accountID, + rules, }), ); @@ -426,6 +421,7 @@ function BaseReportActionContextMenu({ translate, dateFnsLocale, convertToDisplayString, + convertToDisplayStringWithoutCurrency, formatPhoneNumber, harvestReport, harvestReportOriginalID, @@ -448,6 +444,7 @@ function BaseReportActionContextMenu({ reportAttributes, originalReportOfUnapprovedTransaction, memberChangeLogRoomReportName, + rules, }; if ('renderContent' in contextAction) { diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 629c1f649d11..0c8f3c2c80fc 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -36,10 +36,12 @@ import { getAssignedCompanyCardMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, + getCardConnectionBrokenMessage, getCardIssuedMessage, getCategoryTaxRateMessage, getChangedApproverActionMessage, getCompanyAddressUpdateMessage, + getCompanyCardConnectionBroken30DaysMessage, getCompanyCardConnectionBrokenMessage, getCreatedReportForUnapprovedTransactionsMessage, getCurrencyConversionFeeMessage, @@ -236,6 +238,7 @@ import type { ReportActions, ReportAttributesDerivedValue, Report as ReportType, + Rule, Transaction, TransactionViolations, } from '@src/types/onyx'; @@ -309,6 +312,7 @@ type ShouldShow = (args: { moneyRequestPolicy?: OnyxEntry; isHarvestReport?: boolean; currentUserAccountID: number; + rules: OnyxCollection; }) => boolean; type ContextMenuActionPayload = { @@ -347,6 +351,7 @@ type ContextMenuActionPayload = { translate: LocalizedTranslate; dateFnsLocale: LocaleContextProps['dateFnsLocale']; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; harvestReport?: OnyxEntry; harvestReportOriginalID?: string; @@ -369,6 +374,7 @@ type ContextMenuActionPayload = { delegateAccountID: number | undefined; reportAttributes: ReportAttributesDerivedValue['reports'] | undefined; memberChangeLogRoomReportName: string | undefined; + rules: OnyxCollection; }; type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string) => void; @@ -626,9 +632,9 @@ const ContextMenuActions: ContextMenuAction[] = [ isAnonymousAction: false, textTranslateKey: 'reportActionContextMenu.editAction', icon: 'Pencil', - shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, moneyRequestAction, iouTransaction}) => + shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, moneyRequestAction, iouTransaction, rules}) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - (canEditReportAction(reportAction, iouTransaction) || canEditReportAction(moneyRequestAction, iouTransaction)) && + (canEditReportAction(reportAction, iouTransaction, rules) || canEditReportAction(moneyRequestAction, iouTransaction, rules)) && !isArchivedRoom && !isChronosReport, onPress: ( @@ -645,12 +651,13 @@ const ContextMenuActions: ContextMenuAction[] = [ childReportActions, currentUserAccountID, conciergeChat, + personalDetails, }, ) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { const editExpense = () => { const childReportID = reportAction?.childReportID; - openReport({reportID: childReportID, introSelected, betas, hasReportActions: !!childReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: childReportID, introSelected, betas, personalDetails, hasReportActions: !!childReportActions, currentUserAccountID, conciergeChat}); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); }; if (closePopover) { @@ -680,12 +687,12 @@ const ContextMenuActions: ContextMenuAction[] = [ isAnonymousAction: false, textTranslateKey: 'iou.unhold', icon: 'Stopwatch', - shouldShow: ({type, moneyRequestReport, moneyRequestAction, moneyRequestPolicy, areHoldRequirementsMet, iouTransaction, currentUserAccountID}) => { + shouldShow: ({type, moneyRequestReport, moneyRequestAction, moneyRequestPolicy, areHoldRequirementsMet, iouTransaction, currentUserAccountID, rules}) => { if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || !areHoldRequirementsMet) { return false; } const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); - return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID).canUnholdRequest; + return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID, rules).canUnholdRequest; }, onPress: ( closePopover, @@ -699,6 +706,7 @@ const ContextMenuActions: ContextMenuAction[] = [ currentUserPersonalDetails, isTrackIntentUser, delegateAccountID, + rules, }, ) => { if (isDelegateAccessRestricted) { @@ -717,6 +725,7 @@ const ContextMenuActions: ContextMenuAction[] = [ iouTransactionViolations, isTrackIntentUser, delegateAccountID, + rules, ), ); return; @@ -732,6 +741,7 @@ const ContextMenuActions: ContextMenuAction[] = [ iouTransactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); }, getDescription: () => {}, @@ -741,12 +751,12 @@ const ContextMenuActions: ContextMenuAction[] = [ isAnonymousAction: false, textTranslateKey: 'iou.hold', icon: 'Stopwatch', - shouldShow: ({type, moneyRequestReport, moneyRequestAction, moneyRequestPolicy, areHoldRequirementsMet, iouTransaction, currentUserAccountID}) => { + shouldShow: ({type, moneyRequestReport, moneyRequestAction, moneyRequestPolicy, areHoldRequirementsMet, iouTransaction, currentUserAccountID, rules}) => { if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || !areHoldRequirementsMet) { return false; } const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); - return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID).canHoldRequest; + return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID, rules).canHoldRequest; }, onPress: ( closePopover, @@ -760,6 +770,7 @@ const ContextMenuActions: ContextMenuAction[] = [ currentUserPersonalDetails, isTrackIntentUser, delegateAccountID, + rules, }, ) => { if (isDelegateAccessRestricted) { @@ -778,6 +789,7 @@ const ContextMenuActions: ContextMenuAction[] = [ iouTransactionViolations, isTrackIntentUser, delegateAccountID, + rules, ), ); return; @@ -793,6 +805,7 @@ const ContextMenuActions: ContextMenuAction[] = [ iouTransactionViolations, isTrackIntentUser, delegateAccountID, + rules, ); }, getDescription: () => {}, @@ -1011,6 +1024,7 @@ const ContextMenuActions: ContextMenuAction[] = [ translate, dateFnsLocale, convertToDisplayString, + convertToDisplayStringWithoutCurrency, formatPhoneNumber, harvestReport, harvestReportOriginalID, @@ -1021,6 +1035,7 @@ const ContextMenuActions: ContextMenuAction[] = [ personalDetails, memberChangeLogRoomReportName, currentUserAccountID, + rules, }, ) => { const isReportPreviewAction = isReportPreviewActionReportActionsUtils(reportAction); @@ -1055,6 +1070,7 @@ const ContextMenuActions: ContextMenuAction[] = [ movedFromReport, movedToReport, policyTags, + currentUserAccountID, currentUserLogin: currentUserPersonalDetails?.email ?? '', }); // Convert HTML to markdown for clipboard copy to preserve links and formatting @@ -1244,6 +1260,7 @@ const ContextMenuActions: ContextMenuAction[] = [ policy, isTrackIntentUser, report, + rules, }) ? translate('iou.markedAsDone', getOriginalMessage(reportAction)?.message) : translate('iou.submitted', getOriginalMessage(reportAction)?.message), @@ -1320,6 +1337,12 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getIntegrationSyncFailedMessage(translate, reportAction, report?.policyID, isTryNewDotNVPDismissed)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN)) { setClipboardMessage(getCompanyCardConnectionBrokenMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN_30_DAYS)) { + setClipboardMessage(getCompanyCardConnectionBroken30DaysMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN)) { + setClipboardMessage(getCardConnectionBrokenMessage(undefined, getOriginalMessage(reportAction)?.cardName, translate, false)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN_30_DAYS)) { + setClipboardMessage(getCardConnectionBrokenMessage(undefined, getOriginalMessage(reportAction)?.cardName, translate, true)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) { setClipboardMessage(getPlaidBalanceFailureMessage(translate, reportAction)); } else if (isCardIssuedAction(reportAction)) { @@ -1330,6 +1353,7 @@ const ContextMenuActions: ContextMenuAction[] = [ shouldRenderHTML: true, shouldNavigateToCardDetails, policyID: report?.policyID, + buildDynamicRoute: createDynamicRoute, expensifyCard: card, translate, currentUserAccountID, @@ -1429,12 +1453,17 @@ const ContextMenuActions: ContextMenuAction[] = [ } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT)) { setClipboardMessage(getActionableCardFraudAlertMessage(translate, dateFnsLocale, reportAction, getLocalDateFromDatetime, convertToDisplayString)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { - setClipboardMessage(getActionableCard3DSTransactionApprovalMessage(translate, reportAction)); + setClipboardMessage(getActionableCard3DSTransactionApprovalMessage(translate, reportAction, convertToDisplayString, convertToDisplayStringWithoutCurrency)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY) { const displayMessage = getPolicyChangeMessage(translate, reportAction); Clipboard.setString(displayMessage); } else if (isActionableJoinRequest(reportAction)) { - const displayMessage = getJoinRequestMessage(translate, policy, reportAction); + const displayMessage = getJoinRequestMessage( + translate, + policy?.name ?? '', + reportAction, + getPersonalDetailsByID(getOriginalMessage(reportAction)?.accountID, personalDetails), + ); Clipboard.setString(displayMessage); } else if ( reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM || @@ -1655,6 +1684,7 @@ const ContextMenuActions: ContextMenuAction[] = [ transactions, childReportActions, currentUserAccountID, + rules, }) => { // Until deleting parent threads is supported in FE, we will prevent the user from deleting a thread parent let reportID = reportIDParam; @@ -1678,7 +1708,7 @@ const ContextMenuActions: ContextMenuAction[] = [ return ( !!reportIDParam && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - canDeleteReportAction(moneyRequestAction ?? reportAction, reportID, iouTransaction, transactions, childReportActions, currentUserAccountID) && + canDeleteReportAction(moneyRequestAction ?? reportAction, reportID, iouTransaction, transactions, childReportActions, currentUserAccountID, rules) && !isArchivedRoom && !isChronosReport && !isMessageDeleted(reportAction) diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index f438702a791e..e8a8bbe481b7 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -55,7 +55,6 @@ function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEv } type PopoverReportActionContextMenuProps = { - /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -385,6 +384,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro chatReportID: reportIDRef.current, chatReport: report, chatReportActions: reportActions, + transactionThreadReportActions: childReportActions, transactionID: originalMessage?.IOUTransactionID, reportAction, iouReport, diff --git a/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx b/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx index 4174e3014707..1c6699a6b48c 100644 --- a/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx @@ -58,6 +58,7 @@ function DynamicReportDetailsExportPage({route}: DynamicReportDetailsExportPageP 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', ]); @@ -154,7 +155,11 @@ function DynamicReportDetailsExportPage({route}: DynamicReportDetailsExportPageP title="common.export" connectionName={connectionName} onSelectRow={({value}) => { - if (isExported) { + // Only the real "Export to integration" option can push the report into the external accounting + // company again, so it is the only one that should warn about re-exporting. "Mark as exported" + // just logs a per-report exported marker through MarkAsExported and never sends anything to e.g. + // QuickBooks Online, so warning about it there is wrong and blocks re-marking exported reports. + if (isExported && value === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION) { showExportAgainModal(value); } else { confirmExport(value); diff --git a/src/pages/inbox/report/EnableNotificationsBanner.tsx b/src/pages/inbox/report/EnableNotificationsBanner.tsx index 2804f4f9b916..e2720dbc38b1 100644 --- a/src/pages/inbox/report/EnableNotificationsBanner.tsx +++ b/src/pages/inbox/report/EnableNotificationsBanner.tsx @@ -1,5 +1,5 @@ import Banner from '@components/Banner'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/pages/inbox/report/FloatingMessageCounter.tsx b/src/pages/inbox/report/FloatingMessageCounter.tsx index 4ae70fea6d2a..98b71bac2b3e 100644 --- a/src/pages/inbox/report/FloatingMessageCounter.tsx +++ b/src/pages/inbox/report/FloatingMessageCounter.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import Text from '@components/Text'; import getActionBadgeText from '@components/utils/getActionBadgeText'; @@ -21,13 +21,11 @@ import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'; type FloatingPillButtonProps = { - /** Inner button variant */ variant?: ButtonVariant; /** Callback when the button is pressed */ onPress?: () => void; - /** The icon to display */ icon: IconAsset; /** The fill color for the icon */ @@ -36,7 +34,6 @@ type FloatingPillButtonProps = { /** The label text to display */ label: string; - /** Additional text styles */ textStyle?: StyleProp; }; @@ -74,7 +71,6 @@ type FloatingMessageCounterProps = { /** Whether the New Messages indicator is active */ isActive?: boolean; - /** Whether there are new messages */ hasNewMessages: boolean; /** Callback to be called when user clicks the New Messages indicator */ diff --git a/src/pages/inbox/report/LinkPreviewer.tsx b/src/pages/inbox/report/LinkPreviewer.tsx index c21127efecf3..9794d6d98036 100644 --- a/src/pages/inbox/report/LinkPreviewer.tsx +++ b/src/pages/inbox/report/LinkPreviewer.tsx @@ -5,7 +5,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import {fontScale} from '@styles/typography'; import type {LinkMetadata} from '@src/types/onyx/ReportAction'; @@ -67,7 +67,7 @@ function LinkPreviewer({linkMetadata = [], maxAmountOfPreviews = -1}: LinkPrevie )} {!!publisher && ( {publisher} @@ -76,14 +76,14 @@ function LinkPreviewer({linkMetadata = [], maxAmountOfPreviews = -1}: LinkPrevie {!!title && !!url && ( {title} )} - {!!description && {description}} + {!!description && {description}} {!!image?.type && IMAGE_TYPES.has(image.type) && !!image.width && !!image.height && ( ; - /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine: boolean; /** Pre-rendered thread divider node from the parent */ diff --git a/src/pages/inbox/report/ParticipantLocalTime.tsx b/src/pages/inbox/report/ParticipantLocalTime.tsx index c68f6a409e0a..5d8b5c9f93ee 100644 --- a/src/pages/inbox/report/ParticipantLocalTime.tsx +++ b/src/pages/inbox/report/ParticipantLocalTime.tsx @@ -14,7 +14,6 @@ import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type ParticipantLocalTimeProps = { - /** Personal details of the participant */ participant: PersonalDetails; }; diff --git a/src/pages/inbox/report/ReactionList/BaseReactionList.tsx b/src/pages/inbox/report/ReactionList/BaseReactionList.tsx index 8d5a70e0e7a5..08c6e59e264a 100755 --- a/src/pages/inbox/report/ReactionList/BaseReactionList.tsx +++ b/src/pages/inbox/report/ReactionList/BaseReactionList.tsx @@ -27,9 +27,6 @@ import type ReactionListProps from './types'; import HeaderReactionList from './HeaderReactionList'; type BaseReactionListProps = ReactionListProps & { - /** - * Array of personal detail objects - */ users: PersonalDetails[]; /** diff --git a/src/pages/inbox/report/ReactionList/types.ts b/src/pages/inbox/report/ReactionList/types.ts index 66c096067310..8a1860025de5 100644 --- a/src/pages/inbox/report/ReactionList/types.ts +++ b/src/pages/inbox/report/ReactionList/types.ts @@ -2,13 +2,8 @@ type ReactionListProps = { /** Hide the ReactionList modal popover */ onClose?: () => void; - /** The emoji codes */ emojiCodes: string[]; - - /** The name of the emoji */ emojiName: string; - - /** Count of the emoji reactions */ emojiCount: number; }; diff --git a/src/pages/inbox/report/RepliesDivider.tsx b/src/pages/inbox/report/RepliesDivider.tsx index e7f612d0ba1d..f0b6de9fa993 100644 --- a/src/pages/inbox/report/RepliesDivider.tsx +++ b/src/pages/inbox/report/RepliesDivider.tsx @@ -14,7 +14,6 @@ import React from 'react'; import {View} from 'react-native'; type RepliesDividerProps = { - /** Whether we should hide thread divider line */ shouldHideThreadDividerLine: boolean; }; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 03a1fc908e24..3ee20e93bab1 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -69,40 +69,20 @@ type MoneyRequestOptions = Record< >; type AttachmentPickerWithMenuItemsProps = { - /** The report currently being looked at */ report: OnyxEntry; - - /** The personal details of the current user */ currentUserPersonalDetails: OnyxTypes.PersonalDetails; - - /** Callback when the attachment is picked */ onAttachmentPicked: (url: FileObject | FileObject[]) => void; /** Whether or not the full size composer is available */ isFullComposerAvailable: boolean; - /** Whether or not the composer is full size */ isComposerFullSize: boolean; - - /** Whether or not the attachment picker is disabled */ disabled?: boolean; - - /** Sets the menu visibility */ setMenuVisibility: (isVisible: boolean) => void; - - /** Whether or not the menu is visible */ isMenuVisible: boolean; - - /** Report ID */ reportID: string; - - /** Called when opening the attachment picker */ onTriggerAttachmentPicker: () => void; - - /** Called when cancelling the attachment picker */ onCanceledAttachmentPicker?: () => void; - - /** Called when the menu with the items is closed after it was open */ onMenuClosed?: () => void; /** Called when the add action button is pressed */ @@ -111,7 +91,6 @@ type AttachmentPickerWithMenuItemsProps = { /** Called when the menu item is selected */ onItemSelected: () => void; - /** A ref for the add action button */ actionButtonRef: React.RefObject; /** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */ @@ -196,6 +175,7 @@ function AttachmentPickerWithMenuItems({ const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, accountID, ''); const shouldShowEmptyReportConfirmation = useShouldShowEmptyReportConfirmation(report?.policyID); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {getCurrencyDecimals} = useCurrencyListActions(); const selectOption = useCallback( @@ -229,6 +209,7 @@ function AttachmentPickerWithMenuItems({ betas, isTrackIntentUser, getCurrencyDecimals, + rules, true, shouldDismissEmptyReportsConfirmation, ), @@ -240,7 +221,7 @@ function AttachmentPickerWithMenuItems({ if (shouldShowEmptyReportConfirmation) { openCreateReportConfirmation(); } else { - createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, policy, betas, isTrackIntentUser, getCurrencyDecimals, true, false); + createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, policy, betas, isTrackIntentUser, getCurrencyDecimals, rules, true, false); } }; @@ -332,7 +313,7 @@ function AttachmentPickerWithMenuItems({ ], }; - const moneyRequestOptionsList = temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs ?? [], betas, isReportArchived, isRestrictedToPreferredPolicy).map( + const moneyRequestOptionsList = temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs ?? [], betas, rules, isReportArchived, isRestrictedToPreferredPolicy).map( (option) => options[option], ); @@ -354,6 +335,7 @@ function AttachmentPickerWithMenuItems({ icons, betas, draftTransactionIDs, + rules, ]); const createReportOption: PopoverMenuItem[] = useMemo(() => { diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index cde9bb298187..ac8f4a07ac8e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -1,7 +1,7 @@ import type {Emoji} from '@assets/emojis/types'; import type {TextSelection} from '@components/Composer/types'; -import type {Mention} from '@components/MentionSuggestions'; +import type Mention from '@components/MentionSuggestions/types'; import type {ReportActionEditMessageState} from '@pages/inbox/report/ReportActionEditMessageContext'; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index effe586d8e99..a0ae8d5ac108 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -12,7 +12,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; +import {isChatRoom, isGroupChat, isInvoiceReport, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -68,6 +68,7 @@ function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAtt const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); const isReportArchived = useReportIsArchived(report?.reportID); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); @@ -78,9 +79,10 @@ function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAtt const hasReceipt = hasReceiptTransactionUtils(transaction); - const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); - const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; - const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; + const isSettledReport = isSettled(report) || isSettled(parentReport); + const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, rules, isReportArchived, isRestrictedToPreferredPolicy).length; + // Approved reports are not excluded here because shouldAddOrReplaceReceipt already limits them to admins. + const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledReport; const shouldDisplayDualDropZone = canModifyReceipt || hasMoneyRequestOptions; if (shouldDisplayDualDropZone) { diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx index 3a859126dfc0..dbdcc8b43781 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx @@ -13,7 +13,6 @@ import {useComposerSendState, useComposerState} from './ComposerContext'; import ExpandCollapseButton from './ExpandCollapseButton'; type ComposerExpandCollapseButtonProps = ViewProps & { - /** The report ID */ reportID: string; }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 777265b2b8a2..88a07846f3fa 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -1,6 +1,5 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -65,8 +64,6 @@ function ComposerInput() { containerRef.current?.measureInWindow(callback); }; - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const isReportArchived = useReportIsArchived(report?.reportID); @@ -90,8 +87,6 @@ function ComposerInput() { ref={setComposerRef} suggestionsRef={suggestionsRef} isNextModalWillOpenRef={isNextModalWillOpenRef} - isScrollLikelyLayoutTriggered={isScrollLayoutTriggered} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} reportID={reportID} policyID={report?.policyID} includeChronos={chatIncludesChronos(report)} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c970a824a33c..4fbb92d04636 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -1,6 +1,5 @@ import type {Emoji} from '@assets/emojis/types'; -import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; import type {ComposerRef, CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useWideRHPState} from '@components/WideRHPContextProvider'; @@ -50,36 +49,24 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// eslint-disable-next-line no-restricted-imports -import findNodeHandle from '@src/utils/findNodeHandle'; import type {Ref, RefObject} from 'react'; -import type { - BlurEvent, - LayoutChangeEvent, - MeasureInWindowOnSuccessCallback, - NativeMethods, - TextInputContentSizeChangeEvent, - TextInputKeyPressEvent, - TextInputScrollEvent, -} from 'react-native'; +import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, NativeMethods, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import React, {memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {DeviceEventEmitter, NativeModules, StyleSheet, View} from 'react-native'; -import {useFocusedInputHandler} from 'react-native-keyboard-controller'; -import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; +import {useAnimatedRef} from 'react-native-reanimated'; import type {SuggestionsRef} from './ReportActionCompose'; import {useComposerActions, useComposerEditState, useComposerText} from './ComposerContext'; -import getCursorPosition from './getCursorPosition'; -import getScrollPosition from './getScrollPosition'; import getUpdatedSyncSelection from './getUpdatedSyncSelection'; import ReportActionComposeUtils from './ReportActionComposeUtils'; import SilentCommentUpdater from './SilentCommentUpdater'; import Suggestions from './Suggestions'; +import useComposerSuggestions from './useComposerSuggestions'; import useEditComposerToggle from './useEditComposerToggle'; import useLastEditableAction from './useLastEditableAction'; @@ -91,13 +78,8 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; type ComposerWithSuggestionsRef = ComposerRef & { - /** Focus the composer */ focus: (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => void; - - /** Replace the selection with text */ replaceSelectionWithText: OnEmojiSelected; - - /** Get the current text of the composer */ getCurrentText: () => string; /** @@ -106,19 +88,13 @@ type ComposerWithSuggestionsRef = ComposerRef & { */ clearWorklet: () => void; - /** Reset the height of the composer */ resetHeight: () => void; }; type ComposerWithSuggestionsProps = Partial & ForwardedFSClassProps & { - /** Report ID */ reportID: string; - - /** Callback to focus composer */ onFocus: () => void; - - /** Callback to blur composer */ onBlur: (event: BlurEvent) => void; /** Callback when layout of composer changes */ @@ -130,19 +106,10 @@ type ComposerWithSuggestionsProps = Partial & /** Callback when the composer got cleared on the UI thread */ onClear?: (text: string) => void; - /** Whether the composer is full size */ isComposerFullSize: boolean; - - /** Function to set whether the full composer is available */ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; - - /** Whether the menu is visible */ isMenuVisible: boolean; - - /** The placeholder for the input */ inputPlaceholder: string; - - /** Callback when a file is pasted */ onPasteFile: (file: FileObject | FileObject[]) => void; /** Whether the input is disabled, defaults to false */ @@ -151,31 +118,14 @@ type ComposerWithSuggestionsProps = Partial & /** Function to handle sending a message */ onEnterKeyPress: () => void; - /** Function to measure the parent container */ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** Whether the scroll is likely to trigger a layout */ - isScrollLikelyLayoutTriggered: RefObject; - - /** Function to raise the scroll is likely layout triggered */ - raiseIsScrollLikelyLayoutTriggered: () => void; - /** The ref to the suggestions */ suggestionsRef: React.RefObject; - - /** The ref to the next modal will open */ isNextModalWillOpenRef: RefObject; - - /** Whether to include chronos */ includeChronos?: boolean; - - /** Whether report is from group policy */ isGroupPolicyReport: boolean; - - /** policy ID of the report */ policyID?: string; - - /** Reference to the outer element */ ref?: Ref; }; @@ -233,8 +183,6 @@ function ComposerWithSuggestions({ disabled, onEnterKeyPress, measureParentContainer = () => {}, - isScrollLikelyLayoutTriggered, - raiseIsScrollLikelyLayoutTriggered, onClear: onClearProp = () => {}, onLayout, @@ -260,9 +208,6 @@ function ComposerWithSuggestions({ const isFocused = useIsFocused(); const navigation = useNavigation(); const emojisPresentBefore = useRef([]); - const mobileInputScrollPosition = useRef(0); - const cursorPositionValue = useSharedValue({x: 0, y: 0}); - const tag = useSharedValue(-1); const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -327,6 +272,13 @@ function ComposerWithSuggestions({ const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: initialText.length, end: initialText.length}); + const {measureParentContainerAndReportCursor, onSaveScrollAndHideSuggestionMenu, resetScrollPosition, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer, + suggestionsRef, + }); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(initialText); @@ -502,7 +454,7 @@ function ComposerWithSuggestions({ */ const updateComment = useCallback( (commentValue: string, shouldDebounceSaveComment?: boolean) => { - raiseIsScrollLikelyLayoutTriggered(); + raiseIsScrollLayoutTriggered(); // previous text before change const prevText = lastTextRef.current; @@ -588,7 +540,7 @@ function ComposerWithSuggestions({ } }, [ - raiseIsScrollLikelyLayoutTriggered, + raiseIsScrollLayoutTriggered, selection.start, selection.end, findNewlyAddedChars, @@ -760,17 +712,6 @@ function ComposerWithSuggestions({ [setCurrentEditMessageSelection, suggestionsRef, currentEditMessageSelection, selection], ); - const hideSuggestionMenu = useCallback( - (e: TextInputScrollEvent) => { - mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0; - if (!suggestionsRef.current || isScrollLikelyLayoutTriggered.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, - [suggestionsRef, isScrollLikelyLayoutTriggered], - ); - const setShouldBlockSuggestionCalcToFalse = useCallback(() => { if (!suggestionsRef.current) { return false; @@ -986,50 +927,12 @@ function ComposerWithSuggestions({ const onClear = useCallback( (textOnClear: string) => { - mobileInputScrollPosition.current = 0; + resetScrollPosition(); // Note: use the value when the clear happened, not the current value which might have changed already onClearProp(textOnClear); updateComment('', true); }, - [onClearProp, updateComment], - ); - - 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); - }, [tag]); - - useFocusedInputHandler( - { - onSelectionChange: (event) => { - 'worklet'; - - if (event.target === tag.get()) { - cursorPositionValue.set({ - x: event.selection.end.x, - y: event.selection.end.y, - }); - } - }, - }, - [], - ); - const measureParentContainerAndReportCursor = useCallback( - (callback: MeasureParentContainerAndCursorCallback) => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); - const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); - measureParentContainer((x, y, width, height) => { - callback({ - x, - y, - width, - height, - scrollValue, - cursorCoordinates: {x: xPosition, y: yPosition}, - }); - }); - }, - [measureParentContainer, cursorPositionValue, selection], + [onClearProp, updateComment, resetScrollPosition], ); const isTouchEndedRef = useRef(false); @@ -1128,7 +1031,7 @@ function ComposerWithSuggestions({ testID={CONST.COMPOSER.NATIVE_ID} shouldCalculateCaretPosition onLayout={onLayout} - onScroll={hideSuggestionMenu} + onScroll={onSaveScrollAndHideSuggestionMenu} shouldContainScroll={isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} forwardedFSClass={forwardedFSClass} diff --git a/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx index 99748109d7c7..117cb22cbd39 100644 --- a/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx @@ -17,17 +17,11 @@ import React from 'react'; import {View} from 'react-native'; type ExpandCollapseButtonProps = ViewProps & { - /** Whether the full composer is available */ isFullComposerAvailable: boolean; - /** Whether the composer is full size */ isComposerFullSize: boolean; - /** The report ID */ reportID: string; - /** Function to raise the scroll is likely layout triggered */ raiseIsScrollLikelyLayoutTriggered: () => void; - /** Function to set the composer full size */ setIsComposerFullSize: (reportID: string, isFullSize: boolean) => void; - /** Whether the button is disabled */ disabled?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx index 48d7a8e03728..306cc1d502bd 100644 --- a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -20,7 +20,6 @@ type MessageEditCancelButtonProps = ViewProps & { /** Handle clicking on cancel button */ onCancel: () => void; - /** The test ID to use for the button */ testID?: string; }; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 86ff5f8e11ab..6e3fd7c8b082 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -21,7 +21,6 @@ import ComposerSendButton from './ComposerSendButton'; import ComposerTypingIndicator from './ComposerTypingIndicator'; type ReportActionComposeProps = { - /** Report ID */ reportID: string; }; diff --git a/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts index c11d2e73dc8b..010560a9a0f6 100644 --- a/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts @@ -1,14 +1,10 @@ type SilentCommentUpdaterProps = { - /** Updates the comment */ updateComment: (comment: string) => void; /** The ID of the report associated with the comment */ reportID: string; - /** The value of the comment */ value: string; - - /** The ref of the comment */ commentRef: React.RefObject; /** The ref to check whether the comment saving is in progress */ diff --git a/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx b/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx index 80bfd2c10541..e08df12d8498 100644 --- a/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx @@ -17,7 +17,6 @@ type SubmitDraftButtonProps = PressableWithFeedbackProps & { /** The icon to display on the button */ icon: IconAsset; - /** Whether the button is disabled */ isDisabled: boolean; /** Handle clicking on send button */ diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx index bafe9c0d7464..216db6990ec8 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx @@ -223,6 +223,8 @@ function SuggestionEmoji({ [calculateEmojiSuggestion], ), CONST.TIMING.SUGGESTION_DEBOUNCE_TIME, + // maxWait keeps the suggestions refreshing while a key is held down. + {maxWait: CONST.TIMING.SUGGESTION_DEBOUNCE_TIME}, ); useEffect(() => { diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index 2ccd83a9568c..7bc3ee555f7a 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -1,5 +1,5 @@ -import type {Mention} from '@components/MentionSuggestions'; import MentionSuggestions from '@components/MentionSuggestions'; +import type Mention from '@components/MentionSuggestions/types'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -18,7 +18,7 @@ import StringUtils from '@libs/StringUtils'; import {getSortedPersonalDetails, trimLeadingSpace} from '@libs/SuggestionUtils'; import {isValidRoomName} from '@libs/ValidationUtils'; -import {searchInServer} from '@userActions/Report'; +import {searchInServer, searchUserInServer} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -47,6 +47,11 @@ type SuggestionValues = { */ const isMentionCode = (str: string): boolean => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); +/** Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". */ +function normalizeMentionPrefix(prefix: string, prefixType: string): string { + return prefixType === '@' && prefix.length > 1 && prefix.endsWith('.') ? prefix.slice(0, -1) : prefix; +} + const defaultSuggestionsValues: SuggestionValues = { suggestedMentions: [], atSignIndex: -1, @@ -128,20 +133,33 @@ function SuggestionMention({ // Used to decide whether to block the suggestions list from showing to prevent flickering const shouldBlockCalc = useRef(false); + const serverSearchPrefix = normalizeMentionPrefix(suggestionValues.mentionPrefix, suggestionValues.prefixType); + const foundSuggestionsCountForServerSearch = suggestionValues.prefixType === '#' ? suggestionValues.suggestedMentions.length : 0; /** - * Search for reports suggestions in server. + * Search for mention suggestions in server. * * The function is debounced to not perform requests on every keystroke. */ const debouncedSearchInServer = useDebounce( - useCallback(() => { - const foundSuggestionsCount = suggestionValues.suggestedMentions.length; - if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) { - searchInServer(suggestionValues.mentionPrefix, policyID); - } - }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, suggestionValues.mentionPrefix, policyID, isGroupPolicyReport]), + useCallback( + (prefixType: string, searchPrefix: string, foundSuggestionsCount: number) => { + if (prefixType === '@' && !searchPrefix.includes(' ')) { + const isHereMention = `@${searchPrefix}`.toLowerCase() === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT; + if (!isHereMention) { + searchUserInServer(searchPrefix); + } + return; + } + + if (prefixType === '#' && foundSuggestionsCount < CONST.AUTO_COMPLETE_SUGGESTER.MIN_ROOM_SUGGESTIONS_BEFORE_SERVER_SEARCH && isGroupPolicyReport) { + searchInServer(searchPrefix, policyID); + } + }, + [policyID, isGroupPolicyReport], + ), CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME, + {maxWait: CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME}, ); const formatLoginPrivateDomain = useCallback( @@ -491,7 +509,7 @@ function SuggestionMention({ let atSignIndex: number | undefined; let suggestionWord = ''; - let prefix: string; + let prefix = ''; let prefixType = ''; // Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it) @@ -507,13 +525,9 @@ function SuggestionMention({ prefix = suggestionWord.substring(1); prefixType = suggestionWord.substring(0, 1); - } else { - prefix = lastWord.substring(1); } - // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". - const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); - const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; + const normalizedPrefix = normalizeMentionPrefix(prefix, prefixType); // Keep the raw prefix for highlight so dots are preserved in the UI. const mentionPrefix = prefix; @@ -539,9 +553,10 @@ function SuggestionMention({ nextState.shouldShowSuggestionMenu = true; } - // Early return if there is no update const currentState = suggestionValuesRef.current; - if (currentState.suggestedMentions.length === 0 && nextState.suggestedMentions?.length === 0) { + const hasNoSuggestions = currentState.suggestedMentions.length === 0 && nextState.suggestedMentions?.length === 0; + const hasSameMentionContext = currentState.atSignIndex === atSignIndex && currentState.mentionPrefix === mentionPrefix && currentState.prefixType === prefixType; + if (hasNoSuggestions && hasSameMentionContext) { return; } @@ -562,6 +577,8 @@ function SuggestionMention({ [calculateMentionSuggestion], ), CONST.TIMING.SUGGESTION_DEBOUNCE_TIME, + // maxWait keeps the suggestions refreshing while a key is held down. + {maxWait: CONST.TIMING.SUGGESTION_DEBOUNCE_TIME}, ); useEffect(() => { @@ -569,8 +586,8 @@ function SuggestionMention({ }, [value, selection.start, selection.end, debouncedCalculateMentionSuggestion]); useEffect(() => { - debouncedSearchInServer(); - }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, policyID, value, debouncedSearchInServer]); + debouncedSearchInServer(suggestionValues.prefixType, serverSearchPrefix, foundSuggestionsCountForServerSearch); + }, [suggestionValues.prefixType, serverSearchPrefix, foundSuggestionsCountForServerSearch, policyID, value, debouncedSearchInServer]); const updateShouldShowSuggestionMenuToFalse = useCallback(() => { setSuggestionValues((prevState) => { diff --git a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx index 0c08b0896056..326597ab5cdc 100644 --- a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx @@ -19,10 +19,7 @@ type SuggestionProps = { /** The current input value */ value: string; - /** The current selection value */ selection: TextSelection; - - /** Callback to update the current selection */ setSelection: (newSelection: TextSelection) => void; /** Callback to update the comment draft */ @@ -34,13 +31,8 @@ type SuggestionProps = { /** Report composer focus state */ isComposerFocused?: boolean; - /** Callback to reset the keyboard input */ resetKeyboardInput?: () => void; - - /** Whether the auto suggestion picker is large */ isAutoSuggestionPickerLarge?: boolean; - - /** The height of the composer */ composerHeight?: number; /** If current composer is connected with report from group policy */ @@ -49,7 +41,6 @@ type SuggestionProps = { /** The policyID of the report connected to current composer */ policyID?: string; - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts index e2f750764272..160dcaad6ff6 100644 --- a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts @@ -1,7 +1,5 @@ type GetUpdatedSyncSelectionProps = { - /** The comment with space inserted */ commentWithSpaceInserted: string; - /** The new comment */ newComment: string; /** The position of the comment */ position: number; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts index ef0665f9e0d3..7f78f40060e8 100644 --- a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts @@ -3,11 +3,8 @@ import type {ComposerRef} from '@components/Composer/types'; import type {RefObject} from 'react'; type UpdateNativeTextInputValueProps = { - /** The text to update */ text: string; - /** Whether to force a native value update */ shouldForceNativeValueUpdate: boolean; - /** The ref to the composer */ composerRef: RefObject; }; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 17ff0ca76f0b..f0a16a7653e6 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -14,6 +14,7 @@ import {canUserPerformWriteAction, generateReportID, isConciergeChatReport} from import {startSpan} from '@libs/telemetry/activeSpans'; import getSendMessageListWeight from '@libs/telemetry/getSendMessageListWeight'; import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; +import {getSendMessageVisibleSpanID} from '@libs/telemetry/sendMessageSpans'; import {useActionListContext} from '@pages/inbox/ActionListContext'; @@ -111,7 +112,7 @@ function useComposerSubmit(reportID: string) { [CONST.TELEMETRY.ATTRIBUTE_REPORT_ACTION_COUNT]: reportActionCount, [CONST.TELEMETRY.ATTRIBUTE_MONEY_REQUEST_PREVIEW_COUNT]: moneyRequestPreviewCount, }; - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${optimisticReportActionID}`, { + startSpan(getSendMessageVisibleSpanID(optimisticReportActionID), { name: 'send-message-visible', op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, attributes, diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts new file mode 100644 index 000000000000..21f768e95792 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts @@ -0,0 +1,114 @@ +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; + +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; + +// eslint-disable-next-line no-restricted-imports +import findNodeHandle from '@src/utils/findNodeHandle'; + +import type {RefObject} from 'react'; +import type {MeasureInWindowOnSuccessCallback, TextInputScrollEvent} from 'react-native'; + +import {useEffect, useRef} from 'react'; +import {useFocusedInputHandler} from 'react-native-keyboard-controller'; +import {useSharedValue} from 'react-native-reanimated'; + +import type {SuggestionsRef} from './ComposerContext'; + +import getCursorPosition from './getCursorPosition'; +import getScrollPosition from './getScrollPosition'; + +type UseComposerSuggestionsParams = { + /** Ref to the composer input the suggestions are attached to */ + composerRef: RefObject; + + /** The current selection of the composer, used as the cursor position on web */ + selection: TextSelection; + + /** Measures the container the suggestion menu is positioned against */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + + /** Suggestions ref owned by a parent. When omitted, the hook owns the ref itself */ + suggestionsRef?: RefObject; +}; + +/** + * Wires up everything a composer needs to render + */ +function useComposerSuggestions({composerRef, selection, measureParentContainer, suggestionsRef: suggestionsRefProp}: UseComposerSuggestionsParams) { + const ownSuggestionsRef = useRef(null); + const suggestionsRef = suggestionsRefProp ?? ownSuggestionsRef; + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const mobileInputScrollPosition = useRef(0); + const cursorPositionValue = useSharedValue({x: 0, y: 0}); + const tag = useSharedValue(-1); + + 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); + }, [tag, composerRef]); + + useFocusedInputHandler( + { + onSelectionChange: (event) => { + 'worklet'; + + if (event.target === tag.get()) { + cursorPositionValue.set({ + x: event.selection.end.x, + y: event.selection.end.y, + }); + } + }, + }, + [], + ); + + const measureParentContainerAndReportCursor = (callback: MeasureParentContainerAndCursorCallback) => { + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); + const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); + measureParentContainer((x, y, width, height) => { + callback({ + x, + y, + width, + height, + scrollValue, + cursorCoordinates: {x: xPosition, y: yPosition}, + }); + }); + }; + + const hideSuggestionMenu = () => { + suggestionsRef.current?.updateShouldShowSuggestionMenuToFalse(false); + }; + + const onSaveScrollAndHideSuggestionMenu = (event: TextInputScrollEvent) => { + // The offset is recorded even for a layout-triggered scroll, so the menu stays anchored to the real caret position. + mobileInputScrollPosition.current = event?.nativeEvent?.contentOffset?.y ?? 0; + + if (isScrollLayoutTriggered.current) { + return; + } + + hideSuggestionMenu(); + }; + + /** Forgets the tracked scroll offset, e.g. when the composer input is cleared and scrolled back to the top */ + const resetScrollPosition = () => { + mobileInputScrollPosition.current = 0; + }; + + return { + suggestionsRef, + measureParentContainerAndReportCursor, + hideSuggestionMenu, + onSaveScrollAndHideSuggestionMenu, + resetScrollPosition, + raiseIsScrollLayoutTriggered, + }; +} + +export default useComposerSuggestions; diff --git a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts index d91b577db77e..262c855fd973 100644 --- a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts @@ -6,7 +6,6 @@ import lodashDebounce from 'lodash/debounce'; import {useState} from 'react'; type UseDebouncedCommentValidationProps = { - /** The report ID */ reportID: string | undefined; /** Whether the composer is in edit mode */ diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 3585548b8729..c740bd597cec 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -13,10 +13,7 @@ import ReportActionComposeUtils from './ReportActionComposeUtils'; import updateNativeTextInputValue from './updateNativeTextInputValue'; type UseEditComposerToggleProps = { - /** The selection of the composer */ selection: TextSelection; - - /** The ref to the composer */ composerRef: RefObject; /** Handle changing the selection of the composer */ @@ -56,9 +53,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, isEditingInComposer?: boolean; /** Whether to move the selection to the end of the text */ shouldMoveSelectionToEnd?: boolean; - /** The selection to apply */ selection?: TextSelection | null; - /** Whether to force a native value update */ shouldForceNativeValueUpdate?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 8b11d34266ee..4b856d90e1ba 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -19,17 +19,11 @@ import type {DebouncedFuncLeading} from 'lodash'; import type React from 'react'; type UseEditMessageProps = { - /** The report ID */ reportID: string | undefined; - /** The original report ID */ originalReportID: string | undefined; - /** The report action */ reportAction: OnyxTypes.ReportAction | null | undefined; - /** Whether to scroll to the last message */ shouldScrollToLastMessage?: boolean; - /** The debounced comment max length validation */ debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; - /** The ref to the composer */ composerRef: React.RefObject; }; diff --git a/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts index 6d530116e121..407bd61c669a 100644 --- a/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts +++ b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts @@ -21,6 +21,7 @@ function useLastEditableAction(reportID: string): OnyxEntry !isMoneyRequestAction(action) && canEditReportAction(action, undefined, undefined)); + return [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined, rules)); } export default useLastEditableAction; diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts index 9036d2cb8998..4d0fcd20a75a 100644 --- a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts +++ b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts @@ -18,6 +18,7 @@ function useShouldAddOrReplaceReceipt(reportID: string) { const isReportArchived = useReportIsArchived(report?.reportID); const allReportTransactions = useReportTransactionsCollection(reportID); const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isTransactionThreadView = isReportTransactionThread(report); @@ -36,7 +37,7 @@ function useShouldAddOrReplaceReceipt(reportID: string) { const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); const canEditReceipt = canUserPerformWriteAction && - canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && + canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, rules}) && !transaction?.receipt?.isTestDriveReceipt; const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index fe5d56fed644..a46a0c738b18 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -20,31 +20,21 @@ function noop() { type ReportActionEditMessageState = ValueOf; type ReportActionActiveEdit = { - /** The report ID */ editingReportID: string | null; - /** The report action ID */ editingReportActionID: string | null; - /** The report action */ editingReportAction: OnyxTypes.ReportAction | null; - /** The editing message */ editingMessage: string | null; }; type ReportActionEditMessageContextValue = ReportActionActiveEdit & { - /** The current edit message selection */ currentEditMessageSelection: TextSelection | null; - /** The editing state */ editingState: ReportActionEditMessageState; }; type ReportActionEditMessageContextActions = { - /** Set the editing message */ setEditingMessage: Dispatch>; - /** Set the current edit message selection */ setCurrentEditMessageSelection: Dispatch>; - /** Submit the edit */ submitEdit: () => void; - /** Stop the editing */ stopEditing: () => void; }; @@ -65,7 +55,6 @@ const ReportActionEditMessageActionsContext = createContext; - /** Report action belonging to the report's parent */ parentReportAction: OnyxEntry; - - /** The transaction thread report's parentReportAction */ parentReportActionForTransactionThread?: OnyxEntry; - - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** Should the comment have the appearance of being grouped with the previous comment? */ @@ -127,7 +122,6 @@ type ReportActionItemProps = { /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: boolean; - /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine?: boolean; /** Report action ID that was referenced in the deeplink to report */ @@ -136,7 +130,6 @@ type ReportActionItemProps = { /** Callback to be called on onPress */ onPress?: () => void; - /** If this is the first visible report action */ isFirstVisibleReportAction: boolean; /** @@ -145,13 +138,8 @@ type ReportActionItemProps = { */ isThreadReportParentAction?: boolean; - /** IF the thread divider line will be used */ shouldUseThreadDividerLine?: boolean; - - /** Whether context menu should be displayed */ shouldDisplayContextMenu?: boolean; - - /** Linked transaction route error */ linkedTransactionRouteError?: Errors; /** Whether to show border for MoneyRequestReportPreviewContent */ diff --git a/src/pages/inbox/report/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index 8a1d86d7df37..c284acd7a82a 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -31,16 +31,9 @@ import ReportActionItemCreated from './ReportActionItemCreated'; import ReportActionItemSingle from './ReportActionItemSingle'; type ReportActionItemContentCreatedProps = { - /** Report action belonging to the report's parent */ parentReportAction: OnyxEntry; - - /** The transaction ID */ transactionID: string | undefined; - - /** The draft message */ draftMessage: string | undefined; - - /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine: boolean; }; diff --git a/src/pages/inbox/report/ReportActionItemCreated.tsx b/src/pages/inbox/report/ReportActionItemCreated.tsx index 42e6a02e8e43..cf975cdf0f92 100644 --- a/src/pages/inbox/report/ReportActionItemCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemCreated.tsx @@ -7,6 +7,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useOptimisticPersonalDetails from '@hooks/useOptimisticPersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,7 +27,6 @@ import {View} from 'react-native'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; type ReportActionItemCreatedProps = { - /** The id of the report */ reportID: string | undefined; /** The id of the policy */ @@ -49,6 +49,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr const {accountID: currentUserAccountID} = currentUserPersonalDetail; const [conciergePersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: conciergePersonalDetailSelector}); const [reportOwnerPersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(report?.ownerAccountID)}); + const optimisticPersonalDetails = useOptimisticPersonalDetails(); const otherParticipantAccountID = Object.keys(report?.participants ?? {}) @@ -78,6 +79,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr reportOwnerPersonalDetail, currentUserPersonalDetail, conciergePersonalDetail, + optimisticPersonalDetails, ) } > diff --git a/src/pages/inbox/report/ReportActionItemFragment.tsx b/src/pages/inbox/report/ReportActionItemFragment.tsx index 27ec4643cea7..a684cc6b5fe1 100644 --- a/src/pages/inbox/report/ReportActionItemFragment.tsx +++ b/src/pages/inbox/report/ReportActionItemFragment.tsx @@ -23,19 +23,13 @@ import TextCommentFragment from './comment/TextCommentFragment'; import ReportActionItemMessageHeaderSender from './ReportActionItemMessageHeaderSender'; type ReportActionItemFragmentProps = { - /** Users accountID */ accountID: number; - - /** The report action's id */ reportActionID?: string; - - /** The message fragment needing to be displayed */ fragment: Message | undefined; /** Message(text) of an IOU report action */ iouMessage?: string; - /** The reportAction's source */ source?: OriginalMessageSource; /** Should this fragment be contained in a single line? */ @@ -47,7 +41,6 @@ type ReportActionItemFragmentProps = { /** The accountID of the copilot who took this action on behalf of the user */ delegateAccountID?: number; - /** icon */ actorIcon?: OnyxCommon.Icon; /** Whether the comment is a thread parent message/the first message in a thread */ @@ -65,15 +58,9 @@ type ReportActionItemFragmentProps = { /** Used to format RTL display names in Old Dot system messages e.g. Arabic */ isFragmentContainingDisplayName?: boolean; - /** The pending action for the report action */ pendingAction?: OnyxCommon.PendingAction; - - /** The report action name */ actionName?: ReportActionName; - moderationDecision?: DecisionName; - - /** Whether the fragment should show a tooltip */ shouldShowTooltip?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionItemFrame.tsx b/src/pages/inbox/report/ReportActionItemFrame.tsx index d9bbac886634..ea206362ec45 100644 --- a/src/pages/inbox/report/ReportActionItemFrame.tsx +++ b/src/pages/inbox/report/ReportActionItemFrame.tsx @@ -14,7 +14,6 @@ type ReportActionItemFrameProps = { /** Action content */ children: React.ReactNode; - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** Report for this action */ @@ -26,7 +25,6 @@ type ReportActionItemFrameProps = { /** True when this message is edited inline on a wide layout; right-aligns the reaction row under the composer. */ isEditingInline: boolean; - /** Whether the report action is a whisper */ isWhisper: boolean; /** Whether the search-page UI is active */ diff --git a/src/pages/inbox/report/ReportActionItemMessage.tsx b/src/pages/inbox/report/ReportActionItemMessage.tsx index dcf78797f89f..252c64ad3d6f 100644 --- a/src/pages/inbox/report/ReportActionItemMessage.tsx +++ b/src/pages/inbox/report/ReportActionItemMessage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -21,7 +21,6 @@ import ReportActionMessageContent from './actionContents/ReportActionMessageCont import TextCommentFragment from './comment/TextCommentFragment'; type ReportActionItemMessageProps = { - /** The report action */ action: ReportAction; /** Should the comment have the appearance of being grouped with the previous comment? */ @@ -33,7 +32,6 @@ type ReportActionItemMessageProps = { /** Whether or not the message is hidden by moderation */ isHidden?: boolean; - /** The ID of the report */ reportID: string | undefined; }; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index dff13702c3e0..90634e30cfd3 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,13 +1,11 @@ import type {Emoji} from '@assets/emojis/types'; -import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -37,24 +35,17 @@ import {isBlockedFromConcierge} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -// eslint-disable-next-line no-restricted-imports -import findNodeHandle from '@src/utils/findNodeHandle'; -import type {MeasureInWindowOnSuccessCallback, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {useFocusedInputHandler} from 'react-native-keyboard-controller'; -import {useSharedValue} from 'react-native-reanimated'; - -import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; -import getCursorPosition from './ReportActionCompose/getCursorPosition'; -import getScrollPosition from './ReportActionCompose/getScrollPosition'; import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import SubmitDraftButton from './ReportActionCompose/SubmitDraftButton'; import Suggestions from './ReportActionCompose/Suggestions'; +import useComposerSuggestions from './ReportActionCompose/useComposerSuggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; @@ -64,7 +55,6 @@ import useDebouncedSaveDraft from './useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; type ReportActionItemMessageEditProps = { - /** All the data of the action */ action: OnyxTypes.ReportAction; /** ReportID that holds the comment we're editing */ @@ -76,7 +66,6 @@ type ReportActionItemMessageEditProps = { /** PolicyID of the policy the report belongs to */ policyID?: string; - /** Reference to the outer element */ ref?: React.Ref; }; @@ -105,10 +94,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const suggestionsRef = useRef(null); - const mobileInputScrollPosition = useRef(0); - const cursorPositionValue = useSharedValue({x: 0, y: 0}); - const tag = useSharedValue(-1); const emojisPresentBefore = useRef([]); const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); @@ -144,8 +129,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy isEditing: true, }); - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const [modal = DEFAULT_MODAL_VALUE] = useOnyx(ONYXKEYS.MODAL); const [onyxInputFocused = false] = useOnyx(ONYXKEYS.INPUT_FOCUSED); @@ -153,6 +136,12 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); + const {suggestionsRef, measureParentContainerAndReportCursor, hideSuggestionMenu, onSaveScrollAndHideSuggestionMenu, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer: (callback) => containerRef.current?.measureInWindow(callback), + }); + // Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. const {saveDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); @@ -285,24 +274,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy updateDraft(insertText(draft, selection, `${emoji} `)); }; - const hideSuggestionMenu = useCallback(() => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, [suggestionsRef]); - const onSaveScrollAndHideSuggestionMenu = useCallback( - (e: TextInputScrollEvent) => { - if (isScrollLayoutTriggered.current) { - return; - } - mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0; - - hideSuggestionMenu(); - }, - [isScrollLayoutTriggered, hideSuggestionMenu], - ); - /** * Key event handlers that short cut to saving/canceling. * @@ -333,70 +304,13 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy deleteDraft(); } }, - [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft], - ); - - const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, []); - - const measureParentContainerAndReportCursor = useCallback( - (callback: MeasureParentContainerAndCursorCallback) => { - const performMeasurement = () => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); - const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); - measureContainer((x, y, width, height) => { - callback({ - x, - y, - width, - height, - scrollValue, - cursorCoordinates: {x: xPosition, y: yPosition}, - }); - }); - }; - - performMeasurement(); - }, - [cursorPositionValue, measureContainer, selection], - ); - - 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); - }, [tag]); - useFocusedInputHandler( - { - onSelectionChange: (event) => { - 'worklet'; - - if (event.target === tag.get()) { - cursorPositionValue.set({ - x: event.selection.end.x, - y: event.selection.end.y, - }); - } - }, - }, - [], + [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft, suggestionsRef], ); useEffect(() => { debouncedCommentMaxLengthValidation(draft); }, [draft, debouncedCommentMaxLengthValidation]); - useEffect(() => { - if (isFocused) { - return; - } - - hideSuggestionMenu(); - }, [isFocused, hideSuggestionMenu]); - return ( <> setIsFocused(false)} + onBlur={() => { + setIsFocused(false); + hideSuggestionMenu(); + }} selection={selection} onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} isGroupPolicyReport={isGroupPolicyReport} diff --git a/src/pages/inbox/report/ReportActionItemMessageHeaderSender/types.ts b/src/pages/inbox/report/ReportActionItemMessageHeaderSender/types.ts index 9dde664eb197..df34ac1a94cf 100644 --- a/src/pages/inbox/report/ReportActionItemMessageHeaderSender/types.ts +++ b/src/pages/inbox/report/ReportActionItemMessageHeaderSender/types.ts @@ -1,10 +1,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; type ReportActionItemMessageHeaderSenderProps = { - /** Text to display */ fragmentText: string | undefined; - - /** Users accountID */ accountID: number; /** Should this fragment be contained in a single line? */ @@ -13,7 +10,6 @@ type ReportActionItemMessageHeaderSenderProps = { /** The accountID of the copilot who took this action on behalf of the user */ delegateAccountID?: number; - /** Actor icon */ actorIcon?: OnyxCommon.Icon; /** Whether the fragment should show a tooltip */ diff --git a/src/pages/inbox/report/ReportActionItemMessageWithExplain.tsx b/src/pages/inbox/report/ReportActionItemMessageWithExplain.tsx index e0ec3ce4b885..716f3a98630e 100644 --- a/src/pages/inbox/report/ReportActionItemMessageWithExplain.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageWithExplain.tsx @@ -27,13 +27,8 @@ import React from 'react'; import ReportActionItemBasicMessage from './ReportActionItemBasicMessage'; type ReportActionItemMessageWithExplainProps = { - /** The message to display */ message: string; - - /** All the data of the action item */ action: OnyxEntry; - - /** The child report of the action item */ childReport: OnyxEntry; /** Original report from which the given reportAction is first created */ diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index 2d6d6e17c9e6..75c80e64476a 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -27,32 +27,17 @@ import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import RepliesDivider from './RepliesDivider'; type ReportActionItemParentActionProps = { - /** All the data of the action item */ action: ReportAction; - - /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine?: boolean; /** The id of the report */ reportID: string; - - /** The current report is displayed */ report: OnyxEntry; - - /** The transaction thread report associated with the current report, if any */ transactionThreadReport: OnyxEntry; - - /** Report actions belonging to the report's parent */ parentReportAction: OnyxEntry; - - /** Whether we should display "Replies" divider */ shouldDisplayReplyDivider: boolean; - - /** If this is the first visible report action */ isFirstVisibleReportAction: boolean; - - /** If the thread divider line will be used */ shouldUseThreadDividerLine?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionItemSingle.tsx b/src/pages/inbox/report/ReportActionItemSingle.tsx index 0f91d024d2fb..b60bb9b2be38 100644 --- a/src/pages/inbox/report/ReportActionItemSingle.tsx +++ b/src/pages/inbox/report/ReportActionItemSingle.tsx @@ -39,7 +39,6 @@ import ReportActionItemFragment from './ReportActionItemFragment'; import VacationDelegateText from './VacationDelegateText'; type ReportActionItemSingleProps = Partial & { - /** All the data of the action */ action: OnyxEntry; /** Styles for the outermost View */ @@ -51,13 +50,8 @@ type ReportActionItemSingleProps = Partial & { /** IOU Report for this action, if any */ iouReport?: OnyxEntry; - /** Show header for action */ showHeader?: boolean; - - /** If the action is being hovered */ isHovered?: boolean; - - /** If the action is active */ isActive?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionItemThread.tsx b/src/pages/inbox/report/ReportActionItemThread.tsx index c10d85801ad5..01dca5e36a31 100644 --- a/src/pages/inbox/report/ReportActionItemThread.tsx +++ b/src/pages/inbox/report/ReportActionItemThread.tsx @@ -23,10 +23,7 @@ import React from 'react'; import {View} from 'react-native'; type ReportActionItemThreadProps = { - /** The current report */ report: OnyxEntry; - - /** All the data of the action item */ reportAction: ReportAction; /** Whether the thread item / message is being hovered */ diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a733aa6ed3d3..1f471b3fb6a3 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,7 +1,9 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; @@ -10,6 +12,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -47,7 +50,6 @@ import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -77,10 +79,7 @@ type ReportActionsListContentProps = { /** The ID of the report to display actions for */ reportID: string; - /** The Concierge chat report */ conciergeChat: OnyxEntry; - - /** Callback executed on list layout */ onLayout?: (event: LayoutChangeEvent) => void; }; @@ -134,7 +133,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {isOffline} = useNetwork(); const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; - const {sessionStartTime} = useConciergeSessionState(); + const sessionStartTime = useConciergeSessionStartTime(); const didLayout = useRef(false); @@ -144,6 +143,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); + // Owned here rather than by the callout, which unmounts as the layout and composer change size. + useRetireMerchantRuleSuggestionOnLeave(reportID); + // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); @@ -151,6 +153,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const isReportArchived = !!isArchivedReport(reportNameValuePairs); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportAttributesSelector = (value: OnyxEntry) => { const attrs = value?.reports?.[reportID]; @@ -191,6 +194,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, isScrolledOverThreshold: hasScrolledOverThreshold, hasOnceLoadedReportActions: !!hasOnceLoadedReportActions, + newMessageBoundaryTime: isConciergeHiddenHistory ? sessionStartTime : undefined, }); const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ @@ -421,6 +425,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct policy, report, isTrackIntentUser, + rules, }); /** @@ -446,6 +451,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ( <> + {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view + does not carry it out of sight. Renders nothing on the layouts the composer mount serves. */} + ; - - /** The transaction thread report's parentReportAction */ parentReportActionForTransactionThread: OnyxEntry; /** Report for this action */ @@ -33,7 +28,6 @@ type ReportActionsListItemRendererProps = { /** Should the comment have the appearance of being grouped with the previous comment? */ displayAsGroup: boolean; - /** If the thread divider line should be hidden */ shouldHideThreadDividerLine: boolean; /** Should we display the new marker on top of the comment? */ @@ -42,13 +36,8 @@ type ReportActionsListItemRendererProps = { /** Report action ID that was referenced in the deeplink to report */ linkedReportActionID?: string; - /** Whether we should display "Replies" divider */ shouldDisplayReplyDivider: boolean; - - /** If this is the first visible report action */ isFirstVisibleReportAction: boolean; - - /** If the thread divider line will be used */ shouldUseThreadDividerLine?: boolean; /** Animate highlight action in few seconds */ diff --git a/src/pages/inbox/report/ReportActionsListPaddingView.tsx b/src/pages/inbox/report/ReportActionsListPaddingView.tsx index 25c14d9f208c..6c64d51cd2b1 100644 --- a/src/pages/inbox/report/ReportActionsListPaddingView.tsx +++ b/src/pages/inbox/report/ReportActionsListPaddingView.tsx @@ -13,9 +13,7 @@ import {View} from 'react-native'; import useShouldShowComposerForActiveEditDraft from './useShouldShowComposerForActiveEditDraft'; type ReportActionsListPaddingViewProps = ChildrenProps & { - /** The report currently being looked at */ report: OnyxTypes.Report; - /** Whether the report is archived */ isReportArchived: boolean; }; diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 7d230e1f9128..4836a3f05caf 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -2,6 +2,7 @@ import AnonymousReportFooter from '@components/AnonymousReportFooter'; import ArchivedReportFooter from '@components/ArchivedReportFooter'; import Banner from '@components/Banner'; import BlockedReportFooter from '@components/BlockedReportFooter'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import OfflineIndicator from '@components/OfflineIndicator'; import SwipeableView from '@components/SwipeableView'; @@ -106,8 +107,19 @@ function ReportFooter() { ); + // The callout decides for itself whether this mount suits the layout and the composer size. + const merchantRuleBanner = ( + + ); return ( + {merchantRuleBanner} {shouldShowEnableNotificationsBanner ? ( <> diff --git a/src/pages/inbox/report/SearchActionHeader.tsx b/src/pages/inbox/report/SearchActionHeader.tsx index 64e0219b54db..371a65833fff 100644 --- a/src/pages/inbox/report/SearchActionHeader.tsx +++ b/src/pages/inbox/report/SearchActionHeader.tsx @@ -3,6 +3,7 @@ import {useIsOnSearch} from '@components/Search/SearchScopeProvider'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; @@ -11,7 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getChatListItemReportName, isChatThread, isInvoiceReport} from '@libs/ReportUtils'; -import variables from '@styles/variables'; +import {fontScale} from '@styles/typography'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, Transaction} from '@src/types/onyx'; @@ -22,7 +23,6 @@ import React from 'react'; import {View} from 'react-native'; type SearchActionHeaderProps = { - /** The report action being rendered. */ action: ReportAction; /** The report this action belongs to. */ report: OnyxEntry; @@ -37,6 +37,7 @@ type SearchActionHeaderProps = { function SearchActionHeaderContent({action, report, isWhisper, onPress, children}: SearchActionHeaderProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const personalDetailsList = usePersonalDetails(); const parentReportID = isChatThread(report) ? report.parentReportID : undefined; @@ -50,7 +51,7 @@ function SearchActionHeaderContent({action, report, isWhisper, onPress, children const reportTransactionsCollection = useReportTransactionsCollection(reportForHeaderReportID); const linkedTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction); - const reportName = getChatListItemReportName(action, report, parentReport, conciergeReportID, linkedTransactions, translate, personalDetailsList); + const reportName = getChatListItemReportName(action, report, parentReport, conciergeReportID, linkedTransactions, translate, convertToDisplayString, personalDetailsList); return ( @@ -58,7 +59,7 @@ function SearchActionHeaderContent({action, report, isWhisper, onPress, children {translate('common.in')}  { onPress?.(); }} diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx index a79be7182b07..da7ce605e3a1 100644 --- a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -13,10 +13,7 @@ import React from 'react'; import {View} from 'react-native'; type ShowPreviousMessagesButtonProps = { - /** The ID of the report this list item belongs to */ reportID: string; - - /** The action type of the report action being rendered for this list item */ actionType: ReportAction['actionName']; /** Whether there are previous messages hidden before the session start */ diff --git a/src/pages/inbox/report/ThreadDivider.tsx b/src/pages/inbox/report/ThreadDivider.tsx index 0657d90e8dd6..e4c2f0df49a5 100644 --- a/src/pages/inbox/report/ThreadDivider.tsx +++ b/src/pages/inbox/report/ThreadDivider.tsx @@ -15,9 +15,7 @@ import React from 'react'; import {View} from 'react-native'; type ThreadDividerProps = { - /** Whether the thread divider should display a new marker */ shouldDisplayNewMarker: boolean; - /** Callback to be called on press */ onPress?: () => void; }; diff --git a/src/pages/inbox/report/TripSummary.tsx b/src/pages/inbox/report/TripSummary.tsx index 449c2a34fd8a..8e3bdc422834 100644 --- a/src/pages/inbox/report/TripSummary.tsx +++ b/src/pages/inbox/report/TripSummary.tsx @@ -10,7 +10,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; type TripSummaryProps = { - /** The report ID */ reportID: string | undefined; }; diff --git a/src/pages/inbox/report/UserTypingEventListener.tsx b/src/pages/inbox/report/UserTypingEventListener.tsx index 9ccf5a5f380a..0ed0c563775b 100644 --- a/src/pages/inbox/report/UserTypingEventListener.tsx +++ b/src/pages/inbox/report/UserTypingEventListener.tsx @@ -16,7 +16,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {useEffect, useRef} from 'react'; type UserTypingEventListenerProps = { - /** The report currently being looked at */ report: OnyxTypes.Report; }; function UserTypingEventListener({report}: UserTypingEventListenerProps) { diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 492a6b4ddc67..e02e80162456 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -18,6 +18,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { getChangedApproverActionMessage, + getCompanyCardConnectionBroken30DaysMessage, getCommuterExclusionMessage, getCompanyCardConnectionBrokenMessage, getDelegateSubmitMessage, @@ -87,7 +88,6 @@ import ReportMentionWhisperContent from './ReportMentionWhisperContent'; import SimpleMessageContent, {isSimpleMessageAction} from './SimpleMessageContent'; type ActionContentRouterProps = { - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** Report for this action */ @@ -102,16 +102,12 @@ type ActionContentRouterProps = { /** The IOU/Expense report we are paying */ iouReport?: OnyxTypes.Report; - /** Report ID for the current report */ reportID: string | undefined; /** Should the comment have the appearance of being grouped with the previous comment? */ displayAsGroup: boolean; - /** ReportAction draft message */ draftMessage: string | undefined; - - /** Whether the report action is a whisper */ isWhisper: boolean; /** Whether the report action is hovered (or context menu / emoji picker active) */ @@ -123,7 +119,6 @@ type ActionContentRouterProps = { /** Toggle the hidden state of the message */ updateHiddenState: (isHiddenValue: boolean) => void; - /** Whether the provided report is a closed expense report with no expenses */ isClosedExpenseReportWithNoExpenses?: boolean; /** Whether the report action is the "Created" action of a harvest-created expense report */ @@ -135,10 +130,7 @@ type ActionContentRouterProps = { /** Whether the search-page UI is active */ isOnSearch: boolean; - /** Toggle whether the payment method popover is active */ setIsPaymentMethodPopoverActive: (value: boolean) => void; - - /** Whether the user is a track intent user */ isTrackIntentUser?: boolean; }; @@ -485,6 +477,13 @@ function ActionContentRouter({ ); } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN_30_DAYS)) { + return ( + + ${getCompanyCardConnectionBroken30DaysMessage(translate, action)}`} /> + + ); + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) { return ( diff --git a/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx b/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx index 4b46739540c6..31674f0664a6 100644 --- a/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx +++ b/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx @@ -42,6 +42,7 @@ function ApprovalFlowContent({action, policyID, reportID, originalReport, isTrac const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isDEWPolicy = hasDynamicExternalWorkflow(policy); const isPendingAdd = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; @@ -73,6 +74,7 @@ function ApprovalFlowContent({action, policyID, reportID, originalReport, isTrac isTrackIntentUser, policy, report: originalReport, + rules, }) ) { return ; @@ -104,6 +106,7 @@ function ApprovalFlowContent({action, policyID, reportID, originalReport, isTrac isTrackIntentUser, policy, report: originalReport, + rules, }) ) { return ; diff --git a/src/pages/inbox/report/actionContents/CardBrokenConnectionContent.tsx b/src/pages/inbox/report/actionContents/CardBrokenConnectionContent.tsx index fcbb7244eecd..e7e0a0786327 100644 --- a/src/pages/inbox/report/actionContents/CardBrokenConnectionContent.tsx +++ b/src/pages/inbox/report/actionContents/CardBrokenConnectionContent.tsx @@ -9,7 +9,7 @@ import {getCardConnectionBrokenMessage, getOriginalMessage} from '@libs/ReportAc import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; -import type CONST from '@src/CONST'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {ReportAction} from '@src/types/onyx'; @@ -18,7 +18,7 @@ import {cardByIdSelector} from '@selectors/Card'; import React from 'react'; type CardBrokenConnectionContentProps = { - action: ReportAction; + action: ReportAction; }; function CardBrokenConnectionContent({action}: CardBrokenConnectionContentProps) { @@ -33,9 +33,12 @@ function CardBrokenConnectionContent({action}: CardBrokenConnectionContentProps) const connectionLink = cardID && isPersonalCardBrokenConnection(card) ? `${environmentURL}/${ROUTES.SETTINGS_WALLET_PERSONAL_CARD_DETAILS.getRoute(String(cardID))}` : undefined; + const is30DaysReminder = action.actionName === CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN_30_DAYS; + const brokenConnectionMessage = getCardConnectionBrokenMessage(card, cardName, translate, is30DaysReminder, connectionLink); + return ( - ${getCardConnectionBrokenMessage(card, cardName, translate, connectionLink)}`} /> + ${brokenConnectionMessage}`} /> ); } diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index e77cba4582f5..be1543a172dd 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import FollowupListSkeleton from '@components/ReportActionItem/FollowupListSkeleton'; @@ -28,11 +28,11 @@ import { isResolvedConciergeCategoryOptions, isResolvedConciergeDescriptionOptions, } from '@libs/ReportActionsUtils'; -import {createDraftTransactionAndNavigateToParticipantSelector} from '@libs/ReportUtils'; import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard'; import {doesUserHavePaymentCardAdded} from '@libs/SubscriptionUtils'; import {isSplitChildTransaction} from '@libs/TransactionUtils'; +import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; import {dismissTrackExpenseActionableWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -67,7 +67,6 @@ function AddPaymentCardButton() { } type ConciergeOptionsButtonsProps = { - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** Report that owns this action for mutations (thread / merged-list cases use the original report) */ @@ -122,7 +121,6 @@ function ConciergeOptionsButtons({action, actionOwnerReport, reportID, options}: } type SuggestedFollowupButtonsProps = { - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** Report that owns this action for mutations (thread / merged-list cases use the original report) */ @@ -178,7 +176,6 @@ function SuggestedFollowupButtons({action, actionOwnerReport, reportID, followup } type TrackExpenseButtonsProps = { - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** ID of the report that owns this action */ @@ -283,13 +280,11 @@ function TrackExpenseButtons({action, actionOwnerReportID}: TrackExpenseButtonsP } type ChatActionableButtonsProps = { - /** All the data of the action item */ action: OnyxTypes.ReportAction; /** ID of the original report from which the given reportAction is first created */ originalReportID: string | undefined; - /** ID of the report currently being displayed */ reportID: string | undefined; /** Whether Concierge is still composing the followup list for this action, so its placeholder should be shown */ diff --git a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx index 72a02fc3eea7..fe26451ee493 100644 --- a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx @@ -1,5 +1,5 @@ import {AttachmentContext} from '@components/AttachmentContext'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import Text from '@components/Text'; diff --git a/src/pages/inbox/report/actionContents/ChatTransactionPreview.tsx b/src/pages/inbox/report/actionContents/ChatTransactionPreview.tsx index 54ce728bd724..3da9133a7aae 100644 --- a/src/pages/inbox/report/actionContents/ChatTransactionPreview.tsx +++ b/src/pages/inbox/report/actionContents/ChatTransactionPreview.tsx @@ -20,6 +20,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -39,7 +40,6 @@ type ChatTransactionPreviewProps = { /** Whether the preview should navigate to the split bill details screen on press */ shouldShowSplitPreview: boolean; - /** The ID of the transaction to preview */ transactionID: string | undefined; }; @@ -53,6 +53,7 @@ function ChatTransactionPreview({action, reportID, chatReport, iouReport, should const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const reportPreviewStyles = StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayout, 1, undefined, undefined); @@ -78,6 +79,8 @@ function ChatTransactionPreview({action, reportID, chatReport, iouReport, should const createdTransactionThreadReport = createTransactionThreadReport({ introSelected, conciergeChat, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, currentUserLogin: personalDetail.email ?? '', currentUserAccountID: personalDetail.accountID, betas, diff --git a/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx index 7dddee1fb880..925bf05744a0 100644 --- a/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx +++ b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; @@ -20,10 +20,7 @@ import React from 'react'; import {View} from 'react-native'; type ConfirmWhisperContentProps = { - /** All the data of the action item */ action: ReportAction; - - /** Report ID for the current report */ reportID: string | undefined; /** ID of the original report from which the given reportAction is first created */ diff --git a/src/pages/inbox/report/actionContents/FraudAlertContent.tsx b/src/pages/inbox/report/actionContents/FraudAlertContent.tsx index f6381f5e6e40..2543a344b276 100644 --- a/src/pages/inbox/report/actionContents/FraudAlertContent.tsx +++ b/src/pages/inbox/report/actionContents/FraudAlertContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; diff --git a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx index 722ad77f0107..8f72d91d599e 100644 --- a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx +++ b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import RenderHTML from '@components/RenderHTML'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; diff --git a/src/pages/inbox/report/actionContents/IntegrationMessage.tsx b/src/pages/inbox/report/actionContents/IntegrationMessage.tsx index 1f6b8ed7995e..7fb03d547baf 100644 --- a/src/pages/inbox/report/actionContents/IntegrationMessage.tsx +++ b/src/pages/inbox/report/actionContents/IntegrationMessage.tsx @@ -10,7 +10,6 @@ import type CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; type IntegrationMessageProps = { - /** Integration report action to render. */ action: ReportAction; }; diff --git a/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx b/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx index 3f7f1812ec46..7cb0c9f2597e 100644 --- a/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx +++ b/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx @@ -18,7 +18,6 @@ import React from 'react'; import ReportActionMessageContent from './ReportActionMessageContent'; type IouReportActionMessageProps = { - /** The report action */ action: ReportAction; /** Should the comment have the appearance of being grouped with the previous comment? */ @@ -30,7 +29,6 @@ type IouReportActionMessageProps = { /** Whether or not the message is hidden by moderation */ isHidden?: boolean; - /** The ID of the report */ reportID: string | undefined; }; diff --git a/src/pages/inbox/report/actionContents/JoinRequestContent.tsx b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx index 302f16d0a316..df38c77f9ecf 100644 --- a/src/pages/inbox/report/actionContents/JoinRequestContent.tsx +++ b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import useLocalize from '@hooks/useLocalize'; @@ -12,6 +12,8 @@ import {acceptJoinRequest, declineJoinRequest} from '@userActions/Policy/Member' import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {personalDetailsSelector} from '@src/selectors/PersonalDetails'; +import {policyNameSelector} from '@src/selectors/Policy'; import type {ReportAction} from '@src/types/onyx'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; @@ -26,13 +28,16 @@ type JoinRequestContentProps = { function JoinRequestContent({action, actionOwnerReportID, policyID}: JoinRequestContentProps) { const {translate} = useLocalize(); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyName = ''] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyNameSelector}); - const isJoinRequestUnresolved = getOriginalMessage(action)?.choice === ('' as JoinWorkspaceResolution); + const originalMessage = getOriginalMessage(action); + const [requesterDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(originalMessage?.accountID)}); + + const isJoinRequestUnresolved = originalMessage?.choice === ('' as JoinWorkspaceResolution); return ( - + {isJoinRequestUnresolved && ( - - {translate('gps.desktop.title')} - {translate('gps.desktop.subtitle')} - - - - + ); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/types.ts b/src/pages/iou/request/step/IOURequestStepDistanceGPS/types.ts index 1949684d6644..b023562632f5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/types.ts +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/types.ts @@ -9,7 +9,6 @@ import type {OnyxEntry} from 'react-native-onyx'; type IOURequestStepDistanceGPSProps = WithCurrentUserPersonalDetailsProps & WithWritableReportOrNotFoundProps & { - /** The transaction object being modified in Onyx */ transaction: OnyxEntry; }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 3b6d481e257b..b0b233f4a42e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -59,7 +59,6 @@ import withWritableReportOrNotFound from './withWritableReportOrNotFound'; type IOURequestStepDistanceMapProps = WithCurrentUserPersonalDetailsProps & WithWritableReportOrNotFoundProps & { - /** The transaction object being modified in Onyx */ transaction: OnyxEntry; }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ce50a03cbbab..67c6640da308 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,5 +1,6 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; +import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import ReceiptImage from '@components/ReceiptImage'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; @@ -9,6 +10,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; import useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; @@ -38,6 +40,7 @@ import {restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/action import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import getPlatform from '@libs/getPlatform'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -69,6 +72,7 @@ import {View} from 'react-native'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import useOdometerImageHandlers from './IOURequestStepDistance/hooks/useOdometerImageHandlers'; +import useOdometerKeyboardVerticalOffset from './IOURequestStepDistance/hooks/useOdometerKeyboardVerticalOffset'; import useOdometerNavigation from './IOURequestStepDistance/hooks/useOdometerNavigation'; import useOdometerReadingsState from './IOURequestStepDistance/hooks/useOdometerReadingsState'; import useOdometerTransactionBackup from './IOURequestStepDistance/hooks/useOdometerTransactionBackup'; @@ -78,7 +82,6 @@ import withWritableReportOrNotFound from './withWritableReportOrNotFound'; type IOURequestStepDistanceOdometerProps = WithCurrentUserPersonalDetailsProps & WithWritableReportOrNotFoundProps & { - /** The transaction object being modified in Onyx */ transaction: OnyxEntry; }; @@ -124,6 +127,7 @@ function IOURequestStepDistanceOdometer({ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); + const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); @@ -154,6 +158,14 @@ function IOURequestStepDistanceOdometer({ const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; const delegateAccountID = useDelegateAccountID(); const isFocused = useIsFocused(); + // Android can recycle this screen's native view while it's backgrounded, leaving KeyboardAvoidingView's internal + // state pointing at a stale view. Remounting is the only way to reset it. Scoped to Android, since that's the + // only platform where this recycling happens — iOS/web would otherwise pay for a remount they don't need. + let keyboardAvoidingViewInstanceKey = 'static'; + if (getPlatform() === CONST.PLATFORM.ANDROID) { + keyboardAvoidingViewInstanceKey = isFocused ? 'focused' : 'unfocused'; + } + const {keyboardVerticalOffset, onLayout: measureOwnLayout} = useOdometerKeyboardVerticalOffset(); const shouldUseDefaultExpensePolicy = useMemo( () => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam), @@ -163,6 +175,7 @@ function IOURequestStepDistanceOdometer({ const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ policyID: report?.policyID ?? (shouldAutoReportToDefaultWorkspace ? defaultExpensePolicy?.id : undefined), isOdometerDistanceRequest: true, + isEditingExistingDistanceRequest: isEditing, }); const mileageRate = DistanceRequestUtils.getRate({ @@ -190,6 +203,7 @@ function IOURequestStepDistanceOdometer({ const [odometerDraft] = useOnyx(ONYXKEYS.ODOMETER_DRAFT); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const { startReading, @@ -434,8 +448,10 @@ function IOURequestStepDistanceOdometer({ reportPolicyTags, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + violations: allTransactionViolations, getCurrencyDecimals, getCurrencySymbol, + rules, }); } Navigation.goBack(); @@ -617,142 +633,157 @@ function IOURequestStepDistanceOdometer({ shouldShowWrapper={!isCreatingNewRequest} includeSafeAreaPaddingBottom > - - - {/* Start Reading */} - - - { - lastFocusedInputRef.current = startReadingInputRef.current; - }} - /> + {/* The create flow has no ScreenWrapper of its own (`shouldShowWrapper` is false), so this is what keeps the buttons above the keyboard here. */} + + + + {/* Start Reading */} + + + { + lastFocusedInputRef.current = startReadingInputRef.current; + }} + /> + + {!isEditing && ( + + + + )} - {!isEditing && ( - - + + { + lastFocusedInputRef.current = endReadingInputRef.current; + }} /> - - )} + + {!isEditing && ( + + + + )} + + + {/* Total Distance Display - always shown, updated live */} + + + {`${translate('distance.odometer.totalDistance')}: ${totalDistance !== null ? roundToTwoDecimalPlaces(totalDistance) : 0} ${unit}`} + + - {/* End Reading */} - - - { - lastFocusedInputRef.current = endReadingInputRef.current; - }} + + {/* Form Error Message */} + {!!formError && ( + - - {!isEditing && ( - - - + {translate('distance.odometer.saveForLater')} + )} - - - {/* Total Distance Display - always shown, updated live */} - - - {`${translate('distance.odometer.totalDistance')}: ${totalDistance !== null ? roundToTwoDecimalPlaces(totalDistance) : 0} ${unit}`} - - - - - {/* Form Error Message */} - {!!formError && ( - - )} - {/* Save for later Button */} - {isCreatingNewRequest && ( + {/* Next/Save Button */} - )} - {/* Next/Save Button */} - + - + ); } diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx index 3dfdff605a82..674b9e75b314 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx @@ -15,7 +15,6 @@ import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimate import {scheduleOnRN} from 'react-native-worklets'; type SliderProps = { - /** Function called when the slider ratio changes */ onSliderRatioChange: (ratio: number) => void; }; diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx index deb8cee60a5e..3dccf4879904 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DistanceMapView from '@components/DistanceMapView'; import Text from '@components/Text'; diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx index 282928b4e527..dfeac1033e17 100644 --- a/src/pages/iou/request/step/IOURequestStepHours.tsx +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index 6e9ff117abf6..a31bf48e3658 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext'; import Icon from '@components/Icon'; import ImageSVG from '@components/ImageSVG'; diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index f6360215f606..3195860a166b 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index 58ed0eec7585..4ebb662ba720 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -5,7 +5,6 @@ import useChangeTransactionsReportReports from '@hooks/useChangeTransactionsRepo import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import {setCustomUnitID, setCustomUnitRateID} from '@libs/actions/IOU/MoneyRequest'; import {clearSubrates} from '@libs/actions/IOU/PerDiem'; @@ -115,10 +114,9 @@ function useReportSelectionActions({ const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const {removeTransaction} = useSearchSelectionActions(); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const reports = useChangeTransactionsReportReports(transaction ? [transaction] : [], undefined); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const delegateAccountID = useDelegateAccountID(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -177,14 +175,7 @@ function useReportSelectionActions({ return; } - if (isNewManualExpenseFlowEnabled) { - Navigation.goBack(backPath); - return; - } - - const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportOrDraftReportFromValue?.chatReportID); - // `goBack` replaces the current route when the confirmation screen isn't on the stack. - Navigation.goBack(iouConfirmationPageRoute, {compareParams: false}); + Navigation.goBack(backPath); }; const handleRegularReportSelection = (item: TransactionGroupListItem, report: OnyxEntry) => { @@ -222,6 +213,7 @@ function useReportSelectionActions({ transactions: targetTransactions, allTransactionViolation: transactionViolations, reports: reportsForCall, + rules, isTrackIntentUser, personalPolicyOutputCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`]?.outputCurrency, selfDMReportActions, @@ -252,6 +244,7 @@ function useReportSelectionActions({ transactions: targetTransactions, allTransactionViolation: transactionViolations, reports, + rules, isTrackIntentUser, personalPolicyOutputCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`]?.outputCurrency, selfDMReportActions, diff --git a/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx index fb1773a502cc..d63f9d9adb8b 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx @@ -2,7 +2,7 @@ import AttachmentCarouselView from '@components/Attachments/AttachmentCarousel/A import useCarouselArrows from '@components/Attachments/AttachmentCarousel/useCarouselArrows'; import useAttachmentErrors from '@components/Attachments/AttachmentView/useAttachmentErrors'; import type {Attachment} from '@components/Attachments/types'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx index 188248fb4a58..04b815f87344 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import RenderHTML from '@components/RenderHTML'; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx index 4120e8158139..e8aff46cb445 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx @@ -1,5 +1,5 @@ import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt.tsx index 276495054900..7692c0216868 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ImageSVG from '@components/ImageSVG'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index f74d9389dc54..aed8f615ea91 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -69,13 +69,11 @@ type CameraViewportProps = { /** Current camera permission status; used to disable the flash button until granted */ cameraPermissionStatus: PermissionStatus | null; - /** Whether the camera flash is currently on */ flash: boolean; /** Whether the camera device supports flash */ hasFlash: boolean; - /** Updater function to toggle flash state */ setFlash: (updater: (prev: boolean) => boolean) => void; }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx index 599a64d82c47..ff56f237ef12 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx @@ -13,7 +13,6 @@ type GpsPermissionGateProps = { /** Receipt files awaiting confirmation */ receiptFiles: ReceiptFile[]; - /** Resets the permission flow state */ resetPermissionFlow: () => void; /** Called when GPS permission is granted or denied, with the receipt files and grant status */ diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/NavigateGlobalCreateContext.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/NavigateGlobalCreateContext.tsx index bd3246d7ae12..2ea15b28a670 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/NavigateGlobalCreateContext.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/NavigateGlobalCreateContext.tsx @@ -6,6 +6,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useSelfDMReport from '@hooks/useSelfDMReport'; import {navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import deferNavigate from '@libs/Navigation/deferNavigate'; import Navigation from '@libs/Navigation/Navigation'; import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; @@ -104,24 +105,28 @@ function NavigateGlobalCreateSubscriber({fnRef, iouType, reportID, transactionID // If the user previously selected different participants in confirmation, preserve that choice if (transaction?.participants && transaction.participants.at(0)?.reportID !== targetReport?.reportID) { const isTrackExpense = transaction.participants.at(0)?.reportID === selfDMReport?.reportID; + const preservedParticipants = transaction.participants; + const preservedReportID = transaction.reportID; - const setParticipantsPromises = transactionIDs.map((tid) => setMoneyRequestParticipants(tid, transaction.participants)); - Promise.all(setParticipantsPromises).then(() => { + for (const tid of transactionIDs) { + setMoneyRequestParticipants(tid, preservedParticipants); + } + deferNavigate(() => { if (isTrackExpense) { endScanProcessAndStartConfirmationMountSpan(); Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.TRACK, transactionID, selfDMReport?.reportID)); } else { - navigateToConfirmationPage(iouType, transactionID, reportID, backToReport, iouType === CONST.IOU.TYPE.CREATE, transaction.reportID); + navigateToConfirmationPage(iouType, transactionID, reportID, backToReport, iouType === CONST.IOU.TYPE.CREATE, preservedReportID); } }); return; } - const setParticipantsPromises = transactionIDs.map((tid) => { + for (const tid of transactionIDs) { setTransactionReport(tid, {reportID: transactionReportID}, true); - return setMoneyRequestParticipantsFromReport(tid, targetReport, currentUserPersonalDetails.accountID); - }); - Promise.all(setParticipantsPromises).then(() => { + setMoneyRequestParticipantsFromReport(tid, targetReport, currentUserPersonalDetails.accountID); + } + deferNavigate(() => { endScanProcessAndStartConfirmationMountSpan(); Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, transactionID, targetReport?.reportID)); }); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/index.tsx index 1f5d87a6891f..b53394fe41a9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FlatList from '@components/FlatList/FlatList'; import Image from '@components/Image'; import {PressableWithFeedback} from '@components/Pressable'; @@ -47,7 +47,6 @@ type ReceiptPreviewsProps = { /** If the receipts preview should be shown */ isMultiScanEnabled: boolean; - /** If a photo is currently being captured */ isCapturingPhoto?: boolean; /** Whether the component is rendered in landscape orientation */ diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 54e912f067df..66b4f0cb5677 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -100,6 +100,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportIDToCheck = isMoneyRequestReportReportUtils(report) ? report?.chatReportID : report?.reportID; const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`); const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); @@ -116,24 +117,26 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const [transactions] = useOptimisticDraftTransactions(transaction); const {isMultiScanEnabled} = useMultiScanState(); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); - const {getCurrencyDecimals} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); const {disableMultiScan} = useMultiScanActions(); const {setIsLoaderVisible} = useFullScreenLoaderActions(); const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); const [receiptFiles, setReceiptFiles] = useState([]); - const participants = getMoneyRequestParticipantOptions( - currentUserPersonalDetails.accountID, + const participants = getMoneyRequestParticipantOptions({ + currentUserAccountID: currentUserPersonalDetails.accountID, report, policy, personalDetails, conciergeReportID, - isArchived, + privateIsArchived: isArchived, + rules, reportAttributesDerived, reportDraft, translate, + convertToDisplayString, dateFnsLocale, - ); + }); const participantsPolicyTags = useParticipantsPolicyTags(participants); // Whether this expense's sole destination is the current user's self-DM. Forwarded to the cleanup helpers so the @@ -321,10 +324,12 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, optimisticTransactionIDs, optimisticChatReportID, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + isDraftChatReport: !!reportDraft, isTrackIntentUser, delegateAccountID, conciergeChat, formatPhoneNumber, + rules, }; const scanDestinationReportID = iouType === CONST.IOU.TYPE.TRACK ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar.tsx index b61019831351..9982c8bece6a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar.tsx @@ -39,7 +39,6 @@ type ScannerControlsBarProps = { /** Whether the camera device supports flash */ hasFlash: boolean; - /** Updater function to toggle flash state */ setFlash: (updater: (prev: boolean) => boolean) => void; /** Sets whether the attachment picker modal is open */ diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index 2ee5e1a33401..69a76c67a719 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -15,11 +15,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {getMatchingVendors, getVendorEmptyState, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; -import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/utils'; - import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -67,7 +65,7 @@ function IOURequestStepVendor({ const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); const isOnXero = isXeroActiveMatchingSource(policy); - const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); + 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; @@ -135,8 +133,8 @@ function IOURequestStepVendor({ icon={illustrations.Telescope} iconWidth={variables.emptyListIconWidth} iconHeight={variables.emptyListIconHeight} - title={isOnXero ? translate('workspace.xero.noSuppliersFound') : translate('workspace.qbo.noAccountsFound')} - subtitle={isOnXero ? translate('workspace.xero.noSuppliersFoundDescription') : translate('workspace.qbo.noAccountsFoundDescription', integrationName)} + title={emptyState.title} + subtitle={emptyState.subtitle} containerStyle={styles.pb10} /> ) : null; diff --git a/src/pages/iou/request/step/StepScreenDragAndDropWrapper.tsx b/src/pages/iou/request/step/StepScreenDragAndDropWrapper.tsx index fd165fad763e..30de0c5838c3 100644 --- a/src/pages/iou/request/step/StepScreenDragAndDropWrapper.tsx +++ b/src/pages/iou/request/step/StepScreenDragAndDropWrapper.tsx @@ -17,7 +17,6 @@ type StepScreenDragAndDropWrapperProps = { /** The title to show in the header (should be translated already) */ headerTitle: string; - /** A function triggered when the back button is pressed */ onBackButtonPress: () => void; /** A function triggered when the entry transition is ended. Useful for auto-focusing elements. */ @@ -29,7 +28,6 @@ type StepScreenDragAndDropWrapperProps = { /** An ID used for unit testing */ testID: string; - /** The children to render */ children: ((isDraggingOver: boolean) => ReactNode) | ReactNode; }; diff --git a/src/pages/iou/request/step/StepScreenWrapper.tsx b/src/pages/iou/request/step/StepScreenWrapper.tsx index c1d24231be7d..81a35d29f947 100644 --- a/src/pages/iou/request/step/StepScreenWrapper.tsx +++ b/src/pages/iou/request/step/StepScreenWrapper.tsx @@ -19,7 +19,6 @@ type StepScreenWrapperProps = { /** The title to show in the header (should be translated already) */ headerTitle: string; - /** A function triggered when the back button is pressed */ onBackButtonPress: () => void; /** A function triggered when the entry transition is ended. Useful for auto-focusing elements. */ @@ -28,22 +27,17 @@ type StepScreenWrapperProps = { /** Whether or not the wrapper should be shown (sometimes screens can be embedded inside another screen that already is using a wrapper) */ shouldShowWrapper: boolean; - /** Whether or not to display not found page */ shouldShowNotFoundPage?: boolean; - - /** Whether to show offline indicator */ shouldShowOfflineIndicator?: boolean; /** An ID used for unit testing */ testID: string; - /** Whether or not to include safe area padding */ includeSafeAreaPaddingBottom?: boolean; /** Returns a function as a child to pass insets to or a node to render without insets */ children: ReactNode | ((props: ScreenWrapperChildrenProps) => ReactNode); - /** Flag to indicate if the keyboard avoiding view should be enabled */ shouldEnableKeyboardAvoidingView?: boolean; /** Menu items to display in the header three-dots / action button */ diff --git a/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts b/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts index 1510706fc53f..4de5a5be0924 100644 --- a/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts +++ b/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts @@ -23,6 +23,7 @@ type GetSubmitExpensePreMountDestinationRouteParams = { iouType: IOUType; isCreatingTrackExpense: boolean; isSelfDMDestination: boolean; + isOptimisticNewChatDestination: boolean; isLookingAroundUser: boolean; /** Whether the flow relocates an already-tracked expense (SUBMIT/SHARE/CATEGORIZE) rather than creating one in place. */ isMovingTransactionFromTrackExpense: boolean; @@ -41,11 +42,11 @@ function getSubmitExpensePreMountDestinationRoute({ iouType, isCreatingTrackExpense, isSelfDMDestination, + isOptimisticNewChatDestination, isLookingAroundUser, isMovingTransactionFromTrackExpense, }: GetSubmitExpensePreMountDestinationRouteParams): Route | undefined { - // Unlike getSkipConfirmationPreMountDestinationRoute (which lets usePreMountDestination own the narrow gate), this builder - // returns undefined on wide up front - it avoids the nav reads below, and reveal() would never consume a wide result anyway. + // Bail out early on wide layout: nothing here is ever shown on wide, so skip the navigation reads below entirely. if (!isTransactionReady || !getIsNarrowLayout()) { return undefined; } @@ -85,12 +86,17 @@ function getSubmitExpensePreMountDestinationRoute({ // screen opens, so the destination is a report the user has never been on. Skipping it costs only the pre-mount. const isReplacingVisibleReport = !hasPreInsertedFullscreen && isMovingTransactionFromTrackExpense && isReportTopmostSplitNavigator() && Navigation.getTopmostReportId() !== destinationReportID; - // The report must be in the REPORT collection so the pre-inserted screen can render immediately. A draft-only report - // (e.g. the expense chat of a freshly created draft workspace in the zero-workspace "Submit to my employer" flow) can't - // render - the report screen only reads COLLECTION.REPORT - so pre-inserting one would strand the user on an infinite - // skeleton if they back out before submitting. Passing an empty draft to getReportOrDraftReport skips its REPORT_DRAFT - // fallback while keeping the module-cache fallback for real reports that useOnyx hasn't hydrated yet. - const isDestinationReportLoaded = !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, {}, destinationReport)?.reportID; + // Passing {} as the draft argument only blocks the REPORT_DRAFT collection fallback. A draft the caller + // passes in via destinationReport still resolves here, because getReportOrDraftReport checks its `report` + // slot before falling back to the draft slot - and that is intentional: the caller copies that draft into + // COLLECTION.REPORT before reveal, so it is safe to treat as renderable. + const isDestinationReportRenderable = !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, {}, destinationReport)?.reportID; + // Only pre-insert a report that's actually renderable. This is false when the destination ID points at a report + // that exists on the server but isn't in Onyx yet (e.g. deep-linked or evicted reportID with no draft) - pre-inserting + // it would show an infinite skeleton after backing out. + // An optimistic new chat is the one exception: it has no report row yet, but that's fine since submit + // will create it under this same ID. + const isDestinationReportLoaded = isOptimisticNewChatDestination || isDestinationReportRenderable; const shouldPreInsertReport = canUseReportPreInsert && isOutsideRHP && hasValidDestination && isDestinationReportLoaded && !isReplacingVisibleReport; if (!shouldPreInsertSearch && !shouldPreInsertReport) { @@ -103,7 +109,10 @@ function getSubmitExpensePreMountDestinationRoute({ }); } - return ROUTES.REPORT_WITH_ID.getRoute(destinationReportID); + // The last argument tells the report screen this ID is client-generated and doesn't exist on the server + // yet, so it should keep showing its normal loading state instead of fetching (which would 403 and show + // a not-found page). The other arguments in between aren't used for this route. + return ROUTES.REPORT_WITH_ID.getRoute(destinationReportID, undefined, undefined, undefined, undefined, isOptimisticNewChatDestination); } export default getSubmitExpensePreMountDestinationRoute; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index dae43943ddb8..ee3ad5799c54 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -22,7 +22,7 @@ import {reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import {getStringifiedGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; -import {getExistingTransactionID, isLookingAroundSearchRoutingActive, isSelfDMSoleDestination, resolveOptimisticChatReportID} from '@libs/IOUUtils'; +import {getExistingTransactionID, getReusableP2PReportID, isLookingAroundSearchRoutingActive, isSelfDMSoleDestination, resolveOptimisticChatReportID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; @@ -151,6 +151,13 @@ type UseExpenseSubmissionParams = { // Navigation backToReport?: string; + + /** + * Called once validation has passed and the write is guaranteed to happen. Clear a pre-mount + * pre-mount marker here, not earlier - clearing it before validation could pass risks orphaning + * the pre-mounted report if validation then bails with no write. + */ + onExpenseWriteWillStart?: () => void; }; type SendMoneyReportIDs = { @@ -199,6 +206,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { draftTransactionIDs, privateIsArchivedMap, backToReport, + onExpenseWriteWillStart, } = params; // Localization @@ -236,13 +244,17 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const activePolicy = useActivePolicy(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Reports const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${findSelfDMReportID()}`); const reportTransactions = useReportTransactions(report?.reportID); const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; - const [isDraftChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${currentChatReport?.reportID}`, {selector: isDraftReportSelector}); + const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserPersonalDetails.accountID); + // 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; + const [isDraftChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${destinationChatReportID}`, {selector: isDraftReportSelector}); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`); const selectedParticipants = participants.filter((participant) => participant.selected); @@ -265,8 +277,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } const selectedParticipantsForRequest = iouType === CONST.IOU.TYPE.SPLIT ? splitParticipants : selectedParticipants; - const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserPersonalDetails.accountID); - const firstSelectedParticipantReportID = selectedParticipantsForRequest.at(0)?.reportID; const [selectedParticipantsReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${firstSelectedParticipantReportID}`); const iouReportPolicyID = (moneyRequestReportID ? moneyRequestReport?.policyID : undefined) ?? currentChatReport?.policyID ?? selectedParticipantsReport?.policyID; @@ -421,8 +431,15 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (requiresLinkedTracked && !transactions.every((item) => item.linkedTrackedExpenseReportAction && item.linkedTrackedExpenseReportID)) { return; } - - const optimisticChatReportID = generateReportID(); + onExpenseWriteWillStart?.(); + + // For a brand-new P2P recipient, reuse the optimistic report ID the confirmation screen already + // committed to the transaction, so the chat report built here is the one the screen subscribes + // to - otherwise it'd wait forever on an ID that's never created. + const transactionReportID = transaction?.reportID; + const reusableP2PReportID = getReusableP2PReportID(participant, transactionReportID); + const participantAccountIDs = [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID]; + const {chatReportID: optimisticChatReportID} = resolveOptimisticChatReportID(participantAccountIDs, undefined, reusableP2PReportID); const optimisticCreatedReportActionID = rand64(); const optimisticReportPreviewActionID = rand64(); let existingIOUReport: Report | undefined; @@ -559,6 +576,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isTrackIntentUser, delegateAccountID, formatPhoneNumber, + rules, }); existingIOUReport = iouReport; if (!iouReport) { @@ -592,6 +610,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, existingIOUReport: undefined, betas, + rules, currentUserAccountIDParam: currentUserPersonalDetails.accountID, }) : undefined; @@ -606,6 +625,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (!participant || isEmptyObject(transaction.comment) || isEmptyObject(transaction.comment.customUnit)) { return; } + onExpenseWriteWillStart?.(); if (isTrackExpense) { // Mirror the action's bail: a submit it would no-op must not clean up or dismiss. if (!isEmptyObject(policy) && hasCompletePerDiemCustomUnit(transaction.comment?.customUnit)) { @@ -651,10 +671,18 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } else if (!report?.reportID && participant.isPolicyExpenseChat && participant.reportID) { existingChatReport = getReportOrDraftReport(participant.reportID); } - const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( - [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], - existingChatReport, - ); + // The recipient can be swapped without this screen remounting, so `existingChatReport` above + // can still be whoever was selected before. Use the ID confirmation committed for the current + // pick instead, so the pre-mounted report stays aligned with a brand-new P2P recipient. + const transactionReportID = transaction.reportID; + // Reuse it so the pre-mounted screen subscribes to the report created on submission. + const reusableP2PReportID = !isExpenseReport ? getReusableP2PReportID(participant, transactionReportID) : undefined; + const participantAccountIDs = [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID]; + const reportIDs = + !isExpenseReport && !participant.isPolicyExpenseChat + ? resolveOptimisticChatReportID(participantAccountIDs, undefined, reusableP2PReportID) + : resolveOptimisticChatReportID(participantAccountIDs, existingChatReport); + const {optimisticChatReportID, chatReportID} = reportIDs; const activeReportID = isExpenseReport ? report?.reportID : chatReportID; const notifyReportID = isExpenseReport && Navigation.getTopmostReportId() === report?.reportID ? report?.reportID : chatReportID; @@ -704,6 +732,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { formatPhoneNumber, delegateAccountID, isTrackIntentUser, + rules, }); const targetReportID = backToReport ?? activeReportID; // When backToReport exists we are creating the expense from chat, not the expense report, so no pending transaction registration needed. @@ -741,6 +770,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (requiresLinkedTracked && !transactions.every((item) => item.linkedTrackedExpenseReportAction && item.linkedTrackedExpenseReportID)) { return; } + onExpenseWriteWillStart?.(); const optimisticSelfDMReportID = selfDMReport?.reportID ?? generateReportID(); // When the destination resolved to the current user/self-DM, force the self-DM as the chat (clearing any // non-self route report) so getTrackExpenseInformation defaults to the self-DM instead of the route report. @@ -775,7 +805,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { getCurrencyDecimals, report: trackReport, isDraftPolicy, - isDraftChatReport, + isDraftChatReport: !!isDraftChatReport, action, existingTransaction: item, participantParams: { @@ -842,6 +872,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { reportActionsList: policyExpenseChatReportActions, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, delegateAccountID, + rules, }); } performPostBatchCleanup({ @@ -867,13 +898,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (!participant) { return; } + onExpenseWriteWillStart?.(); - // For a brand-new P2P recipient (no existing chat), the confirmation screen has already committed the draft - // transaction to a freshly generated optimistic reportID via setTransactionReport. Build the optimistic chat - // report at that same ID so the report the screen subscribes to is the one that actually gets created. - // Otherwise the builder mints a different ID and the screen hangs waiting on a report that never materializes. - const isBrandNewP2PRecipient = !report && !participant.isPolicyExpenseChat && !participant.reportID; - const optimisticChatReportID = isBrandNewP2PRecipient && !!transaction.reportID && transaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transaction.reportID : undefined; + // Same reasoning as above: reuse the confirmation screen's optimistic report ID for a brand-new + // P2P recipient, so the screen isn't left subscribed to a report ID that's never created. + const optimisticChatReportID = getReusableP2PReportID(participant, transaction.reportID); const shouldIncludeCommuterExclusionOverrides = hasAppliedCommuterExclusion(transaction); const {chatReportID: distanceChatReportID, transactionID: distanceTransactionID} = createDistanceRequestIOUActions({ @@ -933,6 +962,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { delegateAccountID, formatPhoneNumber, participantsPolicyTags, + rules, }); const isExpenseReport = isMoneyRequestReportReportUtils(report); @@ -1065,6 +1095,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isTrackIntentUser, formatPhoneNumber, participantsPolicyTags, + rules, }); if (shouldHandleNavigation) { cleanupAfterExpenseCreate({draftTransactionIDs: [CONST.IOU.OPTIMISTIC_TRANSACTION_ID], shouldWaitForUpcomingTransition: true}); @@ -1113,6 +1144,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isTrackIntentUser, formatPhoneNumber, participantsPolicyTags, + rules, }); if (shouldHandleNavigation) { cleanupAfterExpenseCreate({draftTransactionIDs: [CONST.IOU.OPTIMISTIC_TRANSACTION_ID], shouldWaitForUpcomingTransition: true}); @@ -1241,9 +1273,12 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const {optimisticChatReportID, chatReportID} = resolvedReportIDs ?? resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); + // An explicit optimistic ID means the selected recipient has no chat yet. Do not let a stale page-level + // report override that ID in getSendMoneyParams when the recipient changed without remounting this screen. + const sendMoneyReport = optimisticChatReportID ? undefined : report; const sendMoneyParams = { getCurrencyDecimals, - report, + report: sendMoneyReport, quickAction, amount: transaction.amount, currency, @@ -1260,9 +1295,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }; if (paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + onExpenseWriteWillStart?.(); setIsConfirmed(true); sendMoneyElsewhere(sendMoneyParams); } else if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + onExpenseWriteWillStart?.(); setIsConfirmed(true); sendMoneyWithWallet(sendMoneyParams); } else { diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 39e6c6235211..3e6d474e3e64 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -28,7 +28,6 @@ type WithFullTransactionOrNotFoundOnyxProps = { /** Indicates whether the report data is loading */ transaction: OnyxEntry; - /** Indicates whether the transaction data is loading */ isLoadingTransaction?: boolean; }; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index fd1f31c8626b..11b096d48148 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -6,7 +6,7 @@ import AttachmentView, {checkIsFileImage} from '@components/Attachments/Attachme import useAttachmentErrors from '@components/Attachments/AttachmentView/useAttachmentErrors'; import type {Attachment} from '@components/Attachments/types'; import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -214,7 +214,7 @@ function AttachmentModalBaseContent({ const {isAttachmentLoaded} = useContext(AttachmentStateContext); const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); - const isFileImage = typeof source !== 'function' && checkIsFileImage(source, fileToDisplay?.name); + const isFileImage = typeof source !== 'function' && checkIsFileImage(source, fileToDisplay?.name, fileToDisplay?.type); const isSourceLoaded = isAttachmentLoaded?.(source) || (!!currentPreviewSource && isAttachmentLoaded?.(currentPreviewSource)); const shouldShowDownloadButton = useMemo(() => { const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH || shouldAllowDownloadOutsideReportContext; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts index 125598f1ba8e..39f8ad63a6bf 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts @@ -40,7 +40,6 @@ type AttachmentModalBaseContentProps = { /** Optional source (URL, SVG function) for the image shown. If not passed in via props must be specified when modal is opened. */ source?: AvatarSource; - /** The id of the attachment. */ attachmentID?: string; /** Fallback source (URL, SVG function) for the image shown. */ @@ -67,16 +66,12 @@ type AttachmentModalBaseContentProps = { /** The report that has this attachment */ report?: OnyxEntry; - /** The ID of the current report */ reportID?: string; - - /** The type of the attachment */ type?: ValueOf; /** If the attachment originates from a note, the accountID will represent the author of that note. */ accountID?: number; - /** The data is loading or not */ isLoading?: boolean; /** Denotes whether it is a workspace avatar or not */ @@ -85,34 +80,18 @@ type AttachmentModalBaseContentProps = { /** Denotes whether it can be an icon (ex: SVG) */ maybeIcon?: boolean; - /** Whether to display not found page */ shouldShowNotFoundPage?: boolean; - - /** Whether to show an attachment carousel */ shouldShowCarousel?: boolean; - - /** Whether to show download button */ shouldShowDownloadButton?: boolean; - - /** Whether to show rotate button */ shouldShowRotateButton?: boolean; - - /** Callback triggered when the rotate button is pressed */ onRotateButtonPress?: () => void; /** Whether we should show a loading indicator replacing the rotate button */ isRotating?: boolean; - /** Whether to disable send button */ shouldDisableSendButton?: boolean; - - /** Whether to display help button */ shouldDisplayHelpButton?: boolean; - - /** Whether to minimize menu button */ shouldMinimizeMenuButton?: boolean; - - /** The link of the attachment */ attachmentLink?: string; /** Ref to the submit button */ @@ -136,7 +115,6 @@ type AttachmentModalBaseContentProps = { /** Set when `onConfirm` navigates off the current screen, so the modal sequences its close with that transition instead of flashing the screen underneath. */ confirmLeavesScreen?: boolean; - /** Callback triggered when the modal is closed */ onClose?: (options?: AttachmentModalOnCloseOptions) => void; /** Optional callback to fire when we want to do something after attachment carousel changes. */ diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 220fb3049c43..04a8d04556dc 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ReceiptCropView from '@components/ReceiptCropView'; import type {CropRect} from '@components/ReceiptCropView'; @@ -79,6 +79,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const policy = usePolicy(report?.policyID); const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; @@ -163,8 +164,8 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const [sourceUri, setSourceUri] = useState(''); const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}); - const canDeleteReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, isDeleteAction: true, transaction}); + const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, rules}); + const canDeleteReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, isDeleteAction: true, transaction, rules}); const receiptFilename = transaction?.receipt?.filename; const isStitchedOdometerReceipt = isOdometerDistanceRequest(transaction) && !imageType; diff --git a/src/pages/media/AttachmentModalScreen/types.ts b/src/pages/media/AttachmentModalScreen/types.ts index 97fc2e8e8741..ca5f2047cf52 100644 --- a/src/pages/media/AttachmentModalScreen/types.ts +++ b/src/pages/media/AttachmentModalScreen/types.ts @@ -7,7 +7,6 @@ import type ModalType from '@src/types/utils/ModalType'; import type {AttachmentModalBaseContentProps} from './AttachmentModalBaseContent/types'; type AttachmentModalContainerModalProps = { - /** The type of the modal */ modalType?: ModalType; /** Callback to fire when the modal is shown */ @@ -16,7 +15,6 @@ type AttachmentModalContainerModalProps = { /** Callback to fire when the modal is closed */ onClose?: () => void; - /** Whether to handle navigation back */ shouldHandleNavigationBack?: boolean; }; diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx index ffb869f343f4..0c024b54d1c6 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/AddAgentPage.tsx @@ -1,5 +1,6 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues, FormRef} from '@components/Form/types'; @@ -11,17 +12,18 @@ import TextInput from '@components/TextInput'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {buildFileFromAvatarCropResult} from '@libs/AvatarCropUtils'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; -import {isMobile} from '@libs/Browser'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -43,6 +45,9 @@ import type {TextInputKeyPressEvent} from 'react-native'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; +import {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from './const'; +import scrollToMultilineInput from './scrollToMultilineInput'; + type AddAgentPageProps = PlatformStackScreenProps; type AddAgentPageContentProps = { @@ -54,11 +59,14 @@ type AddAgentPageContentProps = { }; function AddAgentPageContent({route, template}: AddAgentPageContentProps) { + const StyleUtils = useStyleUtils(); const policyID = route.params?.policyID; const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); - const shouldUseScrollableLayout = useIsInLandscapeMode() || (isMobile() && windowWidth > windowHeight); + const {isKeyboardActive} = useKeyboardState(); + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const {accountID: ownerAccountID, login: ownerLogin, displayName} = useCurrentUserPersonalDetails(); const defaultAgentName = template?.name ?? (displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined); const defaultPrompt = template?.prompt ?? translate('addAgentPage.defaultPrompt'); @@ -153,6 +161,9 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { Navigation.navigate(ROUTES.AGENT_REPORT.getRoute(optimisticReportID), {forceReplace: true}); }; + const promptTopOffsetRef = useRef(0); + const handleInputFocus = () => scrollToMultilineInput(formRef, isInLandscapeMode, promptTopOffsetRef.current); + const agentAvatar = avatarSource ? ( - Navigation.goBack(ROUTES.SETTINGS_AGENTS_NEW.getRoute(policyID ? {policyID} : undefined))} - /> + + Navigation.goBack(ROUTES.SETTINGS_AGENTS_NEW.getRoute(policyID ? {policyID} : undefined))} + /> + - + { + promptTopOffsetRef.current = event.nativeEvent.layout.y; + }} + > {`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`} diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index 976b27b6171f..b46fb66312e2 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; @@ -20,7 +20,6 @@ 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 usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; @@ -34,8 +33,6 @@ import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; import {getLatestError} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; - import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@userActions/Agent'; import CONST from '@src/CONST'; @@ -56,8 +53,6 @@ function AgentsPage() { const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Trashcan']); const chatWithAgent = useChatWithAgent(); const switchToDelegator = useSwitchToDelegator(); - const {isBetaEnabled} = usePermissions(); - const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const {showConfirmModal} = useConfirmModal(); const showRuleBotGuardModal = useRuleBotGuardModal(); const [selectedAgents, setSelectedAgents] = useState([]); @@ -70,11 +65,8 @@ function AgentsPage() { const canSelectMultiple = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true; useEffect(() => { - if (!isCustomAgentEnabled) { - return; - } openAgentsPage(); - }, [isCustomAgentEnabled]); + }, []); const handleErrorClose = (pendingAction: PendingAction | null | undefined, accountID: number) => { if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { @@ -249,10 +241,6 @@ function AgentsPage() { ); - if (!isCustomAgentEnabled) { - return ; - } - return ( clearAgentNameUpdateError(accountID)} > - ; function EditPromptPage({route}: EditPromptPageProps) { + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const styles = useThemeStyles(); - const shouldUseScrollableLayout = useIsInLandscapeMode(); + const {isKeyboardActive} = useKeyboardState(); + const isInLandscapeMode = useIsInLandscapeMode(); + const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const accountID = route.params.accountID; const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const formRef = useRef(null); + const promptTopOffsetRef = useRef(0); + const scrollToInput = () => scrollToMultilineInput(formRef, isInLandscapeMode, promptTopOffsetRef.current); const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; @@ -72,44 +84,53 @@ function EditPromptPage({route}: EditPromptPageProps) { testID={EditPromptPage.displayName} includeSafeAreaPaddingBottom offlineIndicatorStyle={styles.mtAuto} - shouldEnableMaxHeight={shouldUseScrollableLayout} > - Navigation.goBack()} - /> + + Navigation.goBack()} + /> + - - + + { + promptTopOffsetRef.current = event.nativeEvent.layout.y; + }} + > + + + {translate('workspace.rules.agentRules.disclaimer')} - {translate('workspace.rules.agentRules.disclaimer')} ); diff --git a/src/pages/settings/Agents/NewAgentPage.tsx b/src/pages/settings/Agents/NewAgentPage.tsx index b6f3e5280bfc..cc36f4ffef38 100644 --- a/src/pages/settings/Agents/NewAgentPage.tsx +++ b/src/pages/settings/Agents/NewAgentPage.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/settings/Agents/const/getPromptMaxHeightOnKeyboardOpenLandscapeMode.ts b/src/pages/settings/Agents/const/getPromptMaxHeightOnKeyboardOpenLandscapeMode.ts new file mode 100644 index 000000000000..68af21c18925 --- /dev/null +++ b/src/pages/settings/Agents/const/getPromptMaxHeightOnKeyboardOpenLandscapeMode.ts @@ -0,0 +1,7 @@ +import variables from '@styles/variables'; + +function getPromptMaxHeightOnKeyboardOpenLandscapeMode(maxPromptLinesWithKeyboard: number) { + return variables.componentSizeLarge + variables.lineHeightXLarge * (maxPromptLinesWithKeyboard - 1); +} + +export default getPromptMaxHeightOnKeyboardOpenLandscapeMode; diff --git a/src/pages/settings/Agents/const/index.ios.ts b/src/pages/settings/Agents/const/index.ios.ts new file mode 100644 index 000000000000..6e7180062c4a --- /dev/null +++ b/src/pages/settings/Agents/const/index.ios.ts @@ -0,0 +1,8 @@ +import getPromptMaxHeightOnKeyboardOpenLandscapeMode from './getPromptMaxHeightOnKeyboardOpenLandscapeMode'; + +// The iOS keyboard is smaller than the Android keyboard, so we want to show one more line compared to the Android version. +const MAX_PROMPT_LINES_WITH_KEYBOARD = 3; +const PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE = getPromptMaxHeightOnKeyboardOpenLandscapeMode(MAX_PROMPT_LINES_WITH_KEYBOARD); + +// eslint-disable-next-line import/prefer-default-export +export {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE}; diff --git a/src/pages/settings/Agents/const/index.ts b/src/pages/settings/Agents/const/index.ts new file mode 100644 index 000000000000..ff8f80e29b72 --- /dev/null +++ b/src/pages/settings/Agents/const/index.ts @@ -0,0 +1,7 @@ +import getPromptMaxHeightOnKeyboardOpenLandscapeMode from './getPromptMaxHeightOnKeyboardOpenLandscapeMode'; + +const MAX_PROMPT_LINES_WITH_KEYBOARD = 2; +const PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE = getPromptMaxHeightOnKeyboardOpenLandscapeMode(MAX_PROMPT_LINES_WITH_KEYBOARD); + +// eslint-disable-next-line import/prefer-default-export +export {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE}; diff --git a/src/pages/settings/Agents/scrollToMultilineInput/index.ios.ts b/src/pages/settings/Agents/scrollToMultilineInput/index.ios.ts new file mode 100644 index 000000000000..5a78415570a2 --- /dev/null +++ b/src/pages/settings/Agents/scrollToMultilineInput/index.ios.ts @@ -0,0 +1,12 @@ +import type {FormRef} from '@components/Form/types'; + +import type {RefObject} from 'react'; + +function scrollToMultilineInput(formRef: RefObject, shouldScrollToMultilineInput: boolean, inputTopOffset: number) { + if (!shouldScrollToMultilineInput || !formRef.current) { + return; + } + formRef.current.scrollTo(inputTopOffset); +} + +export default scrollToMultilineInput; diff --git a/src/pages/settings/Agents/scrollToMultilineInput/index.ts b/src/pages/settings/Agents/scrollToMultilineInput/index.ts new file mode 100644 index 000000000000..029ee78e39e0 --- /dev/null +++ b/src/pages/settings/Agents/scrollToMultilineInput/index.ts @@ -0,0 +1,9 @@ +import type {FormRef} from '@components/Form/types'; + +import type {RefObject} from 'react'; + +// This function is only needed on iOS where there is no auto scroll to the multiline text input on focus. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function scrollToMultilineInput(formRef: RefObject, shouldScrollToMultilineInput: boolean, inputTopOffset: number) {} + +export default scrollToMultilineInput; diff --git a/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx index 0fa4e5834c37..a81ee55ce16a 100644 --- a/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx +++ b/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -94,16 +95,15 @@ function DynamicExitSurveyConfirmPage() { )} - + ); diff --git a/src/pages/settings/ExitSurvey/DynamicExitSurveyReasonPage.tsx b/src/pages/settings/ExitSurvey/DynamicExitSurveyReasonPage.tsx index 294256ab2c13..07fc4c68e458 100644 --- a/src/pages/settings/ExitSurvey/DynamicExitSurveyReasonPage.tsx +++ b/src/pages/settings/ExitSurvey/DynamicExitSurveyReasonPage.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FixedFooter from '@components/FixedFooter'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -150,23 +151,21 @@ function DynamicExitSurveyReasonPage() { )} - - + ); diff --git a/src/pages/settings/Profile/AgentAIPromptSection.tsx b/src/pages/settings/Profile/AgentAIPromptSection.tsx index 192515434a47..c82661e8d2c6 100644 --- a/src/pages/settings/Profile/AgentAIPromptSection.tsx +++ b/src/pages/settings/Profile/AgentAIPromptSection.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ErrorMessageRow from '@components/ErrorMessageRow'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Section from '@components/Section'; diff --git a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx index 4e5c8be94263..57b7c03a44bf 100644 --- a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx @@ -1,6 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import UserAvatar from '@components/Avatar/UserAvatar'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import UserInitialsAvatar from '@components/UserInitialsAvatar'; @@ -33,9 +33,7 @@ type AvatarPreviewProps = { isRemoved: boolean; /** Callback when the current avatar photo is removed */ onImageRemoved: () => void; - /** The image data */ imageData: ImageData; - /** The function to set the error */ setError: (error: TranslationPaths | null, phraseParam?: Record) => void; /** Opens the avatar crop screen for the picked image */ openCropper: (image: FileObject) => void; diff --git a/src/pages/settings/Profile/Contacts/DynamicContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/DynamicContactMethodsPage.tsx index 91f2f6e2e837..2591ec8269e1 100644 --- a/src/pages/settings/Profile/Contacts/DynamicContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/DynamicContactMethodsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/settings/Profile/Contacts/DynamicNewContactMethodConfirmValidateCodePage.tsx b/src/pages/settings/Profile/Contacts/DynamicNewContactMethodConfirmValidateCodePage.tsx index 69ee7ba41963..3d5f7c26a090 100644 --- a/src/pages/settings/Profile/Contacts/DynamicNewContactMethodConfirmValidateCodePage.tsx +++ b/src/pages/settings/Profile/Contacts/DynamicNewContactMethodConfirmValidateCodePage.tsx @@ -43,6 +43,7 @@ function DynamicNewContactMethodConfirmValidateCodePage() { requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.ADD_CONTACT_METHOD})} + validateCodeReasonCode={COMMON_CONST.VALIDATE_CODE_REASONS.ADD_CONTACT_METHOD} descriptionPrimary={translate('contacts.enterSecurityCode', contactMethod)} validateCodeActionErrorField="addedLogin" validateError={validateCodeError} diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx index 812c1860616e..4319f583bfc6 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DelegatorList from '@components/DelegatorList'; import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; import FixedFooter from '@components/FixedFooter'; diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 46f5a38280e2..223e8df994e4 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -2,7 +2,8 @@ import ActivityIndicator from '@components/ActivityIndicator'; import UserAvatar from '@components/Avatar/UserAvatar'; import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; import AvatarSkeleton from '@components/AvatarSkeleton'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemGroup from '@components/MenuItemGroup'; @@ -205,20 +206,22 @@ function ProfilePage() { testID="ProfilePage" shouldShowOfflineIndicatorInWideScreen > - { - if (route.params?.backTo) { - Navigation.goBack(route.params?.backTo); - return; - } - Navigation.goBack(); - }} - shouldShowBackButton={shouldUseNarrowLayout} - shouldDisplaySearchRouter - shouldDisplayHelpButton - shouldUseHeadlineHeader - /> + + { + if (route.params?.backTo) { + Navigation.goBack(route.params?.backTo); + return; + } + Navigation.goBack(); + }} + shouldShowBackButton={shouldUseNarrowLayout} + shouldDisplaySearchRouter + shouldDisplayHelpButton + shouldUseHeadlineHeader + /> + - Navigation.navigate(ROUTES.SETTINGS_TIMEZONE_SELECT)} + isDisabled={!!timezone.automatic} + value={timezone.selected} /> diff --git a/src/pages/settings/Report/DynamicReportSettingsPage.tsx b/src/pages/settings/Report/DynamicReportSettingsPage.tsx index a2fce3a8195a..e250f12273c9 100644 --- a/src/pages/settings/Report/DynamicReportSettingsPage.tsx +++ b/src/pages/settings/Report/DynamicReportSettingsPage.tsx @@ -1,6 +1,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -73,52 +73,49 @@ function DynamicReportSettingsPage({report, policy}: DynamicReportSettingsPagePr /> {shouldShowNotificationPref && ( - { if (!reportID) { return; } Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NOTIFICATION_PREFERENCES.getRoute(reportID))); }} + value={notificationPreference} /> )} - {shouldShowWriteCapability && - (shouldAllowWriteCapabilityEditing ? ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.path))} - /> - ) : ( - - - {translate('writeCapabilityPage.label')} - - - {writeCapabilityText} - - - ))} + {shouldShowWriteCapability && shouldAllowWriteCapabilityEditing && ( + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.path))} + value={writeCapabilityText} + /> + )} + {shouldShowWriteCapability && !shouldAllowWriteCapabilityEditing && ( + + + {translate('writeCapabilityPage.label')} + + + {writeCapabilityText} + + + )} {!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE && (shouldAllowChangeVisibility ? ( - { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_VISIBILITY.path)); }} + value={translate(`newRoomPage.visibilityOptions.${report.visibility}`)} /> ) : ( diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 5f8a85091d6f..84fd8918fc7d 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -40,6 +40,14 @@ const REPORT_DETAILS_DEFAULT_COLUMNS: SearchCustomColumnIds[] = [ CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, ]; +const REPORT_DETAILS_CUSTOM_COLUMNS = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS); + +function isReportDetailsCustomColumn(column: string): column is SearchCustomColumnIds { + return REPORT_DETAILS_CUSTOM_COLUMNS.some((customColumn) => customColumn === column); +} + +const ALL_REPORT_DETAILS_CUSTOM_COLUMNS = REPORT_DETAILS_CUSTOM_COLUMNS.filter(isReportDetailsCustomColumn); + function ReportDetailsColumnsPage() { const route = useRoute>(); const reportID = route.params.reportID; @@ -58,8 +66,6 @@ function ReportDetailsColumnsPage() { }); const currentUserDetails = useCurrentUserPersonalDetails(); - const allTypeCustomColumns = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS) as SearchCustomColumnIds[]; - // Wait for transactions to load before rendering. ColumnsSettingsList snapshots // currentColumns in useState on mount and does not sync prop updates, so we must // pass the final value on first render. @@ -69,7 +75,7 @@ function ReportDetailsColumnsPage() { // return for this report so data-driven columns (e.g. Exchange rate, Original amount, // Tax rate, Tax amount) appear pre-selected when they have data on the table. const effectiveColumns = useMemo(() => { - const savedColumns = (reportDetailsColumns ?? []) as SearchCustomColumnIds[]; + const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn); if (savedColumns.length > 0) { return savedColumns; } @@ -91,8 +97,8 @@ function ReportDetailsColumnsPage() { }); // Filter to only columns available in the custom columns list (drops RECEIPT/TYPE/COMMENTS etc.) - return visibleColumns.filter((col) => allTypeCustomColumns.includes(col as SearchCustomColumnIds)) as SearchCustomColumnIds[]; - }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy, allTypeCustomColumns]); + return visibleColumns.filter(isReportDetailsCustomColumn); + }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy]); const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]); @@ -111,7 +117,7 @@ function ReportDetailsColumnsPage() { return ( `${getKeyForRule(rule)}-${index}`; @@ -50,7 +51,7 @@ function ExpenseRulesPage() { const [expenseRules = getEmptyArray(), expenseRulesResult] = useOnyx(ONYXKEYS.NVP_EXPENSE_RULES); const [selectedRules, setSelectedRules] = useState([]); - const [deleteConfirmModalVisible, setDeleteConfirmModalVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); useEffect(() => { // Clear selection when rule is changed as hash is outdated @@ -58,6 +59,13 @@ function ExpenseRulesPage() { setSelectedRules([]); }, [expenseRules]); + // The confirmation modal is global, so its promise resolves in a closure holding the rules from the render that opened it. + // This ref exposes the collection as it is at confirmation time so that snapshot can be checked for staleness. + const latestExpenseRulesRef = useRef(expenseRules); + useEffect(() => { + latestExpenseRulesRef.current = expenseRules; + }, [expenseRules]); + const hasRules = expenseRules.filter((rule) => isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 0; const isLoading = !hasRules && isLoadingOnyxValue(expenseRulesResult); @@ -91,15 +99,43 @@ function ExpenseRulesPage() { Navigation.navigate(ROUTES.SETTINGS_RULES_EDIT.getRoute(hash)); }; - const handleDeleteRules = () => { - if (selectedRules.length > 0) { - deleteExpenseRules(expenseRules, selectedRules, getKeyForRule); + const handleDeleteRules = (rulesToDelete: string[]) => { + if (rulesToDelete.length > 0) { + deleteExpenseRules(expenseRules, rulesToDelete, getKeyForRule); } - setDeleteConfirmModalVisible(false); turnOffMobileSelectionMode(); setSelectedRules([]); }; + const askForConfirmationToDelete = () => { + const isSingleRule = selectedRules.length === 1; + const rulesToDelete = selectedRules; + const expenseRulesWhenShown = expenseRules; + + showConfirmModal({ + title: translate(isSingleRule ? 'expenseRulesPage.deleteRule.deleteSingle' : 'expenseRulesPage.deleteRule.deleteMultiple'), + prompt: translate(isSingleRule ? 'expenseRulesPage.deleteRule.deleteSinglePrompt' : 'expenseRulesPage.deleteRule.deleteMultiplePrompt'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + + // `deleteExpenseRules` rewrites the whole rule collection from the array it is given, so applying a snapshot + // taken before the rules changed would revive deleted rules and drop ones added while the modal was open. + // The keys are index based, so they cannot be remapped onto the new collection — drop the deletion instead, + // which is what happened before the modal became global and the effect above cleared the selection first. + if (latestExpenseRulesRef.current !== expenseRulesWhenShown) { + handleDeleteRules([]); + return; + } + + handleDeleteRules(rulesToDelete); + }); + }; + const personalExpenseRules: PersonalExpenseRuleRowData[] = expenseRules .map((rule, index) => ({ keyForList: getKeyForList(rule, index), @@ -114,22 +150,24 @@ function ExpenseRulesPage() { .filter((rule) => isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const headerDropdownOptions: Array>> = [ + ...(selectedRules.length === 1 + ? [ + { + icon: icons.Pencil, + text: translate('expenseRulesPage.editRule.title'), + value: CONST.EXPENSE_RULES.BULK_ACTION_TYPES.EDIT, + onSelected: () => navigateToEditRulePage(selectedRules.at(0)), + }, + ] + : []), { icon: icons.Trashcan, text: translate(selectedRules.length === 1 ? 'expenseRulesPage.deleteRule.deleteSingle' : 'expenseRulesPage.deleteRule.deleteMultiple'), value: CONST.EXPENSE_RULES.BULK_ACTION_TYPES.DELETE, shouldSkipFocusRestore: true, - onSelected: () => setDeleteConfirmModalVisible(true), + onSelected: askForConfirmationToDelete, }, ]; - if (selectedRules.length === 1) { - headerDropdownOptions.unshift({ - icon: icons.Pencil, - text: translate('expenseRulesPage.editRule.title'), - value: CONST.EXPENSE_RULES.BULK_ACTION_TYPES.EDIT, - onSelected: () => navigateToEditRulePage(selectedRules.at(0)), - }); - } const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -210,17 +248,6 @@ function ExpenseRulesPage() { headerComponent={hasRules ? expenseRulesSubtitle : undefined} /> )} - - setDeleteConfirmModalVisible(false)} - title={translate(selectedRules.length === 1 ? 'expenseRulesPage.deleteRule.deleteSingle' : 'expenseRulesPage.deleteRule.deleteMultiple')} - prompt={translate(selectedRules.length === 1 ? 'expenseRulesPage.deleteRule.deleteSinglePrompt' : 'expenseRulesPage.deleteRule.deleteMultiplePrompt')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - buttonVariant={CONST.BUTTON_VARIANT.DANGER} - /> ); } diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index 9bc3b4205ce2..43177eac89ef 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -6,7 +6,7 @@ import {updateDraftRule} 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 {getCleanedTagName, getTagLists} from '@libs/PolicyUtils'; +import {getCleanedTagName, getTagLists, matchesParentTagPath} from '@libs/PolicyUtils'; import {trimTag} from '@libs/TagUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; @@ -32,6 +32,7 @@ function AddTagPage({route}: AddTagPageProps) { const tagList = policyTags.find((item) => item.orderWeight === orderWeight); const formTags = getTagArrayFromName(form?.tag ?? ''); const formTag = formTags.at(orderWeight); + const parentTagPath = formTags.slice(0, orderWeight).join(':'); const tagItems = useMemo(() => { const tags: Array<{name: string; value: string}> = []; @@ -40,11 +41,14 @@ function AddTagPage({route}: AddTagPageProps) { if (tag.name !== formTag && !tag.enabled) { continue; } + if (!matchesParentTagPath(tag, parentTagPath)) { + continue; + } tags.push({name: getCleanedTagName(tag.name), value: tag.name}); } return tags; - }, [tagList?.tags, formTag]); + }, [tagList?.tags, formTag, parentTagPath]); const selectedTagItem = tagItems.find(({value}) => value === formTag); diff --git a/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx index c9bfbb6b6989..70441310a5da 100644 --- a/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx @@ -24,7 +24,7 @@ import {View} from 'react-native'; function AddDelegatePage() { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const existingDelegates = @@ -140,7 +140,7 @@ function AddDelegatePage() { label: translate('selectionList.nameEmailOrPhoneNumber'), }} shouldShowLoadingPlaceholder={!areOptionsInitialized} - isLoadingNewOptions={!!isSearchingForReports} + isLoadingNewOptions={!!isSearchingForUsers} shouldShowTextInput /> diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index fcfa27d56137..e74d812f8f66 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -1,14 +1,15 @@ import UserAvatar from '@components/Avatar/UserAvatar'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; +import FormHelpMessage from '@components/FormHelpMessage'; import HeaderPageLayout from '@components/HeaderPageLayout'; import MenuItem from '@components/MenuItem'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -23,6 +24,7 @@ import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; import React from 'react'; +import {View} from 'react-native'; type ConfirmDelegatePageProps = PlatformStackScreenProps; @@ -33,7 +35,6 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { const styles = useThemeStyles(); const login = route.params.login; const role = route.params.role as ValueOf; - const {isOffline} = useNetwork(); const personalDetails = usePersonalDetailByLogin(login); const avatarIcon = personalDetails?.avatar ?? icons.FallbackAvatar; @@ -43,9 +44,8 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { const displayName = formatPhoneNumber(personalDetails?.displayName ?? login ?? ''); const submitButton = ( - + ); return ( @@ -83,13 +83,19 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { - Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role, ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)))} - shouldShowRightIcon - /> + + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role, ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)))} + /> + + ); diff --git a/src/pages/settings/Security/DeviceManagementPage.tsx b/src/pages/settings/Security/DeviceManagementPage.tsx index dd54bd91a912..dafc43df6c9a 100644 --- a/src/pages/settings/Security/DeviceManagementPage.tsx +++ b/src/pages/settings/Security/DeviceManagementPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; diff --git a/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx b/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx index 6a763591eb74..13dba2ee0855 100644 --- a/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx +++ b/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx @@ -1,11 +1,11 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import HeaderPageLayout from '@components/HeaderPageLayout'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import type {LockAccountOnyxKey} from '@userActions/User'; @@ -41,11 +41,10 @@ function LockAccountPageBase({ }: BaseLockAccountComponentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const [isLoading, setIsLoading] = useState(false); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {showConfirmModal} = useConfirmModal(); + const {showConfirmModal, closeModal} = useConfirmModal(); const handleReportSuspiciousActivity = async () => { if (!accountID && !currentUserPersonalDetails.accountID) { @@ -70,21 +69,23 @@ function LockAccountPageBase({ const response = await lockAccount(currentUserPersonalDetails.accountID, accountID, domainAccountID, domainName); setIsLoading(false); + // Passing isConfirmLoading keeps the modal open on confirm, so it has to be closed here. + closeModal(); + handleLockRequestFinish(response); }; const lockAccountButton = ( - + ); return ( diff --git a/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx b/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx index 0e76f0c24641..33ed8c4c1741 100644 --- a/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx +++ b/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx @@ -10,9 +10,9 @@ import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; @@ -45,6 +45,7 @@ function MergeResultPage() { const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, {selector: isTrackingSelector}); const {params} = useRoute>(); const {environmentURL} = useEnvironment(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const {result, login, backTo} = params; const lazyIllustrations = useMemoizedLazyIllustrations(['RunningTurtle', 'LockClosedOrange']); const isLoadingTryNewDot = isLoadingOnyxValue(tryNewDotMetadata); @@ -78,7 +79,7 @@ function MergeResultPage() { html={translate( 'mergeAccountsPage.mergeFailureUncreatedAccountDescription', login, - `${environmentURL}/${createDynamicRoute(DYNAMIC_ROUTES.CONTACT_METHODS.path)}`, + `${environmentURL}/${buildDynamicRoute(DYNAMIC_ROUTES.CONTACT_METHODS.path)}`, )} /> @@ -199,7 +200,18 @@ function MergeResultPage() { illustration: lazyIllustrations.LockClosedOrange, }, }; - }, [login, translate, userEmailOrPhone, styles, isTrackingGPS, environmentURL, lazyIllustrations.LockClosedOrange, lazyIllustrations.RunningTurtle, isClassicRedirectBlocked]); + }, [ + login, + translate, + userEmailOrPhone, + styles, + isTrackingGPS, + environmentURL, + buildDynamicRoute, + lazyIllustrations.LockClosedOrange, + lazyIllustrations.RunningTurtle, + isClassicRedirectBlocked, + ]); useEffect(() => { /** diff --git a/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx b/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx index a175ef2dad3e..0837ecf29436 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import type {BaseTwoFactorAuthFormRef} from '@components/TwoFactorAuthForm/types'; diff --git a/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx index ac39d2650a37..0b4c324e0250 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx @@ -1,5 +1,5 @@ import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; diff --git a/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx index 412ac78155ad..1a6b5f2e8354 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayToggle'; diff --git a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx index f5493b644caa..dd6651ec2c2a 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx @@ -1,6 +1,6 @@ import expensifyLogo from '@assets/images/expensify-logo-round-transparent.png'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayToggle'; import QRCode from '@components/QRCode'; diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx index ef9728166b28..50810e70cb91 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx index 6f512030859d..3492cdd58ca0 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import TwoFactorAuthForm from '@components/TwoFactorAuthForm'; diff --git a/src/pages/settings/Security/TwoFactorAuth/ToggleTwoFactorAuthForm.tsx b/src/pages/settings/Security/TwoFactorAuth/ToggleTwoFactorAuthForm.tsx index 62309f7cb071..11748db43c3b 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ToggleTwoFactorAuthForm.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ToggleTwoFactorAuthForm.tsx @@ -16,8 +16,10 @@ import type {Ref} from 'react'; import React from 'react'; type BaseTwoFactorAuthFormProps = { - // Set this to true in order to call the validateTwoFactorAuth action which is used when setting up 2FA for the first time. - // Set this to false in order to disable 2FA when a valid code is entered. + /** + * Set this to true in order to call the validateTwoFactorAuth action which is used when setting up 2FA for the first time. + * Set this to false in order to disable 2FA when a valid code is entered. + */ validateInsteadOfDisable?: boolean; /** Callback that is called when the text input is focused */ @@ -26,7 +28,6 @@ type BaseTwoFactorAuthFormProps = { /** Whether the form should autofocus on mobile devices */ shouldAutoFocusOnMobile?: boolean; - /** Reference to the outer element */ ref: Ref; }; diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSecretDisplay.tsx b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSecretDisplay.tsx index 9acb9ff4b99a..1c1eee23f23e 100644 --- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSecretDisplay.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSecretDisplay.tsx @@ -20,7 +20,6 @@ type TwoFactorAuthSecretDisplayProps = { /** The contact method (email) for the authenticator URL */ contactMethod: string; - /** The two-factor auth secret key */ secretKey: string; /** Optional description text to show above the QR code */ diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx index 5d458fdf2957..083a4f8eee6b 100644 --- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx @@ -21,7 +21,6 @@ import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; type TwoFactorAuthWrapperProps = ChildrenProps & { - /** Name of the step */ stepName: ValueOf; /** Title of the Header */ @@ -33,10 +32,7 @@ type TwoFactorAuthWrapperProps = ChildrenProps & { /** Method to trigger when pressing back button of the header */ onBackButtonPress?: () => void; - /** Flag to indicate if the keyboard avoiding view should be enabled */ shouldEnableKeyboardAvoidingView?: boolean; - - /** Flag to indicate if max height should be enabled */ shouldEnableMaxHeight?: boolean; }; diff --git a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx index 433d1aeb2afb..8487ec04373f 100644 --- a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx +++ b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FeedbackSurvey from '@components/FeedbackSurvey'; import FixedFooter from '@components/FixedFooter'; diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index 75035bdf15cf..92e5477df3be 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -19,34 +19,24 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; type BillingBannerProps = { - /** The title of the banner. */ title: string | React.ReactNode; - - /** The subtitle of the banner. */ subtitle: string | React.ReactNode; /** The icon to display in the banner. */ icon: IconAsset; - /** The type of brick road indicator to show. */ brickRoadIndicator?: ValueOf; /** Styles to apply to the container. */ style?: StyleProp; - /** Styles to apply to the title. */ titleStyle?: StyleProp; - - /** Styles to apply to the subtitle. */ subtitleStyle?: StyleProp; /** An icon to be rendered instead of the RBR / GBR indicator. */ rightIcon?: IconAsset; - /** Callback to be called when the right icon is pressed. */ onRightIconPress?: () => void; - - /** Accessibility label for the right icon. */ rightIconAccessibilityLabel?: string; /** Sentry label for the right icon button. Defaults to `CONST.SENTRY_LABEL.BILLING_BANNER.RIGHT_ICON`. */ diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx index 3661ae06ac6a..3b735c599d8f 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import RenderHTML from '@components/RenderHTML'; @@ -28,10 +28,7 @@ type EarlyDiscountBannerProps = { /** Whether the banner is being displayed on the subscription page. */ isSubscriptionPage: boolean; - /** The Onboarding help dropdown button to display */ onboardingHelpDropdownButton?: React.JSX.Element; - - /** Function to trigger when the discount banner is dismissed */ onDismissedDiscountBanner?: () => void; /** Has user active Schedule call with guide */ diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx index 6ae01456ff69..83377c474859 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx @@ -14,7 +14,6 @@ type SubscriptionBillingBannerProps = Omit; diff --git a/src/pages/settings/Subscription/CardSection/CardSectionDataEmpty/index.tsx b/src/pages/settings/Subscription/CardSection/CardSectionDataEmpty/index.tsx index 5fda22f56c5d..32db971f254e 100644 --- a/src/pages/settings/Subscription/CardSection/CardSectionDataEmpty/index.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSectionDataEmpty/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import ImageSVG from '@components/ImageSVG'; import Text from '@components/Text'; diff --git a/src/pages/settings/Subscription/FreeTrial.tsx b/src/pages/settings/Subscription/FreeTrial.tsx index 1166b5ad9906..a7cd45a56658 100644 --- a/src/pages/settings/Subscription/FreeTrial.tsx +++ b/src/pages/settings/Subscription/FreeTrial.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; diff --git a/src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx b/src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx index 58c5b75a2310..a9ffcc02ed05 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx @@ -18,7 +18,6 @@ import {View} from 'react-native'; import SubscriptionPlanCard from './SubscriptionPlanCard'; type ComparePlansModalProps = { - /** Whether the modal is visible */ isModalVisible: boolean; /** Updates modal visibility */ diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx index b01457e39f1b..7db884f78ff5 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx index 33f9e2e580a4..7dda9eab54c9 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx @@ -30,13 +30,11 @@ import SubscriptionPlanCardActionButton from './SubscriptionPlanCardActionButton type PersonalPolicyTypeExcludedProps = Exclude, 'personal'>; type SubscriptionPlanCardProps = { - /** Subscription plan to display */ subscriptionPlan: PersonalPolicyTypeExcludedProps | null; /** Whether the plan card was rendered inside the comparison modal */ isFromComparisonModal?: boolean; - /** Closes comparison modal */ closeComparisonModal?: () => void; }; diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx index dc8be1de2870..db5cbda97a66 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; @@ -29,19 +29,13 @@ import {View} from 'react-native'; import type {PersonalPolicyTypeExcludedProps} from './SubscriptionPlanCard'; type SubscriptionPlanCardActionButtonProps = { - /** Subscription plan to display */ subscriptionPlan: PersonalPolicyTypeExcludedProps | null; /** Whether the plan card was rendered inside the comparison modal */ isFromComparisonModal: boolean; - /** Whether the plan is currently used */ isSelected: boolean; - - /** Closes comparison modal */ closeComparisonModal?: () => void; - - /** Additional style props */ style?: StyleProp; }; diff --git a/src/pages/settings/Subscription/SubscriptionPlan/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx index d67678c847c5..028b69492c2e 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import Section from '@components/Section'; import Text from '@components/Text'; diff --git a/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx b/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx index 5ddfdfd02d4e..bfbe1fefba8f 100644 --- a/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx +++ b/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx @@ -18,6 +18,7 @@ import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import {useIsFocused} from '@react-navigation/core'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -39,13 +40,14 @@ function SubscriptionSettingsPage({route}: SubscriptionSettingsPageProps) { const [isAppLoading = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const shouldShowPage = !!subscriptionPlan || (amountOwed ?? 0) > 0; + const isFocused = useIsFocused(); useEffect(() => { - if (shouldShowPage || isAppLoading) { + if (shouldShowPage || isAppLoading || !isFocused) { return; } Navigation.removeScreenFromNavigationState(SCREENS.SETTINGS.SUBSCRIPTION.ROOT); - }, [isAppLoading, shouldShowPage]); + }, [isAppLoading, shouldShowPage, isFocused]); if (!shouldShowPage && isAppLoading) { return ; diff --git a/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx b/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx index c99d8e55b5a3..6b919459f9c6 100644 --- a/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx +++ b/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx @@ -1,6 +1,6 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -32,15 +32,13 @@ function Confirmation({onNext}: ConfirmationProps) { return ( {translate('subscription.subscriptionSize.confirmDetails')} - - + + + + + ); +} + +DynamicBetaOverridesPage.displayName = 'DynamicBetaOverridesPage'; + +export default DynamicBetaOverridesPage; diff --git a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx index 35204b8d59e0..5b13036a8cc4 100644 --- a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx +++ b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx @@ -63,7 +63,7 @@ function TroubleshootPage() { const troubleshootIllustration = useTroubleshootSectionIllustration(); const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isProduction, isDevelopment} = useEnvironment(); + const {isDevelopment} = useEnvironment(); const {shouldUseNarrowLayout} = useResponsiveLayout(); useDocumentTitle(translate('initialSettingsPage.aboutPage.troubleshoot')); const [isLoading, setIsLoading] = useState(false); @@ -233,11 +233,9 @@ function TroubleshootPage() { menuItems={menuItems} shouldUseSingleExecution /> - {!isProduction && ( - - - - )} + + + {isDevelopment && ( diff --git a/src/pages/settings/VerifyAccountPageBase.tsx b/src/pages/settings/VerifyAccountPageBase.tsx index 2be084db871a..0859bb746256 100644 --- a/src/pages/settings/VerifyAccountPageBase.tsx +++ b/src/pages/settings/VerifyAccountPageBase.tsx @@ -110,6 +110,7 @@ function VerifyAccountPageBase({navigateBackTo, navigateForwardTo, handleClose, descriptionPrimary={translate('contacts.featureRequiresValidate')} descriptionSecondary={translate('contacts.enterSecurityCode', contactMethod)} sendValidateCode={sendValidateCode} + validateCodeReasonCode={COMMON_CONST.VALIDATE_CODE_REASONS.VALIDATE_ACCOUNT} validateCodeActionErrorField="validateLogin" validatePendingAction={loginData?.pendingFields?.validateCodeSent} handleSubmitForm={handleSubmitForm} diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx b/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx index d3e53ae9d030..d692f5329d87 100644 --- a/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx +++ b/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import IllustratedHeaderPageLayout from '@components/IllustratedHeaderPageLayout'; import LottieAnimations from '@components/LottieAnimations'; import Text from '@components/Text'; @@ -6,7 +7,6 @@ import ValidateCodeInput from '@components/ValidateCodeInput'; import type {ValidateCodeInputHandle} from '@components/ValidateCodeInput'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useNonPersonalCardList from '@hooks/useNonPersonalCardList'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -43,7 +43,6 @@ function ActivatePhysicalCardPageBase({cardID = '', navigateBackTo, isFromDomain const styles = useThemeStyles(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const cardList = useNonPersonalCardList() ?? getEmptyObject(); const [formError, setFormError] = useState(''); @@ -134,9 +133,8 @@ function ActivatePhysicalCardPageBase({cardID = '', navigateBackTo, isFromDomain ref={activateCardCodeInputRef} /> - + ); } diff --git a/src/pages/settings/Wallet/AddCardToDigitalWalletPage.tsx b/src/pages/settings/Wallet/AddCardToDigitalWalletPage.tsx new file mode 100644 index 000000000000..685af8492ac5 --- /dev/null +++ b/src/pages/settings/Wallet/AddCardToDigitalWalletPage.tsx @@ -0,0 +1,220 @@ +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; +import ConfirmationPage from '@components/ConfirmationPage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ImageSVG from '@components/ImageSVG'; +import LoadingIndicator from '@components/LoadingIndicator'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import ValidateCodeActionContent from '@components/ValidateCodeActionModal/ValidateCodeActionContent'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePrimaryContactMethod from '@hooks/usePrimaryContactMethod'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {approveDigitalWalletCardAddition, clearCardListErrors, getExpensifyCardPendingWalletApproval} from '@libs/actions/Card'; +import {requestValidateCodeAction} from '@libs/actions/User'; +import {getWalletProviderNameKey, isCardPendingDigitalWalletApproval} from '@libs/CardUtils'; +import {getLatestErrorMessageField} 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 NotFoundPage from '@pages/ErrorPage/NotFoundPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +type AddCardToDigitalWalletPageProps = PlatformStackScreenProps; + +type SubmittedWalletRequest = { + isApproved: boolean; + walletNameKey: ReturnType; +}; + +function AddCardToDigitalWalletPage({ + route: { + params: {cardID}, + }, +}: AddCardToDigitalWalletPageProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['CardIntoWallet', 'ThumbsUpStars', 'CardDenied']); + const primaryLogin = usePrimaryContactMethod(); + + const [card, cardMetadata] = useOnyx(ONYXKEYS.CARD_LIST, {selector: (cardList) => cardList?.[cardID]}); + const [isCheckingPendingApproval] = useOnyx(ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL); + const currentCardID = card?.cardID; + const latestError = getLatestErrorMessageField(card); + + const [isVerifying, setIsVerifying] = useState(false); + const [submittedRequest, setSubmittedRequest] = useState(); + + const pendingApproval = card?.nameValuePairs?.pendingDigitalWalletApproval; + + // Last four digits the cardholder confirmed, or the card's own last four + const lastFourDigits = pendingApproval?.cardLastFourDigits ?? card?.lastFourPAN ?? ''; + + const currentWalletNameKey = getWalletProviderNameKey(pendingApproval?.walletProvider); + const walletName = translate(`addCardToDigitalWallet.${submittedRequest?.walletNameKey ?? currentWalletNameKey}`); + + // The backend drops the pending approval after the request is resolved, so a still-pending card means it failed + const hasPendingApproval = isCardPendingDigitalWalletApproval(card); + const requestStatus = (() => { + if (!submittedRequest) { + return 'idle'; + } + if (card?.isLoading) { + return 'submitting'; + } + return hasPendingApproval ? 'failed' : 'resolved'; + })(); + + useEffect(() => { + if (isCheckingPendingApproval !== undefined) { + return; + } + getExpensifyCardPendingWalletApproval(); + }, [isCheckingPendingApproval]); + + useEffect(() => { + if (!currentCardID) { + return; + } + clearCardListErrors(currentCardID); + return () => clearCardListErrors(currentCardID); + }, [currentCardID]); + + const isWaitingForPendingApproval = !isOffline && isCheckingPendingApproval !== false && !hasPendingApproval && !submittedRequest; + + if (isWaitingForPendingApproval || (!card && isLoadingOnyxValue(cardMetadata))) { + return ; + } + + if (!card || (!hasPendingApproval && !submittedRequest)) { + return ; + } + + const denyRequest = () => { + setSubmittedRequest({isApproved: false, walletNameKey: currentWalletNameKey}); + approveDigitalWalletCardAddition(card.cardID, false); + }; + + const confirmRequest = (validateCode: string) => { + setSubmittedRequest({isApproved: true, walletNameKey: currentWalletNameKey}); + approveDigitalWalletCardAddition(card.cardID, true, validateCode); + }; + + if (isVerifying && requestStatus !== 'resolved') { + return ( + requestValidateCodeAction({reasonCode: CONST.EXPENSIFY_CARD.APPROVE_DIGITAL_WALLET_VALIDATE_CODE_REASON, reasonCardID: card.cardID})} + validateCodeReasonCode={CONST.EXPENSIFY_CARD.APPROVE_DIGITAL_WALLET_VALIDATE_CODE_REASON} + validateError={latestError} + clearError={() => clearCardListErrors(card.cardID)} + onClose={() => setIsVerifying(false)} + /> + ); + } + + if (requestStatus === 'resolved') { + const isSuccess = !!submittedRequest?.isApproved; + + return ( + + + Navigation.goBack()} + /> + + ); + } + + return ( + + + + + + + + {translate('addCardToDigitalWallet.confirmHeading')} + {translate('addCardToDigitalWallet.confirmDescription', {walletName, lastFourDigits})} + + + clearCardListErrors(card.cardID)} + > + {requestStatus === 'submitting' ? ( + + + + ) : ( + + + {translate('addCardToDigitalWallet.deny')} + + setIsVerifying(true)} + > + {translate('addCardToDigitalWallet.confirm')} + + + )} + + + + + ); +} + +export default AddCardToDigitalWalletPage; diff --git a/src/pages/settings/Wallet/CardDetailsActionButtons.tsx b/src/pages/settings/Wallet/CardDetailsActionButtons.tsx index cc5a27bc1300..a23ff3b33812 100644 --- a/src/pages/settings/Wallet/CardDetailsActionButtons.tsx +++ b/src/pages/settings/Wallet/CardDetailsActionButtons.tsx @@ -1,8 +1,8 @@ -import Button from '@components/ButtonComposed'; -import type {ButtonProps} from '@components/ButtonComposed'; -import ButtonIcon from '@components/ButtonComposed/primitives/ButtonIcon'; -import ButtonKeyboardShortcut from '@components/ButtonComposed/primitives/ButtonKeyboardShortcut'; -import ButtonText from '@components/ButtonComposed/primitives/ButtonText'; +import Button from '@components/Button'; +import type {ButtonProps} from '@components/Button'; +import ButtonIcon from '@components/Button/primitives/ButtonIcon'; +import ButtonKeyboardShortcut from '@components/Button/primitives/ButtonKeyboardShortcut'; +import ButtonText from '@components/Button/primitives/ButtonText'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx b/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx index 558e9f37c7de..26c45ca7b0b1 100644 --- a/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx +++ b/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx @@ -26,7 +26,7 @@ import type {AccountData, BankAccount} from '@src/types/onyx'; import type {BankName} from '@src/types/onyx/Bank'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; type BankAccountListItem = ListItem & { @@ -64,6 +64,10 @@ function ChooseTransferAccountPage() { const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const icons = useMemoizedLazyExpensifyIcons(['Plus']); const selectedAccountID = walletTransfer?.selectedAccountID; + + const [draftAccountID, setDraftAccountID] = useState(); + const currentSelectedAccountID = draftAccountID ?? selectedAccountID; + const bankAccountOptions = useMemo(() => { const options = Object.values(bankAccountsList ?? {}).map((bankAccount, index): BankAccountListItem => { const bankName = (bankAccount.accountData?.additionalData?.bankName ?? '') as BankName; @@ -85,12 +89,12 @@ function ChooseTransferAccountPage() { ) : null, alternateText: `${translate('workspace.expensifyCard.accountEndingIn')} ${getLastFourDigits(bankAccountNumber)}`, keyForList: `${bankAccountID}-${index}`, - isSelected: bankAccountID?.toString() === selectedAccountID, + isSelected: bankAccountID?.toString() === currentSelectedAccountID, bankAccount, }; }); return options; - }, [bankAccountsList, selectedAccountID, styles, translate]); + }, [bankAccountsList, currentSelectedAccountID, styles, translate]); const initiallyFocusedItemKey = useMemo(() => { if (!selectedAccountID) { @@ -100,12 +104,27 @@ function ChooseTransferAccountPage() { return selectedOption?.keyForList; }, [bankAccountOptions, selectedAccountID]); + const saveTransferAccount = () => { + const selectedOption = bankAccountOptions.find((option) => option.value?.toString() === currentSelectedAccountID); + selectAccountAndNavigateBack(selectedOption?.bankAccount?.accountType, selectedOption?.bankAccount?.accountData); + }; + + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: saveTransferAccount, + isDisabled: !currentSelectedAccountID || currentSelectedAccountID === selectedAccountID, + }; + if (isLoadingOnyxValue(walletTransferResult)) { return ; } return ( - + Navigation.goBack(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE)} @@ -114,14 +133,12 @@ function ChooseTransferAccountPage() { { - const accountType = value?.bankAccount?.accountType; - const accountData = value?.bankAccount?.accountData; - selectAccountAndNavigateBack(accountType, accountData); - }} + onSelectRow={(value) => setDraftAccountID(value?.value?.toString())} + confirmButtonOptions={confirmButtonOptions} shouldSingleExecuteRowSelect shouldUpdateFocusedIndex initiallyFocusedItemKey={initiallyFocusedItemKey} + addBottomSafeAreaPadding listFooterContent={ ; +type EnableGlobalReimbursementsAgreementsPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS | typeof SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS +>; const inputIDs = { provideTruthfulInformation: INPUT_IDS.PROVIDE_TRUTHFUL_INFORMATION, @@ -24,8 +27,10 @@ const inputIDs = { }; function EnableGlobalReimbursementsAgreementsPage({route}: EnableGlobalReimbursementsAgreementsPageProps) { + const {getBusinessRoute, getSignRoute, isDynamic} = useEnableGlobalReimbursementsNavigation(); const bankAccountID = route.params?.bankAccountID; - const [currency = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]?.bankCurrency}); + const [bankAccountCurrency = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]?.bankCurrency}); + const currency = route.params?.bankCurrency ?? bankAccountCurrency; const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const defaultValues: Record = Object.fromEntries( Object.keys(inputIDs).map((key) => { @@ -33,13 +38,24 @@ function EnableGlobalReimbursementsAgreementsPage({route}: EnableGlobalReimburse return [typedKey, enableGlobalReimbursementsDraft?.[typedKey] ?? false]; }), ) as Record; + const bankStatementDefaultValue = enableGlobalReimbursementsDraft?.[INPUT_IDS.BANK_STATEMENT] ?? []; + + const persistedRouteParams = { + bankCountry: route.params?.bankCountry, + bankCurrency: route.params?.bankCurrency, + }; const goBack = () => { - Navigation.goBack(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM)); + const confirmRoute = getBusinessRoute(Number(bankAccountID), CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM, undefined, persistedRouteParams); + if (isDynamic) { + Navigation.navigate(confirmRoute, {forceReplace: true}); + return; + } + Navigation.goBack(confirmRoute); }; const goToSignPage = () => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute(Number(bankAccountID))); + Navigation.navigate(getSignRoute(Number(bankAccountID), persistedRouteParams), isDynamic ? {forceReplace: true} : undefined); }; return ( @@ -53,6 +69,8 @@ function EnableGlobalReimbursementsAgreementsPage({route}: EnableGlobalReimburse currency={currency} startStepIndex={1} stepNames={CONST.ENABLE_GLOBAL_REIMBURSEMENTS.STEP_INDEX_LIST} + bankStatementInputID={INPUT_IDS.BANK_STATEMENT} + bankStatementDefaultValue={bankStatementDefaultValue} /> ); } diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx index cbe380c0fb25..bb53ce086f8b 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx @@ -1,10 +1,14 @@ import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import useEnableGlobalReimbursementsNavigation from '@hooks/useEnableGlobalReimbursementsNavigation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useRootNavigationState from '@hooks/useRootNavigationState'; import useSubPage from '@hooks/useSubPage'; import {getCorpayOnboardingFields} from '@libs/actions/BankAccounts'; +import getActiveTabName from '@libs/Navigation/helpers/getActiveTabName'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -13,8 +17,8 @@ import {clearErrors} from '@userActions/FormActions'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useEffect} from 'react'; @@ -27,7 +31,10 @@ import Confirmation from './subPages/Confirmation'; import PaymentVolume from './subPages/PaymentVolume'; import RegistrationNumber from './subPages/RegistrationNumber'; -type EnableGlobalReimbursementsBusinessPageProps = PlatformStackScreenProps; +type EnableGlobalReimbursementsBusinessPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS | typeof SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS +>; const pages = [ {pageName: CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER, component: RegistrationNumber}, @@ -41,17 +48,29 @@ function EnableGlobalReimbursementsBusinessPage({route}: EnableGlobalReimburseme const {translate} = useLocalize(); const bankAccountID = route.params?.bankAccountID; const [bankAccount] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]}); - const currency = bankAccount?.bankCurrency ?? ''; - const country = bankAccount?.bankCountry as Country; + const country = (route.params?.bankCountry ?? bankAccount?.bankCountry ?? '') as Country; + const currency = route.params?.bankCurrency ?? bankAccount?.bankCurrency ?? ''; + + const persistedRouteParams = { + bankCountry: country || undefined, + bankCurrency: currency || undefined, + }; + + const {getAgreementsRoute, getBusinessRoute, getRootBackPath, isDynamic} = useEnableGlobalReimbursementsNavigation(); + const topmostFullScreenRoute = useRootNavigationState((state) => state?.routes.findLast((navigationRoute) => isFullScreenName(navigationRoute.name))); + const activeTab = getActiveTabName(topmostFullScreenRoute); + + const buildBusinessRoute = (subPage: string, action?: 'edit') => getBusinessRoute(Number(bankAccountID), subPage, action, persistedRouteParams); const goToAgreementsPage = () => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID))); + Navigation.navigate(getAgreementsRoute(Number(bankAccountID), persistedRouteParams), isDynamic ? {forceReplace: true} : undefined); }; const {CurrentPage, isEditing, pageIndex, prevPage, nextPage, moveTo} = useSubPage({ pages, onFinished: goToAgreementsPage, - buildRoute: (pageName, action) => ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), pageName, action), + buildRoute: (pageName, action) => buildBusinessRoute(pageName, action), + shouldReplaceRoute: isDynamic, }); useEffect(() => { @@ -63,7 +82,12 @@ function EnableGlobalReimbursementsBusinessPage({route}: EnableGlobalReimburseme }, []); const goBackToConfirmStep = () => { - Navigation.goBack(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM)); + const confirmRoute = buildBusinessRoute(CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM); + if (isDynamic) { + Navigation.navigate(confirmRoute, {forceReplace: true}); + return; + } + Navigation.goBack(confirmRoute); }; const handleBackButtonPress = () => { @@ -74,7 +98,22 @@ function EnableGlobalReimbursementsBusinessPage({route}: EnableGlobalReimburseme } if (pageIndex === 0) { - Navigation.goBack(); + if (isDynamic) { + Navigation.goBack(getRootBackPath()); + return; + } + + switch (activeTab) { + case NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR: + Navigation.goBack(getRootBackPath()); + break; + case NAVIGATORS.REPORTS_SPLIT_NAVIGATOR: + Navigation.closeRHPFlow(); + break; + default: + Navigation.goBack(); + break; + } return; } diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx index 1a5eb494fb2e..daef9a705ad9 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx @@ -1,8 +1,10 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import PushRowFieldsStep from '@components/SubStepForms/PushRowFieldsStep'; import useEnableGlobalReimbursementsStepFormSubmit from '@hooks/useEnableGlobalReimbursementsStepFormSubmit'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import getListOptionsFromCorpayPicklist from '@pages/ReimbursementAccount/NonUSD/utils/getListOptionsFromCorpayPicklist'; import type {BusinessInfoSubPageProps} from '@pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/types'; @@ -11,12 +13,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; import React, {useMemo} from 'react'; +import {View} from 'react-native'; const {APPLICANT_TYPE_ID, BUSINESS_CATEGORY} = INPUT_IDS; const STEP_FIELDS = [APPLICANT_TYPE_ID, BUSINESS_CATEGORY]; function BusinessType({onNext, onMove, isEditing}: BusinessInfoSubPageProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const [corpayOnboardingFields] = useOnyx(ONYXKEYS.CORPAY_ONBOARDING_FIELDS); @@ -55,7 +59,11 @@ function BusinessType({onNext, onMove, isEditing}: BusinessInfoSubPageProps) { }); if (corpayOnboardingFields === undefined) { - return null; + return ( + + + + ); } return ( diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx index 938a5cfdb729..501468a1e234 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx @@ -1,5 +1,6 @@ import DocusignFullStep from '@components/SubStepForms/DocusignFullStep'; +import useEnableGlobalReimbursementsNavigation from '@hooks/useEnableGlobalReimbursementsNavigation'; import useOnyx from '@hooks/useOnyx'; import {clearEnableGlobalReimbursementsForUSDBankAccount, enableGlobalReimbursementsForUSDBankAccount} from '@libs/actions/BankAccounts'; @@ -9,27 +10,51 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import React, {useEffect} from 'react'; -type EnableGlobalReimbursementsSignPageProps = PlatformStackScreenProps; +type EnableGlobalReimbursementsSignPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN | typeof SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN +>; function EnableGlobalReimbursementsSignPage({route}: EnableGlobalReimbursementsSignPageProps) { + const {getAgreementsRoute, isDynamic} = useEnableGlobalReimbursementsNavigation(); const bankAccountID = route.params?.bankAccountID; const [bankAccount] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]}); - const currency = bankAccount?.bankCurrency ?? ''; - const country = bankAccount?.bankCountry; + const currency = route.params?.bankCurrency ?? bankAccount?.bankCurrency ?? ''; + const country = route.params?.bankCountry ?? bankAccount?.bankCountry; const [enableGlobalReimbursements] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS); - const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); + const [enableGlobalReimbursementsDraft, enableGlobalReimbursementsDraftMetadata] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); + const isLoadingDraft = isLoadingOnyxValue(enableGlobalReimbursementsDraftMetadata); const defaultValue = enableGlobalReimbursementsDraft?.[INPUT_IDS.ACH_AUTHORIZATION_FORM] ?? []; + const bankStatement = enableGlobalReimbursementsDraft?.[INPUT_IDS.BANK_STATEMENT]; + + const persistedRouteParams = { + bankCountry: route.params?.bankCountry, + bankCurrency: route.params?.bankCurrency, + }; const goBack = () => { - Navigation.goBack(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID))); + const agreementsRoute = getAgreementsRoute(Number(bankAccountID), persistedRouteParams); + if (isDynamic) { + Navigation.navigate(agreementsRoute, {forceReplace: true}); + return; + } + Navigation.goBack(agreementsRoute); }; + useEffect(() => { + if (bankStatement?.length || isLoadingDraft) { + return; + } + + Navigation.navigate(getAgreementsRoute(Number(bankAccountID), persistedRouteParams), isDynamic ? {forceReplace: true} : undefined); + }, [bankAccountID, bankStatement?.length, getAgreementsRoute, isDynamic, isLoadingDraft, persistedRouteParams]); + const onSubmit = () => { enableGlobalReimbursementsForUSDBankAccount({ inputs: JSON.stringify({ @@ -48,6 +73,7 @@ function EnableGlobalReimbursementsSignPage({route}: EnableGlobalReimbursementsS purposeOfTransactionId: CONST.NON_USD_BANK_ACCOUNT.PURPOSE_OF_TRANSACTION_ID, }), achAuthorizationForm: enableGlobalReimbursementsDraft?.[INPUT_IDS.ACH_AUTHORIZATION_FORM].at(0), + bankStatement: bankStatement?.at(0), bankAccountID: Number(bankAccountID), }); }; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx index 073a69a82c95..731ec9f27ca7 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 65ff1016a008..2965e918d3eb 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -2,7 +2,8 @@ import cardScarf from '@assets/images/card-scarf.svg'; import ActivityIndicator from '@components/ActivityIndicator'; import AddToWalletButton from '@components/AddToWalletButton/index'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CardPreview from '@components/CardPreview'; import ConfirmModal from '@components/ConfirmModal'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; @@ -464,17 +465,16 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { shouldShowRightComponent={canRevealPIN} rightComponent={ canRevealPIN ? ( - + ) : undefined } /> diff --git a/src/pages/settings/Wallet/ImportTransactionsPage.tsx b/src/pages/settings/Wallet/ImportTransactionsPage.tsx index 06d9f85fd71d..42c3edc7d5e6 100644 --- a/src/pages/settings/Wallet/ImportTransactionsPage.tsx +++ b/src/pages/settings/Wallet/ImportTransactionsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint.tsx index 70bb4539bcf7..e558473293fd 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint.tsx @@ -1,7 +1,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import LottieAnimations from '@components/LottieAnimations'; -import MenuItem from '@components/MenuItem'; +import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -110,21 +110,17 @@ function AccountFlowEntryPoint({policyName = '', onBackButtonPress}: AccountFlow {translate('workspace.bankAccount.connectBankAccountNote')} - - + -
diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index f23ca7b30031..fcb1abea6420 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -3,7 +3,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import FormHelpMessage from '@components/FormHelpMessage'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import RenderHTML from '@components/RenderHTML'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -33,7 +33,6 @@ type MenuItemProps = { id: string; description: string; title: string; - shouldShowRightIcon: boolean; onPress: () => void; interactive?: boolean; disabled?: boolean; @@ -95,7 +94,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: 'bankCountry', description: translate('common.country'), title: translate(`allCountries.${formValues.bankCountry}` as TranslationPaths), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.COUNTRY_SELECTOR); }, @@ -105,7 +103,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: 'bankCurrency', description: translate('common.currency'), title: `${formValues.bankCurrency} - ${getCurrencySymbol(formValues.bankCurrency)}`, - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); }, @@ -118,7 +115,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); }, @@ -130,7 +126,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.ACCOUNT_TYPE); }, @@ -144,7 +139,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.BANK_INFORMATION); }, @@ -158,7 +152,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: fieldName === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY ? translate(`allCountries.${formValues.bankCountry}` as TranslationPaths) : getTitle(field, fieldName), - shouldShowRightIcon: fieldName !== CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY, onPress: () => { onMove(STEP_INDEXES.ACCOUNT_HOLDER_INFORMATION); }, @@ -183,16 +176,14 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp {translate('addPersonalBankAccount.confirmationStepHeader')} {translate('addPersonalBankAccount.confirmationStepSubHeader')} - {summaryItems.map(({id, description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( - ( + ))} ; - /** List item style */ listItemStyle?: StyleProp; /** Whether the add bank account button should be shown on the list */ shouldShowAddBankAccount?: boolean; - /** Additional style for the add bank account item */ addBankAccountItemStyle?: StyleProp; - - /** Whether the assigned cards should be shown on the list */ shouldShowAssignedCards?: boolean; /** Whether connection statuses and sync details should be shown */ @@ -126,22 +122,14 @@ type PaymentMethodListProps = { /** Whether the bank accounts should be displayed in private and business sections */ shouldShowBankAccountSections?: boolean; - /** The policy ID associated with the workspace, if component is rendered in workspace context */ - policyID?: string; - /** Function to be called when the user presses the add bank account button */ onAddBankAccountPress?: () => void; /** The icon to be displayed in the right side of the payment method item */ itemIconRight?: IconAsset; - /** Type of payment method to filter by */ filterType?: ValueOf; - - /* Currency of payment method to filter by */ filterCurrency?: string; - - /** Account states to exclude from the list */ excludeStates?: Array>; /** Bank account ID of an account that we do not want to show (i.e. it's already connected) */ @@ -150,10 +138,7 @@ type PaymentMethodListProps = { /** Whether to show the default badge for the payment method */ shouldHideDefaultBadge?: boolean; - /** Optional array of menu items to be displayed in the three dots menu */ threeDotsMenuItems?: PopoverMenuItem[]; - - /** Callback for when the three dots menu is pressed */ onThreeDotsMenuPress?: PaymentMethodPressHandler | CardPressHandler; }; @@ -194,7 +179,6 @@ function PaymentMethodList({ shouldShowRightIcon = true, invoiceTransferBankAccountID, shouldShowBankAccountSections = false, - policyID = '', onAddBankAccountPress = () => {}, itemIconRight, filterType, @@ -645,12 +629,7 @@ function PaymentMethodList({ const onPressItem = () => { if (!isUserValidated && !shouldSkipDefaultAccountValidation) { - const path = Navigation.getActiveRoute(); - if (path.includes(ROUTES.WORKSPACES_LIST.route) && policyID) { - Navigation.navigate(ROUTES.WORKSPACE_INVOICES_VERIFY_ACCOUNT.getRoute(policyID)); - } else { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path)); - } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path)); return; } onAddBankAccountPress(); diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index e639aa52b928..3ac3c87efd0c 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ConnectionStatusBadge from '@components/ConnectionStatusBadge'; import ConnectionStatusMessage from '@components/ConnectionStatusMessage'; import Hoverable from '@components/Hoverable'; @@ -84,19 +84,13 @@ type PaymentMethodItem = PaymentMethod & { } & BankIcon; type PaymentMethodListItemProps = { - /** The payment method item to render */ item: PaymentMethodItem; /** Whether to show the default badge for this payment method */ shouldShowDefaultBadge: boolean; - /** Optional array of menu items to be displayed in the three dots menu */ threeDotsMenuItems?: PopoverMenuItem[]; - - /** Callback for when the three dots menu is pressed */ onThreeDotsMenuPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void; - - /** List item style */ listItemStyle?: StyleProp; }; diff --git a/src/pages/settings/Wallet/PersonalCardDetailsPage.tsx b/src/pages/settings/Wallet/PersonalCardDetailsPage.tsx index eeb039497b8d..5eaaaf501624 100644 --- a/src/pages/settings/Wallet/PersonalCardDetailsPage.tsx +++ b/src/pages/settings/Wallet/PersonalCardDetailsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; @@ -19,7 +19,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isUsingStagingApi} from '@libs/ApiUtils'; +import {getActiveServer} from '@libs/ApiUtils'; import navigateToCardTransactions from '@libs/CardNavigationUtils'; import {getCardFeedIcon, getPlaidInstitutionIconUrl, isCardConnectionBroken, isPersonalCard} from '@libs/CardUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; @@ -55,7 +55,7 @@ type PersonalCardDetailsPageProps = PlatformStackScreenProps { @@ -52,7 +54,6 @@ function PersonalCardUpgradePage() { introSelected, policyName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), currency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, - policyOwnerEmail: '', transitionFromOldDot: false, makeMeAdmin: false, policyID, @@ -65,6 +66,7 @@ function PersonalCardUpgradePage() { isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); setIsUpgraded(true); }; diff --git a/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx b/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx index 5931968c4319..e27494c9a124 100644 --- a/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx +++ b/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx b/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx index 8d32a13a2df9..c22cba9868f3 100644 --- a/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx +++ b/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx index 1702f297e00c..c5dd255edf04 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx @@ -1,5 +1,6 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -129,14 +130,13 @@ function TravelCVVPage() { ); } else if (!cvv && !isSignedInAsDelegate) { actionButton = ( - + ); } diff --git a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx index d4cff80430c8..06ee102f07c1 100644 --- a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx +++ b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import ErrorMessageRow from '@components/ErrorMessageRow'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/settings/Wallet/WalletExpensifyCardSpendRulesPage.tsx b/src/pages/settings/Wallet/WalletExpensifyCardSpendRulesPage.tsx index d68a07f4d2c8..87ef58ca8e8e 100644 --- a/src/pages/settings/Wallet/WalletExpensifyCardSpendRulesPage.tsx +++ b/src/pages/settings/Wallet/WalletExpensifyCardSpendRulesPage.tsx @@ -18,7 +18,6 @@ function WalletExpensifyCardSpendRulesPage({route}: WalletExpensifyCardSpendRule void; - /** Card limit type */ limitType?: CardLimitType; - - /** Hint text for the card */ cardHintText?: string; }; diff --git a/src/pages/settings/useSettingsNavigationMenuData.ts b/src/pages/settings/useSettingsNavigationMenuData.ts index 8a7caf9f3d72..d06b0409faf4 100644 --- a/src/pages/settings/useSettingsNavigationMenuData.ts +++ b/src/pages/settings/useSettingsNavigationMenuData.ts @@ -5,7 +5,6 @@ import useIsAgentAccount from '@hooks/useIsAgentAccount'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import Navigation from '@libs/Navigation/Navigation'; @@ -103,7 +102,6 @@ function useSettingsNavigationMenuData(): {accountMenuItemsData: MenuSection; ge const icons = useMemoizedLazyExpensifyIcons(['Bot', 'Gear', 'Profile', 'Heart', 'Info', 'QuestionMark', 'Lightbulb', 'Lock', 'Users', 'CreditCard', 'Wallet', 'Bolt']); const [amountOwed = 0] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const subscriptionPlan = useSubscriptionPlan(); - const {isBetaEnabled} = usePermissions(); const isAgentAccount = useIsAgentAccount(); const accountItems: MenuData[] = [ @@ -151,7 +149,7 @@ function useSettingsNavigationMenuData(): {accountMenuItemsData: MenuSection; ge }, ]; - if (!isAgentAccount && isBetaEnabled(CONST.BETAS.CUSTOM_AGENT)) { + if (!isAgentAccount) { const rulesIndex = accountItems.findIndex((item) => item.screenName === SCREENS.SETTINGS.RULES.ROOT); accountItems.splice(rulesIndex + 1, 0, { translationKey: 'agentsPage.title', diff --git a/src/pages/signin/ChooseSSOOrValidateCode.tsx b/src/pages/signin/ChooseSSOOrValidateCode.tsx index 330c739cd972..f96601ee2a80 100644 --- a/src/pages/signin/ChooseSSOOrValidateCode.tsx +++ b/src/pages/signin/ChooseSSOOrValidateCode.tsx @@ -1,10 +1,10 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FormHelpMessage from '@components/FormHelpMessage'; import Text from '@components/Text'; import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,7 +35,6 @@ function ChooseSSOOrValidateCode({setIsUsingValidateCode}: ChooseSSOOrValidateCo const styles = useThemeStyles(); const {isKeyboardShown} = useKeyboardState(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -52,8 +51,7 @@ function ChooseSSOOrValidateCode({setIsUsingValidateCode}: ChooseSSOOrValidateCo <> {translate('samlSignIn.welcomeSAMLEnabled')} - + @@ -72,8 +70,7 @@ function ChooseSSOOrValidateCode({setIsUsingValidateCode}: ChooseSSOOrValidateCo - + {!!account && !isEmptyObject(account.errors) && } clearSignInData()} /> diff --git a/src/pages/signin/LoginForm/types.ts b/src/pages/signin/LoginForm/types.ts index e9fbe6f74c86..b601f254ef37 100644 --- a/src/pages/signin/LoginForm/types.ts +++ b/src/pages/signin/LoginForm/types.ts @@ -2,16 +2,12 @@ import type {ForwardedRef} from 'react'; import type {SubmitBehavior} from 'react-native'; type LoginFormProps = { - /** Function used to scroll to the top of the page */ scrollPageToTop?: () => void; /** Should we dismiss the keyboard when transitioning away from the page? */ submitBehavior?: SubmitBehavior; - /** Whether the content is visible. */ isVisible: boolean; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/pages/signin/SAMLSignInPage/handleSAMLLoginError.ts b/src/pages/signin/SAMLSignInPage/handleSAMLLoginError.ts new file mode 100644 index 000000000000..529806edefbf --- /dev/null +++ b/src/pages/signin/SAMLSignInPage/handleSAMLLoginError.ts @@ -0,0 +1,16 @@ +import Navigation from '@libs/Navigation/Navigation'; + +import {clearSignInData, setAccountError} from '@userActions/Session'; + +import ROUTES from '@src/ROUTES'; + +function handleSAMLLoginError(errorMessage: string, shouldClearSignInData: boolean) { + if (shouldClearSignInData) { + clearSignInData(); + } + + setAccountError(errorMessage); + Navigation.goBack(ROUTES.INBOX); +} + +export default handleSAMLLoginError; diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index ef9a65b74fbb..6ffc1114c48b 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -8,7 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; -import {handleSAMLLoginError, postSAMLLogin} from '@libs/LoginUtils'; +import {postSAMLLogin} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import {clearSignInData, setAccountError, setIsAuthenticatingWithShortLivedToken, signInWithShortLivedAuthToken} from '@userActions/Session'; @@ -23,9 +23,11 @@ import type {WebBrowserAuthSessionResult} from 'expo-web-browser'; import {openAuthSessionAsync} from 'expo-web-browser'; import React, {useCallback, useEffect, useRef, useState} from 'react'; +import handleSAMLLoginError from './handleSAMLLoginError'; + function SAMLSignInPage() { - const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); + const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); const [showNavigation, shouldShowNavigation] = useState(true); const [SAMLUrl, setSAMLUrl] = useState(''); const {translate} = useLocalize(); @@ -68,9 +70,10 @@ function SAMLSignInPage() { Log.hmmm('SAMLSignInPage - No JSON parameter found in callback URL'); } - if (!account?.isLoading && credentials?.login && shortLivedAuthToken) { + // A forced re-auth leaves account.isLoading true until sign-in, so the token alone decides here. + if (credentials?.login && shortLivedAuthToken) { Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(shortLivedAuthToken, true); + signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath); return; } @@ -87,7 +90,7 @@ function SAMLSignInPage() { Navigation.navigate(ROUTES.HOME); }); }, - [credentials?.login, account?.isLoading, translate], + [credentials?.login, lastVisitedPath, translate], ); useEffect(() => { diff --git a/src/pages/signin/SAMLSignInPage/index.tsx b/src/pages/signin/SAMLSignInPage/index.tsx index d71ac42daccd..f96bb2a9493c 100644 --- a/src/pages/signin/SAMLSignInPage/index.tsx +++ b/src/pages/signin/SAMLSignInPage/index.tsx @@ -3,13 +3,15 @@ import SAMLLoadingIndicator from '@components/SAMLLoadingIndicator'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {handleSAMLLoginError, postSAMLLogin} from '@libs/LoginUtils'; +import {postSAMLLogin} from '@libs/LoginUtils'; import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import React, {useEffect} from 'react'; +import handleSAMLLoginError from './handleSAMLLoginError'; + function SAMLSignInPage() { const {translate} = useLocalize(); const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); diff --git a/src/pages/signin/SMSDeliveryFailurePage.tsx b/src/pages/signin/SMSDeliveryFailurePage.tsx index a7cd1bb5d7cb..21af62a8913e 100644 --- a/src/pages/signin/SMSDeliveryFailurePage.tsx +++ b/src/pages/signin/SMSDeliveryFailurePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Text from '@components/Text'; diff --git a/src/pages/signin/SignInPageLayout/BackgroundImage/types.ts b/src/pages/signin/SignInPageLayout/BackgroundImage/types.ts index e158a43e7bef..e5dc1200e4a6 100644 --- a/src/pages/signin/SignInPageLayout/BackgroundImage/types.ts +++ b/src/pages/signin/SignInPageLayout/BackgroundImage/types.ts @@ -1,5 +1,4 @@ type BackgroundImageProps = { - /** The width of the image. */ width: number; /** Is the window width narrow, like on a mobile device */ diff --git a/src/pages/signin/SignInPageLayout/types.ts b/src/pages/signin/SignInPageLayout/types.ts index c14379d3f9a6..b5463bd20ff2 100644 --- a/src/pages/signin/SignInPageLayout/types.ts +++ b/src/pages/signin/SignInPageLayout/types.ts @@ -30,8 +30,6 @@ type SignInPageLayoutProps = { customHeroBody?: string; navigateFocus?: () => void; - - /** Reference to the outer element */ ref?: ForwardedRef; }; diff --git a/src/pages/signin/SignUpWelcomeForm.tsx b/src/pages/signin/SignUpWelcomeForm.tsx index 612361bf55e2..e14e958eeafe 100644 --- a/src/pages/signin/SignUpWelcomeForm.tsx +++ b/src/pages/signin/SignUpWelcomeForm.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FormHelpMessage from '@components/FormHelpMessage'; diff --git a/src/pages/signin/UnlinkLoginForm.tsx b/src/pages/signin/UnlinkLoginForm.tsx index f147fe92c668..9a1a39847902 100644 --- a/src/pages/signin/UnlinkLoginForm.tsx +++ b/src/pages/signin/UnlinkLoginForm.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; @@ -60,7 +60,7 @@ function UnlinkLoginForm() { redirectToSignIn()} + onPress={() => redirectToSignIn(CONST.SIGN_OUT_REASON.UNLINK_LOGIN)} sentryLabel={CONST.SENTRY_LABEL.SIGN_IN.GO_BACK} > {translate('common.back')} diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx index fef0475d24cf..4aa61bf6552e 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import SafariFormWrapper from '@components/Form/SafariFormWrapper'; import FormHelpMessage from '@components/FormHelpMessage'; import Icon from '@components/Icon'; @@ -434,8 +435,7 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco )} - + diff --git a/src/pages/signin/ValidateCodeForm/types.ts b/src/pages/signin/ValidateCodeForm/types.ts index 44363149572c..d67802d822a5 100644 --- a/src/pages/signin/ValidateCodeForm/types.ts +++ b/src/pages/signin/ValidateCodeForm/types.ts @@ -10,8 +10,6 @@ type ValidateCodeFormProps = { setIsUsingRecoveryCode: (value: boolean) => void; isVisible: boolean; - - /** Reference to the outer element */ ref: ForwardedRef; }; diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 11726750c4ac..18454a498787 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -1,4 +1,3 @@ -import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DisplayNames from '@components/DisplayNames'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; @@ -6,12 +5,13 @@ import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import {useMenuItemConfig, useMenuItemInteraction} from '@components/MenuItem/MenuItemContext'; -import MenuItemEmptyField from '@components/MenuItem/presets/MenuItemEmptyField'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import UserPill from '@components/UserPill'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -29,8 +29,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {createTaskAndNavigate, dismissModalAndClearOutTaskInfo, getAssignee, getShareDestination, setShareDestinationValue} from '@libs/actions/Task'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {getPersonalDetailsForAccountIDs} from '@libs/PersonalDetailsUtils'; -import {getDisplayNamesWithTooltips, isAllowedToComment} from '@libs/ReportUtils'; +import {isAllowedToComment} from '@libs/ReportUtils'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -74,6 +73,7 @@ function DynamicNewTaskPage() { const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportAttributes = useReportAttributes(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -83,15 +83,9 @@ function DynamicNewTaskPage() { const styles = useThemeStyles(); const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const assignee = getAssignee(task?.assigneeAccountID ?? CONST.DEFAULT_NUMBER_ID, personalDetails, translate, formatPhoneNumber); - const assigneeTooltipDetails = getDisplayNamesWithTooltips( - getPersonalDetailsForAccountIDs(task?.assigneeAccountID ? [task.assigneeAccountID] : [], personalDetails), - false, - localeCompare, - formatPhoneNumber, - translate, - ); + const assigneePersonalDetails = task?.assigneeAccountID ? personalDetails?.[task.assigneeAccountID] : undefined; const shareDestination = task?.shareDestination - ? getShareDestination(parentReport, personalDetails, localeCompare, formatPhoneNumber, policy, conciergeReportID, translate, reportAttributes, pendingDeleteMemberAccountIDs) + ? getShareDestination(parentReport, personalDetails, localeCompare, formatPhoneNumber, policy, conciergeReportID, translate, rules, reportAttributes, pendingDeleteMemberAccountIDs) : undefined; const ancestors = useAncestors(parentReport); const taskKey = `${task?.assignee}|${task?.assigneeAccountID}|${task?.description}|${task?.parentReportID}|${task?.shareDestination}|${task?.title}`; @@ -223,38 +217,29 @@ function DynamicNewTaskPage() { titleStyle={styles.flex1} /> {assignee?.displayName ? ( - - {!!task?.assigneeAccountID && ( - - - - )} - - - - {!!assignee.subtitle && {formatPhoneNumber(assignee.subtitle)}} + {translate('task.assignee')} + - + ) : ( - )} @@ -290,20 +275,12 @@ function DynamicNewTaskPage() { ) : ( - - - - {translate('common.share')} - - - {translate('common.required')} - {!!navigateToShareDestination && } - - - + {translate('common.required')} + )} diff --git a/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx b/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx index 6344ff7fc961..8421bfdb17c2 100644 --- a/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx +++ b/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx @@ -52,7 +52,7 @@ function DynamicTaskAssigneeSelectorModal() { const reportID = !isNewTaskFlow && route.params && 'reportID' in route.params ? route.params.reportID : undefined; const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [task] = useOnyx(ONYXKEYS.TASK); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -244,7 +244,7 @@ function DynamicTaskAssigneeSelectorModal() { initialScrollIndex={0} initiallyFocusedItemKey={initiallyFocusedOptionKey} shouldShowLoadingPlaceholder={!areOptionsInitialized} - isLoadingNewOptions={!!isSearchingForReports} + isLoadingNewOptions={!!isSearchingForUsers} shouldUpdateFocusedIndex shouldShowTextInput /> diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 8386442e52c5..e11fcc4ccb6e 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -5,7 +5,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsWorkspacesTabFocused from '@hooks/useIsWorkspacesTabFocused'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -33,13 +32,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Beta, Report} from '@src/types/onyx'; +import type {Beta, Report, Rule} from '@src/types/onyx'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import callOrReturn from '@src/types/utils/callOrReturn'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; /* eslint-disable rulesdir/no-negated-variables */ import {useIsFocused} from '@react-navigation/native'; @@ -58,12 +57,13 @@ const ACCESS_VARIANTS = { iouType?: IOUType, isReportArchived?: boolean, isRestrictedToPreferredPolicy?: boolean, + rules?: OnyxCollection, ) => !!iouType && isValidMoneyRequestType(iouType) && // Allow the user to submit the expense if we are submitting the expense in global menu or the report can create the expense - (isEmptyObject(report?.reportID) || canCreateRequest(report, policy, iouType, isReportArchived, betas, isRestrictedToPreferredPolicy)) && + (isEmptyObject(report?.reportID) || canCreateRequest(report, policy, iouType, isReportArchived, betas, rules, isRestrictedToPreferredPolicy)) && (iouType !== CONST.IOU.TYPE.INVOICE || canSendInvoice), } as const satisfies Record< string, @@ -76,6 +76,7 @@ const ACCESS_VARIANTS = { iouType?: IOUType, isArchivedReport?: boolean, isRestrictedToPreferredPolicy?: boolean, + rules?: OnyxCollection, ) => boolean >; @@ -88,12 +89,10 @@ type AccessOrNotFoundWrapperChildrenProps = { /** The report currently being looked at */ policy: OnyxEntry; - /** Indicated whether the report data is loading */ isLoadingReportData: OnyxEntry; }; type AccessOrNotFoundWrapperProps = { - /** The children to render */ children: ((props: AccessOrNotFoundWrapperChildrenProps) => React.ReactNode) | React.ReactNode; /** The id of the report that holds the transaction */ @@ -175,8 +174,8 @@ function AccessOrNotFoundWrapper({ const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const {login = ''} = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); - const {isBetaEnabled} = usePermissions(); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const isPolicyIDInRoute = !!policyID?.length; const isMoneyRequest = !!iouType && isValidMoneyRequestType(iouType); @@ -199,9 +198,7 @@ function AccessOrNotFoundWrapper({ const shouldShowFullScreenLoadingIndicator = !isMoneyRequest && (isLoadingReportData !== false || !!policy?.isLoading) && isPolicyEmpty; // Pass categories so that migrated corporate policies with only Classic category rules (areRulesEnabled === undefined) are correctly treated as enabled. - // Collect workspaces can only access Rules when the rulesRevamp beta is enabled. - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const isFeatureEnabled = featureName ? isPolicyFeatureEnabledUtil(policy, featureName, policyCategories, isRulesRevampEnabled) : true; + const isFeatureEnabled = featureName ? isPolicyFeatureEnabledUtil(policy, featureName, policyCategories) : true; const {isOffline} = useNetwork(); @@ -210,7 +207,7 @@ function AccessOrNotFoundWrapper({ const isPageAccessible = accessVariantsToCheck.reduce((acc, variant) => { const accessFunction = ACCESS_VARIANTS[variant]; if (variant === CONST.IOU.ACCESS_VARIANTS.CREATE) { - return acc && accessFunction(policy, login, report, !!canSendInvoice, betas, iouType, isReportArchived, isRestrictedToPreferredPolicy); + return acc && accessFunction(policy, login, report, !!canSendInvoice, betas, iouType, isReportArchived, isRestrictedToPreferredPolicy, rules); } if (variant === CONST.POLICY.ACCESS_VARIANTS.ADMIN) { return acc && canEditWorkspaceSettings(policy, login, canBeAccessedIfArchived); diff --git a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx index 855af9a27098..faf5f8a06232 100644 --- a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx +++ b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx @@ -6,6 +6,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useOnyx from '@hooks/useOnyx'; import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -41,6 +42,7 @@ function DynamicWorkspaceConfirmationPage() { const isAnnualSubscription = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; const activePolicy = useActivePolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); // On narrow layout the new workspace is mounted under this RHP and revealed when the modal // dismisses (via revealRouteBeforeDismissingModal). The reveal waits for the new screen to lay @@ -51,7 +53,7 @@ function DynamicWorkspaceConfirmationPage() { const onSubmit = (params: WorkspaceConfirmationSubmitFunctionParams) => { // policyID is always supplied by WorkspaceConfirmationForm (stable per form instance). const policyID = params.policyID; - const isDifferentOwner = !!params.owner && params.owner !== (currentUserPersonalDetails.email ?? ''); + const isDifferentOwner = !!params.owner?.email && params.owner.email !== (currentUserPersonalDetails.email ?? ''); const shouldShowSuccessPage = isDifferentOwner && !params.makeMeAdmin; const workspaceRoute = isSmallScreenWidth ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID); const routeToNavigate = shouldShowSuccessPage ? ROUTES.WORKSPACE_CONFIRMATION_SUCCESS : workspaceRoute; @@ -60,7 +62,7 @@ function DynamicWorkspaceConfirmationPage() { } createWorkspaceWithPolicyDraftAndNavigateToIt({ introSelected, - policyOwnerEmail: params.owner, + policyOwner: params.owner, policyName: params.name, transitionFromOldDot: false, makeMeAdmin: params.makeMeAdmin, @@ -79,6 +81,7 @@ function DynamicWorkspaceConfirmationPage() { isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription, }); }; diff --git a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx index cd2c24c42205..b0bb8e7279e8 100644 --- a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx @@ -19,6 +19,7 @@ import {clearErrors, openWorkspaceInvitePage as policyOpenWorkspaceInvitePage} f import {searchUserInServer} from '@libs/actions/Report'; import {READ_COMMANDS} from '@libs/API/types'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -58,7 +59,7 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun const {translate, formatPhoneNumber} = useLocalize(); const dynamicBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_INVITE.path); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -277,11 +278,13 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun confirmButtonOptions={{ onConfirm: inviteUser, isDisabled: !selectedOptions.length, + isFooterConfirmEnabled: selectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldShowLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} footerContent={footerContent} - isLoadingNewOptions={!!isSearchingForReports} + isLoadingNewOptions={!!isSearchingForUsers} addBottomSafeAreaPadding /> diff --git a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index 5da50a948a3f..7ecc1fe48d94 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 4ddc3e0c4568..49f7720d21c8 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -99,7 +99,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac 'Document', 'ExpensifyAppIcon', 'ExpensifyCard', - 'Feed', 'Folder', 'Gear', 'Hashtag', @@ -113,6 +112,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac 'LuggageWithLines', 'Clock', 'Bolt', + 'Bot', ]); const policyName = policy?.name ?? ''; @@ -182,7 +182,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac previousPendingFields: prevPendingFields, shouldShowEnterCredentialsError, shouldShowRBR, - isRulesRevampBetaEnabled: isBetaEnabled(CONST.BETAS.RULES_REVAMP), isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), convertToDisplayString, }).map((item) => ({ diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 11ca2d734e71..737aa1591b8e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import DecisionModal from '@components/DecisionModal'; diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index f6cdb604ab86..fd919e3d3ff5 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -39,6 +39,7 @@ import { hasAccountingFeatureConnection, hasVendorFeature, isControlPolicy, + isMCPEnabled, isPerDiemEnabled, isTimeTrackingEnabled, tryNavigateToSubmitWorkspaceUpgrade, @@ -59,6 +60,7 @@ import { enablePolicyConnections, enablePolicyHR, enablePolicyInvoicing, + enablePolicyMCP, enablePolicyReceiptPartners, enablePolicyRules, enablePolicyTaxes, @@ -91,7 +93,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const {isBetaEnabled} = usePermissions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const isVendorMatchingEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); const illustrations = useMemoizedLazyIllustrations([ 'FolderOpen', @@ -99,7 +100,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro 'CompanyCard', 'Workflows', 'InvoiceBlue', - 'Rules', 'Flash', 'Tag', 'PerDiem', @@ -112,6 +112,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro 'ReceiptPartners', 'Clock', 'Members', + 'AiAutomation', ]); const policyID = policy?.id; @@ -168,9 +169,14 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro // vendors right now), so it can't double as the visibility gate. // // Beta gating mirrors `hasVendorFeature`: QBO (R1) is GA, so a connected QBO workspace always - // sees the row regardless of the `vendorMatching` beta. Sage Intacct (R2) and Xero (R3) haven't - // reached GA, so they only show the row while the beta is enabled. - const vendorMatchingConnection = getConnectedIntegration(policy, [CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]); + // sees the row regardless of the `vendorMatching` beta. Sage Intacct (R2), Xero (R3), and Rillet (R4) + // haven't reached GA, so they only show the row while the beta is enabled. + const vendorMatchingConnection = getConnectedIntegration(policy, [ + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, + CONST.POLICY.CONNECTIONS.NAME.XERO, + CONST.POLICY.CONNECTIONS.NAME.RILLET, + ]); const shouldShowVendorsFeature = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || (isVendorMatchingEnabled && !!vendorMatchingConnection); const warnAccountingManagesOrganizeFeature = async () => { @@ -417,6 +423,27 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)); }} /> + { + if (!policyID) { + return; + } + enablePolicyMCP(policyID, isEnabled); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_MCP.getRoute(policyID)); + }} + /> @@ -531,10 +558,10 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro }} /> {isLocked ? ( - ) : ( diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index c638ebc19e9e..026c252de8c1 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -1,7 +1,7 @@ import AttachmentPicker from '@components/AttachmentPicker'; import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 81c626a07a73..647869f5d52d 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -41,8 +41,6 @@ import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & Pick & { shouldSkipVBBACall?: boolean; - - /** The text to display in the header */ headerText: string; /** Main content of the page */ @@ -69,13 +67,11 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** Policy feature permission needed to show this page */ policyFeature?: PolicyFeature; - /** Whether to show the not found page */ shouldShowNotFoundPage?: boolean; /** Makes firstRender ref display loading page before isLoading is change to true */ showLoadingAsFirstRender?: boolean; - /** Policy values needed in the component */ policy: OnyxEntry; /** @@ -85,16 +81,12 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & * */ icon?: IconAsset; - /** Content to be added to the header */ headerContent?: ReactNode; - - /** TestID of the component */ testID?: string; /** Whether the page is loading, example any other API call in progress */ isLoading?: boolean; - /** Whether to use the headline header */ shouldUseHeadlineHeader?: boolean; /** @@ -102,7 +94,6 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & */ addBottomSafeAreaPadding?: boolean; - /** Content to be added as modal */ modals?: ReactNode; /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index fa1608c4d8df..6ec96548d145 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {TableHandle} from '@components/Table'; import type {WorkspaceRowData, WorkspaceTableColumnKey} from '@components/Tables/WorkspaceListTable'; import WorkspaceListTable from '@components/Tables/WorkspaceListTable'; diff --git a/src/pages/workspace/accounting/AccountingContext/contexts.ts b/src/pages/workspace/accounting/AccountingContext/contexts.ts new file mode 100644 index 000000000000..eac11863cc28 --- /dev/null +++ b/src/pages/workspace/accounting/AccountingContext/contexts.ts @@ -0,0 +1,23 @@ +import {createContext, useContext} from 'react'; + +import type {AccountingActionsContextType, AccountingStateContextType} from './types'; + +import {defaultAccountingActionsContextValue, defaultAccountingStateContextValue} from './default'; + +/** + * The contexts and their hooks live here rather than next to the provider so that the integration flow + * components can read the accounting state without importing the provider, which imports the flow components + * back through accounting/utils and would close an import cycle. + */ +const AccountingStateContext = createContext(defaultAccountingStateContextValue); +const AccountingActionsContext = createContext(defaultAccountingActionsContextValue); + +function useAccountingState(): AccountingStateContextType { + return useContext(AccountingStateContext); +} + +function useAccountingActions(): AccountingActionsContextType { + return useContext(AccountingActionsContext); +} + +export {AccountingActionsContext, AccountingStateContext, useAccountingActions, useAccountingState}; diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index d7ac26e9d109..b9d1616e34ae 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -21,14 +21,12 @@ import type {RefObject} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {createContext, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; -import type {AccountingActionsContextType, AccountingStateContextType, ActiveIntegration, ActiveIntegrationState} from './types'; +import type {ActiveIntegration, ActiveIntegrationState} from './types'; -import {defaultAccountingActionsContextValue, defaultAccountingStateContextValue, popoverAnchorRefsInitialValue} from './default'; - -const AccountingStateContext = createContext(defaultAccountingStateContextValue); -const AccountingActionsContext = createContext(defaultAccountingActionsContextValue); +import {AccountingActionsContext, AccountingStateContext, useAccountingActions, useAccountingState} from './contexts'; +import {popoverAnchorRefsInitialValue} from './default'; type AccountingContextProviderProps = ChildrenProps & { policy: OnyxEntry; @@ -49,12 +47,14 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', ]); const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID); const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID); const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policyID); const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policyID); const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policyID); + const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID); const [cardFeeds] = useCardFeeds(policyID); const [cardLists] = useCardsLists(); @@ -78,6 +78,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider certinia: hasReusablePoliciesConnectedToCertinia, rillet: hasReusablePoliciesConnectedToRillet, dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, }, undefined, undefined, @@ -111,6 +112,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider hasReusablePoliciesConnectedToCertinia, hasReusablePoliciesConnectedToRillet, hasReusablePoliciesConnectedToDualEntry, + hasReusablePoliciesConnectedToCampfire, accountingIcons, cardFeeds, cardLists, @@ -160,6 +162,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider certinia: hasReusablePoliciesConnectedToCertinia, rillet: hasReusablePoliciesConnectedToRillet, dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, }, policy, activeIntegration.key, @@ -201,12 +204,4 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider ); } -function useAccountingState(): AccountingStateContextType { - return useContext(AccountingStateContext); -} - -function useAccountingActions(): AccountingActionsContextType { - return useContext(AccountingActionsContext); -} - export {AccountingContextProvider, useAccountingState, useAccountingActions}; diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index 1792d57109ca..d6c959e56334 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -10,7 +11,6 @@ import Text from '@components/Text'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -54,7 +54,6 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { const {integration, policyID} = route.params; const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const {startIntegrationFlow} = useAccountingActions(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['TreasureChestGreenWithSparkle']); const integrations = policy?.receiptPartners; @@ -124,22 +123,20 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { const buttons = ( {!!config.claimOfferLink && ( - + )} - + ); diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 5a9dbb2ac3b4..b9a2a3ff98ab 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,5 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CollapsibleSection from '@components/CollapsibleSection'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -93,6 +94,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policy?.id); const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policy?.id); const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policy?.id); + const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policy?.id); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const theme = useTheme(); @@ -130,21 +132,26 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', ]); const [cardFeeds] = useCardFeeds(policyID); const [cardLists] = useCardsLists(); const connectionSyncStage = connectionSyncProgress?.stageInProgress; const canUseDualEntryIntegration = isBetaEnabled(CONST.BETAS.DUALENTRY) || !!policy?.connections?.dualEntry; + const canUseCampfireIntegration = isBetaEnabled(CONST.BETAS.CAMPFIRE) || !!policy?.connections?.campfire; const accountingIntegrations = useMemo( () => CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { if (name === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY) { return canUseDualEntryIntegration; } + if (name === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { + return canUseCampfireIntegration; + } return true; }), - [canUseDualEntryIntegration], + [canUseDualEntryIntegration, canUseCampfireIntegration], ); const accountingIntegrationOptions = useMemo( () => @@ -316,6 +323,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; + const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; const certiniaConfig = policy?.connections?.financialforce?.config; const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); @@ -455,6 +463,25 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) : undefined, }; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return !campfireSubsidiaryList?.length + ? {} + : { + description: translate('workspace.campfire.subsidiary'), + iconRight: icons.ArrowRight, + title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: [styles.sectionMenuItemTopDescription], + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; default: return undefined; @@ -487,6 +514,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { certinia: hasReusablePoliciesConnectedToCertinia, rillet: hasReusablePoliciesConnectedToRillet, dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, }, undefined, undefined, @@ -531,7 +559,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { isBadgeSuccess: isXero, shouldShowBadgeBelow: shouldUseNarrowLayout, rightComponent: ( - + ), }; }) @@ -575,6 +602,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { certinia: hasReusablePoliciesConnectedToCertinia, rillet: hasReusablePoliciesConnectedToRillet, dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, }, policy, undefined, @@ -720,7 +748,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { accountingIntegrationOptions, isConnectedToIntuitEnterpriseSuite, shouldUseNarrowLayout, - isOffline, startIntegrationFlow, popoverAnchorRefs, datetimeToRelative, @@ -728,6 +755,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { hasReusablePoliciesConnectedToCertinia, hasReusablePoliciesConnectedToRillet, hasReusablePoliciesConnectedToDualEntry, + hasReusablePoliciesConnectedToCampfire, hasReusablePoliciesConnectedToQBD, canWriteAccounting, showReadOnlyModal, @@ -752,6 +780,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { certinia: hasReusablePoliciesConnectedToCertinia, rillet: hasReusablePoliciesConnectedToRillet, dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, }, undefined, undefined, @@ -773,7 +802,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ...iconProps, title: integrationData?.title, rightComponent: ( - + ), interactive: false, // On native iOS, `accessible={true}` collapses the row and all its descendants into a single accessibility element, @@ -825,12 +853,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { hasReusablePoliciesConnectedToCertinia, hasReusablePoliciesConnectedToRillet, hasReusablePoliciesConnectedToDualEntry, + hasReusablePoliciesConnectedToCampfire, hasReusablePoliciesConnectedToQBD, styles.justifyContentCenter, styles.buttonOpacityDisabled, styles.buttonDisabled, styles.sectionMenuItemTopDescription, - isOffline, startIntegrationFlow, popoverAnchorRefs, accountingIcons, diff --git a/src/pages/workspace/accounting/campfire/CampfireExistingConnectionsPage.tsx b/src/pages/workspace/accounting/campfire/CampfireExistingConnectionsPage.tsx new file mode 100644 index 000000000000..e5eb3b15dc52 --- /dev/null +++ b/src/pages/workspace/accounting/campfire/CampfireExistingConnectionsPage.tsx @@ -0,0 +1,82 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; +import MenuItemList from '@components/MenuItemList'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useReusablePoliciesConnectedTo from '@hooks/useReusablePoliciesConnectedTo'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {copyExistingPolicyConnection} from '@libs/actions/connections'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type CampfireExistingConnectionsPageProps = PlatformStackScreenProps; + +function CampfireExistingConnectionsPage({route}: CampfireExistingConnectionsPageProps) { + const {translate, datetimeToRelative} = useLocalize(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['LinkCopy']); + const policyID: string = route.params.policyID; + const {reusablePoliciesConnectedTo: reusablePoliciesConnectedToCampfire} = useReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID); + + const menuItems = reusablePoliciesConnectedToCampfire.map((policy) => { + const lastSuccessfulSyncDate = policy.connections?.campfire?.lastSync?.successfulDate; + const date = lastSuccessfulSyncDate ? datetimeToRelative(lastSuccessfulSyncDate) : undefined; + return { + title: policy.name, + key: policy.id, + avatarID: policy.id, + icon: policy.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy.name), + iconType: CONST.ICON_TYPE_WORKSPACE, + shouldShowRightIcon: true, + description: date ? translate('workspace.common.lastSyncDate', CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.campfire, date) : translate('workspace.accounting.campfire'), + onPress: () => { + copyExistingPolicyConnection(policy.id, policyID, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE); + Navigation.dismissModal(); + }, + }; + }); + + return ( + + Navigation.goBack()} + /> + + {translate('workspace.common.existingConnectionsDescription', CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE)} + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SETUP.getRoute(policyID))} + /> + {translate('workspace.common.existingConnections')} + + + + ); +} + +export default CampfireExistingConnectionsPage; diff --git a/src/pages/workspace/accounting/campfire/CampfireSetupPage.tsx b/src/pages/workspace/accounting/campfire/CampfireSetupPage.tsx new file mode 100644 index 000000000000..421aaa5cfc9f --- /dev/null +++ b/src/pages/workspace/accounting/campfire/CampfireSetupPage.tsx @@ -0,0 +1,111 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isAuthenticationError} from '@libs/actions/connections'; +import {connectToCampfire} from '@libs/actions/connections/Campfire'; +import {addErrorMessage} 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 CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/CampfireCredentialsForm'; + +import React from 'react'; +import {View} from 'react-native'; + +type CampfireSetupPageProps = PlatformStackScreenProps; + +function CampfireSetupPage({route}: CampfireSetupPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const policyID: string = route.params.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const config = policy?.connections?.campfire?.config; + const shouldBeBlocked = !!config?.isConfigured && !isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE); + + const confirmCredentials = (values: FormOnyxValues) => { + connectToCampfire(policyID, values[INPUT_IDS.API_KEY]); + Navigation.dismissModal(); + }; + + const formItems = Object.values(INPUT_IDS); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + + for (const formItem of formItems) { + if (values[formItem]) { + continue; + } + addErrorMessage(errors, formItem, translate('common.error.fieldRequired')); + } + return errors; + }; + + return ( + + + {translate('workspace.campfire.enterCredentials')} + + + + + {formItems.map((formItem, index) => ( + + + + ))} + + + ); +} + +export default CampfireSetupPage; diff --git a/src/pages/workspace/accounting/campfire/CampfireSubsidiarySelector.tsx b/src/pages/workspace/accounting/campfire/CampfireSubsidiarySelector.tsx new file mode 100644 index 000000000000..acc99969f159 --- /dev/null +++ b/src/pages/workspace/accounting/campfire/CampfireSubsidiarySelector.tsx @@ -0,0 +1,101 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import type {SelectorType} from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearCampfireErrorField, updateCampfireSubsidiary} from '@libs/actions/connections/Campfire'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type {CampfireSubsidiary} from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type SubsidiaryListItem = ListItem & { + value: CampfireSubsidiary['id']; +}; + +function CampfireSubsidiarySelector({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const subsidiaries = policy?.connections?.campfire?.data?.subsidiaries; + const campfireConfig = policy?.connections?.campfire?.config; + const currentSubsidiaryID = campfireConfig?.subsidiaryID ?? CONST.DEFAULT_NUMBER_ID.toString(); + const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); + + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const data: SubsidiaryListItem[] = subsidiaries + ? subsidiaries.map((subsidiaryItem) => ({ + text: subsidiaryItem.name, + keyForList: subsidiaryItem.id, + isSelected: subsidiaryItem.id === currentSubsidiaryID, + value: subsidiaryItem.id, + })) + : []; + const {filteredData, textInputOptions} = useSelectionListSearch(data); + + const updateSubsidiary = ({keyForList, value}: SelectorType) => { + if (!keyForList || keyForList === currentSubsidiaryID) { + return; + } + + updateCampfireSubsidiary(policyID, value, currentSubsidiaryID); + Navigation.goBack(); + }; + + const listEmptyContent = ( + + ); + + const listHeaderComponent = ( + + {translate('workspace.campfire.subsidiarySelectDescription')} + + ); + + return ( + Navigation.goBack()} + title="workspace.campfire.subsidiary" + listEmptyContent={listEmptyContent} + pendingAction={settingsPendingAction([CONST.CAMPFIRE_CONFIG.SUBSIDIARY_ID], campfireConfig?.pendingFields)} + errors={getLatestErrorField(campfireConfig ?? {}, CONST.CAMPFIRE_CONFIG.SUBSIDIARY_ID)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearCampfireErrorField(policyID, CONST.CAMPFIRE_CONFIG.SUBSIDIARY_ID)} + /> + ); +} + +export default withPolicyConnections(CampfireSubsidiarySelector); diff --git a/src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx b/src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx index fcf3e0611726..8f0a70251e7c 100644 --- a/src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx +++ b/src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -12,6 +12,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; import {View} from 'react-native'; @@ -20,22 +22,37 @@ type CertiniaPrerequisitesStepProps = SubPageProps & { isSandbox: boolean; }; +const PAGE_NAMES = CONST.CERTINIA_PREREQUISITES.PAGE_NAME; +type PageName = ValueOf; + +const PAGE_NAME_VALUES = Object.values(PAGE_NAMES); +const TITLE_KEYS = { + [PAGE_NAMES.INSTALL_BUNDLE]: 'workspace.certinia.prerequisites.installBundle', + [PAGE_NAMES.SETUP_CONTACTS]: 'workspace.certinia.prerequisites.setupContacts', + [PAGE_NAMES.OAUTH]: 'workspace.certinia.prerequisites.oauth', +} satisfies Record; +const BUTTON_KEYS = { + [PAGE_NAMES.INSTALL_BUNDLE]: 'workspace.certinia.prerequisites.installBundleConfirm', + [PAGE_NAMES.SETUP_CONTACTS]: 'workspace.certinia.prerequisites.setupContactsConfirm', + [PAGE_NAMES.OAUTH]: 'workspace.certinia.prerequisites.connectButton', +} satisfies Record; + +function isPageName(pageName: string | undefined): pageName is PageName { + return pageName !== undefined && PAGE_NAME_VALUES.some((configuredPageName) => configuredPageName === pageName); +} + function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbox}: CertiniaPrerequisitesStepProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const isLastStep = currentPageName === CONST.CERTINIA_PREREQUISITES.PAGE_NAME.OAUTH; - - const pageNames = CONST.CERTINIA_PREREQUISITES.PAGE_NAME; - const titleKey = `workspace.certinia.prerequisites.${currentPageName}` as TranslationPaths; - const descriptionKey = `workspace.certinia.prerequisites.${currentPageName}Description` as TranslationPaths; - const buttonKey = isLastStep - ? ('workspace.certinia.prerequisites.connectButton' as TranslationPaths) - : (`workspace.certinia.prerequisites.${currentPageName}Confirm` as TranslationPaths); + const pageName = isPageName(currentPageName) ? currentPageName : PAGE_NAMES.INSTALL_BUNDLE; + const isLastStep = pageName === PAGE_NAMES.OAUTH; + const titleKey = TITLE_KEYS[pageName]; + const buttonKey = BUTTON_KEYS[pageName]; let stepContent; - if (currentPageName === pageNames.INSTALL_BUNDLE) { + if (pageName === PAGE_NAMES.INSTALL_BUNDLE) { stepContent = ( @@ -58,7 +75,7 @@ function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbo ); - } else if (currentPageName === pageNames.SETUP_CONTACTS) { + } else if (pageName === PAGE_NAMES.SETUP_CONTACTS) { stepContent = ( {[ @@ -79,7 +96,7 @@ function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbo ); } else { - stepContent = {translate(descriptionKey)}; + stepContent = {translate('workspace.certinia.prerequisites.oauthDescription')}; } return ( diff --git a/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx b/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx index 0931ffbc7f8d..ef1efa26ff35 100644 --- a/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx +++ b/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; @@ -89,11 +89,10 @@ function TravelBillingContinuousReconciliationSection({policy, connectionName, i /> {!!travelBillingContinuousReconciliation && ( - )} diff --git a/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx b/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx index e83c196274d1..6898aa1eb280 100644 --- a/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx +++ b/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx index 759c8f4fd202..6369261d0ee9 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx @@ -1,5 +1,5 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; @@ -73,14 +73,13 @@ function SageIntacctAutoSyncPageBase({policy, navigateBackTo}: SageIntacctAutoSy /> {!!autoSync?.enabled && ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index b9a5c3fb1609..c6c7f75801fd 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -73,9 +73,7 @@ function DynamicSageIntacctDefaultVendorPage() { () => ( - {isReimbursable - ? translate('workspace.sageIntacct.defaultVendorDescription', true) - : translate('workspace.accounting.defaultVendorSelectHeader', translate('workspace.accounting.connectionName', CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT))} + {isReimbursable ? translate('workspace.sageIntacct.defaultVendorDescription', true) : translate('workspace.accounting.defaultVendorSelectHeader')} ), diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx index 712bd4e0d4a1..862508c29f67 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx @@ -1,4 +1,5 @@ import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -44,10 +45,9 @@ function DynamicSageIntacctTravelBillingConfigurationPage({policy}: WithPolicyCo connectionName={CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT} onBackButtonPress={() => Navigation.goBack(backPath)} > - { if (!policyID) { diff --git a/src/pages/workspace/accounting/intacct/import/DimensionTypeSelector.tsx b/src/pages/workspace/accounting/intacct/import/DimensionTypeSelector.tsx index b857dea07313..06fe1d949240 100644 --- a/src/pages/workspace/accounting/intacct/import/DimensionTypeSelector.tsx +++ b/src/pages/workspace/accounting/intacct/import/DimensionTypeSelector.tsx @@ -12,7 +12,6 @@ import React from 'react'; import {View} from 'react-native'; type DimensionTypeSelectorProps = { - /** Error text to display */ errorText?: string; /** Business type to display */ diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx index 909f061b2dc0..a1e8154e7f9c 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ConnectionLayout from '@components/ConnectionLayout'; import FixedFooter from '@components/FixedFooter'; import Icon from '@components/Icon'; diff --git a/src/pages/workspace/accounting/intacct/types.ts b/src/pages/workspace/accounting/intacct/types.ts index 52021f1c00be..98764bca0042 100644 --- a/src/pages/workspace/accounting/intacct/types.ts +++ b/src/pages/workspace/accounting/intacct/types.ts @@ -26,13 +26,11 @@ type MenuItemToRender = MenuItemWithSubscribedSettings & { }; type MenuItem = MenuItemProps & { - /** Type of the item */ type: 'menuitem'; /** The type of action that's pending */ pendingAction: OfflineWithFeedbackProps['pendingAction']; - /** Whether the item should be hidden */ shouldHide?: boolean; /** Any error message to show */ @@ -43,21 +41,16 @@ type MenuItem = MenuItemProps & { }; type ToggleItem = ToggleSettingOptionRowProps & { - /** Type of the item */ type: 'toggle'; - - /** Whether the item should be hidden */ shouldHide?: boolean; }; type AccordionItem = { - /** Type of the item */ type: 'accordion'; /** Items nested inside the accordion */ children: MenuItemToRender[]; - /** Whether the item should be hidden */ shouldHide: boolean; /** Indicates if the accordion is expanded */ diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx index 186e32a91b55..c30d93a5967a 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; diff --git a/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAutoSyncPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAutoSyncPage.tsx index d81d8506a405..b1dae1e2715f 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAutoSyncPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAutoSyncPage.tsx @@ -1,6 +1,6 @@ import Accordion from '@components/Accordion'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -80,14 +80,13 @@ function DynamicNetSuiteAutoSyncPage({policy, route}: WithPolicyConnectionsProps isToggleTriggered={shouldAnimateAccordionSection} > - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NETSUITE_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx index e8a057a927f0..ad67f567065d 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx @@ -1,10 +1,11 @@ +import Button from '@components/Button'; import ConnectionLayout from '@components/ConnectionLayout'; +import FixedFooter from '@components/FixedFooter'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; -import type {SelectorType} from '@components/SelectionScreen'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; @@ -26,8 +27,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import {View} from 'react-native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; type MenuListItem = ListItem & { value: ValueOf; @@ -46,40 +46,68 @@ function DynamicNetSuiteInvoiceItemPreferenceSelectPage({policy}: WithPolicyConn const selectedValue = Object.values(CONST.NETSUITE_INVOICE_ITEM_PREFERENCE).find((value) => value === config?.invoiceItemPreference) ?? CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE; + const [draftPreference, setDraftPreference] = useState>(); + const currentPreference = draftPreference ?? selectedValue; + const options: MenuListItem[] = useMemo( () => Object.values(CONST.NETSUITE_INVOICE_ITEM_PREFERENCE).map((postingPreference) => ({ value: postingPreference, text: translate(`workspace.netsuite.invoiceItem.values.${postingPreference}.label`), keyForList: postingPreference, - isSelected: selectedValue === postingPreference, + isSelected: currentPreference === postingPreference, })), - [selectedValue, translate], + [currentPreference, translate], ); const goBack = useCallback(() => { Navigation.goBack(backPath); }, [backPath]); - const selectInvoicePreference = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.invoiceItemPreference && policyID) { - updateNetSuiteInvoiceItemPreference(policyID, row.value, config?.invoiceItemPreference); - } - if (row.value === CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE) { - goBack(); - } - }, - [config?.invoiceItemPreference, policyID, goBack], - ); + const savePreference = useCallback(() => { + if (currentPreference !== config?.invoiceItemPreference && policyID) { + updateNetSuiteInvoiceItemPreference(policyID, currentPreference, config?.invoiceItemPreference); + } + // Selecting CREATE completes the flow, so we return to the previous screen. SELECT reveals the invoice-item sub-menu, so we stay. + if (currentPreference === CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE) { + goBack(); + } + }, [currentPreference, config?.invoiceItemPreference, policyID, goBack]); + + // Rendered as the list footer so the invoice-item sub-menu sits directly under the options and the Save button stays pinned to the bottom of the screen. + const invoiceItemFooterContent = useMemo(() => { + if (config?.invoiceItemPreference !== CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.SELECT) { + return null; + } + return ( + + { + if (!policyID) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_SELECT.path)); + }} + brickRoadIndicator={areSettingsInErrorFields([CONST.NETSUITE_CONFIG.INVOICE_ITEM], config?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + + ); + }, [config?.invoiceItemPreference, config?.pendingFields, config?.errorFields, policyID, selectedItem, translate]); - // Update focused index when selectedValue changes (after an error reverts the selection) + // Update focused index when the current preference changes (after an error reverts the selection) useEffect(() => { const selectedIndex = options.findIndex((option) => option.isSelected); if (selectedIndex !== -1 && selectionListRef.current) { selectionListRef.current?.updateFocusedIndex(selectedIndex); } - }, [selectedValue, options]); + }, [currentPreference, options]); return ( { - selectInvoicePreference(selection as MenuListItem); + onSelectRow={(selection: MenuListItem) => { + setDraftPreference(selection.value); }} ListItem={SingleSelectListItem} + listFooterContent={invoiceItemFooterContent} showScrollIndicator shouldUpdateFocusedIndex initiallyFocusedItemKey={options.find((mode) => mode.isSelected)?.keyForList} style={{containerStyle: [styles.pb0]}} /> - {config?.invoiceItemPreference === CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.SELECT && ( - - - { - if (!policyID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_SELECT.path)); - }} - brickRoadIndicator={areSettingsInErrorFields([CONST.NETSUITE_CONFIG.INVOICE_ITEM], config?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> - - - )} + {/* The Save button sits outside OfflineWithFeedback so its error row renders directly under the list rather than under the button. */} + + + ); } diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx index bab7feb9f409..cdfcae2160b9 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx @@ -37,12 +37,8 @@ type NetSuiteImportCustomFieldViewProps = WithPolicyConnectionsProps & { /** Whether the record is of type custom segment or list */ importCustomField: ImportCustomFieldsKeys; - /** Index of the current record */ valueIndex: number; - - /** Selected field of the current record */ fieldName: string; - policyID: string; }; }; diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx index 5c68ec015f84..820a43ed1cd2 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx @@ -10,7 +10,6 @@ import ROUTES from '@src/ROUTES'; import React from 'react'; type NetSuiteCustomListPickerProps = { - /** Current value of the selected item */ value?: string; /** Policy ID from the parent route's URL params (preferred over policy?.id because it is set before the Onyx policy record hydrates) */ diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx index f39875de4bea..ee36eedf3892 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx @@ -4,6 +4,7 @@ import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; @@ -12,6 +13,7 @@ import {setDraftValues} 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 moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {CustomListSelectorType} from '@pages/workspace/accounting/netsuite/types'; @@ -37,6 +39,8 @@ function NetSuiteCustomListSelectorPage({ const policy = usePolicy(policyID); const [formDraft] = useOnyx(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM_DRAFT); const currentCustomListValue = formDraft?.[INPUT_IDS.LIST_NAME] ?? ''; + // Freeze the pre-selected custom list when the page opened so it stays pinned to the top for the whole open/focus cycle. + const initialCustomListValue = useInitialSelection(currentCustomListValue, {resetOnFocus: true}); const rawCustomLists = policy?.connections?.netsuite?.options?.data?.customLists; @@ -50,15 +54,18 @@ function NetSuiteCustomListSelectorPage({ id: customListRecord.id, })); + // Pin the frozen pre-selected list to the top of the full list before search filtering, so it stays pinned while searching. + const orderedCustomListData = moveInitialSelectionToTop(customListData, initialCustomListValue ? [initialCustomListValue] : []); + const searchRegex = new RegExp(Str.escapeForRegExp(debouncedSearchValue.trim()), 'i'); - const filteredCustomLists = customListData.filter((customListRecord) => searchRegex.test(customListRecord.text ?? '')); + const filteredCustomLists = orderedCustomListData.filter((customListRecord) => searchRegex.test(customListRecord.text ?? '')); const isEmpty = debouncedSearchValue.trim() && !filteredCustomLists.length; return { options: isEmpty ? [] : filteredCustomLists, showTextInput: customListData.length >= CONST.STANDARD_LIST_ITEM_LIMIT, }; - }, [debouncedSearchValue, rawCustomLists, currentCustomListValue]); + }, [debouncedSearchValue, rawCustomLists, currentCustomListValue, initialCustomListValue]); const textInputOptions = useMemo( () => ({ @@ -104,9 +111,11 @@ function NetSuiteCustomListSelectorPage({ textInputOptions={textInputOptions} onSelectRow={onSelectRow} ListItem={SingleSelectListItem} - initiallyFocusedItemKey={currentCustomListValue} + initiallyFocusedItemKey={initialCustomListValue} + shouldScrollToFocusedIndexOnMount={false} shouldSingleExecuteRowSelect shouldStopPropagation + shouldUpdateFocusedIndex addBottomSafeAreaPadding /> diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx index ec9fbcd4297c..2c9e69dc96d6 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx @@ -1,11 +1,11 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import type {CustomFieldSubPageWithPolicy} from '@pages/workspace/accounting/netsuite/types'; @@ -20,7 +20,6 @@ import {View} from 'react-native'; function ConfirmCustomListStep({onMove, netSuiteCustomFieldFormValues: values, onNext}: CustomFieldSubPageWithPolicy) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: true}); @@ -38,30 +37,28 @@ function ConfirmCustomListStep({onMove, netSuiteCustomFieldFormValues: values, o {translate('workspace.common.letsDoubleCheck')} {fieldNames.map((fieldName, index) => ( - { onMove(index); }} /> ))} - + ); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx index e99dd24e2f6c..1662b2ae6b84 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx @@ -1,11 +1,11 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import type {CustomFieldSubPageWithPolicy} from '@pages/workspace/accounting/netsuite/types'; @@ -22,7 +22,6 @@ function ConfirmCustomSegmentStep({onMove, customSegmentType, netSuiteCustomFiel const {translate} = useLocalize(); const fieldNames = [INPUT_IDS.SEGMENT_NAME, INPUT_IDS.INTERNAL_ID, INPUT_IDS.SCRIPT_ID, INPUT_IDS.MAPPING]; - const {isOffline} = useNetwork(); const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: true}); @@ -38,32 +37,30 @@ function ConfirmCustomSegmentStep({onMove, customSegmentType, netSuiteCustomFiel {translate('workspace.common.letsDoubleCheck')} {fieldNames.map((fieldName, index) => ( - { onMove(index + 1); }} /> ))} - + ); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx index 8034beb9cd14..4a5c511f48c9 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ConnectionLayout from '@components/ConnectionLayout'; import FixedFooter from '@components/FixedFooter'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; @@ -47,10 +47,7 @@ type HelpLinkComponentProps = { /** Callback to localize content */ translate: LocaleContextProps['translate']; - /** Theme styles to apply to the component */ styles: ThemeStyles; - - /** Text alignment style for the Text component */ alignmentStyle: StyleProp; }; diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx index fffdda486137..c8f770d65e88 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx @@ -1,6 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -37,7 +37,6 @@ type NetSuiteImportCustomFieldViewProps = WithPolicyConnectionsProps & { /** Whether the record is of type custom segment or list */ importCustomField: ImportCustomFieldsKeys; - /** Index of the current record */ valueIndex: number; }; }; @@ -125,11 +124,10 @@ function NetSuiteImportCustomFieldView({ {fieldList.map((fieldName) => { const isEditable = !config?.pendingFields?.[importCustomField] && isNetSuiteCustomFieldPropertyEditable(customField, fieldName); return ( - )} - {qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && ( - <> - - updateManyPolicyConnectionConfigs( - policyID, - CONST.POLICY.CONNECTIONS.NAME.QBO, - { - [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR]: isOn, - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: isOn - ? (policy?.connections?.quickbooksOnline?.data?.vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE) - : CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }, - { - [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR]: qboConfig?.autoCreateVendor, - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableBillDefaultVendorObject?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }, - ) + {qboNonReimbursableDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && ( + + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT.getRoute(policyID))} + brickRoadIndicator={ + areSettingsInErrorFields([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR], qboConfig?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined } - onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR)} - shouldPlaceSubtitleBelowSwitch + shouldShowRightIcon /> - - - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT.getRoute(policyID))} - brickRoadIndicator={ - areSettingsInErrorFields([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR], qboConfig?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - shouldShowRightIcon - /> - - - + )} ); diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx index 28a76d1d2ffd..bfcd8f357396 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx @@ -17,8 +17,6 @@ import {getIsTravelBillingEnabled, getTravelBillingCardSettingsKey} from '@libs/ import goBackFromExportConnection from '@navigation/helpers/goBackFromExportConnection'; import Navigation from '@navigation/Navigation'; -import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@navigation/types'; import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; @@ -26,9 +24,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; import {View} from 'react-native'; @@ -36,8 +32,6 @@ function DynamicQuickbooksExportConfigurationPage({policy}: WithPolicyConnection const {translate} = useLocalize(); const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); const styles = useThemeStyles(); - const route = useRoute>(); - const backTo = route?.params?.backTo; const policyID = policy?.id; const policyOwner = policy?.owner ?? ''; const qboConfig = policy?.connections?.quickbooksOnline?.config; @@ -55,8 +49,15 @@ function DynamicQuickbooksExportConfigurationPage({policy}: WithPolicyConnection [qboConfig?.nonReimbursableExpensesExportDestination], ); + const shouldShowCreditCardVendorMenuItem = useMemo( + () => + qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || + qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, + [qboConfig?.nonReimbursableExpensesExportDestination], + ); + const dynamicBackPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path); - const goBack = () => goBackFromExportConnection(shouldShowVendorMenuItems, backTo, dynamicBackPath); + const goBack = () => goBackFromExportConnection(shouldShowVendorMenuItems, dynamicBackPath); const menuItems = [ { @@ -101,8 +102,9 @@ function DynamicQuickbooksExportConfigurationPage({policy}: WithPolicyConnection subscribedSettings: [ CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSE_ACCOUNT, - ...(shouldShowVendorMenuItems ? [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR] : []), - ...(shouldShowVendorMenuItems && qboConfig?.autoCreateVendor ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR] : []), + // The Default vendor row is now always visible on the company card page for whichever export destination is active, so subscribe to the matching vendor key unconditionally. `autoCreateVendor` is no longer edited from this flow. It lives on the Advanced page and is subscribed there. + ...(shouldShowVendorMenuItems ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR] : []), + ...(shouldShowCreditCardVendorMenuItem ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR] : []), ], }, { diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx index 2836aafd3f0f..2223d346ff1b 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx @@ -1,6 +1,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import type {ListItem} from '@components/SelectionList/types'; import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -21,6 +22,7 @@ import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import React from 'react'; +import {View} from 'react-native'; type VendorConfigKey = 'nonReimbursableBillDefaultVendor' | 'nonReimbursableCreditCardDefaultVendor'; @@ -74,6 +76,12 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen Navigation.goBack(); }; + const listHeaderContent = ( + + {translate('workspace.accounting.defaultVendorSelectHeader')} + + ); + const listEmptyContent = ( Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} > - { if (!newBankAccountID) { @@ -156,7 +157,7 @@ function ExpensifyCardDynamicReconciliation({policyID, workspaceAccountID, domai description={translate('workspace.accounting.chooseReconciliationAccount.chooseBankAccount')} html={translate( 'workspace.accounting.chooseReconciliationAccount.settlementAccountReconciliation', - `${environmentURL}${createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT.path)}`, + `${environmentURL}${buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT.path)}`, settlementAccountEnding, )} selectedBankAccountID={reconciliationBankAccountID} diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 68a1b02f292e..90a8ae26ced0 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -1,3 +1,4 @@ +import ConnectToCampfireFlow from '@components/ConnectToCampfireFlow'; import ConnectToCertiniaFlow from '@components/ConnectToCertiniaFlow'; import ConnectToDualEntryFlow from '@components/ConnectToDualEntry'; import ConnectToNetSuiteFlow from '@components/ConnectToNetSuiteFlow'; @@ -64,14 +65,14 @@ function getAccountingIntegrationData( connectionName: PolicyConnectionName, policyID: string, translate: LocaleContextProps['translate'], - existingConnections: {sageIntacct: boolean; qbd: boolean; certinia: boolean; rillet: boolean; dualEntry: boolean}, + existingConnections: {sageIntacct: boolean; qbd: boolean; certinia: boolean; rillet: boolean; dualEntry: boolean; campfire: boolean}, policy?: Policy, key?: number, integrationToDisconnect?: ConnectionName, shouldDisconnectIntegrationBeforeConnecting?: boolean, canUseNetSuiteUSATax?: boolean, expensifyIcons?: Record< - 'IntacctSquare' | 'IntuitSquare' | 'QBOSquare' | 'XeroSquare' | 'NetSuiteSquare' | 'QBDSquare' | 'CertiniaSquare' | 'RilletSquare' | 'DualEntrySquare', + 'IntacctSquare' | 'IntuitSquare' | 'QBOSquare' | 'XeroSquare' | 'NetSuiteSquare' | 'QBDSquare' | 'CertiniaSquare' | 'RilletSquare' | 'DualEntrySquare' | 'CampfireSquare', IconAsset >, cardFeeds?: CombinedCardFeeds, @@ -129,6 +130,15 @@ function getAccountingIntegrationData( } return ROUTES.POLICY_ACCOUNTING_DUALENTRY_SETUP.getRoute(policyID); }; + const getBackToAfterWorkspaceUpgradeRouteForCampfire = () => { + if (integrationToDisconnect) { + return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + } + if (existingConnections.campfire) { + return ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS.getRoute(policyID); + } + return ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SETUP.getRoute(policyID); + }; switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: @@ -162,13 +172,14 @@ function getAccountingIntegrationData( CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSE_ACCOUNT, CONST.QUICKBOOKS_CONFIG.TRAVEL_BILLING_VENDOR, CONST.QUICKBOOKS_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT, + // Both non-reimbursable export destinations now expose an always-visible Default vendor row, so surface the matching vendor key here regardless of `autoCreateVendor` (which is an Advanced setting and is subscribed under subscribedAdvancedSettings below). ...(qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL - ? [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR] - : []), - ...(qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && - policy?.connections?.quickbooksOnline?.config?.autoCreateVendor ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR] : []), + ...(qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || + qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD + ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR] + : []), ], onCardReconciliationPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, CONST.POLICY.CONNECTIONS.ROUTE.QBO)), onAdvancedPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)), @@ -555,6 +566,63 @@ function getAccountingIntegrationData( errorFields: policy?.connections?.dualEntry?.config?.errorFields, }; } + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: { + return { + title: translate('workspace.accounting.campfire'), + icon: expensifyIcons?.CampfireSquare, + setupConnectionFlow: ( + + ), + onImportPagePress: () => null, + subscribedImportSettings: [ + CONST.CAMPFIRE_CONFIG.ENABLE_NEW_CATEGORIES, + CONST.CAMPFIRE_CONFIG.SYNC_TAX_RATES, + ...(policy?.connections?.campfire?.data?.fields?.map((field) => `${CONST.CAMPFIRE_CONFIG.FIELD_MAPPING_PREFIX}${field.id}`) ?? []), + ], + onExportPagePress: () => null, + subscribedExportSettings: [ + CONST.CAMPFIRE_CONFIG.EXPORTER, + CONST.CAMPFIRE_CONFIG.EXPORT_DATE, + CONST.CAMPFIRE_CONFIG.REIMBURSABLE, + CONST.CAMPFIRE_CONFIG.NON_REIMBURSABLE, + CONST.CAMPFIRE_CONFIG.DEFAULT_VENDORID, + CONST.CAMPFIRE_CONFIG.CREDIT_CARD_ACCOUNT_ID, + CONST.CAMPFIRE_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS, + ...Object.values(cardFeeds ?? {}).map((program) => `${CONST.CAMPFIRE_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${program.feed}`), + ], + externalSubscribedExportSettingsPendingAction: getCardsCustomExportPendingAction( + cardFeeds ?? {}, + cardList ?? {}, + CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT, + ), + externalSubscribedExportSettingsHasErrorFields: areCardsCustomExportInErrorFields( + cardFeeds ?? {}, + cardList ?? {}, + CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT, + ), + onAdvancedPagePress: () => null, + subscribedAdvancedSettings: [ + CONST.CAMPFIRE_CONFIG.ACCOUNTING_METHOD, + CONST.CAMPFIRE_CONFIG.AUTO_SYNC, + CONST.CAMPFIRE_CONFIG.SYNC_REIMBURSED_REPORTS, + CONST.CAMPFIRE_CONFIG.BILL_PAYMENT_ACCOUNT_ID, + CONST.CAMPFIRE_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS, + CONST.CAMPFIRE_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID, + CONST.CAMPFIRE_CONFIG.SYNC_TRAVEL_BILLING_SETTLEMENTS, + CONST.CAMPFIRE_CONFIG.TRAVEL_BILLING_SETTLEMENTS_BANK_ACCOUNT_ID, + CONST.CAMPFIRE_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT_ID, + ], + workspaceUpgradeNavigationDetails: { + integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.campfire.alias, + backToAfterWorkspaceUpgradeRoute: getBackToAfterWorkspaceUpgradeRouteForCampfire(), + }, + pendingFields: policy?.connections?.campfire?.config?.pendingFields, + errorFields: policy?.connections?.campfire?.config?.errorFields, + }; + } default: return undefined; } diff --git a/src/pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage.tsx b/src/pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage.tsx index 9a8d0b826fba..fb716e5f3790 100644 --- a/src/pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage.tsx @@ -1,5 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -80,14 +80,13 @@ function DynamicXeroAutoSyncPage({policy}: WithPolicyConnectionsProps) { /> {!!autoSync?.enabled && ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx index d3240db5707f..f71a5dfe9e03 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx @@ -35,14 +35,14 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps const {bankAccounts} = policy?.connections?.xero?.data ?? {}; - // Gate the Xero default-supplier row on Xero specifically being configured, not on the global + // Gate the Xero Default vendor row on Xero specifically being configured, not on the global // hasVendorFeature predicate. hasVendorFeature OR's all integrations, so on a dual-connected // workspace where QBO/Intacct is the active vendor-matching source it would still expose the // row even when Xero is mid tenant-switch (config.isConfigured=false) and data.contacts is // stale from the prior tenant — allowing the admin to persist a defaultVendor that flips // invalid the moment the new sync completes. const isVendorFeatureAvailable = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING) && isXeroVendorMatchingActive(policy); - const defaultSupplierName = getXeroSupplierByID(policy, defaultVendor)?.name ?? ''; + const defaultVendorName = getXeroSupplierByID(policy, defaultVendor)?.name ?? ''; const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); @@ -115,9 +115,13 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps ...(isVendorFeatureAvailable ? [ { - description: translate('workspace.xero.defaultSupplier'), + description: translate('workspace.accounting.defaultVendor'), onPress: () => (!policyID ? undefined : Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path))), - title: defaultSupplierName, + title: defaultVendorName, + // No fallback vendor name: Xero posts company card expenses as bank transactions and never + // auto-creates a stand-in vendor, so clearing the default just turns the fallback off. Passing + // one here would tell the admin their expenses export as "Credit Card Misc.", which is QBO-only. + helperText: translate('workspace.accounting.defaultVendorHelperText', !!defaultVendorName), subscribedSettings: [CONST.XERO_CONFIG.DEFAULT_VENDOR], }, ] diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 98454feeb271..63764ebb9d1a 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -1,6 +1,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -24,6 +25,7 @@ import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; // Empty string persisted to defaultVendor when the admin wants to disable the fallback // supplier altogether — gives them a way out when a previously chosen Xero contact was deleted @@ -124,6 +126,15 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy [currentContactID, policyID, isFeatureAvailable, goBack], ); + const listHeaderContent = useMemo( + () => ( + + {translate('workspace.accounting.defaultVendorSelectHeader')} + + ), + [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + ); + const listEmptyContent = useMemo( () => ( Navigation.goBack(backPath)} > - + - {translate('workspace.accounting.import')} + {translate('workspace.accounting.import')} ; - - /** The name of the category */ categoryName?: string; - - /** Function to call when the form is submitted */ onSubmit: (values: FormOnyxValues) => void; - - /** Function to validate the edited values of the form */ validateEdit?: (values: FormOnyxValues) => FormInputErrors; /** Whether to add bottom safe area padding to the form. Should be false when the parent wrapper already handles it. */ diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index c386d01d52b2..6a7da5c93b44 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -1,6 +1,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -19,28 +20,19 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; -import {formatRequiredFieldsTitle} from '@libs/AttendeeUtils'; import getCategoryContextualRules from '@libs/CategoryContextualRulesUtils'; -import { - formatDefaultTaxRateText, - formatRequireItemizedReceiptsOverText, - formatRequireReceiptsOverText, - getCategoryApproverRule, - getCategoryDefaultTaxRate, - getDecodedCategoryName, -} from '@libs/CategoryUtils'; +import {getCategoryApproverRule, getDecodedCategoryName} from '@libs/CategoryUtils'; import {getLatestErrorMessageField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {isDisablingOrDeletingLastEnabledCategory} from '@libs/OptionsListUtils'; -import {arePolicyRulesEnabled, getWorkflowApprovalsUnavailable, hasTags, isAttendeeTrackingEnabled, isControlPolicy, tryNavigateToControlPolicyUpgrade} from '@libs/PolicyUtils'; +import {arePolicyRulesEnabled, getWorkflowApprovalsUnavailable, hasTags, isControlPolicy, tryNavigateToControlPolicyUpgrade} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -74,20 +66,16 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const {policy, categories: policyCategories} = policyData; const {canWrite: canWriteCategories, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.CATEGORIES); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const {environmentURL} = useEnvironment(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); const policyCategory = policyCategories?.[categoryName] ?? Object.values(policyCategories).find((category) => category.previousCategoryName === categoryName); - const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - const policyCategoryExpenseLimitType = policyCategory?.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE; const decodedCategoryName = getDecodedCategoryName(policyCategory?.name ?? ''); - const categoryRulesEnabled = arePolicyRulesEnabled(policy, policyCategories, isRulesRevampEnabled); + const categoryRulesEnabled = arePolicyRulesEnabled(policy, policyCategories); const contextualRules = useMemo(() => { - if (!isRulesRevampEnabled || !policyCategory) { + if (!policyCategory) { return []; } @@ -99,7 +87,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti convertToDisplayString, isOffline, }); - }, [convertToDisplayString, isOffline, isRulesRevampEnabled, policy, policyCategory, translate]); + }, [convertToDisplayString, isOffline, policy, policyCategory, translate]); const shouldPreventDisableOrDelete = isDisablingOrDeletingLastEnabledCategory(policy, policyData.categories, [policyCategory]); const isQuickSettingsFlow = name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS; @@ -137,65 +125,9 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti navigation.setParams({categoryName: policyCategory?.name}); }, [categoryName, navigation, policyCategory?.name, isFocused]); - const flagAmountsOverText = useMemo(() => { - if (policyCategory?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategory?.maxExpenseAmount) { - return ''; - } - - return `${convertToDisplayString(policyCategory?.maxExpenseAmount, policyCurrency)} ${CONST.DOT_SEPARATOR} ${translate( - `workspace.rules.categoryRules.expenseLimitTypes.${policyCategoryExpenseLimitType}`, - )}`; - }, [convertToDisplayString, policyCategory?.maxExpenseAmount, policyCategoryExpenseLimitType, policyCurrency, translate]); - const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], categoryName)?.approver ?? ''; const approverText = usePersonalDetailByLogin(categoryApprover, (personalDetails) => formatPhoneNumber(personalDetails?.displayName ?? categoryApprover)); - const defaultTaxRateText = useMemo(() => { - const taxID = getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); - - if (!taxID) { - return ''; - } - - const taxRate = policy?.taxRates?.taxes[taxID]; - - if (!taxRate) { - return ''; - } - - return formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates); - }, [categoryName, policy?.rules?.expenseRules, policy?.taxRates, translate]); - - const requireReceiptsOverText = useMemo(() => { - if (!policy) { - return ''; - } - return formatRequireReceiptsOverText(translate, policy, policyCategory?.maxAmountNoReceipt, convertToDisplayString); - }, [policy, policyCategory?.maxAmountNoReceipt, translate, convertToDisplayString]); - - const requireItemizedReceiptsOverText = useMemo(() => { - if (!policy) { - return ''; - } - return formatRequireItemizedReceiptsOverText(translate, policy, policyCategory?.maxAmountNoItemizedReceipt, convertToDisplayString); - }, [policy, policyCategory?.maxAmountNoItemizedReceipt, translate, convertToDisplayString]); - - const requiredFieldsTitle = useMemo(() => { - if (!policyCategory) { - return ''; - } - return formatRequiredFieldsTitle(translate, policyCategory, isAttendeeTrackingEnabled(policy)); - }, [policyCategory, translate, policy]); - - const requireFieldsPendingAction = useMemo(() => { - if (isAttendeeTrackingEnabled(policy)) { - // Pending fields are objects so we can't use nullish coalescing - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return policyCategory?.pendingFields?.areAttendeesRequired || policyCategory?.pendingFields?.areCommentsRequired; - } - return policyCategory?.pendingFields?.areCommentsRequired; - }, [policyCategory?.pendingFields, policy]); - // eslint-disable-next-line rulesdir/no-negated-variables const showCannotDeleteOrDisableLastCategoryModal = useCallback(() => { showConfirmModal({ @@ -331,67 +263,69 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti - - { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), - ), - ); - return; - } - Navigation.navigate( - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), - ); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), + ), + ); + return; + } + Navigation.navigate( + isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), + ); + } + : undefined + } + value={policyCategory['GL Code']} /> - { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), - ), - ); - return; - } - Navigation.navigate( - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), - ); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), + ), + ); + return; + } + Navigation.navigate( + isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), + ); + } + : undefined + } + value={policyCategory['Payroll Code']} /> - {categoryRulesEnabled && isRulesRevampEnabled && ( + {categoryRulesEnabled && ( <> - {!!policy?.tax?.trackingEnabled && ( - { - navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - )} )} {canWriteCategories && !isThereAnyAccountingConnection && ( @@ -457,103 +380,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti /> )} - {categoryRulesEnabled && !isRulesRevampEnabled && ( - <> - - {translate('workspace.rules.categoryRules.title')} - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_APPROVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - disabled={approverDisabled} - helperText={ - approverDisabled - ? translate('workspace.rules.categoryRules.enableWorkflows', `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)}`) - : undefined - } - shouldParseHelperText - /> - {!!policy?.tax?.trackingEnabled && ( - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - )} - {/* - * Legacy category rule entry points. When removing the RULES_REVAMP beta, - * delete this entire non-revamp block (and related useMemo values) instead - * of keeping these Category settings routes. - */} - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - shouldRenderAsHTML - /> - - - )} - - {categoryRulesEnabled && isRulesRevampEnabled && (contextualRules.length > 0 || canWriteRules) && ( + {categoryRulesEnabled && (contextualRules.length > 0 || canWriteRules) && ( <> {translate('workspace.rules.categoryRules.title')} diff --git a/src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx b/src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx index d8973e34259a..82d172dd362c 100644 --- a/src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx +++ b/src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx @@ -77,7 +77,7 @@ function DynamicExpenseLimitTypeSelectorPage({ shouldSingleExecuteRowSelect style={{containerStyle: [styles.pt3]}} initiallyFocusedItemKey={currentExpenseLimitType} - alternateNumberOfSupportedLines={3} + alternateNumberOfSupportedLines={4} addBottomSafeAreaPadding /> diff --git a/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx index 3891116b7ec4..07a42d753fe9 100644 --- a/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx @@ -1,38 +1,28 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import SelectionList from '@components/SelectionList'; -import SpendCategorySelectorListItem from '@components/SelectionList/ListItem/SpendCategorySelectorListItem'; -import type {ListItem} from '@components/SelectionList/types'; -import Text from '@components/Text'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; -import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; -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 {hasEnabledOptions} from '@libs/OptionsListUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import {setPolicyShowCategoryGLCodes, setWorkspaceRequiresCategory} from '@userActions/Policy/Category'; +import {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, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; +import React from 'react'; type DynamicWorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps & ( @@ -44,67 +34,15 @@ function DynamicWorkspaceCategoriesSettingsPage({policy, route}: DynamicWorkspac const {policyID} = route.params; const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const policyData = usePolicyData(policyID); - const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; - const currentConnectionName = getCurrentAccountingIntegrationName(policy, translate); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path); - const toggleSubtitle = isConnectedToAccounting && currentConnectionName ? translate('workspace.categories.needCategoryForExportToIntegration', currentConnectionName) : undefined; - - const updateWorkspaceRequiresCategory = useCallback( - (value: boolean) => { - setWorkspaceRequiresCategory(policyData, value); - }, - [policyData], - ); const updateShowCategoryGLCodes = (value: boolean) => { setPolicyShowCategoryGLCodes(policyID, value); }; - const data = useMemo(() => { - if (!policyData.policy?.mccGroup) { - return []; - } - - return Object.entries(policyData.policy?.mccGroup).map( - ([mccKey, mccGroup]): ListItem => ({ - categoryID: mccGroup.category, - keyForList: mccKey, - groupID: mccKey, - tabIndex: -1, - pendingAction: mccGroup?.pendingAction, - }), - ); - }, [policyData.policy]); - - const hasEnabledCategories = hasEnabledOptions(policyData.categories); - const isToggleDisabled = !policy?.areCategoriesEnabled || !hasEnabledCategories || isConnectedToAccounting; - - // Under the revamp only the GL codes toggle is left here, so the page has nothing to show without it. - const shouldBlockEmptySettings = isRulesRevampEnabled && !policy?.glCodes; - - const onSelectItem = (item: ListItem) => { - if (!item.groupID) { - return; - } - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SPEND_CATEGORY_SELECTOR.getRoute(item.groupID))); - }; - - const selectionListHeaderContent = ( - - - {translate('workspace.categories.defaultSpendCategories')} - - {translate('workspace.categories.spendCategoriesDescription')} - - ); + // Only the GL codes toggle is left here, so the page has nothing to show without it. + const shouldBlockEmptySettings = !policy?.glCodes; return ( Navigation.goBack(isQuickSettingsFlow ? backPath : undefined)} /> - {!isRulesRevampEnabled && ( - clearPolicyErrorField(policy?.id, 'requiresCategory')} - shouldPlaceSubtitleBelowSwitch - /> - )} {!!policy?.glCodes && ( clearPolicyErrorField(policy?.id, 'showCategoryGLCodes')} /> )} - {/* Default spend categories moved to Rules > Expense defaults, so they'd be a second source of truth here. */} - {!isRulesRevampEnabled && ( - <> - - - {!!policyData.policy && (data?.length ?? 0) > 0 && ( - - )} - - - )} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 27e2964a2f71..e4f72d216daf 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import DecisionModal from '@components/DecisionModal'; @@ -23,7 +23,6 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; @@ -88,8 +87,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {canWrite: canWriteCategories, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.CATEGORIES); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const [selectedCategoryKeys, setSelectedCategoryKeys] = useState([]); const canSelectMultiple = canWriteCategories && (isSmallScreenWidth ? isMobileSelectionModeEnabled : true); @@ -365,8 +362,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const secondaryActions = useMemo(() => { const menuItems = []; - // Under the revamp the other settings moved to Rules, so this is only worth showing for the GL codes toggle. - if (canWriteCategories && (!isRulesRevampEnabled || !!policy?.glCodes)) { + // The other settings moved to Rules, so this is only worth showing for the GL codes toggle. + if (canWriteCategories && !!policy?.glCodes) { menuItems.push({ icon: icons.Gear, text: translate('common.settings'), @@ -412,9 +409,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { icons.Gear, icons.Table, translate, - navigateToCategoriesSettings, canWriteCategories, - isRulesRevampEnabled, + navigateToCategoriesSettings, policy?.glCodes, policyHasAccountingConnections, hasVisibleCategories, diff --git a/src/pages/workspace/companyCards/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/BankConnection/index.native.tsx index 3e5621cdadbf..dd649fbe6dcc 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.native.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.native.tsx @@ -36,7 +36,6 @@ import {StyleSheet, View} from 'react-native'; import {WebView} from 'react-native-webview'; type BankConnectionProps = { - /** ID of the policy */ policyID?: string; /** Selected feed for assign card flow */ diff --git a/src/pages/workspace/companyCards/BankConnection/index.tsx b/src/pages/workspace/companyCards/BankConnection/index.tsx index 19ea851f3c88..498401f58dc6 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.tsx @@ -40,7 +40,6 @@ import openBankConnection from './openBankConnection'; let customWindow: Window | null = null; type BankConnectionProps = { - /** ID of the policy */ policyID?: string; /** Selected feed for assign card flow */ diff --git a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx index 0c815ab03ffd..d736baa72731 100644 --- a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx +++ b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx @@ -24,7 +24,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isUsingStagingApi} from '@libs/ApiUtils'; +import {getActiveServer} from '@libs/ApiUtils'; import navigateToCardTransactions from '@libs/CardNavigationUtils'; import { getCardFeedIcon, @@ -96,6 +96,7 @@ function DynamicWorkspaceCompanyCardDetailsPage({route}: DynamicWorkspaceCompany 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', ]); const {isOffline} = useNetwork(); @@ -105,7 +106,7 @@ function DynamicWorkspaceCompanyCardDetailsPage({route}: DynamicWorkspaceCompany const {canWrite: canWriteCompanyCards} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.COMPANY_CARDS); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); - const [shouldUseStagingServer = isUsingStagingApi()] = useOnyx(ONYXKEYS.SHOULD_USE_STAGING_SERVER); + const [activeServer = getActiveServer()] = useOnyx(ONYXKEYS.ACTIVE_SERVER); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [allBankCards, allBankCardsMetadata] = useCardsList(feedName); const [cardList, cardListMetadata] = useOnyx(ONYXKEYS.CARD_LIST); @@ -137,7 +138,7 @@ function DynamicWorkspaceCompanyCardDetailsPage({route}: DynamicWorkspaceCompany // Show "Break connection" only when Mock Bank requests target non-production APIs. const isMockBank = bank?.includes(CONST.COMPANY_CARDS.BANK_CONNECTIONS.MOCK_BANK); - const isUsingNonProductionAPI = shouldUseStagingServer || CONFIG.IS_USING_LOCAL_WEB; + const isUsingNonProductionAPI = activeServer !== CONST.SERVER.PRODUCTION || CONFIG.IS_USING_LOCAL_WEB; const shouldShowBreakConnection = isMockBank && isUsingNonProductionAPI; const lastScrape = card?.lastScrape diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx index 40123769eef7..0fa22f7d7858 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx @@ -18,7 +18,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setContactMethodAsDefault} from '@libs/actions/User'; import {getFeedInfo} from '@libs/CardFeedUtils'; import {getCardFeedWithDomainID} from '@libs/CardUtils'; -import {addErrorMessage} from '@libs/ErrorUtils'; +import {addErrorMessage, getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {expensifyLoginsSelector} from '@libs/UserUtils'; @@ -31,6 +31,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {updateSelectedFeed} from '@userActions/Card'; import {linkCardFeedToPolicy} from '@userActions/CompanyCards'; +import {setErrorFields} from '@userActions/FormActions'; import {AddWorkEmail} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -90,13 +91,15 @@ function WorkspaceCompanyCardAddWorkEmailPage({route}: WorkspaceCompanyCardAddWo Navigation.closeRHPFlow(); }) .catch((error: TranslationPaths) => { - addErrorMessage({}, INPUT_IDS.EMAIL, translate(error)); + setErrorFields(ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM, { + [INPUT_IDS.EMAIL]: getMicroSecondOnyxErrorWithMessage(translate(error)), + }); }) .finally(() => { setLoading(false); }); } else { - AddWorkEmail(submittedEmail); + AddWorkEmail(submittedEmail, ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); } setEmail(submittedEmail); }; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx index 7f3166d04163..ef3e2833d584 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DatePicker from '@components/DatePicker'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx index a4eecf163599..fadd22590255 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx index d02eed73915a..7e5847d26ae4 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx @@ -4,9 +4,12 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import type {ListItem} from '@components/SelectionList/types'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; + import CONST from '@src/CONST'; import React, {useCallback, useMemo, useState} from 'react'; @@ -27,27 +30,26 @@ function CustomCloseDateSelectionList({initiallySelectedDay, onConfirmSelectedDa const [selectedDay, setSelectedDay] = useState(initiallySelectedDay); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const [error, setError] = useState(undefined); + // Freeze the day that was selected when this list opened so the pre-selected day stays pinned to the top for the whole open/focus cycle, even as the live selection changes. + const initialSelectedDay = useInitialSelection(selectedDay, {resetOnFocus: true}); const data = useMemo(() => { - return CONST.DATE.MONTH_DAYS.reduce((days, dayValue) => { - const day = { - value: dayValue, - text: dayValue.toString(), - keyForList: dayValue.toString(), - isSelected: dayValue === selectedDay, - }; - - if (debouncedSearchValue) { - if (day.text.includes(debouncedSearchValue)) { - days.push(day); - } - } else { - days.push(day); - } - - return days; - }, []); - }, [selectedDay, debouncedSearchValue]); + const allDays = CONST.DATE.MONTH_DAYS.map((dayValue) => ({ + value: dayValue, + text: dayValue.toString(), + keyForList: dayValue.toString(), + isSelected: dayValue === selectedDay, + })); + + // Pin the frozen initial day to the top of the full list before search filtering, so it stays pinned while searching (search filters the already-pinned list rather than reordering it). + const orderedDays = moveInitialSelectionToTop(allDays, initialSelectedDay ? [initialSelectedDay.toString()] : []); + + if (!debouncedSearchValue) { + return orderedDays; + } + + return orderedDays.filter((day) => day?.text?.includes(debouncedSearchValue)); + }, [selectedDay, debouncedSearchValue, initialSelectedDay]); const selectDayAndClearError = useCallback((item: CustomCloseDateListItem) => { setSelectedDay(item.value); @@ -88,7 +90,8 @@ function CustomCloseDateSelectionList({initiallySelectedDay, onConfirmSelectedDa data={data} ListItem={SingleSelectListItem} onSelectRow={selectDayAndClearError} - initiallyFocusedItemKey={initiallySelectedDay?.toString()} + initiallyFocusedItemKey={initialSelectedDay?.toString()} + shouldScrollToFocusedIndexOnMount={false} confirmButtonOptions={confirmButtonOptions} textInputOptions={textInputOptions} shouldShowListEmptyContent={false} diff --git a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx index 8caf1914fee6..b13910058d5e 100644 --- a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -6,7 +7,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; @@ -43,7 +43,6 @@ function getCardInstructionHeader(feedProvider: CardFeedProvider) { function CardInstructionsStep({policyID}: CardInstructionsStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); @@ -105,15 +104,14 @@ function CardInstructionsStep({policyID}: CardInstructionsStepProps) { - + diff --git a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx index 6127db974d55..a2b4d3dbf0e3 100644 --- a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx @@ -34,7 +34,6 @@ import React from 'react'; import {View} from 'react-native'; type DetailsStepProps = { - /** ID of the current policy */ policyID?: string; /** Existing card feeds for the current policy */ diff --git a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx index 97689caca997..7001fcdd13fe 100644 --- a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx +++ b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx @@ -1,9 +1,10 @@ -import ConfirmModal from '@components/ConfirmModal'; import {useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; import useLocalize from '@hooks/useLocalize'; @@ -27,7 +28,7 @@ import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import AmexCustomFeed from './AmexCustomFeed'; @@ -47,7 +48,7 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { const [addNewCardFeed, addNewCardFeedMetadata] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); const {currentStep} = addNewCardFeed ?? {}; const {isBlockedToAddNewFeeds, isAllFeedsResultLoading, cardFeeds, workspaceAccountID} = useIsBlockedToAddFeed(policyID); - const [isModalVisible, setIsModalVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); const {translate} = useLocalize(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -97,6 +98,21 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { ); } + const handlePlaidExit = () => { + showConfirmModal({ + title: translate('workspace.companyCards.addNewCard.exitModal.title'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + confirmText: translate('workspace.companyCards.addNewCard.exitModal.confirmText'), + cancelText: translate('workspace.companyCards.addNewCard.exitModal.cancelText'), + prompt: translate('workspace.companyCards.addNewCard.exitModal.prompt'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false); + }); + }; + let CurrentStep: React.JSX.Element; switch (currentStep) { case CONST.COMPANY_CARDS.STEP.SELECT_BANK: @@ -130,7 +146,7 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.PLAID_CONNECTION: - CurrentStep = setIsModalVisible(true)} />; + CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.IMPORT_FROM_FILE: CurrentStep = ; @@ -149,19 +165,6 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} > {CurrentStep} - setIsModalVisible(false)} - onConfirm={() => { - setIsModalVisible(false); - navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false); - }} - /> ); } diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index 6a07d5261373..f555906cb960 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -1,4 +1,5 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -9,7 +10,6 @@ import Text from '@components/Text'; import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -52,7 +52,6 @@ function ImportFromFileStep() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); - const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['Download']); const route = useRoute>(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); @@ -143,15 +142,14 @@ function ImportFromFileStep() { {translate('workspace.companyCards.addNewCard.downloadTemplate')} - + diff --git a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx index 833560e7f517..7f89c22376dc 100644 --- a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx @@ -13,7 +13,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isUsingStagingApi} from '@libs/ApiUtils'; +import {getActiveServer} from '@libs/ApiUtils'; import {getBankCardDetailsImage, getCorrectStepForPlaidSelectedBank} from '@libs/CardUtils'; import variables from '@styles/variables'; @@ -34,7 +34,7 @@ function SelectBankStep() { const companyCardBankIcons = useCompanyCardBankIcons(); const {isOffline} = useNetwork(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const [shouldUseStagingServer = isUsingStagingApi()] = useOnyx(ONYXKEYS.SHOULD_USE_STAGING_SERVER); + const [activeServer = getActiveServer()] = useOnyx(ONYXKEYS.ACTIVE_SERVER); const bankSelected = addNewCard?.data?.selectedBank; const [hasError, setHasError] = useState(false); const isOtherBankSelected = bankSelected === CONST.COMPANY_CARDS.BANKS.OTHER; @@ -61,9 +61,8 @@ function SelectBankStep() { const data = Object.values(CONST.COMPANY_CARDS.BANKS) .filter((bank) => { - // Only show Mock Bank when the frontend environment is not production or when using the staging server if (bank === CONST.COMPANY_CARDS.BANKS.MOCK_BANK) { - return CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.PRODUCTION || shouldUseStagingServer; + return CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.PRODUCTION || activeServer !== CONST.SERVER.PRODUCTION; } if (bank === CONST.COMPANY_CARDS.BANKS.FILE_IMPORT) { return false; diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index c7d52078a4f2..bbbf0bf65e2e 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -79,7 +79,7 @@ function AssigneeStep({route}: AssigneeStepProps) { }; }); const [shouldShowError, setShouldShowError] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); const canInviteMembers = canMemberWrite(policy, session?.email ?? '', CONST.POLICY.POLICY_FEATURE.MEMBERS); const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); @@ -320,7 +320,7 @@ function AssigneeStep({route}: AssigneeStepProps) { initiallyFocusedItemKey={selectedAssignee?.keyForList} shouldScrollToFocusedIndexOnMount={false} shouldShowLoadingPlaceholder={!areOptionsInitialized} - isLoadingNewOptions={canInviteMembers && !!isSearchingForReports} + isLoadingNewOptions={canInviteMembers && !!isSearchingForUsers} disableMaintainingScrollPosition shouldUpdateFocusedIndex addBottomSafeAreaPadding diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index c0c8b13bb29a..ef2ed3098895 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -1,7 +1,8 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -10,7 +11,6 @@ import useCardFeeds from '@hooks/useCardFeeds'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; @@ -54,7 +54,6 @@ function ConfirmationStep({route}: ConfirmationStepProps) { const cardID = route.params.cardID; const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD); const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); @@ -81,6 +80,11 @@ function ConfirmationStep({route}: ConfirmationStepProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; + const cardNameTitle = maskCardNumber(cardToAssign?.cardName ?? '', cardToAssign?.bankName); + + const transactionStartDateTitle = + cardToAssign?.dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? translate('workspace.companyCards.fromTheBeginning') : cardToAssign?.startDate; + useEffect(() => { if (!assignCard?.isAssignmentFinished) { return; @@ -183,10 +187,9 @@ function ConfirmationStep({route}: ConfirmationStepProps) { > {translate('workspace.companyCards.letsDoubleCheck')} {translate('workspace.companyCards.confirmationDescription')} - {translate('common.to')} @@ -199,21 +202,15 @@ function ConfirmationStep({route}: ConfirmationStepProps) { testID={CONST.ASSIGN_CARD_CARDHOLDER_ROW_TEST_ID} onPress={() => editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} /> - editStep(CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE)} + value={transactionStartDateTitle} /> - editStep(CONST.COMPANY_CARD.STEP.CARD_NAME)} + value={cardToAssign?.customCardName} /> - + diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index 19dad2b0d1c8..ce7050fa39ce 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import DatePicker from '@components/DatePicker'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index be7caf84c3e7..643991e9e025 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -243,7 +243,7 @@ function getExportMenuItem( shouldShowMenuItem, exportType, data, - exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path), + exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, backTo ?? basePath), }; } case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: { @@ -370,7 +370,7 @@ function getExportMenuItem( title, exportType, shouldShowMenuItem, - exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path), + exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path, backTo ?? basePath), data: resultData.map((card) => ({ value: card.id, text: card.name, diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx index 9435451db862..e152ed1de359 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -87,7 +87,7 @@ function CopyPolicySettingsConfirmPage() { // Editing the workspace selection from this screen can introduce a Collect (Team) target that // requires an upgrade for the already-selected Control-only settings, bypassing the Upgrade step. // Re-gate at submit time and route to the Upgrade step instead of copying when one is still required. - if (shouldShowCopyPolicySettingsUpgradeStep(targetPolicies, parts)) { + if (shouldShowCopyPolicySettingsUpgradeStep(targetPolicies, parts, sourcePolicy)) { Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_UPGRADE.getRoute(sourcePolicyID)); return; } diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx index ff846fea414f..2e66b78373e6 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx @@ -104,7 +104,9 @@ function CopyPolicySettingsSelectFeaturesPage() { ) : 0; const taxesCount = Object.values(sourcePolicy?.taxRates?.taxes ?? {}).filter((tax) => tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; - const reportFieldsCount = Object.values(getReportFieldsByPolicyID(sourcePolicy) ?? {}).filter((field) => field.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; + const policyFields = Object.values(getReportFieldsByPolicyID(sourcePolicy) ?? {}).filter((field) => field.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportFieldsCount = policyFields.filter((field) => field.target !== CONST.REPORT_FIELD_TARGETS.INVOICE).length; + const invoiceFieldsCount = policyFields.filter((field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE).length; const codingRulesCount = Object.values(sourcePolicy?.rules?.codingRules ?? {}).filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; const connectedIntegration = getAllValidConnectedIntegration(sourcePolicy, CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES); const distanceRatesCount = Object.values(getDistanceRateCustomUnit(sourcePolicy)?.rates ?? {}).filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; @@ -133,7 +135,7 @@ function CopyPolicySettingsSelectFeaturesPage() { hasWorkflowRules: !!workflows?.length, hasWorkspaceRules: !!rules?.length, codingRulesCount, - hasInvoiceConfiguration: !!sourcePolicy?.areInvoicesEnabled && !!invoiceConfigurationText, + hasInvoiceConfiguration: !!sourcePolicy?.areInvoicesEnabled && (!!invoiceConfigurationText || invoiceFieldsCount > 0), isCollectPolicy: isCollectPolicy(sourcePolicy), }; @@ -224,8 +226,12 @@ function CopyPolicySettingsSelectFeaturesPage() { return getTimeTrackingCopySettingsDescription(sourcePolicy, translate); case 'receiptPartners': return getReceiptPartnersCopySettingsDescription(sourcePolicy, translate); - case 'invoices': - return invoiceConfigurationText || undefined; + case 'invoices': { + const invoiceDetails = [invoiceConfigurationText, invoiceFieldsCount ? `${invoiceFieldsCount} ${translate('workspace.common.invoiceFields').toLowerCase()}` : ''] + .filter(Boolean) + .join(', '); + return invoiceDetails || undefined; + } default: return undefined; } @@ -305,7 +311,7 @@ function CopyPolicySettingsSelectFeaturesPage() { setCopyPolicySettingsData({parts}).then(() => { // Copying Control-only settings onto a Collect (Team) target requires upgrading it first, // so insert the upgrade step before Confirm; otherwise skip straight to Confirm. - const nextRoute = shouldShowCopyPolicySettingsUpgradeStep(targetPolicies, parts) + const nextRoute = shouldShowCopyPolicySettingsUpgradeStep(targetPolicies, parts, sourcePolicy) ? ROUTES.POLICY_COPY_SETTINGS_UPGRADE.getRoute(sourcePolicyID) : ROUTES.POLICY_COPY_SETTINGS_CONFIRM.getRoute(sourcePolicyID); Navigation.navigate(nextRoute); diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx index 2cb4d1c7bbd5..edd02bc0de1d 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx @@ -45,7 +45,7 @@ function CopyPolicySettingsUpgradePage() { const parts = copyPolicySettingsState?.parts ?? []; const targetPolicies = targetPolicyIDs.map((id) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${id}`]); - const policiesToUpgrade = getCollectTargetsToUpgrade(targetPolicies, parts); + const policiesToUpgrade = getCollectTargetsToUpgrade(targetPolicies, parts, sourcePolicy); // Don't trust an "all targets are Corporate" reading until both Onyx keys have loaded and every // target policy has actually resolved. Otherwise unresolved targets look like `undefined` (not @@ -54,7 +54,7 @@ function CopyPolicySettingsUpgradePage() { const isDataLoaded = !isLoadingOnyxValue(copyPolicySettingsMetadata, policiesMetadata) && areAllTargetPoliciesResolved; const controlOnlyFeatures = formatList( - getControlOnlySelectedParts(targetPolicies, parts) + getControlOnlySelectedParts(targetPolicies, parts, sourcePolicy) .map((part) => { const labelKey = FEATURE_ROWS.find((row) => row.part === part)?.labelKey; return labelKey ? translate(labelKey) : undefined; diff --git a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx index 136ad891c1e5..dfc8080c22c2 100644 --- a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx +++ b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx @@ -9,6 +9,7 @@ import useOnyx from '@hooks/useOnyx'; import useOutstandingBalanceGuard from '@hooks/useOutstandingBalanceGuard'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePrevious from '@hooks/usePrevious'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; @@ -16,7 +17,6 @@ import {close as closeVisibleModal} from '@libs/actions/Modal'; import {calculateBillNewDot, deleteWorkspace, dismissWorkspaceError} from '@libs/actions/Policy/Policy'; import {filterInactiveCards, getCardSettings, isCard} from '@libs/CardUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {isPendingDeletePolicy, shouldBlockWorkspaceDeletionForInvoicifyUser} from '@libs/PolicyUtils'; import {isSubscriptionTypeOfInvoicing} from '@libs/SubscriptionUtils'; @@ -60,6 +60,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork const styles = useThemeStyles(); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const {showConfirmModal, closeModal} = useConfirmModal(); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -257,7 +258,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork hasStartedRef.current = true; if (shouldBlockWorkspaceDeletionForInvoicifyUser(isSubscriptionTypeOfInvoicing(privateSubscription?.type), policies, policyID, session?.accountID)) { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SUBSCRIPTION_DOWNGRADE_BLOCKED.path)); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.SUBSCRIPTION_DOWNGRADE_BLOCKED.path)); onDismiss(); return; } diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index 2a83810417ea..6a8beb4ad1e5 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index c07888a1d8e6..63496df44757 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -1,5 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -279,12 +280,10 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro onClose={() => clearErrorFields('taxRateExternalID')} > - diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 7c7df62014c1..de8c2c657abd 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import type {DropdownOption, WorkspaceDistanceRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -215,6 +215,7 @@ function PolicyDistanceRatesPage({ ); const unitTranslation = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`); + const currentUnit = customUnit?.attributes?.unit; const addRate = () => { Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(policyID)); @@ -489,6 +490,7 @@ function PolicyDistanceRatesPage({ ; +type ContentWrapperProps = { + /** Children to render inside the wrapper */ + children: React.ReactNode; + /** Whether the page should be blocked while offline, because the custom unit hasn't been loaded yet */ + shouldBlockWhenOffline: boolean; +}; + +function ContentWrapper({shouldBlockWhenOffline, children}: ContentWrapperProps) { + const styles = useThemeStyles(); + + const content = ( + + {children} + + ); + + if (shouldBlockWhenOffline) { + return {content}; + } + + return content; +} + function getCommuterExclusionsSummary(commuterExclusions: CommuterExclusions | undefined, defaultUnit: string | undefined, translate: ReturnType['translate']): string { if (commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { return translate('workspace.distanceRates.commuterExclusions.summaryHomeAndOffice'); @@ -86,8 +113,6 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag const defaultUnit = customUnit?.attributes?.unit; const errorFields = customUnit?.errorFields; - const FullPageBlockingView = !customUnit ? FullPageOfflineBlockingView : View; - const clearErrorFields = (fieldName: keyof CustomUnit) => { if (!customUnit?.customUnitID) { return; @@ -167,151 +192,143 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag testID="PolicyDistanceRatesSettingsPage" > - - - - {!!defaultUnit && ( - clearErrorFields('attributes')} - > - Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_UNIT.getRoute(policyID))} - interactive={canWriteDistanceRates} - wrapperStyle={[styles.ph5, styles.mt3]} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.DISTANCE_RATES.UNIT_SELECTOR} - /> - - )} - {!!policy?.areCategoriesEnabled && hasEnabledOptions(policyCategories ?? {}) && !!customUnit?.customUnitID && ( - clearErrorFields('defaultCategory')} - > - - - )} - {isAutoUpdateSupported && ( - clearWorkspaceDistanceAutoUpdateErrors(policyID)} - > - - - - {translate('workspace.distanceRates.autoUpdateGovernmentRate')} - - - - - {translate('workspace.distanceRates.autoUpdateGovernmentRateDescription', translate(countryPhraseTranslationKey))} - - - - )} - setPolicyRequireMapOrGPS(policyID, isOn, policy?.requireMapOrGPS)} - disabled={!canWriteDistanceRates || isRequirementLockedByCommuterExclusions} - disabledAction={withReadOnlyFallback(isRequirementLockedByCommuterExclusions ? showRequirementLockedModal : undefined)} - showLockIcon={!canWriteDistanceRates || isRequirementLockedByCommuterExclusions} - pendingAction={policy?.pendingFields?.requireMapOrGPS} - errors={getLatestErrorField(policy ?? {}, 'requireMapOrGPS')} - onCloseError={() => clearPolicyRequireMapOrGPSErrors(policyID)} + + {!!defaultUnit && ( + clearErrorFields('attributes')} + > + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_UNIT.getRoute(policyID))} + interactive={canWriteDistanceRates} + wrapperStyle={[styles.ph5, styles.mt3]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.DISTANCE_RATES.UNIT_SELECTOR} /> - {isCommuterExclusionsEnabled && ( - clearPolicyCommuterExclusionsErrors(policyID)} - > - Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_COMMUTER_EXCLUSIONS.getRoute(policyID))} - wrapperStyle={[styles.ph5, styles.mt3]} + + )} + {!!policy?.areCategoriesEnabled && hasEnabledOptions(policyCategories ?? {}) && !!customUnit?.customUnitID && ( + clearErrorFields('defaultCategory')} + > + + + )} + {isAutoUpdateSupported && ( + clearWorkspaceDistanceAutoUpdateErrors(policyID)} + > + + + + {translate('workspace.distanceRates.autoUpdateGovernmentRate')} + + - - )} - - - - - {translate('workspace.distanceRates.trackTax')} - - - - {!isPolicyTrackTaxEnabled && ( - - { - Navigation.dismissModal(); - Navigation.isNavigationReady().then(() => { - Navigation.goBack(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); - }); - }} - /> - - )} - + + {translate('workspace.distanceRates.autoUpdateGovernmentRateDescription', translate(countryPhraseTranslationKey))} + + + + )} + setPolicyRequireMapOrGPS(policyID, isOn, policy?.requireMapOrGPS)} + disabled={!canWriteDistanceRates || isRequirementLockedByCommuterExclusions} + disabledAction={withReadOnlyFallback(isRequirementLockedByCommuterExclusions ? showRequirementLockedModal : undefined)} + showLockIcon={!canWriteDistanceRates || isRequirementLockedByCommuterExclusions} + pendingAction={policy?.pendingFields?.requireMapOrGPS} + errors={getLatestErrorField(policy ?? {}, 'requireMapOrGPS')} + onCloseError={() => clearPolicyRequireMapOrGPSErrors(policyID)} + /> + {isCommuterExclusionsEnabled && ( + clearPolicyCommuterExclusionsErrors(policyID)} + > + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_COMMUTER_EXCLUSIONS.getRoute(policyID))} + wrapperStyle={[styles.ph5, styles.mt3]} + /> + + )} + + + + + {translate('workspace.distanceRates.trackTax')} + + + - - + {!isPolicyTrackTaxEnabled && ( + + { + Navigation.dismissModal(); + Navigation.isNavigationReady().then(() => { + Navigation.goBack(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + }); + }} + /> + + )} + + ); diff --git a/src/pages/workspace/downgrade/DowngradeIntro.tsx b/src/pages/workspace/downgrade/DowngradeIntro.tsx index 84c658372460..4c6204943c94 100644 --- a/src/pages/workspace/downgrade/DowngradeIntro.tsx +++ b/src/pages/workspace/downgrade/DowngradeIntro.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; diff --git a/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx b/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx index 0efbb605cbc2..f64a499875a0 100644 --- a/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx +++ b/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx @@ -1,6 +1,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; diff --git a/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx b/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx index 1b3d9d314a97..5281d5723883 100644 --- a/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx +++ b/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx @@ -11,7 +11,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {getCompanyFeeds} from '@libs/CardUtils'; @@ -22,7 +21,6 @@ import {arePolicyRulesEnabled, canModifyPlan, isCollectPolicy} from '@libs/Polic import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; -import CONST from '@src/CONST'; import {downgradeToTeam} from '@src/libs/actions/Policy/Policy'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -56,8 +54,6 @@ function DynamicWorkspaceDowngradePage({route}: DynamicWorkspaceDowngradePagePro const {showConfirmModal, closeModal} = useConfirmModal(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const canPerformDowngrade = () => canModifyPlan(ownerPolicies, policy); const isDowngraded = isCollectPolicy(policy); @@ -102,7 +98,7 @@ function DynamicWorkspaceDowngradePage({route}: DynamicWorkspaceDowngradePagePro Navigation.dismissModal(); return; } - downgradeToTeam(policy.id, policy.type, policy.isAttendeeTrackingEnabled, isRulesRevampEnabled && arePolicyRulesEnabled(policy, policyCategories, isRulesRevampEnabled)); + downgradeToTeam(policy.id, policy.type, policy.isAttendeeTrackingEnabled, arePolicyRulesEnabled(policy, policyCategories)); }; if (!canPerformDowngrade()) { diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 46dc226da347..087838071f51 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -52,7 +52,9 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const categoriesCount = Object.values(policyCategories ?? {}).filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; const codingRulesCount = Object.values(policy?.rules?.codingRules ?? {}).filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; const [selectedItems, setSelectedItems] = useState([]); - const reportFields = Object.values(getReportFieldsByPolicyID(policy) ?? {}).filter((field) => field.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length ?? 0; + const policyFields = Object.values(getReportFieldsByPolicyID(policy) ?? {}).filter((field) => field.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportFields = policyFields.filter((field) => field.target !== CONST.REPORT_FIELD_TARGETS.INVOICE).length; + const invoiceFields = policyFields.filter((field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE).length; const customUnits = getPerDiemCustomUnit(policy); const customUnitRates: Record = customUnits?.rates ?? {}; const allRates = Object.values(customUnitRates)?.filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length ?? 0; @@ -69,6 +71,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const [invoiceConfigurationText = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { selector: invoiceConfigurationTextSelector, }); + const invoiceDetails = [invoiceConfigurationText, invoiceFields ? `${invoiceFields} ${translate('workspace.common.invoiceFields').toLowerCase()}` : ''].filter(Boolean).join(', '); const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; const connectedIntegration = getAllValidConnectedIntegration(policy, accountingIntegrations); @@ -182,7 +185,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm ? { translation: translate('workspace.common.invoices'), value: 'invoices', - alternateText: invoiceConfigurationText || undefined, + alternateText: invoiceDetails || undefined, } : undefined, policy?.isTravelEnabled @@ -207,7 +210,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm ratesCount, isCollect, allRates, - invoiceConfigurationText, + invoiceDetails, codingRulesCount, ]); @@ -255,6 +258,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm expenses: selectedItems.includes('rules'), distance: selectedItems.includes('distanceRates'), invoices: selectedItems.includes('invoices'), + invoiceFields: selectedItems.includes('invoices'), exportLayouts: selectedItems.includes('workflows'), overview: selectedItems.includes('overview'), travel: selectedItems.includes('travel'), diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx index 330d162c10f8..b2816360222c 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx @@ -175,7 +175,7 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa // Only link to the Workflows page when the current user can actually read it. Card admins without Workflows // access would otherwise be dropped onto the Not Found page. When they lack access, render plain (non-linked) text. const canReadWorkflows = canMemberRead(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.WORKFLOWS); - const workspaceWorkflowsPageURL = canReadWorkflows ? `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}` : undefined; + const workspaceWorkflowsPageURL = canReadWorkflows ? `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID, CONST.TAB.WORKFLOWS.APPROVALS)}` : undefined; const data = []; diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index ffc3888edc94..5129c2fb750c 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -7,6 +7,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -114,6 +115,7 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'GustoSquare', ]); const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardImage']); @@ -390,12 +392,10 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen )} - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_NAME.path))} - interactive={canWriteExpensifyCard} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_NAME.path)) : undefined} + value={card?.nameValuePairs?.cardTitle} /> - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_LIMIT.path))} - interactive={canWriteExpensifyCard} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_LIMIT.path)) : undefined} + value={formattedLimit} /> diff --git a/src/pages/workspace/expensifyCard/EmptyCardView.tsx b/src/pages/workspace/expensifyCard/EmptyCardView.tsx index ccce7ed7dbe1..710559e1431f 100644 --- a/src/pages/workspace/expensifyCard/EmptyCardView.tsx +++ b/src/pages/workspace/expensifyCard/EmptyCardView.tsx @@ -15,12 +15,8 @@ import React from 'react'; import {View} from 'react-native'; type EmptyCardViewProps = { - /** Whether the bank account is verified */ isBankAccountVerified: boolean; - /** ID of the current policy */ policyID?: string; - - /** Buttons to display */ buttons: EmptyStateButton[] | undefined; }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx index 1d1d3c52f314..1f6ccf1fad01 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx @@ -14,10 +14,7 @@ import {View} from 'react-native'; import WorkspaceCardsListLabel from './WorkspaceCardsListLabel'; type WorkspaceCardListLabelsProps = { - /** ID of the current policy */ policyID: string; - - /** Card settings */ cardSettings: ExpensifyCardSettingsBase | undefined; }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx index 4916e060691c..8b95ef5fcda9 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx @@ -1,4 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -81,11 +82,10 @@ function WorkspaceCardSettingsPage({route}: WorkspaceCardSettingsPageProps) { > - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT.path))} + value={bankAccountNumber ? `${CONST.MASKED_PAN_PREFIX}${getLastFourDigits(bankAccountNumber)}` : undefined} /> diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index 7fe209d7c3a5..dcd0e91f160b 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import Popover from '@components/Popover'; @@ -50,13 +50,8 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; type WorkspaceCardsListLabelProps = { - /** Label type */ type: ValueOf; - - /** Label value */ value: number; - - /** Additional style props */ style?: StyleProp; }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx index 914a3518c63c..61a4c20c341e 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx @@ -87,7 +87,7 @@ function WorkspaceExpensifyCardAddWorkEmailPage({route}: WorkspaceExpensifyCardA setLoading(false); }); } else { - AddWorkEmail(submittedEmail); + AddWorkEmail(submittedEmail, ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); } setEmail(submittedEmail); }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 5acebd1fe443..dbd1b27d454e 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CardFeedIcon from '@components/CardFeedIcon'; @@ -60,10 +60,7 @@ type WorkspaceExpensifyCardListPageProps = { /** Route from navigation */ route: PlatformStackRouteProp; - /** List of Expensify cards */ cardsList: OnyxEntry; - - /** Fund ID */ fundID: number; }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx index 948d977e4d19..4ac4326bf6a5 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx @@ -26,7 +26,7 @@ import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; type WorkspaceSettlementFrequencyPageProps = PlatformStackScreenProps; @@ -44,6 +44,9 @@ function WorkspaceSettlementFrequencyPage({route}: WorkspaceSettlementFrequencyP const selectedFrequency = settings?.monthlySettlementDate ? CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY : CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY; const isSettlementFrequencyBlocked = !shouldShowMonthlyOption && selectedFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY; + const [selectedFrequencyDraft, setSelectedFrequencyDraft] = useState>(); + const currentFrequency = selectedFrequencyDraft ?? selectedFrequency; + const data = useMemo(() => { const options = []; @@ -51,7 +54,7 @@ function WorkspaceSettlementFrequencyPage({route}: WorkspaceSettlementFrequencyP value: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, text: translate('workspace.expensifyCard.frequency.daily'), keyForList: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, - isSelected: selectedFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, + isSelected: currentFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, }); if (shouldShowMonthlyOption) { @@ -59,19 +62,31 @@ function WorkspaceSettlementFrequencyPage({route}: WorkspaceSettlementFrequencyP value: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, text: translate('workspace.expensifyCard.frequency.monthly'), keyForList: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, - isSelected: selectedFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, + isSelected: currentFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, }); } return options; - }, [translate, shouldShowMonthlyOption, selectedFrequency]); + }, [translate, shouldShowMonthlyOption, currentFrequency]); const updateSettlementFrequency = (value: ValueOf) => { + setSelectedFrequencyDraft(value); + }; + + const saveSettlementFrequency = () => { if (!programKey) { - Log.alert('[WorkspaceSettlementFrequencyPage] updateSettlementFrequency called without a detected card program key'); + Log.alert('[WorkspaceSettlementFrequencyPage] saveSettlementFrequency called without a detected card program key'); return; } - updateSettlementFrequencyUtil(defaultFundID, programKey, value, settings?.monthlySettlementDate); + updateSettlementFrequencyUtil(defaultFundID, programKey, currentFrequency, settings?.monthlySettlementDate); + Navigation.goBack(); + }; + + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: saveSettlementFrequency, + isDisabled: currentFrequency === selectedFrequency, }; return ( @@ -98,6 +113,7 @@ function WorkspaceSettlementFrequencyPage({route}: WorkspaceSettlementFrequencyP ListItem={SingleSelectListItem} onSelectRow={({value}) => updateSettlementFrequency(value)} initiallyFocusedItemKey={selectedFrequency} + confirmButtonOptions={confirmButtonOptions} shouldUpdateFocusedIndex shouldSingleExecuteRowSelect addBottomSafeAreaPadding diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index dc19e8b4aac5..971f33eba325 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -43,16 +43,11 @@ type AssigneeListItem = ListItem & { }; type AssigneeStepProps = { - // The policy that the card will be issued under + /** The policy that the card will be issued under */ policy: OnyxEntry; - /** Array of step names */ stepNames: readonly string[]; - - /** Start from step index */ startStepIndex: number; - - /** Route params */ route: PlatformStackRouteProp; }; @@ -67,7 +62,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); 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) => { @@ -251,7 +246,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr onSelectRow={submit} ListItem={UserListItem} textInputOptions={textInputOptions} - isLoadingNewOptions={canInviteMembers && !!isSearchingForReports} + isLoadingNewOptions={canInviteMembers && !!isSearchingForUsers} initiallyFocusedItemKey={initialAssigneeEmail} shouldScrollToFocusedIndexOnMount={false} disableMaintainingScrollPosition diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx index 35999fbe335d..5328de575d8d 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx @@ -27,13 +27,8 @@ import KeyboardUtils from '@src/utils/keyboard'; import React, {useCallback} from 'react'; type CardNameStepProps = { - /** ID of the policy */ policyID: string | undefined; - - /** Array of step names */ stepNames: readonly string[]; - - /** Start from step index */ startStepIndex: number; }; diff --git a/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx index db805b00ceae..83e956ca21ac 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx @@ -27,10 +27,7 @@ type CardTypeStepProps = { /** The policy that the card will be issued under */ policy: OnyxEntry; - /** Array of step names */ stepNames: readonly string[]; - - /** Start from step index */ startStepIndex: number; }; diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index 512fc989d02a..8cbb24892959 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -1,6 +1,6 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -12,6 +12,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; import AccountUtils from '@libs/AccountUtils'; @@ -21,7 +22,6 @@ import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; -import createDynamicRoute from '@navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -36,15 +36,13 @@ type ConfirmationStepProps = { /** ID of the policy that the card will be issued under */ policyID: string; - /** Array of step names */ stepNames: readonly string[]; - - /** Start from step index */ startStepIndex: number; }; function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationStepProps) { const {translate, formatPhoneNumber} = useLocalize(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -97,10 +95,10 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte // Redirect to the validateCode page when there is an error with the user's validateCode authentication if (errorMessage.toLowerCase().includes('request a new code')) { clearIssueNewCardError(policyID); - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE.path)); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE.path)); } } - }, [issueNewCard, isSuccessful, policyID]); + }, [issueNewCard, isSuccessful, policyID, buildDynamicRoute]); const handleIssueCard = useCallback(() => { if (!policyID) { @@ -113,9 +111,9 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte issueExpensifyCard(defaultFundID, policyID, isBetaEnabled(CONST.BETAS.EXPENSIFY_CARD_EU_UK) ? '' : CONST.COUNTRY.US, '', assigneeTimeZone, data); } else { // Navigate to validateCode page - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE.path)); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE.path)); } - }, [policyID, data, account, defaultFundID, isBetaEnabled, assigneeTimeZone]); + }, [policyID, data, account, defaultFundID, isBetaEnabled, assigneeTimeZone, buildDynamicRoute]); const errorMessage = getLatestErrorMessage(issueNewCard) || (shouldDisableSubmitButton ? translate('workspace.card.issueNewCard.disabledApprovalForSmartLimitError') : ''); @@ -128,6 +126,7 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte }; const translationForLimitType = getTranslationKeyForLimitType(data?.limitType); + const limitTitle = convertToShortDisplayString(data?.limit, data?.currency); const isPhysicalCard = data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.PHYSICAL; const cardReadyTranslationKey = isPhysicalCard ? 'workspace.card.issueNewCard.willBeReadyToShip' : 'workspace.card.issueNewCard.willBeReadyToUse'; @@ -188,52 +187,44 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte > {translate('workspace.card.issueNewCard.letsDoubleCheck')} {translate(cardReadyTranslationKey)} - editStep(CONST.EXPENSIFY_CARD.STEP.ASSIGNEE)} + editStep(CONST.EXPENSIFY_CARD.STEP.ASSIGNEE) : undefined} + value={cardholder} /> - editStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE)} + value={data?.cardType ? translate(`workspace.card.issueNewCard.${data.cardType}Card`) : undefined} /> - editStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE)} + value={limitTitle} /> - editStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE)} + value={translationForLimitType ? translate(translationForLimitType) : undefined} /> {!!expirationDateTitle && shouldShowExpirationDate && ( - editStep(CONST.EXPENSIFY_CARD.STEP.SPEND_RULES)} + value={expirationDateTitle} /> )} {isSpendRuleApplied && areRulesEnabled && ( - editStep(CONST.EXPENSIFY_CARD.STEP.SPEND_RULES)} + value={cardRuleRestrictionsTitle} /> )} - editStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME)} + value={data?.cardTitle} /> requestValidateCodeAction()} + sendValidateCode={() => requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.ISSUE_CARD})} + validateCodeReasonCode={COMMON_CONST.VALIDATE_CODE_REASONS.ISSUE_CARD} validateCodeActionErrorField={data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.PHYSICAL ? 'createExpensifyCard' : 'createAdminIssuedVirtualCard'} handleSubmitForm={handleSubmit} validateError={validateError} diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx index 7eb3d7481ff0..55387af6fbfe 100644 --- a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx @@ -36,13 +36,10 @@ import React, {useCallback, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; type LimitTypeStepProps = { - // The policy that the card will be issued under + /** The policy that the card will be issued under */ policy: OnyxEntry; - /** Start from step index */ startStepIndex: number; - - /** Array of step names */ stepNames: readonly string[]; }; @@ -108,7 +105,7 @@ function LimitTypeStep({policy, stepNames, startStepIndex}: LimitTypeStepProps) setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CARD_TYPE, policyID}); }, [isEditing, policyID]); - const workspaceWorkflowsPageURL = canReadWorkflows ? `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}` : undefined; + const workspaceWorkflowsPageURL = canReadWorkflows ? `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID, CONST.TAB.WORKFLOWS.APPROVALS)}` : undefined; const data = useMemo(() => { const options = []; diff --git a/src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx b/src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx index e7e8c1472374..4fb4826c31be 100644 --- a/src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx @@ -4,6 +4,7 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import FormHelpMessage from '@components/FormHelpMessage'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SpendRuleRestrictionTypeToggle from '@components/SpendRules/SpendRuleRestrictionTypeToggle'; @@ -20,7 +21,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setIssueNewCardData, setIssueNewCardStepAndData} from '@libs/actions/Card'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; +import {isControlPolicy, isPolicyFeatureEnabled, tryNavigateToControlPolicyUpgrade} from '@libs/PolicyUtils'; import {getSpendRuleFormValuesFromCardRule, getSpendRuleSummaryText, getTruncatedSpendRuleSummary} from '@libs/SpendRulesUtils'; import Navigation from '@navigation/Navigation'; @@ -29,7 +30,7 @@ import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOpt import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/IssueNewExpensifyCardForm'; import type {IssueNewCardData} from '@src/types/onyx/Card'; @@ -40,13 +41,10 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; type SetSpendRulesStepProps = { - /* The policy that the card will be issued under */ + /** The ID of the policy that the card will be issued under */ policyID: string; - /** Start from step index */ startStepIndex: number; - - /** Array of step names */ stepNames: readonly string[]; }; @@ -74,6 +72,9 @@ function SetSpendRulesStep({policyID, stepNames, startStepIndex}: SetSpendRulesS const currencyCode = issueNewCard?.data?.currency ?? CONST.CURRENCY.USD; const isVirtualCard = issueNewCard?.data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL; const isSpendRuleVisible = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED, policyCategories); + // Card spend rules stay Control-only even though the general Rules feature is now available to Collect too, so + // Collect sees the toggle locked and gets sent to the upgrade page rather than being able to configure a rule. + const isSpendRuleLocked = isSpendRuleVisible && !isControlPolicy(policy); const spendRuleID = issueNewCard?.data?.spendRuleID; const spendRuleForm = issueNewCard?.data.spendRuleValue ?? {}; @@ -116,6 +117,13 @@ function SetSpendRulesStep({policyID, stepNames, startStepIndex}: SetSpendRulesS setIssueNewCardData(policyID, {spendRuleEnabled: isEnabled}); }; + const promptSpendRuleUpgrade = () => { + // Send them back into this same wizard step, not the workspace Rules page the hook defaults to, so upgrading + // doesn't strand the card they were partway through issuing. + const backToWizard = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.path, ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)); + tryNavigateToControlPolicyUpgrade(policy, CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.alias, backToWizard); + }; + const handleChooseSpendRule = () => { if (!policyID) { return; @@ -277,12 +285,15 @@ function SetSpendRulesStep({policyID, stepNames, startStepIndex}: SetSpendRulesS <> - {spendRuleEnabled && ( + {!isSpendRuleLocked && spendRuleEnabled && ( {spendRuleOption === CONST.EXPENSIFY_CARD.SPEND_RULE_OPTION.COPY_EXISTING && ( - )} diff --git a/src/pages/workspace/fields/CreateFieldsPage.tsx b/src/pages/workspace/fields/CreateFieldsPage.tsx new file mode 100644 index 000000000000..1acff882c919 --- /dev/null +++ b/src/pages/workspace/fields/CreateFieldsPage.tsx @@ -0,0 +1,273 @@ +import FormProvider from '@components/Form/FormProvider'; +import FormValueWatcher from '@components/Form/FormValueWatcher'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextPicker from '@components/TextPicker'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setDraftValues} from '@libs/actions/FormActions'; +import {addErrorMessage} from '@libs/ErrorUtils'; +import {hasCircularReferences} from '@libs/Formula'; +import Navigation from '@libs/Navigation/Navigation'; +import {isRequiredFulfilled} from '@libs/ValidationUtils'; +import {getReportFieldsForTarget, getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue, isReportFieldNameExisting} from '@libs/WorkspaceReportFieldUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import InitialListValueSelector from '@pages/workspace/reports/InitialListValueSelector'; +import TypeSelector from '@pages/workspace/reports/TypeSelector'; + +import {createReportField, setInitialCreateReportFieldsForm} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route as Routes} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; +import type {Policy, Report} from '@src/types/onyx'; +import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useCallback, useRef} from 'react'; +import {View} from 'react-native'; + +type CreateFieldsPageProps = { + policy: OnyxEntry; + policyID: string; + isInvoiceField: boolean; + listValuesRoute: Routes; + getTypeSelectorRoute: (policyID: string, currentType?: PolicyReportFieldType) => Routes; + initialListValueRoute: Routes; + featureName: ValueOf; + testID: string; +}; + +function CreateFieldsPage({policy, policyID, isInvoiceField, listValuesRoute, getTypeSelectorRoute, initialListValueRoute, featureName, testID}: CreateFieldsPageProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const formRef = useRef(null); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + + const reportTypeForTarget = isInvoiceField ? CONST.REPORT.TYPE.INVOICE : CONST.REPORT.TYPE.EXPENSE; + const fieldTarget = isInvoiceField ? CONST.REPORT_FIELD_TARGETS.INVOICE : CONST.REPORT_FIELD_TARGETS.EXPENSE; + + const policyReportIDsSelector = useCallback( + (reports: OnyxCollection) => + Object.values(reports ?? {}) + .filter((report) => report?.policyID === policyID && report.type === reportTypeForTarget) + .map((report) => report?.reportID), + [policyID, reportTypeForTarget], + ); + + const [policyReportIDs] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policyReportIDsSelector}); + + const availableListValuesLength = (formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES] ?? []).filter((disabledListValue) => !disabledListValue).length; + + const submitForm = useCallback( + (values: FormOnyxValues) => { + const shouldClearListInitialValue = values[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength === 0; + createReportField({ + policy, + name: values[INPUT_IDS.NAME], + type: values[INPUT_IDS.TYPE], + initialValue: shouldClearListInitialValue ? '' : values[INPUT_IDS.INITIAL_VALUE], + listValues: formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], + disabledListValues: formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES] ?? [], + policyReportIDs, + isInvoiceField, + }); + setInitialCreateReportFieldsForm(); + Navigation.goBack(); + }, + [availableListValuesLength, formDraft, isInvoiceField, policy, policyReportIDs], + ); + + const validateForm = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const {name, type, initialValue: formInitialValue} = values; + const errors: FormInputErrors = {}; + + if (!isRequiredFulfilled(name)) { + errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.invoiceFieldNameRequiredError' : 'workspace.reportFields.reportFieldNameRequiredError'); + } else if (isReportFieldNameExisting(policy?.fieldList, name)) { + errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.existingInvoiceFieldNameError' : 'workspace.reportFields.existingReportFieldNameError'); + } else if ([...name].length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', [...name].length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH)); + } + + if (!isRequiredFulfilled(type)) { + errors[INPUT_IDS.TYPE] = translate(isInvoiceField ? 'workspace.invoiceFields.invoiceFieldTypeRequiredError' : 'workspace.reportFields.reportFieldTypeRequiredError'); + } + + if (type === CONST.REPORT_FIELD_TYPES.TEXT && !!formInitialValue && formInitialValue.length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('common.error.characterLimitExceedCounter', formInitialValue.length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH); + } + + if ( + (type === CONST.REPORT_FIELD_TYPES.TEXT || type === CONST.REPORT_FIELD_TYPES.FORMULA) && + hasCircularReferences(formInitialValue, name, getReportFieldsForTarget(policy?.fieldList, fieldTarget)) + ) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError'); + } + + if ((type === CONST.REPORT_FIELD_TYPES.TEXT || type === CONST.REPORT_FIELD_TYPES.FORMULA) && !!formInitialValue && !errors[INPUT_IDS.INITIAL_VALUE]) { + const unsupportedFormulaParts = getUnsupportedReportFieldFormulaParts(formInitialValue); + if (unsupportedFormulaParts.length > 0) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.unsupportedFormulaValueError', unsupportedFormulaParts.join(', ')); + } + } + + if (type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) { + errors[INPUT_IDS.INITIAL_VALUE] = translate( + isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError', + ); + } + + return errors; + }, + [availableListValuesLength, fieldTarget, isInvoiceField, policy?.fieldList, translate], + ); + + const validateName = useCallback( + (values: Record) => { + const errors: Record = {}; + const name = values[INPUT_IDS.NAME]; + if (isReportFieldNameExisting(policy?.fieldList, name)) { + errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.existingInvoiceFieldNameError' : 'workspace.reportFields.existingReportFieldNameError'); + } + return errors; + }, + [isInvoiceField, policy?.fieldList, translate], + ); + + const handleOnValueCommitted = (initialValue: string) => { + setDraftValues(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM, { + [INPUT_IDS.TYPE]: hasFormulaPartsInInitialValue(initialValue) ? CONST.REPORT_FIELD_TYPES.FORMULA : CONST.REPORT_FIELD_TYPES.TEXT, + [INPUT_IDS.INITIAL_VALUE]: initialValue, + }); + }; + + const listValues = [...(formDraft?.[INPUT_IDS.LIST_VALUES] ?? [])].sort(localeCompare).join(', '); + + return ( + + + + + {({inputValues}) => ( + + { + if (current[INPUT_IDS.TYPE] === previous[INPUT_IDS.TYPE]) { + return; + } + formRef.current?.resetForm(current); + }} + /> + + + + {inputValues[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && ( + Navigation.navigate(listValuesRoute)} + title={listValues} + numberOfLinesTitle={5} + /> + )} + + {(inputValues[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.TEXT || inputValues[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.FORMULA) && ( + + )} + + {inputValues[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.DATE && ( + + )} + + {inputValues[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && ( + + )} + + )} + + + + ); +} + +export default CreateFieldsPage; diff --git a/src/pages/workspace/fields/FieldsAddListValuePage.tsx b/src/pages/workspace/fields/FieldsAddListValuePage.tsx new file mode 100644 index 000000000000..0d6dfe262ac3 --- /dev/null +++ b/src/pages/workspace/fields/FieldsAddListValuePage.tsx @@ -0,0 +1,137 @@ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PolicyFeature} from '@libs/PolicyUtils'; +import {getReportFieldKey} from '@libs/ReportUtils'; +import {isReportFieldImportedFromIntegration, isReportFieldTargetValid, validateReportFieldListValueName} from '@libs/WorkspaceReportFieldUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import {addReportFieldListValue, createReportFieldsListValue} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useCallback, useMemo} from 'react'; +import {Keyboard} from 'react-native'; + +type FieldsAddListValuePageProps = { + policy: OnyxEntry; + policyID: string; + reportFieldID?: string; + featureName: ValueOf; + policyFeature: PolicyFeature; + expectedTarget: ValueOf; + testID: string; +}; + +function FieldsAddListValuePage({policy, policyID, reportFieldID, featureName, policyFeature, expectedTarget, testID}: FieldsAddListValuePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + const {canWrite} = usePolicyFeatureWriteAccess(policy, policyFeature); + const reportField = reportFieldID ? policy?.fieldList?.[getReportFieldKey(reportFieldID)] : undefined; + const isReportFieldInvalid = !!reportFieldID && (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)); + const draftListValues = formDraft?.[INPUT_IDS.LIST_VALUES]; + const draftDisabledListValues = formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES]; + + const listValues = useMemo(() => { + let reportFieldListValues: string[]; + if (reportFieldID) { + reportFieldListValues = Object.values(reportField?.values ?? {}); + } else { + reportFieldListValues = draftListValues ?? []; + } + return reportFieldListValues; + }, [draftListValues, reportField?.values, reportFieldID]); + + const validate = useCallback( + (values: FormOnyxValues) => + validateReportFieldListValueName(values[INPUT_IDS.VALUE_NAME].trim(), '', listValues, INPUT_IDS.VALUE_NAME, translate), + [listValues, translate], + ); + + const createValue = useCallback( + (values: FormOnyxValues) => { + if (reportFieldID) { + addReportFieldListValue({policy, reportFieldID, valueName: values[INPUT_IDS.VALUE_NAME]}); + } else { + createReportFieldsListValue({ + valueName: values[INPUT_IDS.VALUE_NAME], + listValues: draftListValues ?? [], + disabledListValues: draftDisabledListValues ?? [], + }); + } + Keyboard.dismiss(); + Navigation.goBack(); + }, + [draftDisabledListValues, draftListValues, policy, reportFieldID], + ); + + if (isReportFieldInvalid) { + return ; + } + + return ( + + + + + + + + + ); +} + +export default FieldsAddListValuePage; diff --git a/src/pages/workspace/fields/FieldsEditValuePage.tsx b/src/pages/workspace/fields/FieldsEditValuePage.tsx new file mode 100644 index 000000000000..cca8f40c15df --- /dev/null +++ b/src/pages/workspace/fields/FieldsEditValuePage.tsx @@ -0,0 +1,117 @@ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PolicyFeature} from '@libs/PolicyUtils'; +import {validateReportFieldListValueName} from '@libs/WorkspaceReportFieldUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import {renameReportFieldsListValue} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useCallback} from 'react'; +import {Keyboard} from 'react-native'; + +type FieldsEditValuePageProps = { + policy: OnyxEntry; + policyID: string; + valueIndex: number; + featureName: ValueOf; + policyFeature: PolicyFeature; + testID: string; +}; + +function FieldsEditValuePage({policy, policyID, valueIndex, featureName, policyFeature, testID}: FieldsEditValuePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + const {canWrite} = usePolicyFeatureWriteAccess(policy, policyFeature); + + const currentValueName = formDraft?.listValues?.[valueIndex] ?? ''; + + const validate = useCallback( + (values: FormOnyxValues) => + validateReportFieldListValueName(values[INPUT_IDS.NEW_VALUE_NAME].trim(), currentValueName, formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], INPUT_IDS.NEW_VALUE_NAME, translate), + [currentValueName, formDraft, translate], + ); + + const editValue = useCallback( + (values: FormOnyxValues) => { + const valueName = values[INPUT_IDS.NEW_VALUE_NAME]?.trim(); + if (currentValueName !== valueName) { + renameReportFieldsListValue({ + valueIndex, + newValueName: valueName, + listValues: formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], + }); + } + Keyboard.dismiss(); + Navigation.goBack(); + }, + [currentValueName, formDraft, valueIndex], + ); + + return ( + + + + + + + + + ); +} + +export default FieldsEditValuePage; diff --git a/src/pages/workspace/fields/FieldsInitialListValuePage.tsx b/src/pages/workspace/fields/FieldsInitialListValuePage.tsx new file mode 100644 index 000000000000..94d9c66858f5 --- /dev/null +++ b/src/pages/workspace/fields/FieldsInitialListValuePage.tsx @@ -0,0 +1,99 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setDraftValues} from '@libs/actions/FormActions'; +import Navigation from '@libs/Navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import ReportFieldsInitialListValuePicker from '@pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; + +import type {ValueOf} from 'type-fest'; + +import React, {useEffect} from 'react'; +import {View} from 'react-native'; + +type FieldsInitialListValuePageProps = { + policyID: string; + featureName: ValueOf; + backPath: Route; + subtitleKey: TranslationPaths; + testID: string; +}; + +function FieldsInitialListValuePage({policyID, featureName, backPath, subtitleKey, testID}: FieldsInitialListValuePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [formDraft, formDraftMetadata] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + + const currentValue = formDraft?.[INPUT_IDS.INITIAL_VALUE] ?? ''; + const isLoadingFormDraft = formDraftMetadata.status !== 'loaded'; + const availableListValuesLength = (formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES] ?? []).filter((disabledListValue) => !disabledListValue).length; + const shouldRedirectToCreatePage = !isLoadingFormDraft && availableListValuesLength === 0; + + useEffect(() => { + if (!shouldRedirectToCreatePage) { + return; + } + Navigation.goBack(backPath); + }, [backPath, shouldRedirectToCreatePage]); + + const selectValue = (value: string) => { + setDraftValues(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM, { + [INPUT_IDS.INITIAL_VALUE]: currentValue === value ? '' : value, + }); + Navigation.goBack(backPath); + }; + + return ( + + + Navigation.goBack(backPath)} + /> + {isLoadingFormDraft || shouldRedirectToCreatePage ? ( + + + + ) : ( + <> + + {translate(subtitleKey)} + + + + )} + + + ); +} + +export default FieldsInitialListValuePage; diff --git a/src/pages/workspace/fields/FieldsInitialValuePage.tsx b/src/pages/workspace/fields/FieldsInitialValuePage.tsx new file mode 100644 index 000000000000..9564a512f4cb --- /dev/null +++ b/src/pages/workspace/fields/FieldsInitialValuePage.tsx @@ -0,0 +1,177 @@ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {hasCircularReferences} from '@libs/Formula'; +import Navigation from '@libs/Navigation/Navigation'; +import {getReportFieldKey} from '@libs/ReportUtils'; +import {isRequiredFulfilled} from '@libs/ValidationUtils'; +import {getReportFieldInitialValue, getReportFieldsForTarget, getUnsupportedReportFieldFormulaParts, isReportFieldTargetValid} from '@libs/WorkspaceReportFieldUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import ReportFieldsInitialListValuePicker from '@pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker'; + +import {updateReportFieldInitialValue} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; + +type FieldsInitialValuePageProps = { + policy: OnyxEntry; + policyID: string; + reportFieldID: string; + featureName: ValueOf; + expectedTarget: ValueOf; + testID: string; +}; + +function FieldsInitialValuePage({policy, policyID, reportFieldID, featureName, expectedTarget, testID}: FieldsInitialValuePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + + const reportField = policy?.fieldList?.[getReportFieldKey(reportFieldID)] ?? null; + const isInvoiceField = reportField?.target === CONST.REPORT_FIELD_TARGETS.INVOICE; + const availableListValuesLength = (reportField?.disabledOptions ?? []).filter((disabledListValue) => !disabledListValue).length; + const currentInitialValue = getReportFieldInitialValue(reportField, translate); + const [initialValue, setInitialValue] = useState(currentInitialValue); + + const submitForm = useCallback( + (values: FormOnyxValues) => { + if (currentInitialValue !== values.initialValue) { + updateReportFieldInitialValue({policy, reportFieldID, newInitialValue: values.initialValue}); + } + Navigation.goBack(); + }, + [currentInitialValue, policy, reportFieldID], + ); + + const submitListValueUpdate = (value: string) => { + updateReportFieldInitialValue({policy, reportFieldID, newInitialValue: currentInitialValue === value ? '' : value}); + Navigation.goBack(); + }; + + const validateForm = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const {initialValue: formInitialValue} = values; + const errors: FormInputErrors = {}; + + if (formInitialValue.length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('common.error.characterLimitExceedCounter', formInitialValue.length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH); + } + + if ( + (reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.type === CONST.REPORT_FIELD_TYPES.FORMULA) && + hasCircularReferences(formInitialValue, reportField?.name, getReportFieldsForTarget(policy?.fieldList, expectedTarget)) + ) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError'); + } + + if ((reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.type === CONST.REPORT_FIELD_TYPES.FORMULA) && !!formInitialValue && !errors[INPUT_IDS.INITIAL_VALUE]) { + const unsupportedFormulaParts = getUnsupportedReportFieldFormulaParts(formInitialValue); + if (unsupportedFormulaParts.length > 0) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.unsupportedFormulaValueError', unsupportedFormulaParts.join(', ')); + } + } + + if (reportField?.type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) { + errors[INPUT_IDS.INITIAL_VALUE] = translate( + isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError', + ); + } + + return errors; + }, + [availableListValuesLength, expectedTarget, isInvoiceField, reportField?.name, reportField?.type, policy?.fieldList, translate], + ); + + if (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)) { + return ; + } + + const isTextFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.TEXT; + const isFormulaFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.FORMULA; + const isListFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.LIST; + + return ( + + + + {isListFieldType && ( + + + {translate(isInvoiceField ? 'workspace.invoiceFields.listValuesInputSubtitle' : 'workspace.reportFields.listValuesInputSubtitle')} + + + )} + + {(isTextFieldType || isFormulaFieldType) && ( + + + + )} + {isListFieldType && ( + + )} + + + ); +} + +export default FieldsInitialValuePage; diff --git a/src/pages/workspace/fields/FieldsListValuesPage.tsx b/src/pages/workspace/fields/FieldsListValuesPage.tsx new file mode 100644 index 000000000000..af984096bca5 --- /dev/null +++ b/src/pages/workspace/fields/FieldsListValuesPage.tsx @@ -0,0 +1,404 @@ +import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ScreenWrapper from '@components/ScreenWrapper'; +import type {ReportFieldListValueRowData} from '@components/Tables/WorkspaceReportFieldListValuesTable'; +import WorkspaceReportFieldListValuesTable from '@components/Tables/WorkspaceReportFieldListValuesTable'; +import Text from '@components/Text'; + +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useFilteredSelection from '@hooks/useFilteredSelection'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useOnyx from '@hooks/useOnyx'; +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 {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import { + deleteReportFieldsListValue, + removeReportFieldListValue, + setReportFieldsListValueEnabled, + updateReportFieldListValueEnabled as updateReportFieldListValueEnabledReportField, +} from '@libs/actions/Policy/ReportField'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PolicyFeature} from '@libs/PolicyUtils'; +import {getReportFieldKey} from '@libs/ReportUtils'; +import {isReportFieldImportedFromIntegration, isReportFieldTargetValid} from '@libs/WorkspaceReportFieldUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route as Routes} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; + +type FieldsListValuesPageProps = { + policy: OnyxEntry; + policyID: string; + reportFieldID?: string; + isInvoicePage: boolean; + featureName: ValueOf; + policyFeature: PolicyFeature; + getValueSettingsRoute: (isInvoiceRoute: boolean, policyID: string, valueIndex: number, reportFieldID?: string) => Routes; + getAddValueRoute: (isInvoiceRoute: boolean, policyID: string, reportFieldID?: string) => Routes; + testID: string; +}; + +type FieldsListValuesTableHeaderProps = { + /** Action buttons to display in a separate row when needed */ + headerButtons: React.ReactNode; + /** Whether to render the action buttons above the list subtitle */ + shouldDisplayButtonsInSeparateLine: boolean; + /** Translated subtitle for the current field type */ + subtitle: string; +}; + +function FieldsListValuesTableHeader({headerButtons, shouldDisplayButtonsInSeparateLine, subtitle}: FieldsListValuesTableHeaderProps) { + const styles = useThemeStyles(); + + return ( + <> + {shouldDisplayButtonsInSeparateLine && {headerButtons}} + + {subtitle} + + + ); +} + +function FieldsListValuesPage({policy, policyID, reportFieldID, isInvoicePage, featureName, policyFeature, getValueSettingsRoute, getAddValueRoute, testID}: FieldsListValuesPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here to use the mobile selection mode on small screens only + // See https://github.com/Expensify/App/issues/48724 for more details + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const {showConfirmModal} = useConfirmModal(); + const {canWrite, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, policyFeature); + + const reportField = reportFieldID ? policy?.fieldList?.[getReportFieldKey(reportFieldID)] : undefined; + const isImportedReportField = isReportFieldImportedFromIntegration(reportField, policy); + const expectedTarget = isInvoicePage ? CONST.REPORT_FIELD_TARGETS.INVOICE : CONST.REPORT_FIELD_TARGETS.EXPENSE; + const isReportFieldInvalid = !!reportFieldID && (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)); + const listInputSubtitleKey = isInvoicePage ? 'workspace.invoiceFields.listInputSubtitle' : 'workspace.reportFields.listInputSubtitle'; + + const [listValues, disabledListValues] = useMemo(() => { + let reportFieldValues: string[]; + let reportFieldDisabledValues: boolean[]; + + if (reportFieldID) { + const reportFieldKey = getReportFieldKey(reportFieldID); + + reportFieldValues = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {}); + reportFieldDisabledValues = Object.values(policy?.fieldList?.[reportFieldKey]?.disabledOptions ?? {}); + } else { + reportFieldValues = formDraft?.listValues ?? []; + reportFieldDisabledValues = formDraft?.disabledListValues ?? []; + } + + return [reportFieldValues, reportFieldDisabledValues]; + }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID]); + + const updateReportFieldListValueEnabled = useCallback( + (value: boolean, valueIndex: number) => { + if (reportFieldID) { + updateReportFieldListValueEnabledReportField({ + policy, + reportFieldID, + valueIndexes: [Number(valueIndex)], + enabled: value, + }); + return; + } + + setReportFieldsListValueEnabled({ + valueIndexes: [valueIndex], + enabled: value, + disabledListValues, + }); + }, + [disabledListValues, policy, reportFieldID], + ); + + const openListValuePage = useCallback( + (valueIndex: number) => { + Navigation.navigate(getValueSettingsRoute(isInvoicePage, policyID, valueIndex, reportFieldID)); + }, + [getValueSettingsRoute, isInvoicePage, policyID, reportFieldID], + ); + + const listValueRows = useMemo( + () => + listValues.map((value, index) => ({ + keyForList: value, + value, + name: value, + index, + enabled: !disabledListValues.at(index), + isLocked: !canWrite, + isSwitchDisabled: !canWrite, + action: () => openListValuePage(index), + onToggleEnabled: (enabled: boolean) => updateReportFieldListValueEnabled(enabled, index), + onDisabledSwitchPress: withReadOnlyFallback(), + })), + [canWrite, disabledListValues, listValues, openListValuePage, updateReportFieldListValueEnabled, withReadOnlyFallback], + ); + + const listValueRowsKeyed = useMemo( + () => + listValueRows.reduce>((acc, row) => { + acc[row.keyForList] = row; + return acc; + }, {}), + [listValueRows], + ); + + const filterListValueRow = useCallback((row?: ReportFieldListValueRowData) => !!row, []); + const [selectedKeys, setSelectedKeys] = useFilteredSelection(listValueRowsKeyed, filterListValueRow); + + const clearTableSelection = useCallback(() => { + setSelectedKeys((prevSelectedKeys) => (prevSelectedKeys.length > 0 ? [] : prevSelectedKeys)); + }, [setSelectedKeys]); + + useCleanupSelectedOptions(clearTableSelection); + + useSearchBackPress({ + onClearSelection: clearTableSelection, + onNavigationCallBack: () => Navigation.goBack(), + }); + + const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Plus', 'Trashcan']); + + const handleDeleteValues = useCallback(() => { + const valuesToDelete = selectedKeys.map((value) => listValues.indexOf(value)).filter((index) => index !== -1); + + if (reportFieldID) { + removeReportFieldListValue({ + policy, + reportFieldID, + valueIndexes: valuesToDelete, + }); + } else { + deleteReportFieldsListValue({ + valueIndexes: valuesToDelete, + listValues, + disabledListValues, + }); + } + + clearTableSelection(); + }, [clearTableSelection, disabledListValues, listValues, policy, reportFieldID, selectedKeys]); + + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + + if (isReportFieldInvalid) { + return ; + } + + const getHeaderButtons = () => { + const options: Array>> = []; + if (canWrite && (isSmallScreenWidth ? isMobileSelectionModeEnabled : selectedKeys.length > 0)) { + if (selectedKeys.length > 0 && !isImportedReportField) { + options.push({ + icon: icons.Trashcan, + text: translate(selectedKeys.length === 1 ? 'workspace.reportFields.deleteValue' : 'workspace.reportFields.deleteValues'), + value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, + shouldSkipFocusRestore: true, + onSelected: () => { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate(selectedKeys.length === 1 ? 'workspace.reportFields.deleteValue' : 'workspace.reportFields.deleteValues'), + prompt: translate(selectedKeys.length === 1 ? 'workspace.reportFields.deleteValuePrompt' : 'workspace.reportFields.deleteValuesPrompt'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + handleDeleteValues(); + }); + }, + }); + } + + const enabledValues = selectedKeys.filter((value) => { + const index = listValues.indexOf(value); + return index !== -1 && !disabledListValues.at(index); + }); + + if (enabledValues.length > 0) { + const valuesToDisable = selectedKeys.reduce((acc, value) => { + const index = listValues.indexOf(value); + if (index !== -1 && !disabledListValues.at(index)) { + acc.push(index); + } + + return acc; + }, []); + + options.push({ + icon: icons.Close, + text: translate(enabledValues.length === 1 ? 'workspace.reportFields.disableValue' : 'workspace.reportFields.disableValues'), + value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, + onSelected: () => { + clearTableSelection(); + + if (reportFieldID) { + updateReportFieldListValueEnabledReportField({ + policy, + reportFieldID, + valueIndexes: valuesToDisable, + enabled: false, + }); + return; + } + + setReportFieldsListValueEnabled({ + valueIndexes: valuesToDisable, + enabled: false, + disabledListValues, + }); + }, + }); + } + + const disabledValues = selectedKeys.filter((value) => { + const index = listValues.indexOf(value); + return index !== -1 && disabledListValues.at(index); + }); + + if (disabledValues.length > 0) { + const valuesToEnable = selectedKeys.reduce((acc, value) => { + const index = listValues.indexOf(value); + if (index !== -1 && disabledListValues.at(index)) { + acc.push(index); + } + + return acc; + }, []); + + options.push({ + icon: icons.Checkmark, + text: translate(disabledValues.length === 1 ? 'workspace.reportFields.enableValue' : 'workspace.reportFields.enableValues'), + value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, + onSelected: () => { + clearTableSelection(); + + if (reportFieldID) { + updateReportFieldListValueEnabledReportField({ + policy, + reportFieldID, + valueIndexes: valuesToEnable, + enabled: true, + }); + return; + } + + setReportFieldsListValueEnabled({ + valueIndexes: valuesToEnable, + enabled: true, + disabledListValues, + }); + }, + }); + } + + return ( + null} + shouldAlwaysShowDropdownMenu + size={CONST.BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedKeys.length})} + options={options} + isSplitButton={false} + style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} + isDisabled={!selectedKeys.length} + /> + ); + } + + if (canWrite && !isImportedReportField) { + return ( + + ); + } + }; + + const selectionModeHeader = isMobileSelectionModeEnabled && isSmallScreenWidth; + const headerButtons = getHeaderButtons(); + const tableHeaderComponent = ( + + ); + + return ( + + + { + if (isMobileSelectionModeEnabled) { + clearTableSelection(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }} + > + {!shouldDisplayButtonsInSeparateLine && headerButtons} + + {isInvoicePage && tableHeaderComponent} + + + + ); +} + +export default FieldsListValuesPage; diff --git a/src/pages/workspace/fields/FieldsSettingsPage.tsx b/src/pages/workspace/fields/FieldsSettingsPage.tsx new file mode 100644 index 000000000000..f98be0c0a714 --- /dev/null +++ b/src/pages/workspace/fields/FieldsSettingsPage.tsx @@ -0,0 +1,171 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +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 usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PolicyFeature} from '@libs/PolicyUtils'; +import {getReportFieldKey} from '@libs/ReportUtils'; +import {getReportFieldInitialValue, getReportFieldTypeTranslationKey, isReportFieldImportedFromIntegration, isReportFieldTargetValid} from '@libs/WorkspaceReportFieldUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import {deleteReportFields} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import type {Route as Routes} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {Str} from 'expensify-common'; +import React from 'react'; +import {View} from 'react-native'; + +type FieldsSettingsPageProps = { + policy: OnyxEntry; + policyID: string; + reportFieldID: string; + featureName: ValueOf; + policyFeature: PolicyFeature; + expectedTarget?: ValueOf; + getListValuesRoute: (policyID: string, reportFieldID: string) => Routes; + getInitialValueRoute: (policyID: string, reportFieldID: string) => Routes; + deleteTitleKey?: 'workspace.reportFields.delete' | 'workspace.invoiceFields.delete'; + deletePromptKey?: 'workspace.reportFields.deleteConfirmation' | 'workspace.invoiceFields.deleteConfirmation'; + testID: string; +}; + +function FieldsSettingsPage({ + policy, + policyID, + reportFieldID, + featureName, + policyFeature, + expectedTarget, + getListValuesRoute, + getInitialValueRoute, + deleteTitleKey = 'workspace.reportFields.delete', + deletePromptKey = 'workspace.reportFields.deleteConfirmation', + testID, +}: FieldsSettingsPageProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + const {showConfirmModal} = useConfirmModal(); + const {canWrite} = usePolicyFeatureWriteAccess(policy, policyFeature); + + const reportFieldKey = getReportFieldKey(reportFieldID); + const reportField = policy?.fieldList?.[reportFieldKey] ?? null; + + if (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)) { + return ; + } + + const isImportedReportField = isReportFieldImportedFromIntegration(reportField, policy); + + const isDateFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.DATE; + const isListFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.LIST; + const isListFieldEmpty = isListFieldType && reportField.disabledOptions.filter((disabledListValue) => !disabledListValue).length <= 0; + const listValues = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {})?.sort(localeCompare); + + const deleteReportFieldAndHideModal = () => { + deleteReportFields({policy, reportFieldsToUpdate: [reportFieldKey]}); + Navigation.goBack(); + }; + + const showDeleteModal = () => { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate(deleteTitleKey), + prompt: translate(deletePromptKey), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + deleteReportFieldAndHideModal(); + }); + }; + + return ( + + + + + + + {isListFieldType && ( + Navigation.navigate(getListValuesRoute(policyID, reportFieldID))} + title={listValues.join(', ')} + numberOfLinesTitle={5} + /> + )} + {!isListFieldEmpty && ( + Navigation.navigate(getInitialValueRoute(policyID, reportFieldID))} + /> + )} + {canWrite && !isImportedReportField && ( + + + + )} + + + + ); +} + +export default FieldsSettingsPage; diff --git a/src/pages/workspace/fields/FieldsTypeSelectorPage.tsx b/src/pages/workspace/fields/FieldsTypeSelectorPage.tsx new file mode 100644 index 000000000000..a0ca85eba0dd --- /dev/null +++ b/src/pages/workspace/fields/FieldsTypeSelectorPage.tsx @@ -0,0 +1,88 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setDraftValues} from '@libs/actions/FormActions'; +import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import type {ReportFieldItemType} from '@pages/workspace/reports/ReportFieldTypePicker'; +import ReportFieldTypePicker from '@pages/workspace/reports/ReportFieldTypePicker'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; +import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; + +import type {ValueOf} from 'type-fest'; + +import React from 'react'; +import {View} from 'react-native'; + +type FieldsTypeSelectorPageProps = { + policyID: string; + currentType?: PolicyReportFieldType; + featureName: ValueOf; + createRoute: Route; + subtitleKey: TranslationPaths; + testID: string; +}; + +function getDefaultInitialValueForReportFieldType(type: PolicyReportFieldType): string { + if (type === CONST.REPORT_FIELD_TYPES.DATE) { + return DateUtils.extractDate(new Date().toString()); + } + if (type === CONST.REPORT_FIELD_TYPES.FORMULA) { + return '{report:id}'; + } + return ''; +} + +function FieldsTypeSelectorPage({policyID, currentType, featureName, createRoute, subtitleKey, testID}: FieldsTypeSelectorPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const selectType = (item: ReportFieldItemType) => { + if (item.value !== currentType) { + setDraftValues(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM, { + [INPUT_IDS.TYPE]: item.value, + [INPUT_IDS.INITIAL_VALUE]: getDefaultInitialValueForReportFieldType(item.value), + }); + } + Navigation.goBack(createRoute); + }; + + return ( + + + Navigation.goBack(createRoute)} + /> + + {translate(subtitleKey)} + + + + + ); +} + +export default FieldsTypeSelectorPage; diff --git a/src/pages/workspace/fields/FieldsValueSettingsPage.tsx b/src/pages/workspace/fields/FieldsValueSettingsPage.tsx new file mode 100644 index 000000000000..32d4f519dd99 --- /dev/null +++ b/src/pages/workspace/fields/FieldsValueSettingsPage.tsx @@ -0,0 +1,186 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import usePrevious from '@hooks/usePrevious'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {deleteReportFieldsListValue, removeReportFieldListValue, setReportFieldsListValueEnabled, updateReportFieldListValueEnabled} from '@libs/actions/Policy/ReportField'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PolicyFeature} from '@libs/PolicyUtils'; +import {getReportFieldKey} from '@libs/ReportUtils'; +import {isReportFieldImportedFromIntegration, isReportFieldTargetValid} from '@libs/WorkspaceReportFieldUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route as Routes} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useMemo} from 'react'; +import {View} from 'react-native'; + +type FieldsValueSettingsPageProps = { + policy: OnyxEntry; + policyID: string; + valueIndex: number; + reportFieldID?: string; + isInvoicePage: boolean; + featureName: ValueOf; + policyFeature: PolicyFeature; + getEditValueRoute: (isInvoiceRoute: boolean, policyID: string, valueIndex: number) => Routes; + testID: string; +}; + +function FieldsValueSettingsPage({policy, policyID, valueIndex, reportFieldID, isInvoicePage, featureName, policyFeature, getEditValueRoute, testID}: FieldsValueSettingsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + const {showConfirmModal} = useConfirmModal(); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + const {canWrite, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, policyFeature); + + const [currentValueName, currentValueDisabled] = useMemo(() => { + let reportFieldValue: string; + let reportFieldDisabledValue: boolean; + + if (reportFieldID) { + const reportFieldKey = getReportFieldKey(reportFieldID); + + reportFieldValue = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {})?.at(valueIndex) ?? ''; + reportFieldDisabledValue = Object.values(policy?.fieldList?.[reportFieldKey]?.disabledOptions ?? {})?.at(valueIndex) ?? false; + } else { + reportFieldValue = formDraft?.listValues?.[valueIndex] ?? ''; + reportFieldDisabledValue = formDraft?.disabledListValues?.[valueIndex] ?? false; + } + + return [reportFieldValue, reportFieldDisabledValue]; + }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID, valueIndex]); + + const reportField = reportFieldID ? policy?.fieldList?.[getReportFieldKey(reportFieldID)] : undefined; + const isImportedReportField = isReportFieldImportedFromIntegration(reportField, policy); + const oldValueName = usePrevious(currentValueName); + const expectedTarget = isInvoicePage ? CONST.REPORT_FIELD_TARGETS.INVOICE : CONST.REPORT_FIELD_TARGETS.EXPENSE; + const isReportFieldInvalid = !!reportFieldID && (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)); + const shouldUseInvoiceRoutes = isInvoicePage; + + if (isReportFieldInvalid || (!currentValueName && !oldValueName)) { + return ; + } + + const deleteListValueAndHideModal = () => { + if (reportFieldID) { + removeReportFieldListValue({policy, reportFieldID, valueIndexes: [valueIndex]}); + } else { + deleteReportFieldsListValue({ + valueIndexes: [valueIndex], + listValues: formDraft?.listValues ?? [], + disabledListValues: formDraft?.disabledListValues ?? [], + }); + } + Navigation.goBack(); + }; + + const showDeleteModal = () => { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate('workspace.reportFields.deleteValue'), + prompt: translate('workspace.reportFields.deleteValuePrompt'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + deleteListValueAndHideModal(); + }); + }; + + const updateListValueEnabled = (value: boolean) => { + if (reportFieldID) { + updateReportFieldListValueEnabled({policy, reportFieldID, valueIndexes: [Number(valueIndex)], enabled: value}); + return; + } + + setReportFieldsListValueEnabled({ + valueIndexes: [valueIndex], + enabled: value, + disabledListValues: formDraft?.disabledListValues ?? [], + }); + }; + + const navigateToEditValue = () => { + Navigation.navigate(getEditValueRoute(shouldUseInvoiceRoutes, policyID, valueIndex)); + }; + + return ( + + + + + + + + {translate('workspace.reportFields.enableValue')} + + + + + + {canWrite && !isImportedReportField && ( + + )} + + + + ); +} + +export default FieldsValueSettingsPage; diff --git a/src/pages/workspace/fields/WorkspaceFieldsSection.tsx b/src/pages/workspace/fields/WorkspaceFieldsSection.tsx new file mode 100644 index 000000000000..4e9a0f4fbab9 --- /dev/null +++ b/src/pages/workspace/fields/WorkspaceFieldsSection.tsx @@ -0,0 +1,289 @@ +import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSoftware'; +import MenuItem from '@components/MenuItem'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Section from '@components/Section'; +import Text from '@components/Text'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; +import Navigation from '@libs/Navigation/Navigation'; +import {getConnectedIntegration, isControlPolicy, shouldShowSyncError} from '@libs/PolicyUtils'; +import type {PolicyFeature} from '@libs/PolicyUtils'; +import {getReportFieldTypeTranslationKey, isReportFieldImportedFromIntegration} from '@libs/WorkspaceReportFieldUtils'; + +import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; + +import {setInitialCreateReportFieldsForm} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {Policy, PolicyConnectionName} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {FlashList} from '@shopify/flash-list'; +import {Str} from 'expensify-common'; +import React, {useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; + +type FieldListValue = NonNullable[string]; + +type FieldListItem = { + fieldID: string; + isDisabled: boolean; + keyForList: string; + pendingAction?: PendingAction; + rightLabel: string; + text: string; +}; + +type WorkspaceFieldsSectionProps = { + policy: OnyxEntry; + policyID: string; + isEnabled: boolean; + pendingAction?: PendingAction | null; + fieldFilter: (field: FieldListValue) => boolean; + titleKey: TranslationPaths; + subtitleKey: TranslationPaths; + importedFromAccountingSoftwareKey: TranslationPaths; + disableTitleKey: TranslationPaths; + disablePromptKey: TranslationPaths; + cannotDisableImportedFieldsPromptKey?: TranslationPaths; + addFieldKey: TranslationPaths; + createRoute: Route; + getSettingsRoute: (policyID: string, fieldID: string) => Route; + upgradeFeatureAlias: string; + upgradeBackToRoute: Route; + enableFields: (policyID: string, enabled: boolean) => void; + openFieldsPage: (policyID: string) => void; + policyFeature: PolicyFeature; + syncErrorConnectionNames?: readonly PolicyConnectionName[]; + titleAccessibilityRole?: typeof CONST.ROLE.HEADER; +}; + +function keyExtractor(item: FieldListItem) { + return item.keyForList; +} + +function WorkspaceFieldsSection({ + policy, + policyID, + isEnabled, + pendingAction, + fieldFilter, + titleKey, + subtitleKey, + importedFromAccountingSoftwareKey, + disableTitleKey, + disablePromptKey, + cannotDisableImportedFieldsPromptKey = 'workspace.reportFields.cannotDisableImportedReportFields', + addFieldKey, + createRoute, + getSettingsRoute, + upgradeFeatureAlias, + upgradeBackToRoute, + enableFields, + openFieldsPage, + policyFeature, + syncErrorConnectionNames, + titleAccessibilityRole, +}: WorkspaceFieldsSectionProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Plus']); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {showConfirmModal} = useConfirmModal(); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); + const syncingAccountingIntegration = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.find((connectionName) => connectionName === connectionSyncProgress?.connectionName); + const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, syncErrorConnectionNames); + const connectedIntegration = getConnectedIntegration(policy) ?? syncingAccountingIntegration; + const isConnectionVerified = connectedIntegration && !isConnectionUnverified(policy, connectedIntegration); + const currentConnectionName = getCurrentAccountingIntegrationName(policy, translate); + const fieldList = policy?.fieldList; + const hasImportedField = useMemo( + () => Object.values(fieldList ?? {}).some((field) => fieldFilter(field) && isReportFieldImportedFromIntegration(field, policy)), + [fieldFilter, fieldList, policy], + ); + const {canWrite, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, policyFeature); + + const fetchFields = useCallback(() => { + openFieldsPage(policyID); + }, [openFieldsPage, policyID]); + + const {isOffline} = useNetwork({onReconnect: fetchFields}); + + useEffect(() => { + fetchFields(); + }, [fetchFields]); + + const fields = useMemo(() => { + if (!fieldList) { + return []; + } + + return Object.values(fieldList) + .filter(fieldFilter) + .sort((a, b) => localeCompare(a.name, b.name)) + .map((field) => ({ + text: field.name, + keyForList: String(field.fieldID), + fieldID: field.fieldID, + pendingAction: field.pendingAction, + isDisabled: field.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + rightLabel: Str.recapitalize(translate(getReportFieldTypeTranslationKey(field.type ?? CONST.REPORT_FIELD_TYPES.TEXT))), + })); + }, [fieldFilter, fieldList, localeCompare, translate]); + + const navigateToFieldSettings = useCallback( + (item: FieldListItem) => { + if (!canWrite) { + return; + } + + Navigation.navigate(getSettingsRoute(policyID, item.fieldID)); + }, + [canWrite, getSettingsRoute, policyID], + ); + + const renderItem = useCallback( + ({item}: ListRenderItemInfo) => ( + + navigateToFieldSettings(item)} + description={item.text} + disabled={item.isDisabled} + shouldShowRightIcon={!item.isDisabled && canWrite} + interactive={!item.isDisabled && canWrite} + rightLabel={item.rightLabel} + descriptionTextStyle={[styles.popoverMenuText, styles.textStrong]} + /> + + ), + [canWrite, navigateToFieldSettings, shouldUseNarrowLayout, styles.ph5, styles.ph8, styles.popoverMenuText, styles.textStrong], + ); + + const headerText = + !hasSyncError && isConnectionVerified && currentConnectionName ? ( + + + + ) : ( + {translate(subtitleKey)} + ); + + const switchAccessibilityLabel = + !hasSyncError && isConnectionVerified && currentConnectionName + ? `${translate(titleKey)}, ${translate(importedFromAccountingSoftwareKey)} ${currentConnectionName} ${translate('workspace.accounting.settings')}` + : `${translate(titleKey)}, ${translate(subtitleKey)}`; + + const isLoading = !isOffline && policy === undefined; + + const showImportedFieldsLockedModal = () => { + if (!hasImportedField) { + return; + } + + showConfirmModal({ + title: translate(disableTitleKey), + prompt: translate(cannotDisableImportedFieldsPromptKey), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + + return ( +
+ { + if (!enabled) { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate(disableTitleKey), + prompt: translate(disablePromptKey), + confirmText: translate('common.disable'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + enableFields(policyID, false); + }); + return; + } + + if (!isControlPolicy(policy)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, upgradeFeatureAlias, upgradeBackToRoute)); + return; + } + + enableFields(policyID, true); + }} + disabled={!canWrite || (isEnabled && hasImportedField)} + disabledAction={withReadOnlyFallback(showImportedFieldsLockedModal)} + showLockIcon={!canWrite} + subMenuItems={ + isEnabled && ( + <> + + {!isLoading && ( + + )} + + {canWrite && ( + { + setInitialCreateReportFieldsForm(); + Navigation.navigate(createRoute); + }} + title={translate(addFieldKey)} + icon={icons.Plus} + style={[styles.sectionMenuItemTopDescription]} + /> + )} + + ) + } + /> +
+ ); +} + +export default WorkspaceFieldsSection; diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index c28fd29d73b4..320fae3fd8bc 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -16,6 +16,7 @@ import { hasVendorFeature, isGroupPolicy, isMatchingVendorListLoaded, + isMCPEnabled, isPerDiemEnabled, isPolicyAdmin, isTimeTrackingEnabled, @@ -56,7 +57,6 @@ type WorkspaceMenuIconMap = Record< | 'Tag' | 'Coins' | 'Workflows' - | 'Feed' | 'Car' | 'LuggageWithLines' | 'ExpensifyCard' @@ -65,7 +65,8 @@ type WorkspaceMenuIconMap = Record< | 'Clock' | 'InvoiceGeneric' | 'Gear' - | 'Bolt', + | 'Bolt' + | 'Bot', IconAsset >; @@ -103,8 +104,6 @@ type GetWorkspaceMenuItemsParams = { shouldShowEnterCredentialsError?: boolean; /** Whether the company cards row should show an error indicator. */ shouldShowRBR?: boolean; - /** Whether the Rules revamp beta is enabled. */ - isRulesRevampBetaEnabled?: boolean; /** Whether the vendor matching beta is enabled. */ isVendorMatchingBetaEnabled?: boolean; /** Formats the invoice account balance for its menu badge. */ @@ -121,7 +120,6 @@ function getWorkspaceMenuItems({ previousPendingFields, shouldShowEnterCredentialsError = false, shouldShowRBR = false, - isRulesRevampBetaEnabled = false, isVendorMatchingBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { @@ -172,10 +170,11 @@ function getWorkspaceMenuItems({ [CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED]: (policy?.isHREnabled === true || isAnyHRConnected(policy)) && canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED), [CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED]: policy?.areExpensifyCardsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED]: policy?.areReportFieldsEnabled, - [CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: arePolicyRulesEnabled(policy, policyCategories, isRulesRevampBetaEnabled), + [CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: arePolicyRulesEnabled(policy, policyCategories), [CONST.POLICY.MORE_FEATURES.ARE_INVOICES_ENABLED]: policy?.areInvoicesEnabled, [CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]: isPerDiemEnabled(policy) && canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED), [CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED]: policy?.receiptPartners?.enabled ?? false, + [CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED]: isMCPEnabled(policy), [CONST.POLICY.MORE_FEATURES.IS_TRAVEL_ENABLED]: policy?.isTravelEnabled, [CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED]: isTimeTrackingEnabled(policy), }; @@ -254,6 +253,17 @@ function getWorkspaceMenuItems({ }); } + if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED] && canReadMoreFeatures) { + items.push({ + translationKey: 'workspace.common.mcp', + icon: icons.Bot, + getRoute: () => ROUTES.WORKSPACE_MCP.getRoute(policyID), + screenName: SCREENS.WORKSPACE.MCP, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.MCP, + highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED, + }); + } + if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.CATEGORIES)) { items.push({ translationKey: 'workspace.common.categories', @@ -314,7 +324,7 @@ function getWorkspaceMenuItems({ if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.RULES)) { items.push({ translationKey: 'workspace.common.rules', - icon: isRulesRevampBetaEnabled ? icons.Bolt : icons.Feed, + icon: icons.Bolt, getRoute: () => ROUTES.WORKSPACE_RULES.getRoute(policyID), brickRoadIndicator: hasPolicyRulesError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, screenName: SCREENS.WORKSPACE.RULES, diff --git a/src/pages/workspace/hr/DynamicHRSyncResultsPage.tsx b/src/pages/workspace/hr/DynamicHRSyncResultsPage.tsx index 05c413753437..82dffb3ea427 100644 --- a/src/pages/workspace/hr/DynamicHRSyncResultsPage.tsx +++ b/src/pages/workspace/hr/DynamicHRSyncResultsPage.tsx @@ -4,7 +4,7 @@ * The sync payload is read from Onyx (the policy's connection sync progress), so only the workspace's * `policyID` needs to travel through the route. */ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; diff --git a/src/pages/workspace/hr/HRApprovalModePageBase.tsx b/src/pages/workspace/hr/HRApprovalModePageBase.tsx index 9404f977adb2..6ed1b51ca6de 100644 --- a/src/pages/workspace/hr/HRApprovalModePageBase.tsx +++ b/src/pages/workspace/hr/HRApprovalModePageBase.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -66,22 +66,22 @@ function HRApprovalModePageBase({policyID, config}: const approvalModeOptions: Array> = [ { - text: translate('workspace.hr.approvalModes.basic.label'), - alternateText: translate('workspace.hr.approvalModes.basic.description'), + text: translate('workspace.merge.approvalModes.basic'), + alternateText: translate('workspace.hr.approvalModeDescriptions.basic'), keyForList: config.approvalModes.BASIC, value: config.approvalModes.BASIC, isSelected: selectedApprovalMode === config.approvalModes.BASIC, }, { - text: translate('workspace.hr.approvalModes.manager.label'), - alternateText: translate('workspace.hr.approvalModes.manager.description', providerName), + text: translate('workspace.merge.approvalModes.manager'), + alternateText: translate('workspace.hr.approvalModeDescriptions.manager', providerName), keyForList: config.approvalModes.MANAGER, value: config.approvalModes.MANAGER, isSelected: selectedApprovalMode === config.approvalModes.MANAGER, }, { - text: translate('workspace.hr.approvalModes.custom.label'), - alternateText: translate('workspace.hr.approvalModes.custom.description'), + text: translate('workspace.merge.approvalModes.custom'), + alternateText: translate('workspace.hr.approvalModeDescriptions.custom'), keyForList: config.approvalModes.CUSTOM, value: config.approvalModes.CUSTOM, isSelected: selectedApprovalMode === config.approvalModes.CUSTOM, diff --git a/src/pages/workspace/hr/WorkspaceHRPage.tsx b/src/pages/workspace/hr/WorkspaceHRPage.tsx index d0f75ac71ba3..2af6b15dc49b 100644 --- a/src/pages/workspace/hr/WorkspaceHRPage.tsx +++ b/src/pages/workspace/hr/WorkspaceHRPage.tsx @@ -1,48 +1,23 @@ -import CollapsibleSection from '@components/CollapsibleSection'; -import ConnectToHRFlow from '@components/ConnectToHRFlow'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import CompactSearchBar from '@components/SearchBar/CompactSearchBar'; -import Section from '@components/Section'; - -import useConfirmModal from '@hooks/useConfirmModal'; import useHRSyncResultsPage from '@hooks/useHRSyncResultsPage'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMergeHRInitialSyncingModal from '@hooks/useMergeHRInitialSyncingModal'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchResults from '@hooks/useSearchResults'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; -import {openPolicyHRPage} from '@libs/actions/PolicyConnections'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import tokenizedSearch from '@libs/tokenizedSearch'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import variables from '@styles/variables'; +import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {useIsFocused} from '@react-navigation/native'; -import React, {useEffect, useState} from 'react'; -import {View} from 'react-native'; - -import type {HRCardDescriptor} from './utils'; +import React from 'react'; -import HRProviderCard from './HRProviderCard'; import {getHRCards} from './utils'; type WorkspaceHRPageProps = PlatformStackScreenProps; @@ -52,25 +27,12 @@ function WorkspaceHRPage({ params: {policyID}, }, }: WorkspaceHRPageProps) { - const {translate, getLocalDateFromDatetime, localeCompare, formatPhoneNumber} = useLocalize(); + const {translate, getLocalDateFromDatetime, formatPhoneNumber} = useLocalize(); const isFocused = useIsFocused(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const policy = usePolicy(policyID); const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare']); - const [activeHRFlow, setActiveHRFlow] = useState<{setupLink: string; key: number} | undefined>(); - const {showConfirmModal} = useConfirmModal(); - - useWorkspaceDocumentTitle(undefined, 'workspace.common.hr'); - - useNetwork({onReconnect: () => openPolicyHRPage(policyID)}); - - useEffect(() => { - openPolicyHRPage(policyID); - }, [policyID]); useHRSyncResultsPage(connectionSyncProgress, isFocused); useMergeHRInitialSyncingModal(policyID, isFocused); @@ -87,144 +49,12 @@ function WorkspaceHRPage({ trinetIcon: icons.TriNetSquare, }); - const connectedCards: HRCardDescriptor[] = []; - const disconnectedCards: HRCardDescriptor[] = []; - for (const card of cards) { - (card.isConnected ? connectedCards : disconnectedCards).push(card); - } - const byName = (a: HRCardDescriptor, b: HRCardDescriptor) => localeCompare(a.displayName, b.displayName); - connectedCards.sort(byName); - disconnectedCards.sort(byName); - - const filterCard = (card: HRCardDescriptor, searchInput: string) => { - return tokenizedSearch([card], searchInput, (c) => [c.displayName]).length > 0; - }; - const [inputValue, setInputValue, filteredDisconnectedCards] = useSearchResults(disconnectedCards, filterCard); - - const {canWrite: canWriteMoreFeatures, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); - - const handleConnect = (card: HRCardDescriptor) => { - if (!card.setupLink) { - return; - } - - if (!canWriteMoreFeatures) { - showReadOnlyModal(); - return; - } - - if (!card.isConnected && connectedCards.length > 0) { - showConfirmModal({ - title: translate('workspace.hr.alreadyConnectedTitle'), - prompt: translate('workspace.hr.alreadyConnectedPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - innerContainerStyle: shouldUseNarrowLayout ? undefined : StyleUtils.getWidthStyle(variables.wideConfirmModalWidth), - }); - return; - } - - // eslint-disable-next-line react-hooks/purity -- random key forces remount on every press, even for the same provider - setActiveHRFlow({setupLink: card.setupLink, key: Math.random()}); - }; - - const maybeSearchBar = disconnectedCards.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - - ); - const filteredDisconnectedHRProviderCards = filteredDisconnectedCards.map((card) => ( - handleConnect(card)} - canWriteMoreFeatures={canWriteMoreFeatures} - showReadOnlyModal={showReadOnlyModal} - /> - )); - return ( - - - {!!activeHRFlow && ( - setActiveHRFlow(undefined)} - /> - )} - Navigation.goBack()} - /> - - -
- - {connectedCards.map((card) => ( - handleConnect(card)} - canWriteMoreFeatures={canWriteMoreFeatures} - showReadOnlyModal={showReadOnlyModal} - /> - ))} - {connectedCards.length === 0 && ( - <> - {maybeSearchBar} - {filteredDisconnectedHRProviderCards} - - )} - - - {connectedCards.length > 0 && disconnectedCards.length > 0 && !connectedCards.some((c) => c.isInitialSyncInProgress) && ( - - {maybeSearchBar} - {filteredDisconnectedHRProviderCards} - - )} -
-
-
-
-
+ category={CONST.POLICY.CONNECTIONS.CATEGORY.HR} + cards={cards} + /> ); } diff --git a/src/pages/workspace/hr/gusto/GustoApprovalModePage.tsx b/src/pages/workspace/hr/gusto/GustoApprovalModePage.tsx index 1aab79a8886d..d751639a3a38 100644 --- a/src/pages/workspace/hr/gusto/GustoApprovalModePage.tsx +++ b/src/pages/workspace/hr/gusto/GustoApprovalModePage.tsx @@ -30,7 +30,7 @@ function GustoApprovalModePage({ approvalModes: CONST.GUSTO.APPROVAL_MODE, getCurrentApprovalMode: (policy) => policy?.connections?.gusto?.config?.approvalMode ?? null, getProviderName: () => translate('workspace.hr.gusto.title'), - getHeaderTitle: () => translate('workspace.hr.approvalMode'), + getHeaderTitle: () => translate('workspace.merge.approvalMode'), handleSave: ({draftApprovalMode, currentApprovalMode}) => updateGustoApprovalMode(policyID, draftApprovalMode, currentApprovalMode), }; diff --git a/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx b/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx index 2ff9138944cf..72b24cad9d74 100644 --- a/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx +++ b/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx @@ -26,7 +26,7 @@ function GustoFinalApproverPage({ isConnected: isGustoConnected, getCurrentFinalApprover: (policy) => policy?.connections?.gusto?.config?.finalApprover ?? null, getProviderName: () => translate('workspace.hr.gusto.title'), - getHeaderTitle: () => translate('workspace.hr.finalApprover'), + getHeaderTitle: () => translate('workspace.merge.finalApprover'), handleSave: ({policyID: id, email, currentFinalApprover}) => updateGustoFinalApprover(id, email, currentFinalApprover), }; diff --git a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx index 73373f6f3745..805158fc8d66 100644 --- a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/hr/utils.ts b/src/pages/workspace/hr/utils.ts index 0728d5cd98ba..7379624e5156 100644 --- a/src/pages/workspace/hr/utils.ts +++ b/src/pages/workspace/hr/utils.ts @@ -4,99 +4,25 @@ import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginPro import {hasSynchronizationErrorMessage, isConnectionInProgress} from '@libs/actions/connections'; import getGustoSetupLink from '@libs/actions/connections/Gusto'; import {getMergeSetupLink} from '@libs/actions/connections/merge'; -import type {MergeConnectionErrorFieldName} from '@libs/actions/connections/merge'; import getZenefitsSetupLink from '@libs/actions/connections/Zenefits'; import {formatList} from '@libs/Localize'; import {getConnectedHRProvider, getHRApprovalMode, isMergeHRCompleteSetupNeeded} from '@libs/merge/HRUtils'; import type {HRConnectionName} from '@libs/merge/HRUtils'; -import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getMergeFinalApproverDisplayName} from '@libs/merge/MergeUtils'; import {getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; +import type {MergeProviderCardDescriptor, MergeProviderConfigRow} from '@pages/workspace/merge/types'; + import CONST from '@src/CONST'; import MERGE_HR_PROVIDERS from '@src/CONST/MERGE_HR_PROVIDERS'; import type {MergeHRProviderSlug} from '@src/CONST/MERGE_HR_PROVIDERS'; import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; -import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {ConnectionName, GustoConnectionConfig, MergeHRConnectionConfig, PolicyConnectionSyncProgress, PolicyConnectionSyncStage, ZenefitsConnectionConfig} from '@src/types/onyx/Policy'; +import type {ConnectionName, GustoConnectionConfig, MergeHRConnectionConfig, PolicyConnectionSyncProgress, ZenefitsConnectionConfig} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import type IconAsset from '@src/types/utils/IconAsset'; import type {OnyxEntry} from 'react-native-onyx'; -type HRCardConfig = MergeHRConnectionConfig | GustoConnectionConfig | ZenefitsConnectionConfig | undefined; - -/** A single editable configuration row (approval mode, final approver, groups) shown on a connected HR card. */ -type HRConfigRow = { - /** Onyx field this row maps to. */ - field: MergeConnectionErrorFieldName; - - /** Translated label shown as the row's top description. */ - description: string; - - /** Current value shown as the row title, or undefined when nothing is set yet. */ - title?: string; - - /** Route opened when the row is tapped. */ - route: Route; - - /** Pending action for this field while an update is in progress. */ - pendingAction?: PendingAction; - - /** Errors for this field when the last update failed. */ - errors?: Errors | null; -}; - -type HRCardDescriptor = { - /** Unique identifier for this card. */ - key: string; - - /** The Onyx connection name that identifies this HR provider. */ - connectionName: HRConnectionName; - - /** Human-readable provider name shown in the UI (e.g. "Gusto", "Zenefits"). */ - displayName: string; - - /** Provider logo — either a remote URL string or a local icon asset. */ - icon: string | IconAsset; - - /** URL to open to start the connection flow for this HR provider. */ - setupLink?: string; - - /** Whether this provider is currently connected to the workspace. */ - isConnected: boolean; - - /** Whether a sync operation is currently running for this provider. */ - isSyncInProgress: boolean; - - /** Whether this provider's first-ever (initial) sync is currently running (Merge HR only). */ - isInitialSyncInProgress?: boolean; - - /** Navigation route to the post-connect setup RHP (group selection). Set only while the admin still needs to finish setup. */ - completeSetupRoute?: Route; - - /** ISO date string of the last successful sync, used for "last synced" display. */ - successfulDate?: string; - - /** Whether the last sync resulted in an error. */ - hasError: boolean; - - /** Whether the card should switch into "reconnect mode". Shows the error message and the Reconnect link. */ - needsReconnect: boolean; - - /** Human-readable error message from the last failed sync attempt. */ - lastSyncErrorMessage?: string; - - /** Current stage of an in-progress sync, used to show step-level progress. */ - syncStageInProgress?: PolicyConnectionSyncStage; - - /** Persisted configuration for the HR connection (approval mode, final approver, pending/error state). */ - config?: HRCardConfig; - - /** Editable configuration rows shown on a connected card, in display order. */ - configRows?: HRConfigRow[]; -}; - type GetHRCardStateParams = { /** The workspace policy to derive HR card state from. */ policy: OnyxEntry; @@ -169,24 +95,24 @@ function getApprovalModeLabel(policy: OnyxEntry, connectionName: HRConne const approvalMode = getHRApprovalMode(policy, connectionName); if (!approvalMode) { - return translate('workspace.hr.notSet'); + return translate('workspace.merge.notSet'); } switch (approvalMode) { case CONST.GUSTO.APPROVAL_MODE.BASIC: case CONST.MERGE.APPROVAL_MODE.BASIC: case CONST.ZENEFITS.APPROVAL_MODE.BASIC: - return translate('workspace.hr.approvalModes.basic.label'); + return translate('workspace.merge.approvalModes.basic'); case CONST.GUSTO.APPROVAL_MODE.MANAGER: case CONST.MERGE.APPROVAL_MODE.MANAGER: case CONST.ZENEFITS.APPROVAL_MODE.MANAGER: - return translate('workspace.hr.approvalModes.manager.label'); + return translate('workspace.merge.approvalModes.manager'); case CONST.GUSTO.APPROVAL_MODE.CUSTOM: case CONST.MERGE.APPROVAL_MODE.CUSTOM: case CONST.ZENEFITS.APPROVAL_MODE.CUSTOM: - return translate('workspace.hr.approvalModes.custom.label'); + return translate('workspace.merge.approvalModes.custom'); default: - return translate('workspace.hr.notSet'); + return translate('workspace.merge.notSet'); } } @@ -201,25 +127,6 @@ function getMergeHRGroupsLabel(policy: OnyxEntry): string | undefined { return formatList(names); } -/** Resolves the final approver email to a display name via personal details. Returns "Not set" when no approver is configured. */ -function getFinalApproverDisplayName( - finalApprover: string | undefined | null, - policyEmployeePersonalDetails: PersonalDetailsByLogin, - translate: LocaleContextProps['translate'], - formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], -): string { - if (!finalApprover) { - return translate('workspace.hr.notSet'); - } - return temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: policyEmployeePersonalDetails[finalApprover], - defaultValue: finalApprover, - shouldFallbackToHidden: false, - translate, - formatPhoneNumber, - }); -} - /** Extracts the connection-specific config object (approval mode, final approver, pending/error fields) from the policy for a given HR provider. */ function getCardConfig(policy: OnyxEntry, connectionName: HRConnectionName): MergeHRConnectionConfig | GustoConnectionConfig | ZenefitsConnectionConfig | undefined { if (connectionName === CONST.POLICY.CONNECTIONS.NAME.GUSTO) { @@ -294,8 +201,8 @@ function getHRCards({ formatPhoneNumber, policyID, ...iconParams -}: GetHRCardsParams): HRCardDescriptor[] { - const cards: HRCardDescriptor[] = []; +}: GetHRCardsParams): MergeProviderCardDescriptor[] { + const cards: MergeProviderCardDescriptor[] = []; for (const provider of STATIC_HR_PROVIDERS) { const {connectionName} = provider; @@ -303,17 +210,17 @@ function getHRCards({ const config = getCardConfig(policy, connectionName); cards.push({ key: provider.key, + category: CONST.POLICY.CONNECTIONS.CATEGORY.HR, connectionName, displayName: translate(provider.titleKey), icon: iconParams[provider.iconParam], setupLink: provider.getSetupLink?.(policyID), - config, configRows: state.isConnected && !state.needsReconnect ? [ { field: 'approvalMode', - description: translate('workspace.hr.approvalMode'), + description: translate('workspace.merge.approvalMode'), title: getApprovalModeLabel(policy, connectionName, translate), route: provider.approvalModeRoute.getRoute(policyID), pendingAction: config?.pendingFields?.approvalMode, @@ -321,8 +228,8 @@ function getHRCards({ }, { field: 'finalApprover', - description: translate('workspace.hr.finalApprover'), - title: getFinalApproverDisplayName(config?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber), + description: translate('workspace.merge.finalApprover'), + title: getMergeFinalApproverDisplayName(config?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber), route: provider.finalApproverRoute.getRoute(policyID), pendingAction: config?.pendingFields?.finalApprover, errors: config?.errorFields?.finalApprover, @@ -342,7 +249,7 @@ function getHRCards({ const needsSetup = state.isConnected && !state.needsReconnect && isMergeHRCompleteSetupNeeded(policy); const groupsRoute = ROUTES.WORKSPACE_HR_MERGE_GROUPS.getRoute(policyID); - const configRows: HRConfigRow[] = + const configRows: MergeProviderConfigRow[] = state.isConnected && !state.needsReconnect ? [ { @@ -355,7 +262,7 @@ function getHRCards({ }, { field: 'approvalMode', - description: translate('workspace.hr.approvalMode'), + description: translate('workspace.merge.approvalMode'), title: getApprovalModeLabel(policy, mergeConnectionName, translate), route: ROUTES.WORKSPACE_HR_MERGE_APPROVAL_MODE.getRoute(policyID), pendingAction: mergeConfig?.pendingFields?.approvalMode, @@ -363,8 +270,8 @@ function getHRCards({ }, { field: 'finalApprover', - description: translate('workspace.hr.finalApprover'), - title: getFinalApproverDisplayName(mergeConfig?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber), + description: translate('workspace.merge.finalApprover'), + title: getMergeFinalApproverDisplayName(mergeConfig?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber), route: ROUTES.WORKSPACE_HR_MERGE_FINAL_APPROVER.getRoute(policyID), pendingAction: mergeConfig?.pendingFields?.finalApprover, errors: mergeConfig?.errorFields?.finalApprover, @@ -374,13 +281,13 @@ function getHRCards({ cards.push({ key: `merge_${slug}`, + category: CONST.POLICY.CONNECTIONS.CATEGORY.HR, connectionName: mergeConnectionName, displayName: providerEntry.displayName, icon: providerEntry.iconUrl, - setupLink: getMergeSetupLink(policyID, slug), + setupLink: getMergeSetupLink(policyID, slug, CONST.MERGE.CATEGORY.HRIS), ...(state.isConnected ? state : disconnectedState), completeSetupRoute: needsSetup ? groupsRoute : undefined, - config: mergeConfig, configRows, }); } @@ -388,5 +295,4 @@ function getHRCards({ return cards; } -export type {HRCardDescriptor}; export {getHRCardState, getHRCards, getApprovalModeLabel}; diff --git a/src/pages/workspace/hr/zenefits/ZenefitsApprovalModePage.tsx b/src/pages/workspace/hr/zenefits/ZenefitsApprovalModePage.tsx index 19fd3372abf0..510cccd35359 100644 --- a/src/pages/workspace/hr/zenefits/ZenefitsApprovalModePage.tsx +++ b/src/pages/workspace/hr/zenefits/ZenefitsApprovalModePage.tsx @@ -30,7 +30,7 @@ function ZenefitsApprovalModePage({ approvalModes: CONST.ZENEFITS.APPROVAL_MODE, getCurrentApprovalMode: (policy) => policy?.connections?.zenefits?.config?.approvalMode ?? null, getProviderName: () => translate('workspace.hr.zenefits.title'), - getHeaderTitle: () => translate('workspace.hr.approvalMode'), + getHeaderTitle: () => translate('workspace.merge.approvalMode'), handleSave: ({draftApprovalMode, currentApprovalMode}) => updateZenefitsApprovalMode(policyID, draftApprovalMode, currentApprovalMode), }; diff --git a/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx b/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx index 5475c07f5282..04009e1b6fd7 100644 --- a/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx +++ b/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx @@ -26,7 +26,7 @@ function ZenefitsFinalApproverPage({ isConnected: isZenefitsConnected, getCurrentFinalApprover: (policy) => policy?.connections?.zenefits?.config?.finalApprover ?? null, getProviderName: () => translate('workspace.hr.zenefits.title'), - getHeaderTitle: () => translate('workspace.hr.finalApprover'), + getHeaderTitle: () => translate('workspace.merge.finalApprover'), handleSave: ({policyID: id, email, currentFinalApprover}) => updateZenefitsFinalApprover(id, email, currentFinalApprover), }; diff --git a/src/pages/workspace/invoices/CreateInvoiceFieldsPage.tsx b/src/pages/workspace/invoices/CreateInvoiceFieldsPage.tsx new file mode 100644 index 000000000000..228f2f308f25 --- /dev/null +++ b/src/pages/workspace/invoices/CreateInvoiceFieldsPage.tsx @@ -0,0 +1,38 @@ +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import CreateFieldsPage from '@pages/workspace/fields/CreateFieldsPage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type CreateInvoiceFieldsPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function CreateInvoiceFieldsPage({ + policy, + route: { + params: {policyID}, + }, +}: CreateInvoiceFieldsPageProps) { + return ( + + ); +} + +export default withPolicyAndFullscreenLoading(CreateInvoiceFieldsPage); diff --git a/src/pages/workspace/invoices/DynamicInvoiceFieldsInitialListValuePage.tsx b/src/pages/workspace/invoices/DynamicInvoiceFieldsInitialListValuePage.tsx new file mode 100644 index 000000000000..47b0752c33be --- /dev/null +++ b/src/pages/workspace/invoices/DynamicInvoiceFieldsInitialListValuePage.tsx @@ -0,0 +1,38 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsInitialListValuePage from '@pages/workspace/fields/FieldsInitialListValuePage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type DynamicInvoiceFieldsInitialListValuePageProps = WithPolicyAndFullscreenLoadingProps & + PlatformStackScreenProps; + +function DynamicInvoiceFieldsInitialListValuePage({ + route: { + params: {policyID}, + }, +}: DynamicInvoiceFieldsInitialListValuePageProps) { + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_INVOICE_FIELDS_INITIAL_LIST_VALUE.path); + + return ( + + ); +} + +export default withPolicyAndFullscreenLoading(DynamicInvoiceFieldsInitialListValuePage); diff --git a/src/pages/workspace/invoices/InvoiceFieldsAddListValuePage.tsx b/src/pages/workspace/invoices/InvoiceFieldsAddListValuePage.tsx new file mode 100644 index 000000000000..514f599cc1f7 --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsAddListValuePage.tsx @@ -0,0 +1,35 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsAddListValuePage from '@pages/workspace/fields/FieldsAddListValuePage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsAddListValuePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function InvoiceFieldsAddListValuePage({ + policy, + route: { + params: {policyID, reportFieldID}, + }, +}: InvoiceFieldsAddListValuePageProps) { + return ( + + ); +} + +export default withPolicyAndFullscreenLoading(InvoiceFieldsAddListValuePage); diff --git a/src/pages/workspace/invoices/InvoiceFieldsEditValuePage.tsx b/src/pages/workspace/invoices/InvoiceFieldsEditValuePage.tsx new file mode 100644 index 000000000000..cf7c20298b39 --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsEditValuePage.tsx @@ -0,0 +1,34 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsEditValuePage from '@pages/workspace/fields/FieldsEditValuePage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsEditValuePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function InvoiceFieldsEditValuePage({ + policy, + route: { + params: {policyID, valueIndex}, + }, +}: InvoiceFieldsEditValuePageProps) { + return ( + + ); +} + +export default withPolicyAndFullscreenLoading(InvoiceFieldsEditValuePage); diff --git a/src/pages/workspace/invoices/InvoiceFieldsInitialValuePage.tsx b/src/pages/workspace/invoices/InvoiceFieldsInitialValuePage.tsx new file mode 100644 index 000000000000..30aed525f0d7 --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsInitialValuePage.tsx @@ -0,0 +1,35 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsInitialValuePage from '@pages/workspace/fields/FieldsInitialValuePage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsInitialValuePageProps = WithPolicyAndFullscreenLoadingProps & + PlatformStackScreenProps; + +function InvoiceFieldsInitialValuePage({ + policy, + route: { + params: {policyID, reportFieldID}, + }, +}: InvoiceFieldsInitialValuePageProps) { + return ( + + ); +} + +export default withPolicyAndFullscreenLoading(InvoiceFieldsInitialValuePage); diff --git a/src/pages/workspace/invoices/InvoiceFieldsListValuesPage.tsx b/src/pages/workspace/invoices/InvoiceFieldsListValuesPage.tsx new file mode 100644 index 000000000000..86320c711482 --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsListValuesPage.tsx @@ -0,0 +1,44 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsListValuesPage from '@pages/workspace/fields/FieldsListValuesPage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsListValuesPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function InvoiceFieldsListValuesPage({ + policy, + route: { + params: {policyID, reportFieldID}, + }, +}: InvoiceFieldsListValuesPageProps) { + return ( + + isInvoiceRoute + ? ROUTES.WORKSPACE_INVOICE_FIELDS_VALUE_SETTINGS.getRoute(id, valueIndex, fieldID) + : ROUTES.WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS.getRoute(id, valueIndex, fieldID) + } + getAddValueRoute={(isInvoiceRoute, id, fieldID) => + isInvoiceRoute ? ROUTES.WORKSPACE_INVOICE_FIELDS_ADD_VALUE.getRoute(id, fieldID) : ROUTES.WORKSPACE_REPORT_FIELDS_ADD_VALUE.getRoute(id, fieldID) + } + testID="InvoiceFieldsListValuesPage" + /> + ); +} + +export default withPolicyAndFullscreenLoading(InvoiceFieldsListValuesPage); diff --git a/src/pages/workspace/invoices/InvoiceFieldsSettingsPage.tsx b/src/pages/workspace/invoices/InvoiceFieldsSettingsPage.tsx new file mode 100644 index 000000000000..c51c024f0a86 --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsSettingsPage.tsx @@ -0,0 +1,40 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsSettingsPage from '@pages/workspace/fields/FieldsSettingsPage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsSettingsPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function InvoiceFieldsSettingsPage({ + policy, + route: { + params: {policyID, reportFieldID}, + }, +}: InvoiceFieldsSettingsPageProps) { + return ( + ROUTES.WORKSPACE_INVOICE_FIELDS_LIST_VALUES.getRoute(id, fieldID)} + getInitialValueRoute={(id, fieldID) => ROUTES.WORKSPACE_INVOICE_FIELDS_EDIT_INITIAL_VALUE.getRoute(id, fieldID)} + deleteTitleKey="workspace.invoiceFields.delete" + deletePromptKey="workspace.invoiceFields.deleteConfirmation" + testID="InvoiceFieldsSettingsPage" + /> + ); +} + +export default withPolicyAndFullscreenLoading(InvoiceFieldsSettingsPage); diff --git a/src/pages/workspace/invoices/InvoiceFieldsTypeSelectorPage.tsx b/src/pages/workspace/invoices/InvoiceFieldsTypeSelectorPage.tsx new file mode 100644 index 000000000000..d69443e1504b --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsTypeSelectorPage.tsx @@ -0,0 +1,32 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsTypeSelectorPage from '@pages/workspace/fields/FieldsTypeSelectorPage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsTypeSelectorPageProps = PlatformStackScreenProps; + +function InvoiceFieldsTypeSelectorPage({ + route: { + params: {policyID, currentType}, + }, +}: InvoiceFieldsTypeSelectorPageProps) { + return ( + + ); +} + +export default InvoiceFieldsTypeSelectorPage; diff --git a/src/pages/workspace/invoices/InvoiceFieldsValueSettingsPage.tsx b/src/pages/workspace/invoices/InvoiceFieldsValueSettingsPage.tsx new file mode 100644 index 000000000000..2ec2e2d7d410 --- /dev/null +++ b/src/pages/workspace/invoices/InvoiceFieldsValueSettingsPage.tsx @@ -0,0 +1,40 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import FieldsValueSettingsPage from '@pages/workspace/fields/FieldsValueSettingsPage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type InvoiceFieldsValueSettingsPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function InvoiceFieldsValueSettingsPage({ + policy, + route: { + params: {policyID, valueIndex, reportFieldID}, + }, +}: InvoiceFieldsValueSettingsPageProps) { + return ( + + isInvoiceRoute ? ROUTES.WORKSPACE_INVOICE_FIELDS_EDIT_VALUE.getRoute(id, index) : ROUTES.WORKSPACE_REPORT_FIELDS_EDIT_VALUE.getRoute(id, index) + } + testID="InvoiceFieldsValueSettingsPage" + /> + ); +} + +export default withPolicyAndFullscreenLoading(InvoiceFieldsValueSettingsPage); diff --git a/src/pages/workspace/invoices/WorkspaceInvoiceFieldsSection.tsx b/src/pages/workspace/invoices/WorkspaceInvoiceFieldsSection.tsx new file mode 100644 index 000000000000..622ce84759cb --- /dev/null +++ b/src/pages/workspace/invoices/WorkspaceInvoiceFieldsSection.tsx @@ -0,0 +1,47 @@ +import usePolicy from '@hooks/usePolicy'; + +import {enablePolicyInvoiceFields} from '@libs/actions/Policy/Policy'; + +import WorkspaceFieldsSection from '@pages/workspace/fields/WorkspaceFieldsSection'; + +import {openPolicyInvoicesPage} from '@userActions/Policy/ReportField'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +type WorkspaceInvoiceFieldsSectionProps = { + policyID: string; +}; + +function WorkspaceInvoiceFieldsSection({policyID}: WorkspaceInvoiceFieldsSectionProps) { + const policy = usePolicy(policyID); + + return ( + field.target === CONST.REPORT_FIELD_TARGETS.INVOICE} + titleKey="workspace.common.invoiceFields" + subtitleKey="workspace.invoiceFields.subtitle" + importedFromAccountingSoftwareKey="workspace.invoiceFields.importedFromAccountingSoftware" + disableTitleKey="workspace.invoiceFields.disableInvoiceFields" + disablePromptKey="workspace.reportFields.disableReportFieldsConfirmation" + addFieldKey="workspace.invoiceFields.addField" + createRoute={ROUTES.WORKSPACE_INVOICE_FIELDS_CREATE.getRoute(policyID)} + getSettingsRoute={ROUTES.WORKSPACE_INVOICE_FIELDS_SETTINGS.getRoute} + upgradeFeatureAlias={CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoiceFields.alias} + upgradeBackToRoute={ROUTES.WORKSPACE_INVOICES.getRoute(policyID)} + enableFields={enablePolicyInvoiceFields} + openFieldsPage={openPolicyInvoicesPage} + policyFeature={CONST.POLICY.POLICY_FEATURE.MORE_FEATURES} + /> + ); +} + +WorkspaceInvoiceFieldsSection.displayName = 'WorkspaceInvoiceFieldsSection'; + +export default WorkspaceInvoiceFieldsSection; diff --git a/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx b/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx index 8b874c4d11e6..fb63dae1a08a 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx @@ -10,6 +10,7 @@ import usePaymentMethodState from '@hooks/usePaymentMethodState'; import type {FormattedSelectedPaymentMethod} from '@hooks/usePaymentMethodState/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import useVerifyAccountAndResume from '@hooks/useVerifyAccountAndResume'; import {isCurrencySupportedForGlobalReimbursement} from '@libs/actions/Policy/Policy'; import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; @@ -148,6 +149,16 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl } }; + const continueAddBankAccountFlow = () => { + if (hasValidExistingAccounts && !shouldShowContinueModal) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(policyID, ROUTES.WORKSPACE_INVOICES.getRoute(policyID))); + return; + } + navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_INVOICES.getRoute(policyID)}); + }; + + const {isUserValidated, verifyAccountAndResume} = useVerifyAccountAndResume(() => continueAddBankAccountFlow()); + const onAddBankAccountPress = () => { if (!canWriteMoreFeatures) { showReadOnlyModal(); @@ -173,11 +184,12 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl return; } - if (hasValidExistingAccounts && !shouldShowContinueModal) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(policyID, ROUTES.WORKSPACE_INVOICES.getRoute(policyID))); + if (!isUserValidated) { + verifyAccountAndResume(undefined); return; } - navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_INVOICES.getRoute(policyID)}); + + continueAddBankAccountFlow(); }; const threeDotsMenuItems = useMemo(() => { @@ -268,14 +280,13 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl onPress={onBankAccountRowPressed} onAddBankAccountPress={onAddBankAccountPress} onThreeDotsMenuPress={paymentMethodPressed} - shouldSkipDefaultAccountValidation={!canWriteMoreFeatures || !isSupportedGlobalReimbursement} + shouldSkipDefaultAccountValidation invoiceTransferBankAccountID={transferBankAccountID} activePaymentMethodID={transferBankAccountID} threeDotsMenuItems={canWriteMoreFeatures ? threeDotsMenuItems : undefined} addBankAccountItemStyle={!canWriteMoreFeatures ? styles.buttonOpacityDisabled : undefined} style={[styles.mt5, shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8]} listItemStyle={shouldUseNarrowLayout ? styles.ph5 : styles.ph8} - policyID={policyID} filterType={CONST.BANK_ACCOUNT.TYPE.BUSINESS} />
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx index a9a89135c9da..e7784c2067dd 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx @@ -19,6 +19,7 @@ import React from 'react'; import {View} from 'react-native'; import WorkspaceInvoiceBalanceSection from './WorkspaceInvoiceBalanceSection'; +import WorkspaceInvoiceFieldsSection from './WorkspaceInvoiceFieldsSection'; import WorkspaceInvoiceVBASection from './WorkspaceInvoiceVBASection'; import WorkspaceInvoicingDetailsSection from './WorkspaceInvoicingDetailsSection'; @@ -63,6 +64,7 @@ function WorkspaceInvoicesPage({route}: WorkspaceInvoicesPageProps) { canWriteMoreFeatures={canWriteMoreFeatures} /> )} + {!!policyID && }
)} diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesVerifyAccountPage.tsx b/src/pages/workspace/invoices/WorkspaceInvoicesVerifyAccountPage.tsx deleted file mode 100644 index 53bc5e5b3a21..000000000000 --- a/src/pages/workspace/invoices/WorkspaceInvoicesVerifyAccountPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; - -import VerifyAccountPageBase from '@pages/settings/VerifyAccountPageBase'; - -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; - -import React from 'react'; - -type WorkspaceInvoicesVerifyAccountPageProps = PlatformStackScreenProps; -function WorkspaceInvoicesVerifyAccountPage({route}: WorkspaceInvoicesVerifyAccountPageProps) { - const workspaceInvoicePath = ROUTES.WORKSPACE_INVOICES.getRoute(route.params.policyID); - return ( - - ); -} - -export default WorkspaceInvoicesVerifyAccountPage; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsSection.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsSection.tsx index 905fb710bee8..511406f695f1 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsSection.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsSection.tsx @@ -15,7 +15,6 @@ import ROUTES from '@src/ROUTES'; import React, {useMemo} from 'react'; type WorkspaceInvoicingDetailsSectionProps = { - /** The current policy ID */ policyID: string; /** Whether the current user can edit miscellaneous settings. */ diff --git a/src/pages/workspace/mcp/WorkspaceMCPPage.tsx b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx new file mode 100644 index 000000000000..440b26b752ac --- /dev/null +++ b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx @@ -0,0 +1,128 @@ +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import variables from '@styles/variables'; + +import {openExternalLink} from '@userActions/Link'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {View} from 'react-native'; + +type WorkspaceMCPPageProps = PlatformStackScreenProps; + +function WorkspaceMCPPage({route}: WorkspaceMCPPageProps) { + const policyID = route.params.policyID; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['ChatGPTSquare', 'ClaudeSquare', 'CursorSquare', 'QuestionMark']); + const policy = usePolicy(policyID); + + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.mcp'); + + const connectors = [ + {icon: icons.ChatGPTSquare, title: translate('workspace.mcp.chatgpt.title'), description: translate('workspace.mcp.chatgpt.subtitle'), url: CONST.CHATGPT_CONNECT_URL}, + {icon: icons.ClaudeSquare, title: translate('workspace.mcp.claude.title'), description: translate('workspace.mcp.claude.subtitle'), url: CONST.CLAUDE_CONNECT_URL}, + {icon: icons.CursorSquare, title: translate('workspace.mcp.cursor.title'), description: translate('workspace.mcp.cursor.subtitle'), url: CONST.CURSOR_MCP_HELP_URL}, + ]; + + return ( + + + Navigation.goBack()} + /> + + +
+ {connectors.map((connector) => ( + openExternalLink(connector.url)} + style={styles.justifyContentCenter} + size={CONST.BUTTON_SIZE.SMALL} + > + {translate('workspace.mcp.connect')} + + } + /> + ))} + + + + {translate('workspace.mcp.helpPrompt')} openExternalLink(CONST.MCP_HELP_URL)}>{translate('workspace.mcp.helpLink')} + + +
+
+
+
+
+ ); +} + +export default WorkspaceMCPPage; diff --git a/src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx b/src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx index 6366eda6282b..394e4c08a90a 100644 --- a/src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx +++ b/src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; diff --git a/src/pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage.tsx b/src/pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage.tsx index 1a5df3ae49da..57727bc07758 100644 --- a/src/pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage.tsx +++ b/src/pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage.tsx @@ -6,9 +6,9 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import shouldShowChangeWorkspaceOwnerPage from '@libs/shouldShowChangeWorkspaceOwnerPage'; @@ -37,6 +37,7 @@ type DynamicWorkspaceOwnerChangeWrapperPageProps = WithPolicyOnyxProps & Platfor function DynamicWorkspaceOwnerChangeWrapperPage({route, policy, isLoadingPolicy}: DynamicWorkspaceOwnerChangeWrapperPageProps) { const styles = useThemeStyles(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); const {translate} = useLocalize(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.path); const [privateStripeCustomerID] = useOnyx(ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID); @@ -62,12 +63,12 @@ function DynamicWorkspaceOwnerChangeWrapperPage({route, policy, isLoadingPolicy} } if (!policy.errorFields && policy.isChangeOwnerFailed) { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_ERROR.path), {forceReplace: true}); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_ERROR.path), {forceReplace: true}); return; } if (!policy?.errorFields?.changeOwner && policy?.isChangeOwnerSuccessful) { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_SUCCESS.path), {forceReplace: true}); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_SUCCESS.path), {forceReplace: true}); return; } @@ -76,7 +77,7 @@ function DynamicWorkspaceOwnerChangeWrapperPage({route, policy, isLoadingPolicy} if (changeOwnerErrors && changeOwnerErrors.length > 0) { Navigation.setParams({error: changeOwnerErrors.at(0)}); } - }, [accountID, policy, policy?.errorFields?.changeOwner, policyID]); + }, [accountID, policy, policy?.errorFields?.changeOwner, policyID, buildDynamicRoute]); const isLoading = isLoadingPolicy || !!policy?.isLoading; diff --git a/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx b/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx index c1b7e6fe9cd0..3962b427d343 100644 --- a/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx +++ b/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx @@ -1,8 +1,9 @@ import MultiAccountAvatar from '@components/Avatar/connected/MultiAccountAvatar'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -12,9 +13,7 @@ import useCloseImportPage from '@hooks/useCloseImportPage'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useImportSpreadsheetConfirmModal from '@hooks/useImportSpreadsheetConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -58,9 +57,6 @@ function ImportedMembersConfirmationPage({route}: ImportedMembersConfirmationPag const canAssignElevatedRoles = canMemberAssignElevatedRole(policy, currentUserLogin); const role = canMemberAssignRole(policy, currentUserLogin, roleFromOnyx) ? roleFromOnyx : CONST.POLICY.ROLE.USER; const [isImporting, setIsImporting] = useState(false); - const {isOffline} = useNetwork(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const personalDetails = usePersonalDetails(); const {setIsClosing} = useCloseImportPage(); @@ -113,7 +109,7 @@ function ImportedMembersConfirmationPage({route}: ImportedMembersConfirmationPag setIsClosing(true); setIsImporting(false); closeImportPage(); - if (isWorkflowsImport && isRulesRevampEnabled) { + if (isWorkflowsImport) { Tab.setSelectedTab(CONST.TAB.WORKFLOWS_TAB_TYPE, CONST.TAB.WORKFLOWS.APPROVALS); } Navigation.goBack(isWorkflowsImport ? ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID) : ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); @@ -169,28 +165,25 @@ function ImportedMembersConfirmationPage({route}: ImportedMembersConfirmationPag
- Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.IMPORTED_MEMBERS_ROLE.path))} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.IMPORTED_MEMBERS_ROLE.path)) : undefined} + value={translate(`workspace.common.roleName`, role)} />
- +
+ {/* A category tax default only applies to expenses created after the rule is saved, so the switch + is locked off. `disabled` draws the lock inside the thumb and routes the press to the explainer. */} - - {isEditing && ( + {/* There is no set of existing expenses for a category rule to preview, so the button is hidden rather than locked. */} + {!isCategoryRule && ( )} @@ -526,7 +693,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, shouldShowRightIcon={canWriteRules} interactive={canWriteRules} title={item.title} - numberOfLinesTitle={isRulesRevampEnabled ? 2 : undefined} + numberOfLinesTitle={2} titleStyle={styles.flex1} shouldRenderAsHTML={item.shouldRenderAsHTML} shouldApplyIconPaddingToHTMLTitle={!!item.icon && !!item.shouldRenderAsHTML} @@ -543,17 +710,13 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const renderSections = () => sections.map((section, sectionIndex) => ( - {isRulesRevampEnabled ? ( - sectionIndex > 0 && ( - <> - - - {translate('workspace.rules.merchantRules.thenApplyFollowingDefaults')} - - - ) - ) : ( - {translate(section.titleTranslationKey)} + {sectionIndex > 0 && ( + <> + + + {translate('workspace.rules.merchantRules.thenApplyFollowingDefaults')} + + )} {section.items.filter((item): item is SectionItemType => !!item).map(renderSectionItem)} @@ -571,14 +734,19 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom > - + + {/* Only while a condition is set, and only on an unsaved rule: resetting a saved one would let it + switch condition type, which the two storage shapes can't express as one edit. */} + {canWriteRules && !isEditingSavedRule && (hasMerchantCondition || hasCategoryCondition) && {translate('common.reset')}} + - {isRulesRevampEnabled && ( - - {translate('workspace.rules.merchantRules.expenseDefaultsSubtitle')} - {translate('workspace.rules.merchantRules.ifAnyExpenseMatches')} - - )} + + {translate('workspace.rules.merchantRules.expenseDefaultsSubtitle')} + {translate('workspace.rules.merchantRules.ifAnyExpenseMatches')} + {renderSections()} {isInLandscapeMode && footer} diff --git a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx index e956104be0ad..da556c16ca26 100644 --- a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx @@ -22,7 +22,7 @@ import variables from '@styles/variables'; 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 type {Transaction} from '@src/types/onyx'; import type {CodingRuleFilter} from '@src/types/onyx/Policy'; @@ -33,12 +33,17 @@ import {FlashList} from '@shopify/flash-list'; import React, {useEffect} from 'react'; import {View} from 'react-native'; -type PreviewMatchesPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type PreviewMatchesPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES +>; function PreviewMatchesPage({route}: PreviewMatchesPageProps) { const ruleID = route.params.ruleID; const policyID = route.params.policyID; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, policyID, ruleID); const theme = useTheme(); const styles = useThemeStyles(); @@ -84,12 +89,7 @@ function PreviewMatchesPage({route}: PreviewMatchesPageProps) { ); const goBack = () => { - if (isEditing) { - Navigation.goBack(ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID)); - return; - } - - Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + Navigation.goBack(backToRoute); }; return ( diff --git a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts new file mode 100644 index 000000000000..34c44c3d0c68 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts @@ -0,0 +1,66 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import isDynamicRouteScreen from '@libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteScreen'; + +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; + +import {useRoute} from '@react-navigation/native'; + +type MerchantRuleRoute = { + /** Whether the flow was entered from the "Create a rule" callout rather than workspace settings */ + isCreatedFromExpense: boolean; + + /** Whether an existing rule is being edited. The callout only creates, so it is never editing. */ + isEditing: boolean; + + /** Where this page's back button and save handler return to */ + backToRoute: Route; + + /** + * Builds a route to another page of this flow, keeping it in the stack the flow was entered from. + * + * @param dynamicSuffixWithParams - the target's dynamic path, params filled in + * @param staticRoute - the target's workspace settings route + */ + getRuleRoute: (dynamicSuffixWithParams: string, staticRoute: Route) => Route; +}; + +/** + * Routing for the merchant rule pages, reachable both from workspace settings and from the "Create a rule" callout. + * + * The callout enters through dynamic routes, whose paths are suffixes on the expense the user came from, so the + * expense stays under the modal. Those pages cannot hardcode the workspace paths: back drops their own suffix, and + * forward appends the next one. + * + * @param dynamicSuffix - this page's dynamic path, dropped from the URL to go back + * @param policyID - the workspace the rule belongs to + * @param ruleID - the rule being edited, absent when creating one + * @param staticBackToRoute - where the settings flow returns to, for pages deeper than the rule page + */ +function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: string, ruleID?: string, staticBackToRoute?: Route): MerchantRuleRoute { + const route = useRoute(); + // Asking the linking config avoids a list here that could drift as screens are added. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- useRoute is untyped here because this hook is shared by every page of the flow, and the repo's other isDynamicRouteScreen callers narrow the same way + const isCreatedFromExpense = isDynamicRouteScreen(route.name as Screen); + // Only workspace settings can reach an edit. `ruleID` is absent on dynamic routes, and `undefined !== 'new'` + // would otherwise read as editing. + const isEditing = !isCreatedFromExpense && !!ruleID && ruleID !== ROUTES.NEW; + // Only the callout's own entry uses this, and the settings flow reaches every one of these pages too. Working the + // path out there would cost the same on each navigation event and then be thrown away. + const dynamicBackToRoute = useDynamicBackPath(dynamicSuffix, isCreatedFromExpense); + const ruleRoute = isEditing && ruleID ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const getRuleRoute = (dynamicSuffixWithParams: string, staticRoute: Route) => (isCreatedFromExpense ? createDynamicRoute(dynamicSuffixWithParams) : staticRoute); + + return { + isCreatedFromExpense, + isEditing, + backToRoute: isCreatedFromExpense ? dynamicBackToRoute : (staticBackToRoute ?? ruleRoute), + getRuleRoute, + }; +} + +export default useMerchantRuleRoute; diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx deleted file mode 100644 index 57ccfc174dea..000000000000 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import Badge from '@components/Badge'; -import MenuItem from '@components/MenuItem'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import SearchBar from '@components/SearchBar'; -import Section from '@components/Section'; -import Text from '@components/Text'; - -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePolicy from '@hooks/usePolicy'; -import useSearchResults from '@hooks/useSearchResults'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import Parser from '@libs/Parser'; -import {getCommaSeparatedTagNameWithSanitizedColons, getVendorRuleDisplayValue, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; -import tokenizedSearch from '@libs/tokenizedSearch'; - -import variables from '@styles/variables'; - -import {clearPolicyCodingRuleErrors} from '@userActions/Policy/Rules'; - -import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type {Policy} from '@src/types/onyx'; -import type {CodingRule} from '@src/types/onyx/Policy'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import React, {useEffect, useMemo} from 'react'; -import {View} from 'react-native'; - -type MerchantRulesSectionProps = { - policyID: string; - canWriteRules: boolean; - showReadOnlyModal: () => void; -}; - -type FieldLabels = { - category: string; - tag: string; - description: string; - tax: string; - vendor: string; -}; - -/** - * Generates a human-readable description of what a coding rule does - */ -function getRuleDescription(rule: CodingRule, translate: ReturnType['translate'], labels: FieldLabels, policy: Policy | undefined): string { - const actions: string[] = []; - - if (rule.merchant) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleMerchant', rule.merchant)); - } - if (rule.category) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.category, getDecodedCategoryName(rule.category))); - } - if (rule.tag) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.tag, getCommaSeparatedTagNameWithSanitizedColons(rule.tag))); - } - if (rule.comment) { - const commentMarkdown = Parser.htmlToMarkdown(rule.comment); - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.description, commentMarkdown)); - } - if (rule.tax?.field_id_TAX?.value) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.tax, `${rule.tax.field_id_TAX.name} (${rule.tax.field_id_TAX.value})`)); - } - if (rule.vendorID) { - const unavailableLabel = translate(isXeroActiveMatchingSource(policy) ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); - const vendorValue = getVendorRuleDisplayValue(policy, rule.vendorID, unavailableLabel); - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.vendor, vendorValue)); - } - if (rule.reimbursable !== undefined) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleReimbursable', rule.reimbursable)); - } - if (rule.billable !== undefined) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleBillable', rule.billable)); - } - - // Lowercase any subsequent rule after the first one - return actions.map((action, index) => (index === 0 ? action : action.charAt(0).toLowerCase() + action.slice(1))).join(', '); -} - -function MerchantRulesSection({policyID, canWriteRules, showReadOnlyModal}: MerchantRulesSectionProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const theme = useTheme(); - const {isOffline} = useNetwork(); - const policy = usePolicy(policyID); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); - - // Hoist iterator-independent translations to avoid redundant calls in the loop - const fieldLabels: FieldLabels = useMemo( - () => ({ - category: translate('common.category').toLowerCase(), - tag: translate('common.tag').toLowerCase(), - description: translate('common.description').toLowerCase(), - tax: translate('common.tax').toLowerCase(), - vendor: translate(isXeroActiveMatchingSource(policy) ? 'common.supplier' : 'common.vendor').toLowerCase(), - }), - [translate, policy], - ); - - const codingRules = policy?.rules?.codingRules; - const hasRules = !isEmptyObject(codingRules); - - const sortedRules = useMemo(() => { - if (!codingRules) { - return []; - } - - return Object.entries(codingRules) - .filter(([, rule]) => !!rule) - .map(([ruleID, rule]) => ({...rule, ruleID})) - .sort((a, b) => { - if (a.created && b.created) { - return a.created < b.created ? 1 : -1; - } - return 0; - }); - }, [codingRules]); - - // Exclude pending-delete rules when online because OfflineWithFeedback hides them visually. - // When offline, keep them so OfflineWithFeedback can show strikethrough styling. - const visibleRules = useMemo(() => sortedRules.filter((rule) => isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [sortedRules, isOffline]); - - const filterRule = (rule: CodingRule & {ruleID: string}, searchInput: string) => tokenizedSearch([rule], searchInput, () => [rule.filters?.right ?? '']).length > 0; - - const [ruleSearchInput, setRuleSearchInput, filteredRules] = useSearchResults(visibleRules, filterRule); - - useEffect(() => { - if (visibleRules.length > CONST.SEARCH_BAR_THRESHOLD) { - return; - } - setRuleSearchInput(''); - }, [visibleRules.length, setRuleSearchInput]); - - const renderTitle = () => ( - - {translate('workspace.rules.merchantRules.title')} - - - ); - - return ( -
- {hasRules && ( - - {visibleRules.length > CONST.SEARCH_BAR_THRESHOLD && ( - - )} - {filteredRules.map((rule) => { - const merchantName = rule.filters?.right ?? ''; - const isExactMatch = rule.filters?.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO; - const matchDescription = translate('workspace.rules.merchantRules.ruleSummaryTitle', merchantName, isExactMatch); - const ruleDescription = getRuleDescription(rule, translate, fieldLabels, policy); - - return ( - - clearPolicyCodingRuleErrors(policyID, rule.ruleID, rule)} - > - Navigation.navigate(ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, rule.ruleID))} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_ITEM} - disabled={rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} - /> - - - ); - })} - - )} - { - if (!canWriteRules) { - showReadOnlyModal(); - return; - } - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - }} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.ADD_MERCHANT_RULE} - /> -
- ); -} - -MerchantRulesSection.displayName = 'MerchantRulesSection'; - -export default MerchantRulesSection; -export {getRuleDescription}; diff --git a/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRuleCategoryPage.tsx b/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRuleCategoryPage.tsx index e4fe8eb826a3..03c348643189 100644 --- a/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRuleCategoryPage.tsx +++ b/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRuleCategoryPage.tsx @@ -1,7 +1,6 @@ import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; @@ -27,8 +26,6 @@ function MerchantTypeRuleCategoryPage({route}: MerchantTypeRuleCategoryPageProps const {policyID, groupID} = route.params; const policy = usePolicy(policyID); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_TYPE_RULE_FORM); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -55,7 +52,7 @@ function MerchantTypeRuleCategoryPage({route}: MerchantTypeRuleCategoryPageProps featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} - shouldBeBlocked={!isRulesRevampEnabled || !canWriteRules} + shouldBeBlocked={!canWriteRules} > ; - -const agentsRulesBannerDismissedSelector = (value: OnyxEntry): boolean => !!value?.[CONST.AGENTS_RULES_BANNER]; - -function PolicyRulesPage(props: PolicyRulesPageProps) { - const {route} = props; - const {translate} = useLocalize(); - const {policyID} = route.params; - const policy = usePolicy(policyID); - useWorkspaceDocumentTitle(policy?.name, 'workspace.common.rules'); - const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); - const icons = useMemoizedLazyExpensifyIcons(['Table']); - const {canWrite: canWriteRules, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const isCustomAgentBetaEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); - const [isAgentsRulesBannerDismissed = false] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {selector: agentsRulesBannerDismissedSelector}); - - const fetchRules = useCallback(() => { - openPolicyRulesPage(policyID); - }, [policyID]); - - useEffect(() => { - // PolicyRulesPageRevamp fetches rules on its own mount — skip here to avoid duplicate OpenPolicyRulesPage calls. - if (isRulesRevampEnabled) { - return; - } - fetchRules(); - }, [fetchRules, isRulesRevampEnabled]); - - if (isRulesRevampEnabled) { - return ; - } - - const moreOptions: Array>> = [ - getImportMerchantRulesOption({policyID, canWriteRules, showReadOnlyModal, translate, icon: icons.Table}), - ]; - - const headerButtons = ( - {}} - shouldAlwaysShowDropdownMenu - customText={translate('common.more')} - options={moreOptions} - isSplitButton={false} - wrapperStyle={styles.flexGrow0} - style={[shouldDisplayButtonsInSeparateLine && styles.w100]} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.IMPORT_MERCHANT_RULES} - /> - ); - - return ( - - - {shouldDisplayButtonsInSeparateLine && {headerButtons}} - - {isCustomAgentBetaEnabled && !isAgentsRulesBannerDismissed && ( - { - if (!canWriteRules) { - showReadOnlyModal(); - return; - } - Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)); - }} - ctaSentryLabel={CONST.SENTRY_LABEL.AGENTS_RULES_BANNER.CTA} - onDismiss={() => dismissProductTraining(CONST.AGENTS_RULES_BANNER, true)} - dismissSentryLabel={CONST.SENTRY_LABEL.AGENTS_RULES_BANNER.DISMISS} - style={[styles.mh5, styles.mb5]} - /> - )} - - - {!!policy?.areExpensifyCardsEnabled && ( - - )} - {isCustomAgentBetaEnabled && ( - - )} - - - - ); -} - -export default PolicyRulesPage; diff --git a/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx b/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx index cc8b97668623..de9dfe0f741b 100644 --- a/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx +++ b/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -12,7 +12,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -83,9 +82,6 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Feed', 'CreditCardExclamation', 'DocumentMagicWand', 'Task', 'Flag', 'Bot', 'Trashcan', 'Table']); const {canWrite: canWriteRules, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const isCustomAgentBetaEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const isMobileSelectionModeEnabled = useMobileSelectionMode(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const [isAgentsRulesBannerDismissed = false] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {selector: agentsRulesBannerDismissedSelector}); @@ -93,7 +89,7 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RULES_TAB_TYPE}`); const lastSelectedTabStr = lastSelectedTab as string | undefined; const resolvedTab: RulesTab = lastSelectedTabStr && isRulesTab(lastSelectedTabStr) ? lastSelectedTabStr : RULES_TAB.GENERAL; - const activeTab: RulesTab = resolvedTab === RULES_TAB.AGENTS && !isCustomAgentBetaEnabled ? RULES_TAB.GENERAL : resolvedTab; + const activeTab = resolvedTab; const [selectedRuleKeysByTab, setSelectedRuleKeysByTab] = useState>>({}); const {showConfirmModal} = useConfirmModal(); @@ -173,8 +169,9 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { const isTableTab = activeTab === RULES_TAB.CARD_RESTRICTIONS || activeTab === RULES_TAB.EXPENSE_DEFAULTS || activeTab === RULES_TAB.REQUIRE_FIELDS || activeTab === RULES_TAB.FLAG_FOR_REVIEW; const isAgentsTab = activeTab === RULES_TAB.AGENTS; + const isGeneralTab = activeTab === RULES_TAB.GENERAL; const shouldShowBulkActions = canWriteRules && isTableTab && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : hasSelectedRules); - const shouldShowAddRuleButton = activeTab === RULES_TAB.GENERAL || !shouldShowBulkActions; + const shouldShowAddRuleButton = isGeneralTab || !shouldShowBulkActions; const handleBackButtonPress = () => { if (isMobileSelectionModeEnabled) { @@ -239,15 +236,11 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { title: translate('workspace.rules.tabs.flagForReview'), icon: icons.Flag, }, - ...(isCustomAgentBetaEnabled - ? [ - { - key: RULES_TAB.AGENTS, - title: translate('workspace.rules.tabs.agents'), - icon: icons.Bot, - }, - ] - : []), + { + key: RULES_TAB.AGENTS, + title: translate('workspace.rules.tabs.agents'), + icon: icons.Bot, + }, ]; const rulesUpgradeAlias = CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.alias; @@ -355,6 +348,9 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) {
); + // Outside the list so it stays pinned: it counts selected rows, which is useless once it scrolls away. + const separateLineButtons = + shouldDisplayButtonsInSeparateLine && !!headerButtons ? {headerButtons} : null; const sharedTableTabProps = { policyID, canWriteRules, @@ -367,11 +363,9 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} - shouldBeBlocked={!isRulesRevampEnabled} > - {!isTableTab && !isAgentsTab && rulesTabSelector} - {shouldDisplayButtonsInSeparateLine && !!headerButtons && {headerButtons}} + {separateLineButtons} - {activeTab === RULES_TAB.GENERAL && ( + {isGeneralTab && ( handleTabPress(RULES_TAB.AGENTS)} + headerComponent={rulesTabSelector} /> )} {isTableTab && ( diff --git a/src/pages/workspace/rules/PublicReceiptVisibilityToggle.tsx b/src/pages/workspace/rules/PublicReceiptVisibilityToggle.tsx index 216344a1efc7..f472d1aa5183 100644 --- a/src/pages/workspace/rules/PublicReceiptVisibilityToggle.tsx +++ b/src/pages/workspace/rules/PublicReceiptVisibilityToggle.tsx @@ -20,7 +20,6 @@ import type {StyleProp, TextStyle} from 'react-native'; import React from 'react'; type PublicReceiptVisibilityToggleProps = { - /** Workspace policy ID */ policyID: string; /** Whether the current user can write Rules settings */ diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx index 3f9248bf40ff..9de1a78fc6c7 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx @@ -2,14 +2,12 @@ import ActivityIndicator from '@components/ActivityIndicator'; import RuleCategoriesDisabledEmptyState from '@components/Rule/RuleCategoriesDisabledEmptyState'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; +import usePolicyCategoryPickerCategories from '@hooks/usePolicyCategoryPickerCategories'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; -import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {setDraftRequireFieldsRule} from '@libs/actions/User'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -22,7 +20,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; -import {useFocusEffect} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; @@ -42,33 +39,24 @@ function RequireFieldsRuleCategoryPageBase({policyID, categoryName}: RequireFiel const isEditing = !!categoryName; const policy = usePolicy(policyID); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const styles = useThemeStyles(); const [form] = useOnyx(ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const areCategoriesEnabled = !!policy?.areCategoriesEnabled; - const fetchPolicyCategories = () => { - if (!areCategoriesEnabled || policyCategories !== undefined) { - return; - } - openPolicyCategoriesPage(policyID); - }; - - const {isOffline} = useNetwork({onReconnect: fetchPolicyCategories}); + const selectedCategoryName = form?.[INPUT_IDS.CATEGORY]; - useFocusEffect(() => { - fetchPolicyCategories(); + const { + categories, + policyCategories, + areCategoriesEnabled, + isLoading: arePolicyCategoriesLoading, + } = usePolicyCategoryPickerCategories({ + policyID, + // Keep the currently selected / route category available, but don't offer other + // categories that already have field requirements (avoids silent overwrite). + isEligible: (category) => category.name === categoryName || category.name === selectedCategoryName || !categoryHasAnyRequireFieldsRule(category), }); - // Only spin while a fetch can actually resolve. Offline there's nothing to wait for, so fall through to the - // picker (empty list + offline indicator) instead of a spinner that never goes away. The reconnect callback - // fetches and flips this back on once we're online. - const arePolicyCategoriesLoading = areCategoriesEnabled && policyCategories === undefined && !isOffline; - - const selectedCategoryName = form?.[INPUT_IDS.CATEGORY]; const selectedCategory = selectedCategoryName ? policyCategories?.[selectedCategoryName] : undefined; const selectedCategoryItem = selectedCategoryName ? { @@ -77,29 +65,7 @@ function RequireFieldsRuleCategoryPageBase({policyID, categoryName}: RequireFiel } : undefined; - const categoryItems = Object.values(policyCategories ?? {}) - .filter((category) => { - if (!category.enabled) { - return false; - } - - // Match the rules table: keep pending-delete categories visible while offline. - if (!isOffline && category.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return false; - } - - // Keep the currently selected / route category available, but don't offer other - // categories that already have field requirements (avoids silent overwrite). - if (category.name === categoryName || category.name === selectedCategoryName) { - return true; - } - - return !categoryHasAnyRequireFieldsRule(category); - }) - .map((category) => { - const decodedCategoryName = getDecodedCategoryName(category.name); - return {name: decodedCategoryName, value: category.name}; - }); + const categoryItems = categories.map((category) => ({name: getDecodedCategoryName(category.name), value: category.name})); const backToRoute = () => getRequireFieldsRuleBackToRoute({ @@ -171,7 +137,7 @@ function RequireFieldsRuleCategoryPageBase({policyID, categoryName}: RequireFiel featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} - shouldBeBlocked={!isRulesRevampEnabled || !canWriteRules} + shouldBeBlocked={!canWriteRules} > { + deleteRequireFieldsRule(policyData, getRequireFieldsRuleKey(categoryName ?? '')); + return true; + }, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.REQUIRE_FIELDS_RULE_DELETE, + // Category settings opens this rule itself, so going back a screen would land on the New rule hub the user + // never passed through. Same route the save path picks, for the same reason. + backTo: initialCategoryName ? (categorySettingsBackPath ?? getWorkspaceCategorySettingsRoute(policyID, initialCategoryName)) : undefined, + }); + if (isEditing && categoryName && !category) { return ; } @@ -424,14 +439,16 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} - shouldBeBlocked={!isRulesRevampEnabled} > - + {translate('workspace.rules.requireFieldsRule.subtitle')} diff --git a/src/pages/workspace/rules/RulesBillableDefaultPage.tsx b/src/pages/workspace/rules/RulesBillableDefaultPage.tsx index 189949314fbe..6c78e78dbf1c 100644 --- a/src/pages/workspace/rules/RulesBillableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesBillableDefaultPage.tsx @@ -5,7 +5,6 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceSettingsTaskCompletion'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -38,8 +37,6 @@ function RulesBillableDefaultPage({ const {translate} = useLocalize(); const styles = useThemeStyles(); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); - const {isBetaEnabled} = usePermissions(); - const isRevamp = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const isCollect = isCollectPolicy(policy); const rulesUpgradeAlias = CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.alias; const upgradeBackTo = ROUTES.RULES_BILLABLE_DEFAULT.getRoute(policyID); @@ -88,7 +85,6 @@ function RulesBillableDefaultPage({ const isBillableTrackingEnabled = policy?.disabledFields?.defaultBillable !== true; // Track-billable is controlled on this page (not Tags), so show defaults whenever tracking is on. - const shouldShowBillableModeList = !isRevamp || isBillableTrackingEnabled; const handleBillableModeSelect = (value: boolean) => { if (isCollect && value && navigateToBillableUpgrade()) { @@ -110,24 +106,22 @@ function RulesBillableDefaultPage({ testID="RulesBillableDefaultPage" > Navigation.goBack()} /> - + {translate('workspace.rules.individualExpenseRules.billableDefaultDescription')} - {isRevamp && ( - toggleBillableExpenses(policy, getReviewWorkspaceSettingsTaskCompletion())} - /> - )} - {shouldShowBillableModeList && ( + toggleBillableExpenses(policy, getReviewWorkspaceSettingsTaskCompletion())} + /> + {isBillableTrackingEnabled && ( Navigation.goBack()} /> Navigation.goBack()} /> Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, categoryName)), + onPress: () => Navigation.navigate(ROUTES.RULES_EXPENSE_DEFAULT_TYPE.getRoute(policyID)), sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS, isWorkspaceOnly: true, }, - ...(isCustomAgentBetaEnabled - ? [ - { - 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, - } satisfies NewRuleOption, - ] - : []), + { + 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; @@ -120,7 +112,6 @@ function RulesNewPage({route}: RulesNewPageProps) { accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} - shouldBeBlocked={!isRulesRevampEnabled} > getProhibitedExpensesState(policy?.prohibitedExpenses), [policy?.prohibitedExpenses]); const [draftProhibitedExpenses, setDraftProhibitedExpenses] = useState(initialProhibitedExpenses); @@ -120,21 +117,21 @@ function RulesProhibitedDefaultPage({ testID="RulesProhibitedDefaultPage" > Navigation.goBack()} /> - + {translate('workspace.rules.individualExpenseRules.prohibitedDefaultDescription')} {PROHIBITED_EXPENSE_KEYS.map((prohibitedExpense) => { const switchComponent = ( - + { - if (isRevamp) { - handleToggle(prohibitedExpense); - return; - } - setPolicyProhibitedExpense(policyID, prohibitedExpense, policy?.prohibitedExpenses); - }} + onToggle={() => handleToggle(prohibitedExpense)} /> ); @@ -166,21 +157,19 @@ function RulesProhibitedDefaultPage({ ); })} - {isRevamp && ( - + - - )} + {translate('common.save')} + + ); diff --git a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx index 04ac4d8d2476..a18116bb3213 100644 --- a/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx +++ b/src/pages/workspace/rules/RulesReimbursableDefaultPage.tsx @@ -5,7 +5,6 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -31,8 +30,6 @@ function RulesReimbursableDefaultPage({ const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); - const {isBetaEnabled} = usePermissions(); - const isRevamp = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const reimbursableMode = getCashExpenseReimbursableMode(policy); @@ -78,7 +75,7 @@ function RulesReimbursableDefaultPage({ testID="RulesReimbursableDefaultPage" > Navigation.goBack()} /> diff --git a/src/pages/workspace/rules/RulesRequireFieldsPage.tsx b/src/pages/workspace/rules/RulesRequireFieldsPage.tsx index 2a8498920974..48f7055fcb5d 100644 --- a/src/pages/workspace/rules/RulesRequireFieldsPage.tsx +++ b/src/pages/workspace/rules/RulesRequireFieldsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -8,7 +8,6 @@ import ScrollView from '@components/ScrollView'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -47,11 +46,9 @@ function RulesRequireFieldsPage({ const {policy} = policyData; const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isBetaEnabled} = usePermissions(); const {showConfirmModal} = useConfirmModal(); // The self-heal below writes to the server, so it needs the same Tags write check the Tags table uses. const {canWrite: canWriteTags} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); // Per WorkspaceMoreFeaturesPage: only accounting connections own these features, not the HR ones in policy.connections. @@ -96,8 +93,8 @@ function RulesRequireFieldsPage({ }, [policyID, policyTags]); useEffect(() => { - // Same self-heal as the Tags table: a required level with no enabled tags can never be satisfied, and it still - // fires a violation, so clear it. Pending edits are skipped so this can't fight a toggle the admin just flipped. + // Same self-heal as the Tags table: a required level with no enabled tags is a rule the workspace can't meet, so + // clear it rather than leave it set. Pending edits are skipped so this can't fight a toggle the admin just flipped. if (!canWriteTags || !hasPerLevelTagRequired) { return; } @@ -179,7 +176,8 @@ function RulesRequireFieldsPage({ setTagRequiredByLevel((previous) => ({...previous, [tagList.orderWeight]: required})); }; - // Lock only when the feature is off (or categories are accounting-controlled). No enabled items just disables, no lock/modal. + // Only the feature being off (or categories being accounting-controlled) gets a tap-to-unlock modal. Switch draws its lock + // icon for anything disabled, so a row disabled purely for having nothing to require still needs its own tooltip. const shouldShowCategoryLock = isCategoryFeatureDisabled || isConnectedToAccounting; const shouldShowTagLock = isTagFeatureDisabled; @@ -190,7 +188,8 @@ function RulesRequireFieldsPage({ const categoryDisabledText = (() => { if (!shouldShowCategoryLock) { - return undefined; + // Feature is on, so the only thing left that disables this row is having no categories to require. + return translate('workspace.rules.individualExpenseRules.noCategoriesToRequirePrompt'); } if (isConnectedToAccounting) { return translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledText'); @@ -226,6 +225,7 @@ function RulesRequireFieldsPage({ if (action !== ModalActions.CONFIRM) { return; } + enablePolicyCategories(policyData, true, false); setWorkspaceRequiresCategory(policyData, true); setCategoryRequired(true); @@ -233,7 +233,8 @@ function RulesRequireFieldsPage({ const tagDisabledText = (() => { if (!shouldShowTagLock) { - return undefined; + // Feature is on, so the only thing left that disables this row is having no tags to require. + return translate('workspace.rules.individualExpenseRules.noTagsToRequirePrompt'); } if (isConnectedToAccounting) { return translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledText'); @@ -241,8 +242,7 @@ function RulesRequireFieldsPage({ return translate('workspace.rules.individualExpenseRules.enableTagsToUnlockPrompt'); })(); - /** Pass orderWeight from a per-level row so only that level is required; omit it for the policy-wide row. */ - const promptEnableTagsForRequireTag = async (orderWeight?: number) => { + const promptEnableTags = async () => { if (!isTagFeatureDisabled) { return; } @@ -264,23 +264,17 @@ function RulesRequireFieldsPage({ const {action} = await showConfirmModal({ title: translate('workspace.rules.individualExpenseRules.enableTagsToUnlockTitle'), - prompt: translate('workspace.rules.individualExpenseRules.enableTagsAndRequirePrompt'), + prompt: translate('workspace.rules.individualExpenseRules.enableTagsPrompt'), confirmText: translate('common.ok'), cancelText: translate('common.cancel'), }); if (action !== ModalActions.CONFIRM) { return; } - enablePolicyTags(policyData, true); - - if (orderWeight !== undefined) { - // setPolicyRequiresTag would require every list, not just this level. - setPolicyTagsRequired(policyData, true, orderWeight); - return; - } - setPolicyRequiresTag(policyData, true); - setTagRequired(true); + // Only turn the feature on. Enabling Tags seeds an empty tag list, so requiring a tag here would set a rule the + // workspace can't meet yet; the admin flips Required themselves once a tag exists. + enablePolicyTags(policyData, true); }; return ( @@ -290,7 +284,6 @@ function RulesRequireFieldsPage({ featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} - shouldBeBlocked={!isRulesRevampEnabled} > promptEnableTagsForRequireTag(tagList.orderWeight) : undefined} + disabledAction={shouldShowTagLock ? promptEnableTags : undefined} pendingAction={tagList.pendingFields?.required} errors={tagList.errorFields?.required ?? undefined} onCloseError={() => clearPolicyTagListErrorField({policyID, tagListIndex, errorField: 'required', policyTags})} @@ -357,7 +350,7 @@ function RulesRequireFieldsPage({ disabled={isTagToggleDisabled} showLockIcon={shouldShowTagLock} disabledText={tagDisabledText} - disabledAction={shouldShowTagLock ? promptEnableTagsForRequireTag : undefined} + disabledAction={shouldShowTagLock ? promptEnableTags : undefined} pendingAction={policy?.pendingFields?.requiresTag} errors={policy?.errorFields?.requiresTag ?? undefined} onCloseError={() => clearPolicyErrorField(policyID, 'requiresTag')} diff --git a/src/pages/workspace/rules/RulesRequireReceiptsPage.tsx b/src/pages/workspace/rules/RulesRequireReceiptsPage.tsx index 3303badbd60b..e4a291cacf72 100644 --- a/src/pages/workspace/rules/RulesRequireReceiptsPage.tsx +++ b/src/pages/workspace/rules/RulesRequireReceiptsPage.tsx @@ -9,7 +9,6 @@ import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceSettingsTaskCompletion'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -44,8 +43,6 @@ function RulesRequireReceiptsPage({ const {translate} = useLocalize(); const styles = useThemeStyles(); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const {getCurrencyDecimals} = useCurrencyListActions(); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; const decimals = getCurrencyDecimals(policyCurrency); @@ -183,7 +180,6 @@ function RulesRequireReceiptsPage({ featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.RULES} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} - shouldBeBlocked={!isRulesRevampEnabled} > ); diff --git a/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx b/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx index 5645b41bf80c..99f566a27181 100644 --- a/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx +++ b/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx @@ -14,7 +14,6 @@ function EditSpendRulePage({route}: EditSpendRulePageProps) { ); diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx index 5b2d42992b58..74ff403664f9 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -13,6 +13,7 @@ import useCanWriteCardSpendRules from '@hooks/useCanWriteCardSpendRules'; import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; import useControlOnlyRuleUpgradeRedirect from '@hooks/useControlOnlyRuleUpgradeRedirect'; import useDefaultFundID from '@hooks/useDefaultFundID'; +import useInitialSelection from '@hooks/useInitialSelection'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -30,6 +31,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig 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 AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -113,6 +115,9 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { }, [spendRuleForm?.cardIDs]), ); + // Freeze the cards selected when this page opened so they stay pinned to the top for the whole open/focus cycle, even as the live selection changes. + const initialSelectedCardIDs = useInitialSelection(spendRuleForm?.cardIDs ?? [], {resetOnFocus: true}); + const goBack = () => Navigation.goBack(); const saveAndGoBack = () => Navigation.goBack(undefined, {shouldSkipFocusRestore: true}); @@ -126,10 +131,13 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { const isCardSettingsLoading = !isOffline && (!expensifyCardSettings || expensifyCardSettings.isLoading) && !expensifyCardSettings?.hasOnceLoaded; const eligibleCards = expensifyCardSettings ? getEligibleCards(cardsList, expensifyCardSettings, ruleID === ROUTES.NEW ? undefined : ruleID) : []; - const filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails); const sortCards = (cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare, translate, formatPhoneNumber); - const [inputValue, setInputValue, filteredCards] = useSearchResults(eligibleCards, filterCard, sortCards); + const sortedCards = sortCards(eligibleCards).map((card) => ({...card, value: String(card.cardID)})); + const orderedCards = moveInitialSelectionToTop(sortedCards, initialSelectedCardIDs); + + const filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails); + const [inputValue, setInputValue, filteredCards] = useSearchResults(orderedCards, filterCard); const listData: ExpensifyCardListItem[] = filteredCards.map((card) => { const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; @@ -255,6 +263,7 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { onSelectRow={toggleCard} selectedItems={selectedCardIDs} ListItem={CardListItem} + shouldScrollToFocusedIndexOnMount={false} shouldUpdateFocusedIndex shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} listEmptyContent={ diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 6684e5e85df0..793e26c76337 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,11 +1,9 @@ -import Button from '@components/ButtonComposed'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import SpendRuleRestrictionTypeToggle from '@components/SpendRules/SpendRuleRestrictionTypeToggle'; import SpendRuleRestrictionTypeToggleRevamp from '@components/SpendRules/SpendRuleRestrictionTypeToggleRevamp'; import Text from '@components/Text'; @@ -17,7 +15,6 @@ import useDefaultFundID from '@hooks/useDefaultFundID'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePressLoading from '@hooks/usePressLoading'; @@ -35,6 +32,7 @@ import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from ' import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -54,7 +52,6 @@ import {View} from 'react-native'; type SpendRulePageBaseProps = { policyID: string; ruleID?: string; - titleKey: TranslationPaths; testID: string; /** Where the Control upgrade page should return to. Defaults to the workspace Rules page. */ @@ -74,7 +71,7 @@ function getErrorMessage(hasSelectedCards: boolean, hasAnyRuleApplied: boolean, return ''; } -function SpendRulePageBase({policyID, ruleID, titleKey, testID, upgradeBackTo}: SpendRulePageBaseProps) { +function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRulePageBaseProps) { const {convertToDisplayString} = useCurrencyListActions(); const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); @@ -84,8 +81,6 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID, upgradeBackTo}: const {showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); const canWriteSpendRules = useCanWriteCardSpendRules(policyID); useControlOnlyRuleUpgradeRedirect(policyID, upgradeBackTo); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const icons = useMemoizedLazyExpensifyIcons(['CreditCardHourglass', 'MoneyCircle', 'CoinsButton', 'Basket']); const domainAccountID = useDefaultFundID(policyID); const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); @@ -235,7 +230,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID, upgradeBackTo}: setExpensifyCardRule(domainAccountID, isEditingRule ? currentRuleID : rand64(), updatedSpendRuleForm, existingRule); clearDraftSpendRule(); - if (!isEditingRule && isRulesRevampEnabled) { + if (!isEditingRule) { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.CARD_RESTRICTIONS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); return; @@ -246,31 +241,26 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID, upgradeBackTo}: }; const deleteRule = () => { - if (!canWriteSpendRules) { - return; - } - if (!existingRule) { - return; + return false; } - showConfirmModal({ - title: translate('workspace.rules.spendRules.deleteRule'), - prompt: translate('workspace.rules.spendRules.deleteRuleConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - - deleteExpensifyCardRule(domainAccountID, currentRuleID, existingRule); - clearDraftSpendRule(); - Navigation.goBack(); - }); + deleteExpensifyCardRule(domainAccountID, currentRuleID, existingRule); + clearDraftSpendRule(); + return true; }; + const isRuleBeingDeleted = existingRule?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const canDeleteRule = canWriteSpendRules && isEditingRule && !!existingRule && !isRuleBeingDeleted; + + const {deleteHeaderProps} = useRuleDeleteHeaderProps({ + canDelete: canDeleteRule, + onDelete: deleteRule, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_DELETE, + titleKey: 'workspace.rules.spendRules.deleteRule', + promptKey: 'workspace.rules.spendRules.deleteRuleConfirmation', + }); + const setSpendRuleRestrictionType = (action: ValueOf | null) => { if (!canWriteSpendRules) { showReadOnlyModal(); @@ -367,7 +357,6 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID, upgradeBackTo}: const handleRestrictionActionSelect = setSpendRuleRestrictionType; const spendRuleSectionSentryLabel = CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SECTION_ITEM; - const merchantRuleSectionSentryLabel = CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM; const renderEditableMenuItem = ({description, title, onPress, sentryLabel, icon}: {description: string; title: string; onPress: () => void; sentryLabel: string; icon?: IconAsset}) => ( ); - const legacyFormContent = ( - <> - {translate('workspace.rules.spendRules.cardsSectionTitle')} - - {translate('workspace.rules.spendRules.spendRuleSectionTitle')} - {currenciesMenuItem} - {maxAmountMenuItem} - - - - - {!isRestrictMerchantsOff && ( - <> - - - - )} - - ); - return ( - - {isRulesRevampEnabled ? revampFormContent : legacyFormContent} + + {revampFormContent} {canWriteSpendRules && ( - {translate('workspace.rules.spendRules.deleteRule')} - - ) : undefined - } /> )} diff --git a/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx b/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx index 37841d16716b..979f0bfd8e50 100644 --- a/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx @@ -2,11 +2,14 @@ import WorkspaceExpenseDefaultsTable from '@components/Tables/WorkspaceExpenseDe import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import {getExpenseDefaultsTableData} from '@libs/MerchantTypeRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; + import React from 'react'; type RulesExpenseDefaultsTabProps = { @@ -22,9 +25,12 @@ function RulesExpenseDefaultsTab({policyID, canWriteRules, selectedKeys, onSelec const {isOffline} = useNetwork(); const policy = usePolicy(policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const expenseDefaultsTableData = getExpenseDefaultsTableData({ policy, policyID, + policyCategories, translate, isOffline, onNavigate: Navigation.navigate, diff --git a/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx b/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx index 376af0dce3a9..7639cdb97bf5 100644 --- a/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx @@ -1,7 +1,8 @@ import AgentPromotionalBanner from '@components/AgentPromotionalBanner'; +import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {dismissProductTraining} from '@libs/actions/Welcome'; @@ -11,6 +12,7 @@ import IndividualExpenseRulesSectionRevamp from '@pages/workspace/rules/Individu import CONST from '@src/CONST'; import React from 'react'; +import {View} from 'react-native'; type RulesGeneralTabProps = { policyID: string; @@ -18,33 +20,44 @@ type RulesGeneralTabProps = { isAgentsRulesBannerDismissed: boolean; /** Opens the Agents tab through the page's tab handler, so Collect gets the Control upgrade page instead. */ onOpenAgentsTab: () => void; + /** The tab selector, rendered inside this tab's scroll like the table tabs render it inside their list. */ + headerComponent: React.ReactElement; }; -function RulesGeneralTab({policyID, canWriteRules, isAgentsRulesBannerDismissed, onOpenAgentsTab}: RulesGeneralTabProps) { +function RulesGeneralTab({policyID, canWriteRules, isAgentsRulesBannerDismissed, onOpenAgentsTab, headerComponent}: RulesGeneralTabProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isBetaEnabled} = usePermissions(); - const isCustomAgentBetaEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + // The tab owns the scroll so the selector scrolls with the content, matching the table tabs where it is the list's + // header. The page's buttons sit above this and stay pinned. return ( - <> - - {isCustomAgentBetaEnabled && !isAgentsRulesBannerDismissed && ( - dismissProductTraining(CONST.AGENTS_RULES_BANNER, true)} - dismissSentryLabel={CONST.SENTRY_LABEL.AGENTS_RULES_BANNER.DISMISS} - style={[styles.mh5, styles.mb5]} + + {headerComponent} + {/* The settings read as a column, so they keep the workspace section width the page used to impose. The + selector above stays full width, since capping it would cut off the last tab. */} + + - )} - + {!isAgentsRulesBannerDismissed && ( + dismissProductTraining(CONST.AGENTS_RULES_BANNER, true)} + dismissSentryLabel={CONST.SENTRY_LABEL.AGENTS_RULES_BANNER.DISMISS} + style={[styles.mh5, styles.mb5]} + /> + )} + + ); } diff --git a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts index 4ac910bdc03a..1b246fbac368 100644 --- a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts +++ b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts @@ -16,9 +16,10 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {deleteExpensifyCardRule} from '@libs/actions/Card'; -import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {deletePolicyCategoryTaxes, openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; import {deletePolicyCodingRule} from '@libs/actions/Policy/Rules'; +import {getCategoryNameFromTaxRuleKey, isCategoryTaxRuleKey} from '@libs/CategoryTaxRulesUtils'; import {deleteFlagForReviewRule, getFlagForReviewTableData} from '@libs/FlagForReviewRulesUtils'; import {getExpenseDefaultsTableData, isMerchantTypeRuleKey} from '@libs/MerchantTypeRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -151,6 +152,8 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c const expenseDefaultsTableData: ExpenseDefaultTableItem[] = getExpenseDefaultsTableData({ policy, policyID, + // Unlike the tables below, the raw value: a category pending deletion is exactly what marks its rule deleting. + policyCategories: policyCategoriesOnyx, translate, isOffline, onNavigate: Navigation.navigate, @@ -244,13 +247,26 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c return; } + // Category tax defaults live in `expenseRules`, a plain array, so they delete together in one pass that threads + // the array through rather than one independent write each. + const selectedCategoryNames: string[] = []; + for (const ruleID of filteredSelectedExpenseDefaultKeys) { if (isMerchantTypeRuleKey(ruleID)) { continue; } + if (isCategoryTaxRuleKey(ruleID)) { + selectedCategoryNames.push(getCategoryNameFromTaxRuleKey(ruleID)); + continue; + } + deletePolicyCodingRule(policy, ruleID); } + + if (selectedCategoryNames.length > 0) { + deletePolicyCategoryTaxes(policy, selectedCategoryNames); + } clearTableSelection(); }, [ activeTab, diff --git a/src/pages/workspace/rules/useRuleDeleteHeaderProps.ts b/src/pages/workspace/rules/useRuleDeleteHeaderProps.ts new file mode 100644 index 000000000000..1a0d6b1d35e1 --- /dev/null +++ b/src/pages/workspace/rules/useRuleDeleteHeaderProps.ts @@ -0,0 +1,99 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {Route} from '@src/ROUTES'; + +import type {ValueOf} from 'type-fest'; + +type RuleDeleteHeaderPropsParams = { + /** Whether there is a rule to delete and the user may delete it. */ + canDelete: boolean; + + /** + * Deletes the rule. Confirming is handled here, and so is leaving the page, but only on a `true` return: a caller + * that bails out keeps the user where they are rather than sending them off a page whose rule is still there. + */ + onDelete: () => boolean; + + sentryLabel: ValueOf; + + /** + * Where to land after deleting, for the editors that can be reached from more than one place. Category settings + * opens its own rules directly, so going back a screen from there would land on the New rule hub the user never + * saw. The save paths pass the same route for the same reason. + */ + backTo?: Route; + + /** Overrides the confirmation copy for rule types that word it their own way. */ + titleKey?: TranslationPaths; + promptKey?: TranslationPaths; +}; + +/** + * The header props that put a rule's delete on a trashcan in the top right, and the confirmation behind it, which + * every rule editor shares so the gesture is the same whichever kind of rule is open. + * + * `HeaderWithBackButton` renders a single three-dots item as the icon itself, so one item is what makes this a + * trashcan rather than a menu. `canDelete` decides whether it appears at all: an unsaved rule has nothing to delete, + * a member without write access can't, and a rule already on its way out would otherwise be deleted twice. + * + * The copy defaults to the pair the rules tables already use for their bulk delete, so deleting one rule and deleting + * several read alike. It lives under `merchantRules` because that table had delete first, not because it is + * merchant-specific. + */ +function useRuleDeleteHeaderProps({canDelete, onDelete, sentryLabel, backTo, titleKey, promptKey}: RuleDeleteHeaderPropsParams) { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + + const confirmDelete = () => { + showConfirmModal({ + title: translate(titleKey ?? 'workspace.rules.merchantRules.deleteRule'), + prompt: translate(promptKey ?? 'workspace.rules.merchantRules.deleteRuleConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + if (!onDelete()) { + return; + } + + Navigation.goBack(backTo); + }); + }; + + const threeDotsMenuItems: PopoverMenuItem[] = [ + { + icon: icons.Trashcan, + text: translate('common.delete'), + onSelected: confirmDelete, + sentryLabel, + }, + ]; + + return { + /** Spread onto `HeaderWithBackButton`. */ + deleteHeaderProps: { + threeDotsMenuItems, + shouldShowThreeDotsButton: canDelete, + shouldMinimizeMenuButton: true, + }, + + /** The same confirmation, for the pre-revamp footer button the two beta-gated pages still show. */ + confirmDelete, + }; +} + +export default useRuleDeleteHeaderProps; diff --git a/src/pages/workspace/tags/DynamicTagSettingsPage.tsx b/src/pages/workspace/tags/DynamicTagSettingsPage.tsx index edee0501c549..1e4197a52972 100644 --- a/src/pages/workspace/tags/DynamicTagSettingsPage.tsx +++ b/src/pages/workspace/tags/DynamicTagSettingsPage.tsx @@ -1,5 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -13,7 +14,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; @@ -64,8 +64,6 @@ function DynamicTagSettingsPage({route, navigation}: DynamicTagSettingsPageProps const policyData = usePolicyData(policyID); const {policy, tags: policyTags} = policyData; const {canWrite: canWriteTags, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const policyTag = getTagListByOrderWeight(policyTags, orderWeight); const {environmentURL} = useEnvironment(); const hasAccountingConnections = hasAccountingConnectionsPolicyUtils(policy); @@ -92,6 +90,8 @@ function DynamicTagSettingsPage({route, navigation}: DynamicTagSettingsPageProps return ; } + const cleanedTagName = getCleanedTagName(currentPolicyTag.name); + const updateWorkspaceTagEnabled = (value: boolean) => { if (shouldPreventDisableOrDelete) { showConfirmModal({ @@ -196,12 +196,10 @@ function DynamicTagSettingsPage({route, navigation}: DynamicTagSettingsPageProps )} - {(!hasDependentTags || !!currentPolicyTag?.['GL Code']) && ( @@ -217,7 +215,7 @@ function DynamicTagSettingsPage({route, navigation}: DynamicTagSettingsPageProps )} - {arePolicyRulesEnabled(policy, policyData.categories, isRulesRevampEnabled) && !isMultiLevelTags && ( + {arePolicyRulesEnabled(policy, policyData.categories) && !isMultiLevelTags && ( <> {translate('workspace.tags.tagRules')} diff --git a/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx index 8d1d3fdd5273..84bb06a470a5 100644 --- a/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx @@ -16,7 +16,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -25,19 +24,11 @@ import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButton import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import { - clearPolicyTagErrors, - clearPolicyTagListErrorField, - clearPolicyTagListErrors, - deletePolicyTags, - openPolicyTagsPage, - setPolicyTagsRequired, - setWorkspaceTagEnabled, -} from '@libs/actions/Policy/Tag'; +import {clearPolicyTagErrors, clearPolicyTagListErrors, deletePolicyTags, openPolicyTagsPage, setPolicyTagsRequired, setWorkspaceTagEnabled} 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 {isDisablingOrDeletingLastEnabledTag, isMakingLastRequiredTagListOptional} from '@libs/OptionsListUtils'; +import {isDisablingOrDeletingLastEnabledTag} from '@libs/OptionsListUtils'; import { getCleanedTagName, getCountOfEnabledTagsOfList, @@ -50,7 +41,6 @@ import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -77,8 +67,6 @@ function DynamicWorkspaceViewTagsPage({route}: DynamicWorkspaceViewTagsProps) { const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Close', 'Checkmark', 'Trashcan']); const {translate} = useLocalize(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const {showConfirmModal} = useConfirmModal(); const dropdownButtonRef = useRef(null); const isFocused = useIsFocused(); @@ -89,7 +77,7 @@ function DynamicWorkspaceViewTagsPage({route}: DynamicWorkspaceViewTagsProps) { const hasDependentTags = useMemo(() => hasDependentTagsPolicyUtils(policy, policyTags), [policy, policyTags]); const isMultiLevelTags = isMultiLevelTagsPolicyUtils(policyTags); const currentPolicyTag = policyTags?.[currentTagListName]; - const {canWrite: canWriteTags, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); + const {canWrite: canWriteTags, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW; const backPath = useDynamicBackPath(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAG_LIST_VIEW.path : DYNAMIC_ROUTES.WORKSPACE_TAG_LIST_VIEW.path); const fetchTags = useCallback(() => { @@ -171,7 +159,6 @@ function DynamicWorkspaceViewTagsPage({route}: DynamicWorkspaceViewTagsProps) { pendingAction: tag.pendingAction, isLocked: !canWriteTags || isDisablingLastEnabledTag, showEnabledSwitch: !hasDependentTags, - showRequiredSwitch: false, action: () => navigateToTagSettings(tag), onToggleEnabled: (enabled: boolean) => { if (isDisablingLastEnabledTag) { @@ -333,6 +320,32 @@ function DynamicWorkspaceViewTagsPage({route}: DynamicWorkspaceViewTagsProps) { const selectionModeHeader = isMobileSelectionModeEnabled && isSmallScreenWidth; const headerButtons = getHeaderButtons(); + const tableHeaderComponent = ( + <> + {shouldDisplayButtonsInSeparateLine && !!headerButtons && {headerButtons}} + + clearPolicyTagListErrors({ + policyID, + tagListIndex: currentPolicyTag.orderWeight, + policyTags, + }) + } + pendingAction={currentPolicyTag.pendingAction} + errorRowStyles={styles.mh5} + > + + + + ); return ( {!shouldDisplayButtonsInSeparateLine && headerButtons} - {shouldDisplayButtonsInSeparateLine && !!headerButtons && {headerButtons}} - {/* Required is configured from Rules once the revamp is on, so this toggle is pre-revamp only. */} - {!hasDependentTags && !isRulesRevampEnabled && ( - - { - if (!canWriteTags) { - showReadOnlyModal(); - return; - } - - if (!isMultiLevelTags) { - showConfirmModal({ - title: translate('workspace.tags.cannotMakeTagListRequired.title'), - prompt: translate('workspace.tags.cannotMakeTagListRequired.description'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - if (isMakingLastRequiredTagListOptional(policy, policyTags, [currentPolicyTag])) { - showConfirmModal({ - title: translate('workspace.tags.cannotMakeAllTagsOptional.title'), - prompt: translate('workspace.tags.cannotMakeAllTagsOptional.description'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - setPolicyTagsRequired(policyData, on, orderWeight); - }} - pendingAction={currentPolicyTag.pendingFields?.required} - errors={currentPolicyTag?.errorFields?.required ?? undefined} - onCloseError={() => - clearPolicyTagListErrorField({ - policyID, - tagListIndex: orderWeight, - errorField: 'required', - policyTags, - }) - } - disabled={!canWriteTags || (!currentPolicyTag?.required && !Object.values(currentPolicyTag?.tags ?? {}).some((tag) => tag.enabled))} - disabledAction={withReadOnlyFallback()} - showLockIcon={!canWriteTags || !isMultiLevelTags || isMakingLastRequiredTagListOptional(policy, policyTags, [currentPolicyTag])} - /> - - )} - - clearPolicyTagListErrors({ - policyID, - tagListIndex: currentPolicyTag.orderWeight, - policyTags, - }) - } - pendingAction={currentPolicyTag.pendingAction} - errorRowStyles={styles.mh5} - > - - + {(tagRows.length === 0 || isLoading) && tableHeaderComponent} {isLoading && ( )} diff --git a/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx b/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx index 7d4161fdfb65..4c7dbb8bc129 100644 --- a/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx +++ b/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 53d75d1cf861..7884246ee055 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import DecisionModal from '@components/DecisionModal'; @@ -21,7 +21,6 @@ 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 {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; @@ -41,7 +40,6 @@ import { openPolicyTagsPage, setPolicyTagsRequired, setWorkspaceTagEnabled, - setWorkspaceTagRequired, } from '@libs/actions/Policy/Tag'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -129,13 +127,11 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { ); const {canWrite: canWriteTags, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - // The revamp moves the multi-level tag settings to Rules, but the GL codes toggle stays here and needs a way in. - const shouldShowTagsSettings = canWriteTags && (!(isRulesRevampEnabled && isMultiLevelTags) || !!policy?.glCodes); + // 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 && !(isRulesRevampEnabled && isMultiLevelTags); + const isSelectionEnabled = canWriteTags && !hasDependentTags && !isMultiLevelTags; const canSelectMultiple = isSelectionEnabled && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); const tagApproverEmails = useMemo(() => { @@ -275,15 +271,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }); }, [showConfirmModal, translate]); - const showAllTagsOptionalWarning = useCallback(() => { - showConfirmModal({ - title: translate('workspace.tags.cannotMakeAllTagsOptional.title'), - prompt: translate('workspace.tags.cannotMakeAllTagsOptional.description'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }, [showConfirmModal, translate]); - const handleTagEnabledToggle = useCallback( (enabled: boolean, tag: PolicyTag) => { if (!canWriteTags) { @@ -301,23 +288,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { [canWriteTags, policyTagLists, showAllTagsDisabledWarning, showReadOnlyModal, updateWorkspaceTagEnabled], ); - const handleTagListRequiredToggle = useCallback( - (required: boolean, policyTagList: PolicyTagList) => { - if (!canWriteTags) { - showReadOnlyModal(); - return; - } - - if (!required && isMakingLastRequiredTagListOptional(policy, policyTags, [policyTagList])) { - showAllTagsOptionalWarning(); - return; - } - - updateWorkspaceRequiresTag(required, policyTagList.orderWeight); - }, - [canWriteTags, policy, policyTags, showAllTagsOptionalWarning, showReadOnlyModal, updateWorkspaceRequiresTag], - ); - const navigateToTagSettings = useCallback( (tagValue: string, orderWeight?: number) => { if (orderWeight !== undefined) { @@ -376,10 +346,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { pendingAction: getPendingAction(policyTagList), isLocked: !canWriteTags || isMakingLastRequiredTagListOptional(policy, policyTags, [policyTagList]), showEnabledSwitch: false, - // Required is configured from Rules once the revamp is on. - showRequiredSwitch: !hasDependentTags && !isRulesRevampEnabled, action: () => navigateToTagSettings(policyTagList.name, policyTagList.orderWeight), - onToggleRequired: (required: boolean) => handleTagListRequiredToggle(required, policyTagList), onClose: () => {}, }); @@ -420,7 +387,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { pending: shouldShowPendingSwitch && tag.pendingFields?.enabled === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, isLocked: !canWriteTags || isLastEnabledTagAndEnabled, showEnabledSwitch: true, - showRequiredSwitch: false, action: () => navigateToTagSettings(tag.name), onToggleEnabled: (enabled: boolean) => handleTagEnabledToggle(enabled, tag), onClose: () => clearPolicyTagErrors({policyID, tagName: tag.name, tagListIndex: 0, policyTags}), @@ -431,11 +397,8 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }, [ canWriteTags, handleTagEnabledToggle, - handleTagListRequiredToggle, - hasDependentTags, isMultiLevelTags, isOffline, - isRulesRevampEnabled, navigateToTagSettings, policy, policyID, @@ -580,7 +543,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { } const selectedTagsObject = selectedTagKeys.map((key) => policyTagLists.at(0)?.tags?.[key]); - const selectedTagLists = selectedTagKeys.map((selectedTag) => policyTagLists.find((policyTagList) => policyTagList.name === selectedTag)); // Without selection there are no bulk actions, so keep the normal header even if selection mode lingered from elsewhere. if (!canWriteTags || !isSelectionEnabled || (shouldUseNarrowLayout ? !isMobileSelectionModeEnabled : selectedTagKeys.length === 0)) { @@ -702,56 +664,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }); } - let requiredTagCount = 0; - const tagListIndexesToMarkRequired: number[] = []; - - let optionalTagCount = 0; - const tagListIndexesToMarkOptional: number[] = []; - - for (const tagName of selectedTagKeys) { - if (tagRowsKeyedByName[tagName]?.required) { - requiredTagCount++; - tagListIndexesToMarkOptional.push(tagRowsKeyedByName[tagName]?.orderWeight ?? 0); - } else { - optionalTagCount++; - tagListIndexesToMarkRequired.push(tagRowsKeyedByName[tagName]?.orderWeight ?? 0); - } - } - - if (requiredTagCount > 0 && !hasDependentTags && isMultiLevelTags && !isRulesRevampEnabled) { - options.push({ - icon: expensifyIcons.Close, - text: translate('workspace.tags.notRequireTags'), - value: CONST.POLICY.BULK_ACTION_TYPES.REQUIRE, - shouldSkipFocusRestore: isMakingLastRequiredTagListOptional(policy, policyTags, selectedTagLists), - onSelected: () => { - if (isMakingLastRequiredTagListOptional(policy, policyTags, selectedTagLists)) { - showConfirmModal({ - title: translate('workspace.tags.cannotMakeAllTagsOptional.title'), - prompt: translate('workspace.tags.cannotMakeAllTagsOptional.description'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - clearTableSelection(); - setWorkspaceTagRequired(policyData, tagListIndexesToMarkOptional, false); - }, - }); - } - - if (optionalTagCount > 0 && !hasDependentTags && isMultiLevelTags && !isRulesRevampEnabled) { - options.push({ - icon: expensifyIcons.Checkmark, - text: translate(requiredTagCount === 1 ? 'workspace.tags.requireTag' : 'workspace.tags.requireTags'), - value: CONST.POLICY.BULK_ACTION_TYPES.NOT_REQUIRED, - onSelected: () => { - clearTableSelection(); - setWorkspaceTagRequired(policyData, tagListIndexesToMarkRequired, true); - }, - }); - } - return ( [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 hasEnabledOptions = hasEnabledOptionsUtil(Object.values(policyTags ?? {}).flatMap(({tags}) => Object.values(tags))); - const updateWorkspaceRequiresTag = useCallback( - (value: boolean) => { - setPolicyRequiresTag(policyData, value); - }, - [policyData], - ); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_SETTINGS; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SETTINGS_TAGS_SETTINGS.path); - const shouldBlockEmptySettings = isRulesRevampEnabled && isMultiLevelTags && !isLoading && !policyData.policy?.glCodes; + // 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); }; - const hasDependentTags = hasDependentTagsUtil(policy, policyTags); return ( {!isMultiLevelTags && ( @@ -83,9 +71,8 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) { pendingAction={policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction} errorRowStyles={styles.mh5} > - { Navigation.navigate( isQuickSettingsFlow @@ -93,54 +80,10 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) { : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EDIT_TAGS.getRoute(policyTagLists.at(0)?.orderWeight ?? 0)), ); }} - shouldShowRightIcon + value={customTagName} /> )} - {!isRulesRevampEnabled && ( - <> - {(!isMultiLevelTags || hasDependentTags) && ( - - - - {translate('workspace.tags.requiresTag')} - - - - - )} - - - - {translate('workspace.tags.trackBillable')} - - toggleBillableExpenses(policy, getReviewWorkspaceSettingsTaskCompletion())} - disabled={!policy?.areTagsEnabled} - /> - - - - )} {!!policy?.glCodes && ( void; - /** Callback to call when the component is pressed. */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void; }; diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx index c888ac7e1f75..b196a1816fa1 100644 --- a/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx +++ b/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index d490b5ec8e5c..17e64d2e945d 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption, WorkspaceTaxRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; diff --git a/src/pages/workspace/travel/GetStartedTravel.tsx b/src/pages/workspace/travel/GetStartedTravel.tsx index 9bf63837b79f..5dfeaaa7b5d5 100644 --- a/src/pages/workspace/travel/GetStartedTravel.tsx +++ b/src/pages/workspace/travel/GetStartedTravel.tsx @@ -1,5 +1,5 @@ import BookTravelButton from '@components/BookTravelButton'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FeatureList from '@components/FeatureList'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; diff --git a/src/pages/workspace/travel/ReviewingRequest.tsx b/src/pages/workspace/travel/ReviewingRequest.tsx index 86e845050b51..de50b97bd262 100644 --- a/src/pages/workspace/travel/ReviewingRequest.tsx +++ b/src/pages/workspace/travel/ReviewingRequest.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FeatureList from '@components/FeatureList'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingExportPage.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingExportPage.tsx index a0a65a69676c..7cce4569c4a7 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingExportPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingExportPage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingFeedSelectorPage.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingFeedSelectorPage.tsx new file mode 100644 index 000000000000..b11409c8974d --- /dev/null +++ b/src/pages/workspace/travel/WorkspaceTravelBillingFeedSelectorPage.tsx @@ -0,0 +1,136 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +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 useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTravelBillingSetupFlow from '@hooks/useTravelBillingSetupFlow'; + +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import type {ExpensifyCardFeedEntry} from '@libs/ExpensifyCardFeedSelectorUtils'; +import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import Navigation from '@navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import variables from '@styles/variables'; + +import {linkCardFeedToPolicy} from '@userActions/CompanyCards'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; + +import React, {useState} from 'react'; + +type TravelBillingFeedListItem = ListItem & { + value: number; +}; + +type WorkspaceTravelBillingFeedSelectorPageProps = PlatformStackScreenProps; + +function WorkspaceTravelBillingFeedSelectorPage({route}: WorkspaceTravelBillingFeedSelectorPageProps) { + const {policyID} = route.params; + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardImage']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); + const startTravelBillingSetup = useTravelBillingSetupFlow(policyID); + const {allFeeds} = useExpensifyCardFeedsForFeedSelector(policyID, [CONST.TRAVEL.PROGRAM_TRAVEL_US]); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [domains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [feedWithError, setFeedWithError] = useState<{fundID?: number; error?: Errors} | undefined>(undefined); + const [linkingFundID, setLinkingFundID] = useState(); + + const goBack = () => Navigation.goBack(ROUTES.WORKSPACE_TRAVEL.getRoute(policyID)); + + const onDismissError = () => setFeedWithError(undefined); + + const toListItem = (entry: ExpensifyCardFeedEntry): TravelBillingFeedListItem => ({ + value: entry.fundID, + text: getExpensifyCardFeedDescription(entry.settings, policies, domains, entry.fundID, cardList), + keyForList: entry.fundID.toString(), + isDisabled: isOffline || linkingFundID !== undefined, + errors: feedWithError?.fundID === entry.fundID ? feedWithError.error : undefined, + leftElement: ( + + ), + }); + + const selectFeed = (feed: TravelBillingFeedListItem) => { + // A second tap while the link resolves would fire the command again and navigate back twice. + if (linkingFundID !== undefined) { + return; + } + setLinkingFundID(feed.value); + linkCardFeedToPolicy(feed.value, policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD, CONST.TRAVEL.PROGRAM_TRAVEL_US) + .then(() => goBack()) + .catch((error: TranslationPaths) => { + setLinkingFundID(undefined); + setFeedWithError({fundID: feed.value, error: getMicroSecondOnyxErrorWithTranslationKey(error)}); + }); + }; + + return ( + + + + + {translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.feedSelectorIntro')} + + } + listFooterContent={ + + } + onDismissError={onDismissError} + addBottomSafeAreaPadding + /> + + + ); +} + +export default WorkspaceTravelBillingFeedSelectorPage; diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx index d49bd9a644b2..0bae3bd70af3 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx @@ -1,5 +1,5 @@ -import Button from '@components/ButtonComposed'; -import ConfirmModal from '@components/ConfirmModal'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import FormHelpMessageRowWithRetryButton from '@components/Domain/FormHelpMessageRowWithRetryButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -10,8 +10,8 @@ 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 useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -57,14 +57,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import TravelBillingLearnHow from './TravelBillingLearnHow'; import TravelBillingSubtitleWrapper from './TravelBillingSubtitleWrapper'; type WorkspaceTravelBillingSectionProps = { - /** The ID of the policy */ policyID: string; }; @@ -74,16 +73,13 @@ type WorkspaceTravelBillingSectionProps = { */ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSectionProps) { const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const {isLargeScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const defaultFundID = useDefaultFundID(policyID); + const {allFeeds: accessibleTravelFeeds} = useExpensifyCardFeedsForFeedSelector(policyID, [CONST.TRAVEL.PROGRAM_TRAVEL_US]); const {showConfirmModal, closeModal} = useConfirmModal(); - const [isDisableConfirmModalVisible, setIsDisableConfirmModalVisible] = useState(false); - const [isOutstandingBalanceModalVisible, setIsOutstandingBalanceModalVisible] = useState(false); - const [isPayBalanceModalVisible, setIsPayBalanceModalVisible] = useState(false); // Ref to track if the "Update to USD" modal is open const isCurrencyModalOpen = useRef(false); @@ -123,6 +119,13 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const shouldShowPayButton = travelSpend > 0 && travelSpend > pendingInvoiceAmount && isMonthlySettlementFrequency && !hasPendingSettlement; const formattedSpend = convertToDisplayString(travelSpend, CONST.CURRENCY.USD); + // Mirror the spend so the pay-balance confirmation settles the balance as it stands when the user confirms. + // The awaited handler would otherwise keep the value captured when the modal was opened. + const travelSpendRef = useRef(travelSpend); + useEffect(() => { + travelSpendRef.current = travelSpend; + }, [travelSpend]); + // Pay-by-invoice customers settle by wire against an invoice, so the pay CTA and modal use invoice copy const isPayByInvoice = getIsTravelBillingPayByInvoice(travelSettings); const payBalanceCtaText = translate( @@ -186,10 +189,20 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const hasTravelProvisioningErrors = isTravelBillingEnabled && !!travelProvisioningErrors && Object.keys(travelProvisioningErrors).length > 0; /** - * Opens the pay balance confirmation modal. + * Opens the pay balance confirmation modal and, once confirmed, triggers the API call with optimistic Onyx update. */ - const handlePayBalance = () => { - setIsPayBalanceModalVisible(true); + const handlePayBalance = async () => { + const result = await showConfirmModal({ + title: payBalanceModalTitle, + prompt: payBalanceModalBody, + confirmText: payBalanceCtaText, + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + payTravelBillingSpend(policyID, defaultFundID, travelSpendRef.current); }; /** @@ -205,15 +218,6 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); }; - /** - * Handles the confirmed payment of the outstanding travel balance. - * Closes the modal and triggers the API call with optimistic Onyx update. - */ - const handleConfirmPayBalance = () => { - setIsPayBalanceModalVisible(false); - payTravelBillingSpend(policyID, defaultFundID, travelSpend); - }; - const continueToggleFlow = () => { if (areTravelPersonalDetailsMissing(privatePersonalDetails)) { shouldResumeToggleRef.current = true; @@ -221,6 +225,12 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection return; } + // The domain already runs a travel feed this workspace can join, so let the admin pick one instead of provisioning another. + if (accessibleTravelFeeds.length > 0) { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TRAVEL_BILLING_SELECT_FEED.path, ROUTES.WORKSPACE_TRAVEL.getRoute(policyID))); + return; + } + // Turning ON - check if bank account setup is needed first if (!eligibleBankAccounts.length || isSetupUnfinished) { // No bank accounts - start add bank account flow @@ -264,6 +274,20 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection continueToggleFlow(); }; + const promptDisableAndDeactivate = async () => { + const result = await showConfirmModal({ + title: translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.title'), + prompt: translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.body'), + confirmText: translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.confirm'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + deactivateTravelBilling(policyID, defaultFundID); + }; + /** * Handle toggle change for Travel Billing. * When turning ON: @@ -288,12 +312,17 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection if (!isEnabled) { // Trying to disable - check for outstanding balance first if (hasOutstandingBalance) { - // Show blocker modal with error message - setIsOutstandingBalanceModalVisible(true); + // Show blocker modal with error message. It is acknowledgement-only, so the result is ignored. + showConfirmModal({ + title: translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.title'), + prompt: translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.body'), + confirmText: translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.confirm'), + shouldShowCancelButton: false, + }); return; } // Show confirmation modal before disabling - setIsDisableConfirmModalVisible(true); + promptDisableAndDeactivate(); return; } @@ -305,11 +334,6 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection continueToggleFlow(); }; - const handleConfirmDisable = () => { - setIsDisableConfirmModalVisible(false); - deactivateTravelBilling(policyID, defaultFundID); - }; - // Dismiss the "Update to USD" modal check if the currency changes to USD externally (e.g. from another device) useEffect(() => { if (policy?.outputCurrency !== CONST.CURRENCY.USD || !isCurrencyModalOpen.current) { @@ -393,14 +417,13 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection {translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.viewOnSpend')} {shouldShowPayButton && canWriteMoreFeatures && ( - + )} @@ -473,57 +496,23 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection ); return ( - <> -
- clearTravelBillingErrors(defaultFundID)} - subMenuItems={travelBillingSubMenuItems} - /> -
- - setIsDisableConfirmModalVisible(false)} - prompt={translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.body')} - confirmText={translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.confirm')} - cancelText={translate('common.cancel')} - buttonVariant={CONST.BUTTON_VARIANT.DANGER} - /> - - setIsOutstandingBalanceModalVisible(false)} - onCancel={() => setIsOutstandingBalanceModalVisible(false)} - prompt={translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.body')} - confirmText={translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.confirm')} - shouldShowCancelButton={false} - /> - - setIsPayBalanceModalVisible(false)} - prompt={payBalanceModalBody} - confirmText={payBalanceCtaText} - cancelText={translate('common.cancel')} - buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} +
+ clearTravelBillingErrors(defaultFundID)} + subMenuItems={travelBillingSubMenuItems} /> - +
); } diff --git a/src/pages/workspace/upgrade/GenericFeaturesView.tsx b/src/pages/workspace/upgrade/GenericFeaturesView.tsx index 7722352e985d..2a12f2108d5f 100644 --- a/src/pages/workspace/upgrade/GenericFeaturesView.tsx +++ b/src/pages/workspace/upgrade/GenericFeaturesView.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; import type {IllustrationName} from '@components/Icon/IllustrationLoader'; diff --git a/src/pages/workspace/upgrade/UpgradeIntro.tsx b/src/pages/workspace/upgrade/UpgradeIntro.tsx index 81d49484910b..d1fc75db9470 100644 --- a/src/pages/workspace/upgrade/UpgradeIntro.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntro.tsx @@ -2,7 +2,6 @@ import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePreferredCurrency from '@hooks/usePreferredCurrency'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -39,9 +38,7 @@ type Props = { function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizing, isDistanceRateUpgrade, isReporting, policyID, backTo, upgradePlanType}: Props) { const styles = useThemeStyles(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const {isBetaEnabled} = usePermissions(); const isCurrentPolicySubmit = isSubmitPolicy(policy); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const {translate} = useLocalize(); const preferredCurrency = usePreferredCurrency(); const hasTeam2025Pricing = useHasTeam2025Pricing(); @@ -91,6 +88,7 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi 'CertiniaSquare', 'RilletSquare', 'DualEntrySquare', + 'CampfireSquare', 'AdvancedApprovalsSquare', 'Unlock', ]); @@ -137,7 +135,7 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi if (feature.id === 'preventSelfApproval' || feature.id === 'autoApproveCompliantReports' || feature.id === 'autoPayApprovedReports') { return translate('workspace.upgrade.approvals.onlyAvailableOnPlan', planParams); } - if (feature.id === CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id && isRulesRevampEnabled) { + if (feature.id === CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id) { return translate('workspace.upgrade.rules.onlyAvailableOnPlanUnlimited', planParams); } return translate(`workspace.upgrade.${feature.id}.onlyAvailableOnPlan`, planParams); diff --git a/src/pages/workspace/upgrade/UpgradeIntroView.tsx b/src/pages/workspace/upgrade/UpgradeIntroView.tsx index 7afc56a2f47c..03bb8b1e751f 100644 --- a/src/pages/workspace/upgrade/UpgradeIntroView.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntroView.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -45,7 +45,6 @@ type UpgradeIntroViewProps = { /** Primary button label. */ buttonText: string; - /** Unlock badge text. */ unlockBadgeText?: string; /** Called when the primary button is pressed. */ diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index dfb0a63b4acf..0a8a2748cb68 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -42,6 +42,7 @@ import { enablePolicyAutoReimbursementLimit, enablePolicyConnections, enablePolicyHR, + enablePolicyInvoiceFields, enablePolicyInvoicing, enablePolicyReportFields, enablePolicyRules, @@ -136,6 +137,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const policyData = usePolicyData(policyID); const policyDataRef = useRef(policyData); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); useEffect(() => { policyDataRef.current = policyData; @@ -175,6 +177,8 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { return; } } + case CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoiceFields.id: + return Navigation.goBack(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)); case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id: return route.params.backTo ? Navigation.goBack(route.params.backTo) : Navigation.goBack(); case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id: @@ -266,6 +270,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { } } break; + case CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoiceFields.id: + enablePolicyInvoiceFields(policyID, true); + break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id: // Re-enabling would re-run the sidebar's "just enabled" highlight on a row that already shows. if (!policy?.areRulesEnabled) { @@ -292,7 +299,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.id: - setWorkspaceApprovalMode(policy, defaultApprover, CONST.POLICY.APPROVAL_MODE.ADVANCED, accountID, email, isTrackIntentUser); + setWorkspaceApprovalMode(policy, defaultApprover, CONST.POLICY.APPROVAL_MODE.ADVANCED, accountID, email, isTrackIntentUser, rules); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmitReport.id: break; @@ -355,6 +362,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { categoryId, getReviewWorkspaceSettingsTaskCompletion, isTrackIntentUser, + rules, ]); useWorkspaceUpgradeConfirmation({ diff --git a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx index 305ab7272668..a2fb6bd800f8 100644 --- a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx +++ b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx @@ -50,7 +50,7 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { [vendors], ); - const headerContent = !!currentConnectionName && ( + const headerContent = currentConnectionName ? ( - ); + ) : undefined; return ( Navigation.goBack()} /> - {headerContent} - + ); diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index aefe124121ad..aa6d6e844bb3 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -54,9 +54,19 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT | typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT | typeof SCREENS.WORKSPACE.REPORT_FIELDS_CREATE + | typeof SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS | typeof SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES + | typeof SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE + | typeof SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE | typeof SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE | typeof SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_CREATE + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_SETTINGS + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_LIST_VALUES + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_ADD_VALUE + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_VALUE + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_INITIAL_VALUE + | typeof SCREENS.WORKSPACE.INVOICE_FIELDS_VALUE_SETTINGS | typeof SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION | typeof SCREENS.WORKSPACE.RULES | typeof SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW diff --git a/src/pages/workspace/withPolicyAndFullscreenLoading.tsx b/src/pages/workspace/withPolicyAndFullscreenLoading.tsx index bcbf320bded1..da3ff2c7a04d 100644 --- a/src/pages/workspace/withPolicyAndFullscreenLoading.tsx +++ b/src/pages/workspace/withPolicyAndFullscreenLoading.tsx @@ -16,10 +16,7 @@ import type {WithPolicyOnyxProps, WithPolicyProps} from './withPolicy'; import withPolicy, {policyDefaultProps} from './withPolicy'; type WithPolicyAndFullscreenLoadingOnyxProps = { - /** Indicated whether the report data is loading */ isLoadingReportData: OnyxEntry; - - /** Personal details of all users */ personalDetails: OnyxEntry; }; diff --git a/src/pages/workspace/workflows/ImportWorkflowsPage.tsx b/src/pages/workspace/workflows/ImportWorkflowsPage.tsx index 6693c96594a9..98801b7dd9f6 100644 --- a/src/pages/workspace/workflows/ImportWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/ImportWorkflowsPage.tsx @@ -5,7 +5,7 @@ import usePolicy from '@hooks/usePolicy'; import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy} from '@libs/PolicyUtils'; +import {goBackFromInvalidPolicy, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -31,7 +31,7 @@ function ImportWorkflowsPage({route}: ImportWorkflowsPageProps) { policyFeature={CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} - shouldBeBlocked={isAnyHRReadOnlyWorkflowMode(policy)} + shouldBeBlocked={isAnyHRReadOnlyWorkflowMode(policy) || shouldHideDynamicExternalWorkflowPeople(policy)} fullPageNotFoundViewProps={{subtitleKey: isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized', onLinkPress: goBackFromInvalidPolicy}} > ; - /** Used to apply styles to the Title */ titleStyle?: StyleProp; /** Optional accessibility role for the title. Only set when the title is a section heading (e.g. CONST.ROLE.HEADER); omit for regular rows. */ titleAccessibilityRole?: typeof CONST.ROLE.HEADER; - /** Used to apply styles to the Subtitle */ subtitleStyle?: StyleProp; - - /** Used to apply styles to the Accordion */ accordionStyle?: StyleProp; - - /** Whether the option is enabled or not */ isActive: boolean; /** Callback to be called when the switch is toggled */ @@ -95,7 +81,6 @@ type ToggleSettingOptionRowProps = { /** Callback to close the error messages */ onCloseError?: () => void; - /** Whether the toggle should be disabled */ disabled?: boolean; /** Whether to show the lock icon even if the switch is enabled */ diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx index 722d0e62a9eb..efd1cbcaef07 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx @@ -24,7 +24,7 @@ import {clearPolicyErrorField, setWorkspaceCurrencyConversionFeesPreference} fro import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; -import React from 'react'; +import React, {useState} from 'react'; type CurrencyConversionFeesPreferenceKey = typeof CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY | typeof CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.EMPLOYEE; @@ -46,21 +46,28 @@ function WorkspaceWorkflowsCurrencyConversionFeesPage({policy, route}: Workspace ? CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY : CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.EMPLOYEE; + const [draftPreference, setDraftPreference] = useState(); + const currentPreference = draftPreference ?? selectedPreference; + const items: CurrencyConversionFeesItem[] = [ { text: translate('workflowsCurrencyConversionFeesPage.companyPays'), keyForList: CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY, - isSelected: selectedPreference === CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY, + isSelected: currentPreference === CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY, }, { text: translate('workflowsCurrencyConversionFeesPage.employeePays'), keyForList: CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.EMPLOYEE, - isSelected: selectedPreference === CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.EMPLOYEE, + isSelected: currentPreference === CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.EMPLOYEE, }, ]; const onSelectPreference = (item: CurrencyConversionFeesItem) => { - const shouldPreferCompany = item.keyForList === CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY; + setDraftPreference(item.keyForList); + }; + + const saveCurrencyConversionFeesPreference = () => { + const shouldPreferCompany = currentPreference === CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY; if (!!policy?.id && shouldPreferCompany !== !!policy.globalReimbursementFXPreferCompany) { setWorkspaceCurrencyConversionFeesPreference(policy.id, shouldPreferCompany, policy.globalReimbursementFXPreferCompany); } @@ -68,6 +75,13 @@ function WorkspaceWorkflowsCurrencyConversionFeesPage({policy, route}: Workspace Navigation.goBack(); }; + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: saveCurrencyConversionFeesPreference, + isDisabled: currentPreference === selectedPreference, + }; + const listHeaderContent = ( {translate('workflowsCurrencyConversionFeesPage.subtitle')}{' '} @@ -110,6 +124,7 @@ function WorkspaceWorkflowsCurrencyConversionFeesPage({policy, route}: Workspace onSelectRow={onSelectPreference} initiallyFocusedItemKey={selectedPreference} customListHeaderContent={listHeaderContent} + confirmButtonOptions={confirmButtonOptions} addBottomSafeAreaPadding /> diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx deleted file mode 100644 index e551bbd71998..000000000000 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; - -import useConfirmModal from '@hooks/useConfirmModal'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; - -import {downloadMembersCSV} from '@libs/actions/Policy/Member'; -import {openPolicyWorkflowsPage} from '@libs/actions/Policy/Policy'; -import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {canMemberRead, isGroupPolicy as isGroupPolicyUtil, isSubmitPolicy} from '@libs/PolicyUtils'; - -import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import ExpenseReportRulesSection from '@pages/workspace/rules/ExpenseReportRulesSection'; -import type {WithPolicyProps} from '@pages/workspace/withPolicy'; -import withPolicy from '@pages/workspace/withPolicy'; -import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; - -import {getPaymentMethods} from '@userActions/PaymentMethods'; - -import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; - -import type {ValueOf} from 'type-fest'; - -import React, {useCallback, useEffect} from 'react'; -import {View} from 'react-native'; - -import WorkflowsApprovalsTab from './tabs/WorkflowsApprovalsTab'; -import WorkflowsPaymentsTab from './tabs/WorkflowsPaymentsTab'; -import WorkflowsSubmissionsTab from './tabs/WorkflowsSubmissionsTab'; -import WorkspaceWorkflowsPageRevamp from './WorkspaceWorkflowsPageRevamp'; - -type WorkspaceWorkflowsPageProps = WithPolicyProps & PlatformStackScreenProps; - -function WorkspaceWorkflowsPage(props: WorkspaceWorkflowsPageProps) { - const {policy, route} = props; - const {policyID} = route.params; - useWorkspaceDocumentTitle(policy?.name, 'workspace.common.workflows'); - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Table', 'Download']); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {showConfirmModal} = useConfirmModal(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - - const isSubmitPolicyWorkspace = isSubmitPolicy(policy); - - const fetchData = useCallback(() => { - // This component still mounts (and keeps its hooks running) when the revamp renders below, so let the revamp - // page own fetching to avoid a duplicate OpenPolicyWorkflowsPage on mount and on every reconnect. - if (isRulesRevampEnabled) { - return; - } - openPolicyWorkflowsPage(policyID, true); - getPaymentMethods(); - }, [policyID, isRulesRevampEnabled]); - - const {isOffline} = useNetwork({onReconnect: fetchData}); - const canReadWorkflows = canMemberRead(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS); - const {canWrite: canWriteWorkflows} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.WORKFLOWS); - const {canWrite: canWriteApprovals, withReadOnlyFallback: withApprovalsReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS); - const {canWrite: canWritePayments, withReadOnlyFallback: withPaymentsReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); - - const {isAccountLocked} = useLockedAccountState(); - const {showLockedAccountModal} = useLockedAccountActions(); - - useEffect(() => { - fetchData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const navigateToSubmitWorkspaceApprovalsUpgrade = useCallback(() => { - Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.alias, ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID))); - }, [policyID]); - - // Reuses the Members spreadsheet importer (it already maps the `submitsTo` / `approvesTo` columns) so approval - // workflows can be bulk-imported directly from the Workflows page. - const importWorkflowsAction = useCallback(() => { - if (isAccountLocked) { - showLockedAccountModal(); - return; - } - if (isOffline) { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: true, - }); - return; - } - // Submit 2026 workspaces gate approvals behind the Submit approvals upgrade, so route them there instead of the importer. - if (isSubmitPolicyWorkspace) { - navigateToSubmitWorkspaceApprovalsUpgrade(); - return; - } - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_IMPORT.getRoute(policyID)); - }, [isAccountLocked, showLockedAccountModal, isOffline, showConfirmModal, translate, policyID, isSubmitPolicyWorkspace, navigateToSubmitWorkspaceApprovalsUpgrade]); - - // The Workflows CSV export reuses the Members export command so the downloaded file is identical to Members > Download CSV. - const downloadWorkflowsAction = useCallback(() => { - if (isOffline) { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: true, - }); - return; - } - downloadMembersCSV( - policyID, - () => { - showConfirmModal({ - title: translate('common.downloadFailedTitle'), - prompt: translate('common.downloadFailedDescription'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }, - translate, - ); - }, [isOffline, showConfirmModal, translate, policyID]); - - if (isRulesRevampEnabled) { - return ; - } - - const shouldBlockApprovalWorkflowEditing = isAnyHRReadOnlyWorkflowMode(policy); - - const approvalSecondaryActions: Array>> = []; - // Importing modifies the workflows, so only offer it when editing is allowed. - if (!shouldBlockApprovalWorkflowEditing) { - approvalSecondaryActions.push({ - icon: expensifyIcons.Table, - text: translate('spreadsheet.importWorkflows'), - onSelected: importWorkflowsAction, - value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, - }); - } - // Downloading is read-only, so it stays available even when editing is blocked. - approvalSecondaryActions.push({ - icon: expensifyIcons.Download, - text: translate('spreadsheet.downloadWorkflows'), - onSelected: downloadWorkflowsAction, - value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, - }); - - const shouldShowSubmissionFrequency = canWriteWorkflows || !canWriteApprovals; - const shouldShowPayments = canMemberRead(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); - - const isGroupPolicy = isGroupPolicyUtil(policy); - const isLoading = !!(policy?.isLoading && policy?.reimbursementChoice === undefined); - - // Show the More dropdown whenever the user can manage workflows. When editing is blocked it renders download-only - // (the Import action is filtered out of approvalSecondaryActions above). - const headerButtons = canWriteApprovals ? ( - - {}} - shouldAlwaysShowDropdownMenu - customText={translate('common.more')} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.MORE_DROPDOWN} - options={approvalSecondaryActions} - isSplitButton={false} - wrapperStyle={styles.flexGrow0} - /> - - ) : undefined; - - return ( - - - - {shouldShowSubmissionFrequency && } - - {shouldShowPayments && } - - - - - ); -} - -export default withPolicy(WorkspaceWorkflowsPage); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPageRevamp.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPageRevamp.tsx index e0bed863cd7a..65f18abbccc5 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPageRevamp.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPageRevamp.tsx @@ -22,13 +22,14 @@ import Tab from '@libs/actions/Tab'; import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {canMemberRead, isGroupPolicy as isGroupPolicyUtil, isSubmitPolicy} from '@libs/PolicyUtils'; +import {canMemberRead, isGroupPolicy as isGroupPolicyUtil, isSubmitPolicy, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import ExpenseReportRulesSection from '@pages/workspace/rules/ExpenseReportRulesSection'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import withPolicy from '@pages/workspace/withPolicy'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import {getPaymentMethods} from '@userActions/PaymentMethods'; @@ -40,7 +41,7 @@ import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import WorkflowsApprovalsTab from './tabs/WorkflowsApprovalsTab'; @@ -111,18 +112,29 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev const persistedTab = WORKFLOWS_TABS_BY_KEY.get(lastSelectedTab ?? '') ?? WORKFLOWS_TAB.SUBMISSIONS; const requestedTab = routeTab ?? persistedTab; + const hasPersistedRouteTabRef = useRef(false); + useEffect(() => { if (!routeTab) { + hasPersistedRouteTabRef.current = false; return; } // Persist the deep-linked tab first so reopening this page lands on it again. if (persistedTab !== routeTab) { Tab.setSelectedTab(CONST.TAB.WORKFLOWS_TAB_TYPE, routeTab); + hasPersistedRouteTabRef.current = true; return; } // Onyx now holds the tab, so drop the param — `goBack` compares params, and a leftover one stops child flows returning here with the plain route from popping. + // Only clear the param this mount handed over: on a repeat visit Onyx already matches, so clearing it straight from mount + // rewrites the route while the navigation is still in flight and drops us out of the split navigator. + if (!hasPersistedRouteTabRef.current) { + return; + } + + hasPersistedRouteTabRef.current = false; Navigation.setParams({tab: undefined}); }, [routeTab, persistedTab]); @@ -230,7 +242,10 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev ); }, [isOffline, showConfirmModal, translate, policyID]); - const shouldBlockApprovalWorkflowEditing = isAnyHRReadOnlyWorkflowMode(policy); + // A Dynamic External Workflow with "Hide People Table Columns" keeps the approval workflows out of the customer's + // hands entirely, so the importer that would edit them is blocked too. + const shouldHideApprovalWorkflows = shouldHideDynamicExternalWorkflowPeople(policy); + const shouldBlockApprovalWorkflowEditing = isAnyHRReadOnlyWorkflowMode(policy) || shouldHideApprovalWorkflows; const approvalSecondaryActions: Array>> = []; // Importing modifies the workflows, so only offer it when editing is allowed. @@ -242,32 +257,36 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, }); } - // Downloading is read-only, so it stays available even when editing is blocked. - approvalSecondaryActions.push({ - icon: expensifyIcons.Download, - text: translate('spreadsheet.downloadWorkflows'), - onSelected: downloadWorkflowsAction, - value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, - }); + // Downloading is read-only, so the read-only HR modes keep it. Hiding the workflow drops it too, because the + // exported CSV is that workflow written out per member. + if (!shouldHideApprovalWorkflows) { + approvalSecondaryActions.push({ + icon: expensifyIcons.Download, + text: translate('spreadsheet.downloadWorkflows'), + onSelected: downloadWorkflowsAction, + value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, + }); + } const isGroupPolicy = isGroupPolicyUtil(policy); const isLoading = !!(policy?.isLoading && policy?.reimbursementChoice === undefined); - // Show the More dropdown whenever the user can manage workflows. When editing is blocked it renders download-only - // (the Import action is filtered out of approvalSecondaryActions above). - const headerButtons = canWriteApprovals ? ( - - {}} - shouldAlwaysShowDropdownMenu - customText={translate('common.more')} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.MORE_DROPDOWN} - options={approvalSecondaryActions} - isSplitButton={false} - wrapperStyle={styles.flexGrow0} - /> - - ) : undefined; + // Show the More dropdown whenever the user can manage workflows and at least one action survives the filters above. + // Hiding the workflow removes both actions, so the dropdown itself goes with them rather than opening empty. + const headerButtons = + canWriteApprovals && approvalSecondaryActions.length > 0 ? ( + + {}} + shouldAlwaysShowDropdownMenu + customText={translate('common.more')} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.MORE_DROPDOWN} + options={approvalSecondaryActions} + isSplitButton={false} + wrapperStyle={styles.flexGrow0} + /> + + ) : undefined; return ( (policy?.achAccount?.reimburser ?? policy?.owner); const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false; const styles = useThemeStyles(); - const {showConfirmModal} = useConfirmModal(); + const {showConfirmModal, closeModal} = useConfirmModal(); const {isLoading, startWithLoading} = usePressLoading({isLoading: sharedBankAccountData?.isLoading ?? false}); const [isAlertVisible, setIsAlertVisible] = useState(false); - const [showValidationModal, setShowValidationModal] = useState(false); - const [showErrorModal, setShowErrorModal] = useState(false); const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); const selectedPayerDisplayName = usePersonalDetailByLogin(selectedPayer, displayNameSelector); const ownerDisplayName = usePersonalDetailByLogin(policy?.owner, displayNameSelector); @@ -215,6 +212,67 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR Navigation.closeRHPFlow(); }; + // Acknowledgement-only modal shown when the bank account still needs to be validated. The result is ignored. + // The only actionable path is the link inside the prompt, which closes the modal before navigating away. + const showBankAccountValidationModal = () => { + showConfirmModal({ + title: translate('workflowsPayerPage.shareBankAccount.validationTitle'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + prompt: ( + + { + closeModal(); + navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}); + }} + html={translate('workflowsPayerPage.shareBankAccount.validationDescription', { + admin: selectedPayerDisplayName ?? '', + })} + /> + + ), + shouldShowCancelButton: false, + confirmText: translate('common.buttonConfirm'), + }); + }; + + // Acknowledgement-only modal shown when the current user isn't allowed to share the bank account. The result is ignored. + // The link inside the prompt closes the modal and opens a chat with the workspace owner. + const showMissingSharePermissionModal = () => { + showConfirmModal({ + title: translate('workflowsPayerPage.shareBankAccount.errorTitle'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + prompt: ( + + { + if (!currentUserPersonalDetails?.accountID || !policy?.ownerAccountID) { + return; + } + closeModal(); + navigateToAndOpenReportWithAccountIDs( + [policy.ownerAccountID], + currentUserPersonalDetails.accountID, + introSelected, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + betas, + personalDetails, + conciergeChat, + ); + }} + html={translate('workflowsPayerPage.shareBankAccount.errorDescription', { + admin: selectedPayerDisplayName ?? '', + owner: ownerDisplayName ?? '', + })} + /> + + ), + shouldShowCancelButton: false, + confirmText: translate('common.buttonConfirm'), + }); + }; + const handleShareBankAccount = () => { // No payer selected — nothing to share with if (!selectedPayer) { @@ -239,7 +297,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR // Bank account setup incomplete — block and show validation if (isBankAccountPartiallySetup(bankAccountInfo?.accountData?.state)) { - setShowValidationModal(true); + showBankAccountValidationModal(); return; } const isAccountAlreadySharedWithCurrentUser = @@ -249,7 +307,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR // Current user has no right to share (not owner, payments admin or a sharee) — show error if (!isOwner && !canCurrentUserManagePayments && !isAccountAlreadyShared && !isAccountAlreadySharedWithCurrentUser) { - setShowErrorModal(true); + showMissingSharePermissionModal(); return; } showConfirmModal({ @@ -354,67 +412,6 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR )} - { - setShowValidationModal(false); - }} - buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} - onCancel={() => setShowValidationModal(false)} - prompt={ - - { - setShowValidationModal(false); - navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}); - }} - html={translate('workflowsPayerPage.shareBankAccount.validationDescription', { - admin: selectedPayerDisplayName ?? '', - })} - /> - - } - shouldShowCancelButton={false} - confirmText={translate('common.buttonConfirm')} - /> - setShowErrorModal(false)} - onConfirm={() => { - setShowErrorModal(false); - }} - buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} - prompt={ - - { - if (!currentUserPersonalDetails?.accountID || !policy?.ownerAccountID) { - return; - } - setShowErrorModal(false); - navigateToAndOpenReportWithAccountIDs( - [policy.ownerAccountID], - currentUserPersonalDetails.accountID, - introSelected, - guidedSetupAndTourStatus?.isSelfTourViewed, - guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - betas, - personalDetails, - conciergeChat, - ); - }} - html={translate('workflowsPayerPage.shareBankAccount.errorDescription', { - admin: selectedPayerDisplayName ?? '', - owner: ownerDisplayName ?? '', - })} - /> - - } - shouldShowCancelButton={false} - confirmText={translate('common.buttonConfirm')} - /> ); } diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx index 6d34faf27ba2..6a1538efdf08 100644 --- a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx +++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx @@ -34,26 +34,20 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; type ApprovalWorkflowEditorProps = { - /** The approval workflow to display */ approvalWorkflow: ApprovalWorkflowOnyx; - - /** Function to remove the approval workflow */ removeApprovalWorkflow?: () => void | Promise; /** The policy for the current route */ policy: OnyxEntry; - /** The policy ID */ policyID: string; - - /** Forwarded ref to pass to the ScrollView */ ref: ForwardedRef; }; function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, policy, policyID, ref}: ApprovalWorkflowEditorProps) { const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); const styles = useThemeStyles(); - const {translate, toLocaleOrdinalWithWords, localeCompare} = useLocalize(); + const {translate, toLocaleOrdinalWithWords, localeCompare, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const approverCount = approvalWorkflow.approvers.length; const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -94,7 +88,9 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic [approvalWorkflow.isDefault, approvalWorkflow.members, localeCompare], ); - const members = approvalWorkflow.isDefault ? translate('workspace.common.everyone') : sortedMembers.map((m) => Str.removeSMSDomain(m.displayName)).join(', '); + const members = approvalWorkflow.isDefault + ? translate('workspace.common.everyone') + : sortedMembers.map((m) => (Str.isSMSLogin(m.displayName) ? formatPhoneNumber(m.displayName) : m.displayName)).join(', '); const memberPills = useMemo( () => @@ -121,12 +117,16 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic if (!previousApprover || !approver) { return; } - return translate('workflowsPage.approverCircularReference', Str.removeSMSDomain(approver.displayName), Str.removeSMSDomain(previousApprover.displayName)); + return translate( + 'workflowsPage.approverCircularReference', + Str.isSMSLogin(approver.displayName) ? formatPhoneNumber(approver.displayName) : approver.displayName, + Str.isSMSLogin(previousApprover.displayName) ? formatPhoneNumber(previousApprover.displayName) : previousApprover.displayName, + ); } return translate(error); }, - [approvalWorkflow.approvers, approvalWorkflow.errors, translate], + [approvalWorkflow.approvers, approvalWorkflow.errors, translate, formatPhoneNumber], ); const editApprover = useCallback( @@ -208,6 +208,7 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic approver, currency, translate, + formatPhoneNumber, convertToDisplayString, }); const hintText = [isApproverInMultipleWorkflows ? translate('workflowsPage.approverInMultipleWorkflows') : undefined, limitDescription].filter(Boolean).join('\n'); @@ -219,7 +220,7 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic pendingAction={getApprovalPendingAction(approverIndex)} > !!employee.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const shouldShowListEmptyContent = !isLoadingApprovalWorkflow && !hasAnyEligibleMember; @@ -115,16 +120,16 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const policyRules = isMultipleApproversBetaEnabled ? getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID) : {}; - // Build a map of member emails to their existing workflow's first approver. With the beta on this - // is derived from the `ONYXKEYS.COLLECTION.RULE` rules; otherwise it falls back to the legacy - // employeeList `submitsTo` (non-default workflows only). + // Build a map of member emails to their existing workflow's first approver, covering non-default + // workflows only. const membersInExistingWorkflows = (() => { + const defaultApprover = getDefaultApprover(policy); + if (isMultipleApproversBetaEnabled) { - return new Map(Object.entries(getRulesSubmitterToFirstApprover(policyRules, policy?.employeeList ?? {}))); + return new Map(Object.entries(getRulesSubmitterToFirstApprover(policyRules, policy?.employeeList ?? {}, defaultApprover))); } const employees = policy?.employeeList ?? {}; - const defaultApprover = getDefaultApprover(policy); const map = new Map(); for (const employee of Object.values(employees)) { @@ -139,11 +144,11 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe return map; })(); - // Beta only: identity (full approver-chain fingerprint) of each submitter's current workflow, plus the - // identity of the workflow being edited. Comparing these — instead of just first approvers — lets us warn - // before moving a member out of a workflow that merely shares its first approver with this one. + // Beta only: a key covering each submitter's full current approver chain, plus the same key for the + // workflow being edited. Comparing whole chains rather than just first approvers lets us warn before + // moving a member out of a workflow that merely shares its first approver with this one. const submitterToWorkflowKey = isMultipleApproversBetaEnabled ? new Map(Object.entries(getRulesSubmitterToWorkflowKey(policyRules, policy?.employeeList ?? {}))) : undefined; - const currentWorkflowKey = approverChainFingerprint(approvalWorkflow?.originalApprovers ?? []); + const currentWorkflowKey = getApproverChainKey(approvalWorkflow?.originalApprovers ?? []); const selectedMembers = ((): SelectionListApprover[] => { if (!approvalWorkflow?.members) { @@ -181,9 +186,10 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const login = personalDetails?.[accountID]?.login ?? member.email; const displayName = member.displayName ?? personalDetail?.displayName ?? member.email; const avatar = member.avatar ?? personalDetail?.avatar; + const formattedDisplayName = Str.isSMSLogin(displayName) ? formatPhoneNumber(displayName) : displayName; return { - text: Str.removeSMSDomain(displayName), + text: formattedDisplayName, alternateText: member.email, keyForList: member.email, isSelected: true, @@ -193,7 +199,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe { source: avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, - name: Str.removeSMSDomain(displayName), + name: formattedDisplayName, id: accountID, }, ], @@ -236,8 +242,10 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe .map((member) => { const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); + const formattedDisplayName = Str.isSMSLogin(member.displayName) ? formatPhoneNumber(member.displayName) : member.displayName; + return { - text: Str.removeSMSDomain(member.displayName), + text: formattedDisplayName, alternateText: member.email, keyForList: member.email, isSelected: false, @@ -247,7 +255,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe { source: member.avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, - name: Str.removeSMSDomain(member.displayName), + name: formattedDisplayName, id: accountID, }, ], @@ -317,6 +325,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe availableOptions.personalDetails, icons.FallbackAvatar, policyMemberEmailsToAccountIDs, + formatPhoneNumber, ]); // Drop any selected members who never made it into the workspace. They were staged for invite but @@ -524,9 +533,11 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe : !!existingApproverEmail && existingApproverEmail !== firstApprover; if (newMember && existingApproverEmail && belongsToDifferentWorkflow) { - const memberName = Str.removeSMSDomain(newMember.text ?? newMember.login ?? ''); + const rawMemberName = newMember.text ?? newMember.login ?? ''; + const memberName = Str.isSMSLogin(rawMemberName) ? formatPhoneNumber(rawMemberName) : rawMemberName; const approverDetails = employeeAndApprovalMembersPersonalDetails[existingApproverEmail]; - const approverName = Str.removeSMSDomain(approverDetails?.displayName ?? existingApproverEmail); + const rawApproverName = approverDetails?.displayName ?? existingApproverEmail; + const approverName = Str.isSMSLogin(rawApproverName) ? formatPhoneNumber(rawApproverName) : rawApproverName; showConfirmModal({ title: translate('workflowsExpensesFromPage.memberAlreadyInWorkflowTitle'), @@ -559,6 +570,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe currentWorkflowKey, showConfirmModal, translate, + formatPhoneNumber, ], ); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx index d36ebb39b27b..42118b870d79 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx @@ -1,6 +1,6 @@ import AmountForm from '@components/AmountForm'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; @@ -24,7 +24,7 @@ import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -48,7 +48,7 @@ type WorkspaceWorkflowsApprovalsApprovalLimitPageProps = WithPolicyAndFullscreen function WorkspaceWorkflowsApprovalsApprovalLimitPage({policy, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApprovalLimitPageProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); const [approvalWorkflow] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); const personalDetailsByEmail = usePersonalDetailsByEmail(); @@ -72,12 +72,19 @@ function WorkspaceWorkflowsApprovalsApprovalLimitPage({policy, isLoadingReportDa const approvalLimit = editedApprovalLimit?.approverEmail === approverEmail ? editedApprovalLimit.value : defaultApprovalLimit; const selectedApproverPersonalDetails = selectedApproverEmail ? personalDetailsByEmail?.[selectedApproverEmail] : undefined; - const selectedApproverDisplayName = selectedApproverEmail ? Str.removeSMSDomain(selectedApproverPersonalDetails?.displayName ?? selectedApproverEmail) : ''; + const selectedApproverName = selectedApproverPersonalDetails?.displayName ?? selectedApproverEmail; + const formattedApproverName = Str.isSMSLogin(selectedApproverName) ? formatPhoneNumber(selectedApproverName) : selectedApproverName; + const selectedApproverDisplayName = selectedApproverEmail ? formattedApproverName : ''; const canWriteApprovals = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canWriteApprovals || isPendingDeletePolicy(policy) || isAnyHRReadOnlyWorkflowMode(policy); + const shouldShowNotFoundView = + (isEmptyObject(policy) && !isLoadingReportData) || + !canWriteApprovals || + isPendingDeletePolicy(policy) || + isAnyHRReadOnlyWorkflowMode(policy) || + shouldHideDynamicExternalWorkflowPeople(policy); - const approverDisplayName = currentApprover ? Str.removeSMSDomain(currentApprover.displayName) : ''; + const approverDisplayName = Str.isSMSLogin(currentApprover?.displayName ?? '') ? formatPhoneNumber(currentApprover?.displayName ?? '') : (currentApprover?.displayName ?? ''); const isApproverSelected = isEditFlow ? approverDisplayName.length > 0 : true; const areLimitFieldsDisabled = isEditFlow && !isApproverSelected; diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index 88a4bab4c68d..dea293a29d5b 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -6,6 +6,7 @@ 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 usePersonalDetailsByEmail from '@hooks/usePersonalDetailsByEmail'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,7 +17,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; -import {getDefaultApprover, getMemberAccountIDsForWorkspace, isExpensifyTeam, shouldFilterExpensifyTeam} from '@libs/PolicyUtils'; +import {getDefaultApprover, getMemberAccountIDsForWorkspace, isExpensifyTeam, shouldFilterExpensifyTeam, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; @@ -42,6 +43,8 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); const isApprovalWorkflowLoading = isLoadingOnyxValue(approvalWorkflowMetadata); const personalDetailsByEmail = usePersonalDetailsByEmail(); + const {isBetaEnabled} = usePermissions(); + const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const approverIndex = Number(route.params.approverIndex) ?? 0; const rhpRoutes = useNavigationState((state) => state.routes); @@ -62,7 +65,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const approversFromWorkflow = approvalWorkflow?.approvers; const isDefault = approvalWorkflow?.isDefault; - const shouldShowNotFoundView = isAnyHRReadOnlyWorkflowMode(policy); + const shouldShowNotFoundView = isAnyHRReadOnlyWorkflowMode(policy) || shouldHideDynamicExternalWorkflowPeople(policy); const shouldFilterOutExpensifyTeam = shouldFilterExpensifyTeam(policy?.owner, currentUserLogin); const allApprovers: SelectionListApprover[] = useMemo(() => { @@ -98,8 +101,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa return null; } - // Do not allow the default approver to be added as the first approver - if (!isDefault && approverIndex === 0 && defaultApprover === email) { + if (!isMultipleApproversBetaEnabled && !isDefault && approverIndex === 0 && defaultApprover === email) { return null; } @@ -134,6 +136,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa isApprovalWorkflowLoading, employeeList, isDefault, + isMultipleApproversBetaEnabled, policy?.preventSelfApproval, policy?.owner, approvalWorkflow?.members, diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index 11231a2ecfd4..2b4505a51442 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -16,7 +16,7 @@ import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -54,7 +54,12 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr const canWriteApprovals = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS); const {isLoading, startWithLoading} = usePressLoading(); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canWriteApprovals || isPendingDeletePolicy(policy) || isAnyHRReadOnlyWorkflowMode(policy); + const shouldShowNotFoundView = + (isEmptyObject(policy) && !isLoadingReportData) || + !canWriteApprovals || + isPendingDeletePolicy(policy) || + isAnyHRReadOnlyWorkflowMode(policy) || + shouldHideDynamicExternalWorkflowPeople(policy); const createApprovalWorkflow = useCallback(() => { if (!approvalWorkflow) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 2ca9ba1ed3fd..bd49593f7327 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -18,7 +18,7 @@ import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, filterRulesForPolicy, getApprovalWorkflowRulesForPolicy} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -71,6 +71,39 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const isDeleting = useRef(false); const {isLoading, startWithLoading} = usePressLoading(); + const getApprovalWorkflowData = () => { + if (!policy || !personalDetails) { + return {}; + } + + const firstApprover = route.params.firstApproverEmail; + const conversionParams = { + policy, + personalDetails, + firstApprover, + localeCompare, + currentUserLogin, + rules: getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID), + }; + const result = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS) + ? convertApprovalWorkflowRulesToWorkflows(conversionParams) + : convertPolicyEmployeesToApprovalWorkflows(conversionParams); + + const memberEmail = route.params.memberEmail; + const currentApprovalWorkflow = + (memberEmail ? result.approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === memberEmail)) : undefined) ?? + result.approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.email === firstApprover); + + return { + defaultWorkflowMembers: result.availableMembers, + usedApproverEmails: result.usedApproverEmails, + currentApprovalWorkflow, + defaultApprovalWorkflow: result.approvalWorkflows.find((workflow) => workflow.isDefault), + }; + }; + + const {currentApprovalWorkflow, defaultApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = getApprovalWorkflowData(); + const updateApprovalWorkflowCallback = () => { if (!approvalWorkflow || !initialApprovalWorkflow) { return; @@ -112,7 +145,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true Navigation.dismissModal({ afterTransition: () => { // Remove the approval workflow using the initial data as it could be already edited - const didRemoveRules = useRulesBackend && removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection); + const didRemoveRules = useRulesBackend && removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection, defaultApprovalWorkflow); if (didRemoveRules) { return; } @@ -122,41 +155,15 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true }); }; - const getApprovalWorkflowData = () => { - if (!policy || !personalDetails) { - return {}; - } - - const firstApprover = route.params.firstApproverEmail; - const conversionParams = { - policy, - personalDetails, - firstApprover, - localeCompare, - currentUserLogin, - rules: getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID), - }; - const result = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS) - ? convertApprovalWorkflowRulesToWorkflows(conversionParams) - : convertPolicyEmployeesToApprovalWorkflows(conversionParams); - - const memberEmail = route.params.memberEmail; - const currentApprovalWorkflow = - (memberEmail ? result.approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === memberEmail)) : undefined) ?? - result.approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.email === firstApprover); - - return { - defaultWorkflowMembers: result.availableMembers, - usedApproverEmails: result.usedApproverEmails, - currentApprovalWorkflow, - }; - }; - - const {currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = getApprovalWorkflowData(); const canWriteApprovals = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS); const shouldShowNotFoundView = - (isEmptyObject(policy) && !isLoadingReportData) || !canWriteApprovals || isPendingDeletePolicy(policy) || !currentApprovalWorkflow || isAnyHRReadOnlyWorkflowMode(policy); + (isEmptyObject(policy) && !isLoadingReportData) || + !canWriteApprovals || + isPendingDeletePolicy(policy) || + !currentApprovalWorkflow || + isAnyHRReadOnlyWorkflowMode(policy) || + shouldHideDynamicExternalWorkflowPeople(policy); // Set the initial approval workflow when the page is loaded useEffect(() => { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsOverLimitApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsOverLimitApproverPage.tsx index 49d958190d61..8ade03913ec0 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsOverLimitApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsOverLimitApproverPage.tsx @@ -13,7 +13,7 @@ import {isAnyHRReadOnlyWorkflowMode} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getMemberAccountIDsForWorkspace, isExpensifyTeam, shouldFilterExpensifyTeam} from '@libs/PolicyUtils'; +import {getMemberAccountIDsForWorkspace, isExpensifyTeam, shouldFilterExpensifyTeam, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; @@ -52,7 +52,7 @@ function WorkspaceWorkflowsApprovalsOverLimitApproverPage({policy, personalDetai const currentApproverEmail = currentApprover?.email; - const shouldShowNotFoundView = isAnyHRReadOnlyWorkflowMode(policy); + const shouldShowNotFoundView = isAnyHRReadOnlyWorkflowMode(policy) || shouldHideDynamicExternalWorkflowPeople(policy); const shouldFilterOutExpensifyTeam = shouldFilterExpensifyTeam(policy?.owner, currentUserLogin); const allApprovers: SelectionListApprover[] = (() => { diff --git a/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx index 5ceb3ba87f9e..d10c10d7885b 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx @@ -30,7 +30,7 @@ import {getConnectedHRProvider, getHRFinalApprover, isAnyHRConnected, isAnyHRRea import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; -import {hasDynamicExternalWorkflow, isControlPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; +import {hasDynamicExternalWorkflow, isControlPolicy, isSubmitPolicy, shouldHideDynamicExternalWorkflowPeople} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import { convertApprovalWorkflowRulesToWorkflows, @@ -111,7 +111,7 @@ function WorkflowsLoadMoreCard({count, onPress}: {count: number; onPress: () => } function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { - const {translate, localeCompare} = useLocalize(); + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -156,20 +156,11 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { const updateApprovalMode = isAdvanceApproval ? CONST.POLICY.APPROVAL_MODE.ADVANCED : CONST.POLICY.APPROVAL_MODE.BASIC; const confirmDisableApprovals = useCallback(() => { - setWorkspaceApprovalMode( - policy, - policy?.owner ?? '', - CONST.POLICY.APPROVAL_MODE.OPTIONAL, - currentUserAccountID, - currentUserEmail, - isTrackIntentUser, - { - transactionViolations, - betas, - personalDetailsList: personalDetails, - }, - rulesCollection, - ); + setWorkspaceApprovalMode(policy, policy?.owner ?? '', CONST.POLICY.APPROVAL_MODE.OPTIONAL, currentUserAccountID, currentUserEmail, isTrackIntentUser, rulesCollection, { + transactionViolations, + betas, + personalDetailsList: personalDetails, + }); }, [betas, policy, transactionViolations, currentUserAccountID, currentUserEmail, personalDetails, isTrackIntentUser, rulesCollection]); const navigateToHRSettings = useCallback(() => { @@ -243,22 +234,26 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { const filterWorkflow = (workflow: ApprovalWorkflow, searchInput: string) => { const searchableTexts: string[] = []; + const pushSearchableName = (value: string) => { + searchableTexts.push(value); + if (Str.isSMSLogin(value)) { + searchableTexts.push(Str.removeSMSDomain(value)); + searchableTexts.push(formatPhoneNumber(value)); + } + }; + if (workflow.isDefault) { searchableTexts.push(everyoneText); } else { for (const member of workflow.members) { - searchableTexts.push(member.displayName); - searchableTexts.push(Str.removeSMSDomain(member.displayName)); - searchableTexts.push(member.email); - searchableTexts.push(Str.removeSMSDomain(member.email)); + pushSearchableName(member.displayName); + pushSearchableName(member.email); } } for (const approver of workflow.approvers) { - searchableTexts.push(approver.displayName); - searchableTexts.push(Str.removeSMSDomain(approver.displayName)); - searchableTexts.push(approver.email); - searchableTexts.push(Str.removeSMSDomain(approver.email)); + pushSearchableName(approver.displayName); + pushSearchableName(approver.email); } return tokenizedSearch([workflow], searchInput, () => searchableTexts).length > 0; @@ -291,6 +286,9 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { const hiddenWorkflowsCount = searchFilteredWorkflows.length - displayedWorkflows.length; const isDEWEnabled = hasDynamicExternalWorkflow(policy); + // A Dynamic External Workflow can be configured to keep the approval workflow out of the customer's hands entirely. + // The info banner below still explains why the section is empty, but nothing else about the workflows is rendered. + const shouldHideApprovalWorkflows = shouldHideDynamicExternalWorkflowPeople(policy); const isHRConnected = isAnyHRConnected(policy); const shouldBlockApprovalWorkflowEditing = isAnyHRReadOnlyWorkflowMode(policy); const approvalSubtitle = useMemo(() => { @@ -359,12 +357,12 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { currentUserAccountID, currentUserEmail, isTrackIntentUser, + rulesCollection, { transactionViolations, betas, personalDetailsList: personalDetails, }, - rulesCollection, ); }} subMenuItems={ @@ -387,79 +385,83 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { )} - {filteredApprovalWorkflows.length > CONST.SEARCH_BAR_THRESHOLD && ( - - )} - 0} - searchValue={workflowSearchInput} - /> - {displayedWorkflows.map((workflow) => { - const firstApproverEmail = workflow.approvers.at(0)?.email ?? ''; - // The first approver isn't unique once rule-based chains diverge, so anchor the key/edit route - // on a member too (each member belongs to exactly one workflow). - const firstMemberEmail = workflow.members.at(0)?.email ?? ''; - - return ( - - { - // Discard stale onyx edits or the Edit page's resume check would surface a prior abandoned session. - clearApprovalWorkflow(); - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, firstApproverEmail, firstMemberEmail)); - } - } - onShowAllMembersPress={ - shouldBlockApprovalWorkflowEditing - ? undefined - : () => { - selectApprovalWorkflowForEdit({ - workflow, - defaultWorkflowMembers: availableMembers, - usedApproverEmails, - }); - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path)); - } - } - currency={policy?.outputCurrency} - isDisabled={shouldBlockApprovalWorkflowEditing || !canWriteApprovals} - hrProviderName={isHRConnected ? hrProviderName : undefined} - isHRAdvancedMode={isHRAdvancedModeEnabled} - hrFinalApproverEmail={isHRAdvancedModeEnabled ? hrFinalApproverEmail : undefined} + {!shouldHideApprovalWorkflows && ( + <> + {filteredApprovalWorkflows.length > CONST.SEARCH_BAR_THRESHOLD && ( + - - ); - })} - {hiddenWorkflowsCount > 0 && ( - setIsWorkflowListExpanded(true)} - /> - )} - {!shouldBlockApprovalWorkflowEditing && canWriteApprovals && ( - + )} + 0} + searchValue={workflowSearchInput} + /> + {displayedWorkflows.map((workflow) => { + const firstApproverEmail = workflow.approvers.at(0)?.email ?? ''; + // The first approver isn't unique once rule-based chains diverge, so anchor the key/edit route + // on a member too (each member belongs to exactly one workflow). + const firstMemberEmail = workflow.members.at(0)?.email ?? ''; + + return ( + + { + // Discard stale onyx edits or the Edit page's resume check would surface a prior abandoned session. + clearApprovalWorkflow(); + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, firstApproverEmail, firstMemberEmail)); + } + } + onShowAllMembersPress={ + shouldBlockApprovalWorkflowEditing + ? undefined + : () => { + selectApprovalWorkflowForEdit({ + workflow, + defaultWorkflowMembers: availableMembers, + usedApproverEmails, + }); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path)); + } + } + currency={policy?.outputCurrency} + isDisabled={shouldBlockApprovalWorkflowEditing || !canWriteApprovals} + hrProviderName={isHRConnected ? hrProviderName : undefined} + isHRAdvancedMode={isHRAdvancedModeEnabled} + hrFinalApproverEmail={isHRAdvancedModeEnabled ? hrFinalApproverEmail : undefined} + /> + + ); + })} + {hiddenWorkflowsCount > 0 && ( + setIsWorkflowListExpanded(true)} + /> + )} + {!shouldBlockApprovalWorkflowEditing && canWriteApprovals && ( + + )} + )} } diff --git a/src/pages/workspace/workflows/tabs/WorkflowsSectionCard.tsx b/src/pages/workspace/workflows/tabs/WorkflowsSectionCard.tsx index 231b0963b118..66bd1d0ccee1 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsSectionCard.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsSectionCard.tsx @@ -1,6 +1,5 @@ import Section from '@components/Section'; -import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,8 +20,6 @@ function WorkflowsSectionCard(item: ToggleSettingOptionRowProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply a correct padding style // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const {isBetaEnabled} = usePermissions(); - const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); return (
) => samlMeta * It filters the domain properties for keys starting with the admin permissions prefix * and returns the values as an array of numbers. * + * The same account can be listed under more than one permission key: the backend keys them by index, while an + * optimistic grant keys them by accountID, so both entries point at that admin until the domain is refetched. Callers + * identify an admin by accountID, so the duplicate is collapsed here. + * * @param domain - The domain object from Onyx * @returns An array of admin account IDs */ function adminAccountIDsSelector(domain: OnyxEntry): number[] { if (!domain) { - return []; + return getEmptyArray(); } - return ( - Object.entries(domain).reduce((acc, [key, value]) => { - if (!key.startsWith(CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) { - return acc; - } + const accountIDs = Object.entries(domain).reduce>((acc, [key, value]) => { + if (!key.startsWith(CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) { + return acc; + } - acc.push(Number(value)); + acc.add(Number(value)); - return acc; - }, []) ?? getEmptyArray() - ); + return acc; + }, new Set()); + + return accountIDs.size > 0 ? [...accountIDs] : getEmptyArray(); } const technicalContactSettingsSelector = (domainMemberSharedNVP: OnyxEntry) => { @@ -218,6 +222,29 @@ function hasPendingAdminshipRequestSelector(accountID: number | undefined) { return (domain: OnyxEntry): boolean => !!accountID && !!domain?.domain_adminRequesters?.[accountID]; } +/** + * Extracts the accountIDs of all pending domain adminship requesters from the domain object. + */ +function pendingAdminRequesterAccountIDsSelector(domain: OnyxEntry): number[] { + if (!domain?.domain_adminRequesters) { + return getEmptyArray(); + } + + const accountIDs = Object.entries(domain.domain_adminRequesters).reduce((acc, [accountID, value]) => { + if (!value) { + return acc; + } + + acc.push(Number(accountID)); + + return acc; + }, []); + + return accountIDs.length > 0 ? accountIDs : getEmptyArray(); +} + +const adminshipRequesterPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.adminshipRequester ?? {}; + /** Creates a selector that extracts the pending action for a security group's setting */ function domainSecurityGroupSettingPendingActionSelector(settingName: keyof DomainSecurityGroupPendingActions, groupID?: string) { return (domainPendingActions: OnyxEntry) => { @@ -261,6 +288,8 @@ export { accountLockSelector, isAdminSelector, hasPendingAdminshipRequestSelector, + pendingAdminRequesterAccountIDsSelector, + adminshipRequesterPendingActionSelector, selectGroupByID, domainSecurityGroupSettingPendingActionSelector, domainSecurityGroupSettingErrorsSelector, diff --git a/src/selectors/PersonalDetails.ts b/src/selectors/PersonalDetails.ts index 48f8a18b255e..060c5bd68408 100644 --- a/src/selectors/PersonalDetails.ts +++ b/src/selectors/PersonalDetails.ts @@ -94,11 +94,28 @@ const isOptimisticPersonalDetailSelector = return isPersonalDetailOptimistic(personalDetailsList[accountID]); }; +/** + * Returns only the personal details that were created optimistically. The optimistic set is tiny compared to the whole + * personal details list, so subscribers using it don't re-render every time an unrelated (server-backed) detail changes. + */ +const optimisticPersonalDetailsSelector = (personalDetailsList: OnyxEntry): PersonalDetailsList => { + const optimisticPersonalDetails: PersonalDetailsList = {}; + for (const [accountID, personalDetail] of Object.entries(personalDetailsList ?? {})) { + if (!personalDetail?.isOptimisticPersonalDetail) { + continue; + } + optimisticPersonalDetails[accountID] = personalDetail; + } + return optimisticPersonalDetails; +}; + const newAccountIDsAndLoginsSelector = (invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs | undefined) => (personalDetailsList: OnyxEntry) => getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, personalDetailsList); const displayNameSelector = (personalDetails: PersonalDetails | undefined) => personalDetails?.displayName; +const accountIDSelector = (personalDetails: PersonalDetails | undefined) => personalDetails?.accountID; + export { avatarStyleColorSelector, personalDetailsSelector, @@ -111,7 +128,9 @@ export { doesPersonalDetailExistSelector, accountIDToLoginSelector, isOptimisticPersonalDetailSelector, + optimisticPersonalDetailsSelector, createDisplayDetailsByAccountIDsSelector, newAccountIDsAndLoginsSelector, displayNameSelector, + accountIDSelector, }; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index d4cee0003629..81d7d5886c2f 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -38,7 +38,8 @@ type ReusablePolicyConnectionName = | typeof CONST.POLICY.CONNECTIONS.NAME.QBD | typeof CONST.POLICY.CONNECTIONS.NAME.CERTINIA | typeof CONST.POLICY.CONNECTIONS.NAME.RILLET - | typeof CONST.POLICY.CONNECTIONS.NAME.DUALENTRY; + | typeof CONST.POLICY.CONNECTIONS.NAME.DUALENTRY + | typeof CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE; const ownerPoliciesSelector = (policies: OnyxCollection, currentUserAccountID: number) => getOwnedPaidPolicies(policies, currentUserAccountID); @@ -113,6 +114,14 @@ const createWorkspaceListPoliciesSelector = const isArchived = isArchivedPolicy(policy); const isJoinRequestPending = !!policy.isJoinRequestPending && !!policy.policyDetailsForNonMembers; + + // A `policy_` record is merged field-by-field, so a freshly joined workspace can show up here before its + // `id` has landed. Such a row has no key, no avatar seed and nothing to navigate to, so skip it until the + // next update fills it in. Join requests are exempt because they carry their ID in `nonMemberDetails`. + if (!policy.id && !isJoinRequestPending) { + continue; + } + let nonMemberDetails: WorkspaceListPolicy['nonMemberDetails']; if (isJoinRequestPending) { const nonMemberEntry = Object.entries(policy.policyDetailsForNonMembers ?? {}).at(0); @@ -405,6 +414,9 @@ const adminPoliciesConnectedToRilletSelector = (policies: OnyxCollection const adminPoliciesConnectedToDualEntrySelector = (policies: OnyxCollection) => Object.values(policies ?? {}).filter((policy): policy is Policy => isAdminPolicyConnectedTo(policy, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY)); +const adminPoliciesConnectedToCampfireSelector = (policies: OnyxCollection) => + Object.values(policies ?? {}).filter((policy): policy is Policy => isAdminPolicyConnectedTo(policy, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE)); + const reusableConnectionAdminSelectors: Record) => Policy[]> = { [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: adminPoliciesConnectedToNetSuiteSelector, [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: adminPoliciesConnectedToSageIntacctSelector, @@ -412,6 +424,7 @@ const reusableConnectionAdminSelectors: Record // cspell:disable-next-line const WORKSPACE_TRANSLATIONS = 'Workspace|Espacio de trabajo|Espace de travail|Spazio di lavoro|ワークスペース|Werkruimte|Przestrzeń robocza|Espaço de trabalho|工作区'; -function lastWorkspaceNumberSelector(policies: OnyxCollection, email: string): number | undefined { +function lastWorkspaceNumberSelector(policies: OnyxCollection, email: string, userDisplayName: string | undefined): number | undefined { const emailParts = email.split('@'); if (emailParts.length !== 2) { return undefined; } - const displayNameForWorkspace = getDisplayNameForWorkspace(email); + const displayNameForWorkspace = getDisplayNameForWorkspace(email, userDisplayName); // find default named workspaces and increment the last number const escapedName = escapeRegExp(displayNameForWorkspace); diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index f39b57423190..9cb7714a62a7 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -314,7 +314,6 @@ function getStableReportSelector(report: OnyxEntry) { privateNotes: report.privateNotes, fieldList: report.fieldList, permissions: getStablePermissions(report.permissions), - tripData: report.tripData, welcomeMessage: report.welcomeMessage, nextStep: report.nextStep, pendingAction: report.pendingAction, diff --git a/src/selectors/SearchFilters.ts b/src/selectors/SearchFilters.ts new file mode 100644 index 000000000000..ffa126f46f4d --- /dev/null +++ b/src/selectors/SearchFilters.ts @@ -0,0 +1,11 @@ +import {getLastSearchQuery} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import type SearchFilters from '@src/types/onyx/SearchFilters'; + +import type {OnyxEntry} from 'react-native-onyx'; + +const lastExpensesSearchQuerySelector = (searchFilters: OnyxEntry): string | undefined => getLastSearchQuery(searchFilters, CONST.SEARCH.SEARCH_KEYS.EXPENSES); + +// eslint-disable-next-line import/prefer-default-export -- additional selectors may be added here +export {lastExpensesSearchQuerySelector}; diff --git a/src/setup/index.ts b/src/setup/index.ts index e720e3ab78ef..6ec4453f8a68 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,6 +1,8 @@ -import '@libs/Middleware/register'; +import cleanupPreMountedDraftReports from '@libs/cleanupPreMountedDraftReports'; import {finishCloudflareSignInFromURL} from '@libs/CloudflareAccess/finishSignInFromURL'; import intlPolyfill from '@libs/IntlPolyfill'; +import registerMiddlewares from '@libs/Middleware/register'; +import {startMainQueue} from '@libs/Network'; import registerReportActionsPagination from '@libs/registerReportActionsPagination'; import {setDeviceID} from '@userActions/Device'; @@ -21,6 +23,8 @@ import telemetry from './telemetry'; const enableDevTools = Config?.USE_REDUX_DEVTOOLS === 'true'; export default function () { + registerMiddlewares(); + telemetry(); toSortedPolyfill.shim(); @@ -61,6 +65,8 @@ export default function () { // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, + // Without a default this server-owned NVP has no row until it arrives, and its loading status holds the Search router behind a skeleton + [ONYXKEYS.RECENT_SEARCHES]: {}, }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], @@ -71,24 +77,30 @@ export default function () { ONYXKEYS.RAM_ONLY_IS_PRODUCT_MARKETING_WINDOW_COVERED, ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, + ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL, ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, + ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, + ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE, ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, + ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, ], }); + cleanupPreMountedDraftReports(); + // Register the commands after Onyx is initialized so every JS runtime can process paginated // responses. Initial snapshots remain asynchronous and gate only pagination, not app startup. registerReportActionsPagination(); @@ -102,6 +114,8 @@ export default function () { // Onyx.init() because a completed exchange persists the session. No-op on every other load. finishCloudflareSignInFromURL(); + startMainQueue(); + initOnyxDerivedValues(); setDeviceID(); diff --git a/src/setup/telemetry/setupSentry.ts b/src/setup/telemetry/setupSentry.ts index fded8ed2ca86..28aa12f4d49c 100644 --- a/src/setup/telemetry/setupSentry.ts +++ b/src/setup/telemetry/setupSentry.ts @@ -64,6 +64,10 @@ function setupSentry(): void { // They carry no stack frames for thirdPartyErrorFilterIntegration to tag; the prefix limits this to the // browser SDK's rejection wording, so a real Error carrying the same text still reports. /^Non-Error promise rejection captured with value: No data found for key/, + // Uncaught IndexedDB rejection from the same Convert script, which inserts into its own database with + // `add()` instead of `put()` and fails whenever the key is already there. Onyx never calls `add()`, so a + // real Onyx write cannot produce this, and the DOMException carries no frames to tag as third-party. + /^ConstraintError: Key already exists in the object store/, ], denyUrls: EXTENSION_DENY_URLS, beforeSendTransaction: processBeforeSendTransactions, diff --git a/src/stories/Button.stories.tsx b/src/stories/Button.stories.tsx deleted file mode 100755 index dfb66dab494c..000000000000 --- a/src/stories/Button.stories.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type {ButtonProps} from '@components/Button'; -import Button from '@components/Button'; -import Text from '@components/Text'; - -import type {Meta, StoryFn} from 'storybook-react-rsbuild'; - -import React, {useState} from 'react'; -import {View} from 'react-native'; - -type ButtonStory = StoryFn; - -/** - * We use the Component Story Format for writing stories. Follow the docs here: - * - * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format - */ -const story: Meta = { - title: 'Components/Button', - component: Button, -}; - -function Template(props: ButtonProps) { - return , ); - const onPress = jest.fn(); - const getButton = () => screen.getByRole(CONST.ROLE.BUTTON, {name: buttonText}); + + /** Locate the button element by its ARIA role and accessible name. */ + const getButton = () => screen.getByRole(CONST.ROLE.BUTTON, {name: LABEL}); afterEach(() => { jest.clearAllMocks(); }); - it('renders correctly with default text', () => { - // Given the component is rendered - renderButton(); + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders children inside the button', () => { + // Given a Button with a text child + renderButton({}, hello world); + + // Then the child appears on screen + expect(screen.getByText('hello world')).toBeOnTheScreen(); + }); + + it('renders without children gracefully', () => { + // Given a Button with no children at all + renderButton({}, null); + + // Then the button itself is still present + expect(getButton()).toBeOnTheScreen(); + }); + + it('renders an ActivityIndicator while loading', () => { + // Given a Button in loading state + const renderResult = renderButton({isLoading: true}); + + // Then a spinner is present to signal the in-progress state + expect(renderResult.UNSAFE_getByType(RNActivityIndicator)).toBeDefined(); + }); + + it('applies the default background color', () => { + // Given a Button with default props + renderButton(); + + // Then the pressable element carries the theme's default button background + expect(screen.getByLabelText(LABEL)).toHaveStyle({ + backgroundColor: colors.productDark400, + }); + }); + + it('applies innerStyles to the pressable element', () => { + // Given a Button with a custom innerStyle + renderButton({innerStyles: {width: '80%'}}); + + // Then the style is merged into the pressable + expect(screen.getByLabelText(LABEL)).toHaveStyle({width: '80%'}); + }); + }); + + // ── ButtonContext ─────────────────────────────────────────────────────────── + // + // Button/Button is the sole owner of ButtonContext. These tests verify + // that every prop the context exposes reaches children correctly. + + describe('ButtonContext', () => { + it('provides sensible defaults: size=medium, no variant, not loading, not disabled, not hovered', () => { + // Given a Button with no extra props + renderButton(); + + // Then context reflects the expected initial state + expect(screen.getByTestId('ctx-size')).toHaveTextContent(CONST.BUTTON_SIZE.MEDIUM); + expect(screen.getByTestId('ctx-variant')).toHaveTextContent('none'); + expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false'); + expect(screen.getByTestId('ctx-isDisabled')).toHaveTextContent('false'); + expect(screen.getByTestId('ctx-isLoading')).toHaveTextContent('false'); + }); + + it('propagates isDisabled to children via context', () => { + renderButton({isDisabled: true}); + expect(screen.getByTestId('ctx-isDisabled')).toHaveTextContent('true'); + }); + + it('propagates isLoading to children via context', () => { + renderButton({isLoading: true}); + expect(screen.getByTestId('ctx-isLoading')).toHaveTextContent('true'); + }); + + it.each(['success', 'danger'] as const)('propagates variant="%s" to children via context', (variant) => { + renderButton({variant}); + expect(screen.getByTestId('ctx-variant')).toHaveTextContent(variant); + }); + + it.each([CONST.BUTTON_SIZE.SMALL, CONST.BUTTON_SIZE.LARGE] as const)('propagates size="%s" to children via context', (size) => { + renderButton({size}); + expect(screen.getByTestId('ctx-size')).toHaveTextContent(size); + }); + + it('toggles isHovered on hoverIn / hoverOut', () => { + // Given a rendered Button + renderButton(); + + // When the pointer enters + fireEvent(getButton(), 'hoverIn'); + + // Then children see isHovered=true + expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('true'); + + // When the pointer leaves + fireEvent(getButton(), 'hoverOut'); + + // Then children see isHovered=false again + expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false'); + }); + + it('suppresses isHovered when isDisabled and stayNormalOnDisable are both true', () => { + // When both flags are set, onHoverIn/Out are not wired up at all. + renderButton({isDisabled: true, stayNormalOnDisable: true}); + + fireEvent(getButton(), 'hoverIn'); + + expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false'); + }); + }); + + // ── Press handling ───────────────────────────────────────────────────────── + + describe('press handling', () => { + it('calls onPress when pressed', () => { + // Given a Button with an onPress handler + renderButton({onPress}); + + // When the button is pressed + fireEvent.press(getButton()); + + // Then onPress is called exactly once + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPressIn when the button press begins', () => { + // Given a Button with an onPressIn handler + const onPressIn = jest.fn(); + renderButton({onPressIn}); + + // When the user starts pressing + fireEvent(getButton(), 'pressIn'); + + // Then onPressIn fires immediately + expect(onPressIn).toHaveBeenCalledTimes(1); + }); + + it('calls onPressOut when the button press ends', () => { + // Given a Button with an onPressOut handler + const onPressOut = jest.fn(); + renderButton({onPressOut}); + + // When the user releases + fireEvent(getButton(), 'pressOut'); + + // Then onPressOut fires + expect(onPressOut).toHaveBeenCalledTimes(1); + }); + + it('blocks onPress when isDisabled is true', () => { + // Given a disabled Button + renderButton({isDisabled: true, onPress}); + + // When the button is pressed + fireEvent.press(getButton()); + + // Then onPress is not called + expect(onPress).not.toHaveBeenCalled(); + }); + + it('blocks onPress when isLoading is true', () => { + // Given a loading Button + renderButton({isLoading: true, onPress}); + + // When the button is pressed + fireEvent.press(getButton()); + + // Then onPress is not called + expect(onPress).not.toHaveBeenCalled(); + }); + + it('calls onLongPress when long-pressed', () => { + // Given a Button with an onLongPress handler + renderButton({onLongPress}); + + // When the button is long-pressed + fireEvent(getButton(), 'longPress'); + + // Then onLongPress is called exactly once + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it('blocks onLongPress when isLongPressDisabled is true', () => { + // Given a Button with long-press disabled + renderButton({onLongPress, isLongPressDisabled: true}); + + // When the button is long-pressed + fireEvent(getButton(), 'longPress'); + + // Then onLongPress is not called + expect(onLongPress).not.toHaveBeenCalled(); + }); + }); + + // ── Disabled-state styles ────────────────────────────────────────────────── + + describe('disabled-state styles', () => { + it.each<{variant: ButtonVariant | undefined; label: string}>([ + {variant: undefined, label: 'none'}, + {variant: 'success', label: 'success'}, + {variant: 'danger', label: 'danger'}, + ])('applies opacity:0.5 when isDisabled (variant=$label)', ({variant}) => { + renderButton({isDisabled: true, variant}); + expect(screen.getByLabelText(LABEL)).toHaveStyle({opacity: 0.5}); + }); + + it('keeps the button looking active when stayNormalOnDisable is true', () => { + // Real-world use-case: a "Save" button that is functionally disabled + // (e.g., while a form is being validated) but must not look grayed-out to + // avoid confusing the user. stayNormalOnDisable preserves the normal + // appearance while the button remains not clickable. + renderButton({isDisabled: true, stayNormalOnDisable: true}); + + // Then there is no opacity reduction — the button looks fully active + expect(screen.getByLabelText(LABEL)).not.toHaveStyle({opacity: 0.5}); + }); + + it('applies a custom disabledStyle when isDisabled is true', () => { + renderButton({isDisabled: true, disabledStyle: {borderWidth: 2}}); + expect(screen.getByLabelText(LABEL)).toHaveStyle({borderWidth: 2}); + }); + }); + + // ── Variant styles ──────────────────────────────────────────────────────── + // + // Each variant must apply the correct background color from the theme. + // These are the colors that users see when they look at different button types. + + describe('variant styles', () => { + it.each([ + {variant: 'success' as const, expectedBg: colors.green400}, + {variant: 'danger' as const, expectedBg: colors.red}, + ])('variant="$variant" applies the correct background color', ({variant, expectedBg}) => { + renderButton({variant}); + expect(screen.getByLabelText(LABEL)).toHaveStyle({ + backgroundColor: expectedBg, + }); + }); - // Then the default text is displayed - expect(screen.getByText(buttonText)).toBeOnTheScreen(); + // The transparent-background invariant for link-style buttons now lives in the + // Link component (see tests/ui/components/Link.tsx) — Button itself no longer + // exposes a 'link' variant. + + it('no variant uses the default button background', () => { + // Implicit check that nothing accidentally overrides the base background. + renderButton(); + expect(screen.getByLabelText(LABEL)).toHaveStyle({ + backgroundColor: colors.productDark400, + }); + }); }); - it('renders without text gracefully', () => { - // Given the component is rendered without text - renderButton({text: undefined}); + // ── Size styles ─────────────────────────────────────────────────────────── + // + // Each size token must produce the correct minHeight and horizontal padding. + // Wrong values here would break the visual rhythm of forms and toolbars. - // Then the button is not displayed - expect(screen.queryByText(buttonText)).not.toBeOnTheScreen(); + describe('size styles', () => { + // getButtonSizeStyle returns the base buttonSmall/Medium/Large styles with paddingHorizontal lower by 4px + it.each([ + { + size: CONST.BUTTON_SIZE.SMALL, + minHeight: variables.componentSizeSmall, + paddingHorizontal: 8, + }, + { + size: CONST.BUTTON_SIZE.MEDIUM, + minHeight: variables.componentSizeNormal, + paddingHorizontal: 12, + }, + { + size: CONST.BUTTON_SIZE.LARGE, + minHeight: variables.componentSizeLarge, + paddingHorizontal: 16, + }, + ])('size="$size" applies minHeight=$minHeight and paddingHorizontal=$paddingHorizontal', ({size, minHeight, paddingHorizontal}) => { + renderButton({size}); + expect(screen.getByLabelText(LABEL)).toHaveStyle({ + minHeight, + paddingHorizontal, + }); + }); }); - it('handles press event correctly', () => { - // Given the component is rendered with an onPress function - renderButton({onPress}); + // ── removeBorderRadius ────────────────────────────────────────────── + // + // When Button is placed in a ButtonGroup, one or both ends must be squared + // off so adjacent buttons sit flush. This verifies that the correct corners + // are zeroed out for each option. + + describe('removeBorderRadius', () => { + it.each([ + { + label: 'right', + removeBorderRadius: 'right' as const, + expectedStyle: {borderTopRightRadius: 0, borderBottomRightRadius: 0}, + }, + { + label: 'left', + removeBorderRadius: 'left' as const, + expectedStyle: {borderTopLeftRadius: 0, borderBottomLeftRadius: 0}, + }, + { + label: 'all', + removeBorderRadius: 'all' as const, + expectedStyle: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }, + ])('removeBorderRadius="$label" zeros out the correct corners', ({removeBorderRadius, expectedStyle}) => { + renderButton({removeBorderRadius}); + expect(screen.getByLabelText(LABEL)).toHaveStyle(expectedStyle); + }); + }); + + // ── Hover styles ────────────────────────────────────────────────────────── + // + // The button must change appearance while the pointer is over it and return + // to normal when the pointer leaves. + + describe('hover styles', () => { + it('applies the default hover background on hover', () => { + // Given a default Button + renderButton(); - // When the button is pressed - fireEvent.press(getButton()); + // When the pointer enters + fireEvent(getButton(), 'hoverIn'); - // Then the onPress function should be called - expect(onPress).toHaveBeenCalledTimes(1); + // Then the button shows the hover background + expect(screen.getByLabelText(LABEL)).toHaveStyle({ + backgroundColor: colors.productDark500, + }); + }); + + it.each([ + {variant: 'success' as const, expectedHoverBg: colors.greenHover}, + {variant: 'danger' as const, expectedHoverBg: colors.redHover}, + ])('variant="$variant" applies its dedicated hover background when hovered', ({variant, expectedHoverBg}) => { + // Variant-specific hover styles override the default hover background so that + // success stays green and danger stays red even when highlighted. + renderButton({variant}); + fireEvent(getButton(), 'hoverIn'); + expect(screen.getByLabelText(LABEL)).toHaveStyle({ + backgroundColor: expectedHoverBg, + }); + }); }); - it('renders loading state', () => { - // Given the component is rendered with isLoading - renderButton({isLoading: true}); + // ── Accessibility ────────────────────────────────────────────────────────── + + describe('accessibility', () => { + it('exposes the button via accessibilityLabel', () => { + // Given a Button is rendered + renderButton(); + + // Then it is reachable via its label (used by screen readers and getByLabelText) + expect(screen.getByLabelText(LABEL)).toBeOnTheScreen(); + }); + + it('forwards accessibilityState to the underlying pressable', () => { + // Given a Button with a custom accessibilityState + renderButton({accessibilityState: {selected: true}}); + + // Then the state is forwarded verbatim to the pressable element + expect(getButton().props.accessibilityState).toMatchObject({ + selected: true, + }); + }); + + it('forwards testID to the underlying pressable', () => { + // Given a Button with a testID + renderButton({testID: 'my-test-id'}); - // Then the loading state is displayed - expect(screen.getByText(buttonText)).not.toBeVisible(); + // Then the element is findable by that testID + expect(screen.getByTestId('my-test-id')).toBeOnTheScreen(); + }); + + it('forwards id to the underlying pressable', () => { + // Given a Button with an id + renderButton({id: 'my-button-id'}); + + // Then the id appears on the accessible element + expect(getButton().props.id).toBe('my-button-id'); + }); + + it('maintains button role when isNested is true', () => { + // isNested signals a button-in-button context (invalid HTML), but the role + // stays as BUTTON so the element remains accessible to screen readers. + renderButton({isNested: true}); + expect(getButton()).toBeOnTheScreen(); + }); + + it('forwards sentryLabel to the underlying pressable', () => { + // Given a Button with a Sentry label (used for performance tracking) + // Note: in the test environment NativeGenericPressable is loaded (not WebGenericPressable), + // so sentryLabel flows through {...rest} directly to the native Pressable as a prop, + // rather than being merged into dataSet (which is the web-only path). + renderButton({sentryLabel: 'submit-button'}); + + expect(getButton().props.sentryLabel).toBe('submit-button'); + }); + + it('forwards ref to the underlying pressable', () => { + // Given a ref attached to the Button + const ref = React.createRef(); + renderButton({ref}); + + // Then the ref is populated after mount (non-null means the native element + // is reachable for imperative calls such as .measure() or .focus()) + expect(ref.current).not.toBeNull(); + }); }); - it('disables button when isDisabled is true', () => { - // Given the component is rendered with isDisabled set to true - renderButton({isDisabled: true, onPress}); + // ── Event handlers ──────────────────────────────────────────────────────── - // When the button is pressed - fireEvent.press(getButton()); + describe('event handlers', () => { + it('calls onMouseDown when a mousedown event fires', () => { + // Given a Button with an onMouseDown handler (e.g. to prevent focus steal) + const onMouseDown = jest.fn(); + renderButton({onMouseDown}); - // Then the onPress function should not be called - expect(onPress).not.toHaveBeenCalled(); + // When a mousedown event fires on the button + fireEvent(getButton(), 'mouseDown'); + + // Then onMouseDown is called + expect(onMouseDown).toHaveBeenCalledTimes(1); + }); + + it('calls onLayout when the component measures itself', () => { + // Given a Button with an onLayout handler (e.g. to position a dropdown) + const onLayout = jest.fn(); + renderButton({onLayout}); + + // When a layout event fires + fireEvent(getButton(), 'layout', { + nativeEvent: {layout: {width: 200, height: 48, x: 0, y: 0}}, + }); + + // Then onLayout is called + expect(onLayout).toHaveBeenCalledTimes(1); + }); }); - it('sets accessibility label correctly', () => { - // Given the component is rendered with an accessibility label - renderButton({accessibilityLabel}); + // ── Haptic feedback ─────────────────────────────────────────────────────── + + describe('haptic feedback', () => { + it('triggers HapticFeedback.press on press when enableHapticFeedback is true', () => { + // Given a Button with haptic feedback enabled (e.g. a primary CTA on mobile) + renderButton({enableHapticFeedback: true}); + + // When the button is pressed + fireEvent.press(getButton()); - // Then the button should be accessible using the provided label - expect(screen.getByLabelText(accessibilityLabel)).toBeOnTheScreen(); + // Then the haptic press pulse fires + expect(HapticFeedback.press).toHaveBeenCalledTimes(1); + }); + + it('does not trigger haptic feedback on press by default', () => { + // Given a Button without haptic feedback + renderButton(); + + // When pressed + fireEvent.press(getButton()); + + // Then no haptic feedback fires + expect(HapticFeedback.press).not.toHaveBeenCalled(); + }); + + it('triggers HapticFeedback.longPress on long press when enableHapticFeedback is true', () => { + // Given a Button with haptic feedback enabled + renderButton({enableHapticFeedback: true}); + + // When the button is long-pressed + fireEvent(getButton(), 'longPress'); + + // BaseGenericPressable fires HapticFeedback.longPress once by default + // (shouldUseHapticsOnLongPress=true). Button.tsx adds a second call when + // enableHapticFeedback=true, so the total is 2. + expect(HapticFeedback.longPress).toHaveBeenCalledTimes(2); + }); }); - it('applies custom styles correctly', () => { - // Given the component is rendered with custom styles - renderButton({accessibilityLabel, innerStyles: {width: '100%'}}); + // ── Layout and composition ──────────────────────────────────────────────── + + describe('layout and composition', () => { + it('applies custom style to the outer wrapper', () => { + // Given a Button with a custom outer style (e.g. margin for spacing) + const renderResult = renderButton({style: {margin: 10}}); + + // `style` maps to PressableWithFeedback's wrapperStyle on the outer OpacityView. + // We find it by scanning for any host View in the tree that carries the margin. + const wrapperView = renderResult.UNSAFE_getAllByType(View).find((v) => { + const flatStyle: unknown = StyleSheet.flatten(v.props.style ?? []); + return typeof flatStyle === 'object' && flatStyle !== null && 'margin' in flatStyle && flatStyle.margin === 10; + }); + expect(wrapperView).toBeDefined(); + }); + + it('applies contentContainerStyle to the content wrapper', () => { + // Given a Button with a custom contentContainerStyle + const renderResult = renderButton({ + contentContainerStyle: {paddingTop: 8}, + }); + + // contentContainerStyle is applied to the flexRow View wrapping all children. + // We find it by scanning host Views for the custom padding. + const contentWrapper = renderResult.UNSAFE_getAllByType(View).find((v) => { + const flatStyle: unknown = StyleSheet.flatten(v.props.style ?? []); + return typeof flatStyle === 'object' && flatStyle !== null && 'paddingTop' in flatStyle && flatStyle.paddingTop === 8; + }); + expect(contentWrapper).toBeDefined(); + }); + + it('renders a full-size absolute overlay when blendOpacity is true', () => { + // Button.tsx renders + // when blendOpacity=true — an overlay that lets content beneath show through. + const renderResult = renderButton({blendOpacity: true}); + + const overlayView = renderResult.UNSAFE_getAllByType(View).find((v) => { + const flatStyle: unknown = StyleSheet.flatten(v.props.style ?? []); + return typeof flatStyle === 'object' && flatStyle !== null && 'position' in flatStyle && flatStyle.position === 'absolute'; + }); + + expect(overlayView).toBeDefined(); - // Then the button should have the custom styles - const buttonContainer = screen.getByLabelText(accessibilityLabel); - expect(buttonContainer).toHaveStyle({backgroundColor: colors.productDark400}); - expect(buttonContainer).toHaveStyle({width: '100%'}); + // The overlay must cover the full button — verify absoluteFill dimensions + const overlayStyle: unknown = StyleSheet.flatten(overlayView?.props.style ?? []); + expect(overlayStyle).toMatchObject({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }); + }); }); }); diff --git a/tests/ui/components/ButtonKeyboardShortcut.tsx b/tests/ui/components/ButtonKeyboardShortcut.tsx index 3b312364cb98..24d01bfd76a9 100644 --- a/tests/ui/components/ButtonKeyboardShortcut.tsx +++ b/tests/ui/components/ButtonKeyboardShortcut.tsx @@ -1,8 +1,8 @@ import {render} from '@testing-library/react-native'; -import Button from '@src/components/ButtonComposed/Button'; -import ButtonKeyboardShortcut from '@src/components/ButtonComposed/primitives/ButtonKeyboardShortcut'; -import type {ButtonKeyboardShortcutProps, ButtonProps} from '@src/components/ButtonComposed/types'; +import Button from '@src/components/Button/Button'; +import ButtonKeyboardShortcut from '@src/components/Button/primitives/ButtonKeyboardShortcut'; +import type {ButtonKeyboardShortcutProps, ButtonProps} from '@src/components/Button/types'; import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; @@ -13,7 +13,7 @@ import React from 'react'; // events does not reach registered handlers. Instead the mock below captures // the callback and config that ButtonKeyboardShortcut passes to // useKeyboardShortcut, letting tests invoke the callback directly — the same -// pattern used across the ButtonComposed test suite. +// pattern used across the Button test suite. let enterKeyCallback: ((event?: KeyboardEvent) => void) | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any let capturedShortcutConfig: Record | undefined; diff --git a/tests/ui/components/ComposedButton.tsx b/tests/ui/components/ComposedButton.tsx deleted file mode 100644 index 06a31e62ce8e..000000000000 --- a/tests/ui/components/ComposedButton.tsx +++ /dev/null @@ -1,605 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; - -import HapticFeedback from '@libs/HapticFeedback'; - -import colors from '@styles/theme/colors'; -import type {ButtonVariant} from '@styles/utils/types'; -import variables from '@styles/variables'; - -import type {ButtonProps} from '@src/components/ButtonComposed'; -import Button from '@src/components/ButtonComposed'; -import {useButtonContext} from '@src/components/ButtonComposed/context'; -import CONST from '@src/CONST'; - -import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import {ActivityIndicator as RNActivityIndicator, StyleSheet, Text, View} from 'react-native'; - -jest.mock('@libs/HapticFeedback', () => ({ - press: jest.fn(), - longPress: jest.fn(), - success: jest.fn(), - error: jest.fn(), -})); - -const LABEL = 'test-button'; - -/** - * Reads ButtonContext and exposes each value as a testID'd Text node so that - * assertions can verify exactly what Button propagates to its children. - */ -function ContextReadout() { - const {variant, size, isHovered, isDisabled, isLoading} = useButtonContext(); - return ( - - {variant ?? 'none'} - {size} - {String(isHovered)} - {String(isDisabled)} - {String(isLoading)} - - ); -} - -describe('ButtonComposed — Button', () => { - const onPress = jest.fn(); - const onLongPress = jest.fn(); - - /** Renders the component with a consistent accessibilityLabel for easy querying. */ - const renderButton = (props: Omit, 'children'> = {}, children: React.ReactNode = ) => - render( - , - ); - - /** Locate the button element by its ARIA role and accessible name. */ - const getButton = () => screen.getByRole(CONST.ROLE.BUTTON, {name: LABEL}); - - afterEach(() => { - jest.clearAllMocks(); - }); - - // ── Rendering ────────────────────────────────────────────────────────────── - - describe('rendering', () => { - it('renders children inside the button', () => { - // Given a Button with a text child - renderButton({}, hello world); - - // Then the child appears on screen - expect(screen.getByText('hello world')).toBeOnTheScreen(); - }); - - it('renders without children gracefully', () => { - // Given a Button with no children at all - renderButton({}, null); - - // Then the button itself is still present - expect(getButton()).toBeOnTheScreen(); - }); - - it('renders an ActivityIndicator while loading', () => { - // Given a Button in loading state - const renderResult = renderButton({isLoading: true}); - - // Then a spinner is present to signal the in-progress state - expect(renderResult.UNSAFE_getByType(RNActivityIndicator)).toBeDefined(); - }); - - it('applies the default background color', () => { - // Given a Button with default props - renderButton(); - - // Then the pressable element carries the theme's default button background - expect(screen.getByLabelText(LABEL)).toHaveStyle({ - backgroundColor: colors.productDark400, - }); - }); - - it('applies innerStyles to the pressable element', () => { - // Given a Button with a custom innerStyle - renderButton({innerStyles: {width: '80%'}}); - - // Then the style is merged into the pressable - expect(screen.getByLabelText(LABEL)).toHaveStyle({width: '80%'}); - }); - }); - - // ── ButtonContext ─────────────────────────────────────────────────────────── - // - // ButtonComposed/Button is the sole owner of ButtonContext. These tests verify - // that every prop the context exposes reaches children correctly. - - describe('ButtonContext', () => { - it('provides sensible defaults: size=medium, no variant, not loading, not disabled, not hovered', () => { - // Given a Button with no extra props - renderButton(); - - // Then context reflects the expected initial state - expect(screen.getByTestId('ctx-size')).toHaveTextContent(CONST.BUTTON_SIZE.MEDIUM); - expect(screen.getByTestId('ctx-variant')).toHaveTextContent('none'); - expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false'); - expect(screen.getByTestId('ctx-isDisabled')).toHaveTextContent('false'); - expect(screen.getByTestId('ctx-isLoading')).toHaveTextContent('false'); - }); - - it('propagates isDisabled to children via context', () => { - renderButton({isDisabled: true}); - expect(screen.getByTestId('ctx-isDisabled')).toHaveTextContent('true'); - }); - - it('propagates isLoading to children via context', () => { - renderButton({isLoading: true}); - expect(screen.getByTestId('ctx-isLoading')).toHaveTextContent('true'); - }); - - it.each(['success', 'danger'] as const)('propagates variant="%s" to children via context', (variant) => { - renderButton({variant}); - expect(screen.getByTestId('ctx-variant')).toHaveTextContent(variant); - }); - - it.each([CONST.BUTTON_SIZE.SMALL, CONST.BUTTON_SIZE.LARGE] as const)('propagates size="%s" to children via context', (size) => { - renderButton({size}); - expect(screen.getByTestId('ctx-size')).toHaveTextContent(size); - }); - - it('toggles isHovered on hoverIn / hoverOut', () => { - // Given a rendered Button - renderButton(); - - // When the pointer enters - fireEvent(getButton(), 'hoverIn'); - - // Then children see isHovered=true - expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('true'); - - // When the pointer leaves - fireEvent(getButton(), 'hoverOut'); - - // Then children see isHovered=false again - expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false'); - }); - - it('suppresses isHovered when isDisabled and stayNormalOnDisable are both true', () => { - // When both flags are set, onHoverIn/Out are not wired up at all. - renderButton({isDisabled: true, stayNormalOnDisable: true}); - - fireEvent(getButton(), 'hoverIn'); - - expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false'); - }); - }); - - // ── Press handling ───────────────────────────────────────────────────────── - - describe('press handling', () => { - it('calls onPress when pressed', () => { - // Given a Button with an onPress handler - renderButton({onPress}); - - // When the button is pressed - fireEvent.press(getButton()); - - // Then onPress is called exactly once - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('calls onPressIn when the button press begins', () => { - // Given a Button with an onPressIn handler - const onPressIn = jest.fn(); - renderButton({onPressIn}); - - // When the user starts pressing - fireEvent(getButton(), 'pressIn'); - - // Then onPressIn fires immediately - expect(onPressIn).toHaveBeenCalledTimes(1); - }); - - it('calls onPressOut when the button press ends', () => { - // Given a Button with an onPressOut handler - const onPressOut = jest.fn(); - renderButton({onPressOut}); - - // When the user releases - fireEvent(getButton(), 'pressOut'); - - // Then onPressOut fires - expect(onPressOut).toHaveBeenCalledTimes(1); - }); - - it('blocks onPress when isDisabled is true', () => { - // Given a disabled Button - renderButton({isDisabled: true, onPress}); - - // When the button is pressed - fireEvent.press(getButton()); - - // Then onPress is not called - expect(onPress).not.toHaveBeenCalled(); - }); - - it('blocks onPress when isLoading is true', () => { - // Given a loading Button - renderButton({isLoading: true, onPress}); - - // When the button is pressed - fireEvent.press(getButton()); - - // Then onPress is not called - expect(onPress).not.toHaveBeenCalled(); - }); - - it('calls onLongPress when long-pressed', () => { - // Given a Button with an onLongPress handler - renderButton({onLongPress}); - - // When the button is long-pressed - fireEvent(getButton(), 'longPress'); - - // Then onLongPress is called exactly once - expect(onLongPress).toHaveBeenCalledTimes(1); - }); - - it('blocks onLongPress when isLongPressDisabled is true', () => { - // Given a Button with long-press disabled - renderButton({onLongPress, isLongPressDisabled: true}); - - // When the button is long-pressed - fireEvent(getButton(), 'longPress'); - - // Then onLongPress is not called - expect(onLongPress).not.toHaveBeenCalled(); - }); - }); - - // ── Disabled-state styles ────────────────────────────────────────────────── - - describe('disabled-state styles', () => { - it.each<{variant: ButtonVariant | undefined; label: string}>([ - {variant: undefined, label: 'none'}, - {variant: 'success', label: 'success'}, - {variant: 'danger', label: 'danger'}, - ])('applies opacity:0.5 when isDisabled (variant=$label)', ({variant}) => { - renderButton({isDisabled: true, variant}); - expect(screen.getByLabelText(LABEL)).toHaveStyle({opacity: 0.5}); - }); - - it('keeps the button looking active when stayNormalOnDisable is true', () => { - // Real-world use-case: a "Save" button that is functionally disabled - // (e.g., while a form is being validated) but must not look grayed-out to - // avoid confusing the user. stayNormalOnDisable preserves the normal - // appearance while the button remains not clickable. - renderButton({isDisabled: true, stayNormalOnDisable: true}); - - // Then there is no opacity reduction — the button looks fully active - expect(screen.getByLabelText(LABEL)).not.toHaveStyle({opacity: 0.5}); - }); - - it('applies a custom disabledStyle when isDisabled is true', () => { - renderButton({isDisabled: true, disabledStyle: {borderWidth: 2}}); - expect(screen.getByLabelText(LABEL)).toHaveStyle({borderWidth: 2}); - }); - }); - - // ── Variant styles ──────────────────────────────────────────────────────── - // - // Each variant must apply the correct background color from the theme. - // These are the colors that users see when they look at different button types. - - describe('variant styles', () => { - it.each([ - {variant: 'success' as const, expectedBg: colors.green400}, - {variant: 'danger' as const, expectedBg: colors.red}, - ])('variant="$variant" applies the correct background color', ({variant, expectedBg}) => { - renderButton({variant}); - expect(screen.getByLabelText(LABEL)).toHaveStyle({ - backgroundColor: expectedBg, - }); - }); - - // The transparent-background invariant for link-style buttons now lives in the - // Link component (see tests/ui/components/Link.tsx) — Button itself no longer - // exposes a 'link' variant. - - it('no variant uses the default button background', () => { - // Implicit check that nothing accidentally overrides the base background. - renderButton(); - expect(screen.getByLabelText(LABEL)).toHaveStyle({ - backgroundColor: colors.productDark400, - }); - }); - }); - - // ── Size styles ─────────────────────────────────────────────────────────── - // - // Each size token must produce the correct minHeight and horizontal padding. - // Wrong values here would break the visual rhythm of forms and toolbars. - - describe('size styles', () => { - // getButtonSizeStyle returns the base buttonSmall/Medium/Large styles with paddingHorizontal lower by 4px - it.each([ - { - size: CONST.BUTTON_SIZE.SMALL, - minHeight: variables.componentSizeSmall, - paddingHorizontal: 8, - }, - { - size: CONST.BUTTON_SIZE.MEDIUM, - minHeight: variables.componentSizeNormal, - paddingHorizontal: 12, - }, - { - size: CONST.BUTTON_SIZE.LARGE, - minHeight: variables.componentSizeLarge, - paddingHorizontal: 16, - }, - ])('size="$size" applies minHeight=$minHeight and paddingHorizontal=$paddingHorizontal', ({size, minHeight, paddingHorizontal}) => { - renderButton({size}); - expect(screen.getByLabelText(LABEL)).toHaveStyle({ - minHeight, - paddingHorizontal, - }); - }); - }); - - // ── removeBorderRadius ────────────────────────────────────────────── - // - // When Button is placed in a ButtonGroup, one or both ends must be squared - // off so adjacent buttons sit flush. This verifies that the correct corners - // are zeroed out for each option. - - describe('removeBorderRadius', () => { - it.each([ - { - label: 'right', - removeBorderRadius: 'right' as const, - expectedStyle: {borderTopRightRadius: 0, borderBottomRightRadius: 0}, - }, - { - label: 'left', - removeBorderRadius: 'left' as const, - expectedStyle: {borderTopLeftRadius: 0, borderBottomLeftRadius: 0}, - }, - { - label: 'all', - removeBorderRadius: 'all' as const, - expectedStyle: { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - }, - }, - ])('removeBorderRadius="$label" zeros out the correct corners', ({removeBorderRadius, expectedStyle}) => { - renderButton({removeBorderRadius}); - expect(screen.getByLabelText(LABEL)).toHaveStyle(expectedStyle); - }); - }); - - // ── Hover styles ────────────────────────────────────────────────────────── - // - // The button must change appearance while the pointer is over it and return - // to normal when the pointer leaves. - - describe('hover styles', () => { - it('applies the default hover background on hover', () => { - // Given a default Button - renderButton(); - - // When the pointer enters - fireEvent(getButton(), 'hoverIn'); - - // Then the button shows the hover background - expect(screen.getByLabelText(LABEL)).toHaveStyle({ - backgroundColor: colors.productDark500, - }); - }); - - it.each([ - {variant: 'success' as const, expectedHoverBg: colors.greenHover}, - {variant: 'danger' as const, expectedHoverBg: colors.redHover}, - ])('variant="$variant" applies its dedicated hover background when hovered', ({variant, expectedHoverBg}) => { - // Variant-specific hover styles override the default hover background so that - // success stays green and danger stays red even when highlighted. - renderButton({variant}); - fireEvent(getButton(), 'hoverIn'); - expect(screen.getByLabelText(LABEL)).toHaveStyle({ - backgroundColor: expectedHoverBg, - }); - }); - }); - - // ── Accessibility ────────────────────────────────────────────────────────── - - describe('accessibility', () => { - it('exposes the button via accessibilityLabel', () => { - // Given a Button is rendered - renderButton(); - - // Then it is reachable via its label (used by screen readers and getByLabelText) - expect(screen.getByLabelText(LABEL)).toBeOnTheScreen(); - }); - - it('forwards accessibilityState to the underlying pressable', () => { - // Given a Button with a custom accessibilityState - renderButton({accessibilityState: {selected: true}}); - - // Then the state is forwarded verbatim to the pressable element - expect(getButton().props.accessibilityState).toMatchObject({ - selected: true, - }); - }); - - it('forwards testID to the underlying pressable', () => { - // Given a Button with a testID - renderButton({testID: 'my-test-id'}); - - // Then the element is findable by that testID - expect(screen.getByTestId('my-test-id')).toBeOnTheScreen(); - }); - - it('forwards id to the underlying pressable', () => { - // Given a Button with an id - renderButton({id: 'my-button-id'}); - - // Then the id appears on the accessible element - expect(getButton().props.id).toBe('my-button-id'); - }); - - it('maintains button role when isNested is true', () => { - // isNested signals a button-in-button context (invalid HTML), but the role - // stays as BUTTON so the element remains accessible to screen readers. - renderButton({isNested: true}); - expect(getButton()).toBeOnTheScreen(); - }); - - it('forwards sentryLabel to the underlying pressable', () => { - // Given a Button with a Sentry label (used for performance tracking) - // Note: in the test environment NativeGenericPressable is loaded (not WebGenericPressable), - // so sentryLabel flows through {...rest} directly to the native Pressable as a prop, - // rather than being merged into dataSet (which is the web-only path). - renderButton({sentryLabel: 'submit-button'}); - - expect(getButton().props.sentryLabel).toBe('submit-button'); - }); - - it('forwards ref to the underlying pressable', () => { - // Given a ref attached to the Button - const ref = React.createRef(); - renderButton({ref}); - - // Then the ref is populated after mount (non-null means the native element - // is reachable for imperative calls such as .measure() or .focus()) - expect(ref.current).not.toBeNull(); - }); - }); - - // ── Event handlers ──────────────────────────────────────────────────────── - - describe('event handlers', () => { - it('calls onMouseDown when a mousedown event fires', () => { - // Given a Button with an onMouseDown handler (e.g. to prevent focus steal) - const onMouseDown = jest.fn(); - renderButton({onMouseDown}); - - // When a mousedown event fires on the button - fireEvent(getButton(), 'mouseDown'); - - // Then onMouseDown is called - expect(onMouseDown).toHaveBeenCalledTimes(1); - }); - - it('calls onLayout when the component measures itself', () => { - // Given a Button with an onLayout handler (e.g. to position a dropdown) - const onLayout = jest.fn(); - renderButton({onLayout}); - - // When a layout event fires - fireEvent(getButton(), 'layout', { - nativeEvent: {layout: {width: 200, height: 48, x: 0, y: 0}}, - }); - - // Then onLayout is called - expect(onLayout).toHaveBeenCalledTimes(1); - }); - }); - - // ── Haptic feedback ─────────────────────────────────────────────────────── - - describe('haptic feedback', () => { - it('triggers HapticFeedback.press on press when enableHapticFeedback is true', () => { - // Given a Button with haptic feedback enabled (e.g. a primary CTA on mobile) - renderButton({enableHapticFeedback: true}); - - // When the button is pressed - fireEvent.press(getButton()); - - // Then the haptic press pulse fires - expect(HapticFeedback.press).toHaveBeenCalledTimes(1); - }); - - it('does not trigger haptic feedback on press by default', () => { - // Given a Button without haptic feedback - renderButton(); - - // When pressed - fireEvent.press(getButton()); - - // Then no haptic feedback fires - expect(HapticFeedback.press).not.toHaveBeenCalled(); - }); - - it('triggers HapticFeedback.longPress on long press when enableHapticFeedback is true', () => { - // Given a Button with haptic feedback enabled - renderButton({enableHapticFeedback: true}); - - // When the button is long-pressed - fireEvent(getButton(), 'longPress'); - - // BaseGenericPressable fires HapticFeedback.longPress once by default - // (shouldUseHapticsOnLongPress=true). Button.tsx adds a second call when - // enableHapticFeedback=true, so the total is 2. - expect(HapticFeedback.longPress).toHaveBeenCalledTimes(2); - }); - }); - - // ── Layout and composition ──────────────────────────────────────────────── - - describe('layout and composition', () => { - it('applies custom style to the outer wrapper', () => { - // Given a Button with a custom outer style (e.g. margin for spacing) - const renderResult = renderButton({style: {margin: 10}}); - - // `style` maps to PressableWithFeedback's wrapperStyle on the outer OpacityView. - // We find it by scanning for any host View in the tree that carries the margin. - const wrapperView = renderResult.UNSAFE_getAllByType(View).find((v) => { - const flatStyle: unknown = StyleSheet.flatten(v.props.style ?? []); - return typeof flatStyle === 'object' && flatStyle !== null && 'margin' in flatStyle && flatStyle.margin === 10; - }); - expect(wrapperView).toBeDefined(); - }); - - it('applies contentContainerStyle to the content wrapper', () => { - // Given a Button with a custom contentContainerStyle - const renderResult = renderButton({ - contentContainerStyle: {paddingTop: 8}, - }); - - // contentContainerStyle is applied to the flexRow View wrapping all children. - // We find it by scanning host Views for the custom padding. - const contentWrapper = renderResult.UNSAFE_getAllByType(View).find((v) => { - const flatStyle: unknown = StyleSheet.flatten(v.props.style ?? []); - return typeof flatStyle === 'object' && flatStyle !== null && 'paddingTop' in flatStyle && flatStyle.paddingTop === 8; - }); - expect(contentWrapper).toBeDefined(); - }); - - it('renders a full-size absolute overlay when blendOpacity is true', () => { - // Button.tsx renders - // when blendOpacity=true — an overlay that lets content beneath show through. - const renderResult = renderButton({blendOpacity: true}); - - const overlayView = renderResult.UNSAFE_getAllByType(View).find((v) => { - const flatStyle: unknown = StyleSheet.flatten(v.props.style ?? []); - return typeof flatStyle === 'object' && flatStyle !== null && 'position' in flatStyle && flatStyle.position === 'absolute'; - }); - - expect(overlayView).toBeDefined(); - - // The overlay must cover the full button — verify absoluteFill dimensions - const overlayStyle: unknown = StyleSheet.flatten(overlayView?.props.style ?? []); - expect(overlayStyle).toMatchObject({ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - }); - }); - }); -}); diff --git a/tests/ui/components/DynamicIOURequestStepParticipantsTest.tsx b/tests/ui/components/DynamicIOURequestStepParticipantsTest.tsx new file mode 100644 index 000000000000..cf0a16866724 --- /dev/null +++ b/tests/ui/components/DynamicIOURequestStepParticipantsTest.tsx @@ -0,0 +1,254 @@ +import {render, screen} from '@testing-library/react-native'; + +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; + +import DynamicIOURequestStepParticipants from '@pages/iou/request/step/DynamicIOURequestStepParticipants'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; +import {createRandomReport} from '../../utils/collections/reports'; +import createMock from '../../utils/createMock'; +import {signInWithTestUser} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const SELF_DM_REPORT_ID = '1'; +const TRANSACTION_ID = '1'; +const POLICY_ID = '2'; +const navigation = createMock['navigation']>({}); + +type CapturedSelectorProps = {shouldBlockParticipantSelection?: (policyID?: string) => boolean}; + +let latestSelectorProps: CapturedSelectorProps = {}; + +// The bug (#99710) lives in how DynamicIOURequestStepParticipants wires shouldBlockParticipantSelection, +// not in the picker's own rendering/search behavior, so the picker itself is stubbed to capture the prop. +jest.mock('@pages/iou/request/MoneyRequestParticipantsSelector', () => ({ + __esModule: true, + default: (props: CapturedSelectorProps) => { + latestSelectorProps = props; + return null; + }, +})); + +// useParticipantSubmission already has its own dedicated test coverage (useParticipantSubmissionTest.ts) and pulls in +// unrelated Onyx-backed hooks (draft transactions, policies, billing NVPs). Stubbing it isolates this test to the +// shouldBlockParticipantSelection wiring that #99710 actually broke. +jest.mock('@hooks/useParticipantSubmission', () => ({ + __esModule: true, + default: () => ({addParticipant: jest.fn(), goToNextStep: jest.fn()}), +})); + +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + getCurrentRoute: jest.fn(() => ({ + name: 'Dynamic_Money_Request_Step_Participants', + params: {}, + })), + getState: jest.fn(() => ({})), + isReady: jest.fn(() => false), + addListener: jest.fn(() => jest.fn()), +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({ + name: 'Dynamic_Money_Request_Step_Participants', + params: {}, + })), + getState: jest.fn(() => ({})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + navigationRef: mockRef, + getActiveRoute: () => '', + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('@react-navigation/core', () => ({ + ...jest.requireActual>('@react-navigation/core'), + useIsFocused: () => true, +})); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({ + name: 'Dynamic_Money_Request_Step_Participants', + params: {}, + })), + getState: jest.fn(() => ({})), + }; + return { + ...jest.requireActual>('@react-navigation/native'), + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + }; +}); + +function HTMLProviderWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('DynamicIOURequestStepParticipants', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + initOnyxDerivedValues(); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + latestSelectorProps = {}; + jest.clearAllTimers(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('blocks selecting a workspace with home-and-office commuter exclusions for a map distance expense when the user has no home address (#99710)', async () => { + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + + const workspacePolicy = { + ...createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.ADMIN, + pendingAction: undefined, + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + }, + }; + const selfDMReport = { + ...createRandomReport(Number(SELF_DM_REPORT_ID), CONST.REPORT.CHAT_TYPE.SELF_DM), + ownerAccountID: ACCOUNT_ID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${workspacePolicy.id}`, workspacePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 1000, + currency: 'USD', + created: '2026-09-07', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + }); + await waitForBatchedUpdates(); + + render( + + + + + + + + + , + ); + + // Wait for both withWritableReportOrNotFound and withFullTransactionOrNotFound to resolve past their + // loading state and render the (stubbed) picker. + await screen.findByTestId('DynamicIOURequestStepParticipants'); + + expect(latestSelectorProps.shouldBlockParticipantSelection).toBeDefined(); + // Without isDistanceRequest wired in, this returned false for a map expense, letting the picker commit the + // selection and navigate to Confirm even though the user has no home address — landing on "Choose recipient". + expect(latestSelectorProps.shouldBlockParticipantSelection?.(workspacePolicy.id)).toBe(true); + }); + + it('allows selecting the same workspace once the user has a home address (#99710)', async () => { + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + + const workspacePolicy = { + ...createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.ADMIN, + pendingAction: undefined, + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + }, + }; + const selfDMReport = { + ...createRandomReport(Number(SELF_DM_REPORT_ID), CONST.REPORT.CHAT_TYPE.SELF_DM), + ownerAccountID: ACCOUNT_ID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${workspacePolicy.id}`, workspacePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 1000, + currency: 'USD', + created: '2026-09-07', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + }); + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: [{street: '123 Main St', city: 'San Francisco', state: 'CA', zip: '94105', country: 'US', current: true}]}); + await waitForBatchedUpdates(); + + render( + + + + + + + + + , + ); + + await screen.findByTestId('DynamicIOURequestStepParticipants'); + + expect(latestSelectorProps.shouldBlockParticipantSelection?.(workspacePolicy.id)).toBe(false); + }); +}); diff --git a/tests/ui/components/ExportWithDropdownMenuTest.tsx b/tests/ui/components/ExportWithDropdownMenuTest.tsx new file mode 100644 index 000000000000..0c0ee0f6d2f3 --- /dev/null +++ b/tests/ui/components/ExportWithDropdownMenuTest.tsx @@ -0,0 +1,116 @@ +import {render} from '@testing-library/react-native'; + +import type {DropdownOption, ReportExportType} from '@components/ButtonWithDropdownMenu/types'; +import ExportWithDropdownMenu from '@components/ReportActionItem/ExportWithDropdownMenu'; + +import {exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; + +import CONST from '@src/CONST'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; + +const REPORT_ID = '1001'; +const POLICY_ID = 'policy1'; +const mockReport = {reportID: REPORT_ID, policyID: POLICY_ID, reportName: 'Approved report'} as Report; + +// Captures the props ExportWithDropdownMenu hands to the dropdown so the test can fire `onPress` for a +// specific export option without rendering the real menu. +const mockDropdownProps: {current: {onPress?: (event: unknown, value: ReportExportType) => void; options?: Array>} | undefined} = {current: undefined}; +jest.mock('@components/ButtonWithDropdownMenu', () => ({ + __esModule: true, + default: (props: {onPress?: (event: unknown, value: ReportExportType) => void}) => { + mockDropdownProps.current = props; + return null; + }, +})); + +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: jest.fn(() => [undefined])})); + +jest.mock('@libs/actions/Report', () => ({ + exportToIntegration: jest.fn(), + markAsManuallyExported: jest.fn(), +})); + +jest.mock('@libs/actions/Policy/Policy', () => ({savePreferredExportMethod: jest.fn()})); + +// The report is already exported, which is the state that used to trigger the "export again" confirmation for +// BOTH dropdown options. +jest.mock('@libs/ReportUtils', () => ({ + canBeExported: () => true, + isExported: () => true, + getIntegrationIcon: () => undefined, +})); + +describe('ExportWithDropdownMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDropdownProps.current = undefined; + mockShowConfirmModal.mockResolvedValue({action: 'CONFIRM'}); + }); + + const renderMenu = () => + render( + , + ); + + it('marks an already-exported report as manually exported without showing the export-again modal', () => { + /** + * Given: an already-exported report on a QuickBooks Online workspace, shown in the report preview's + * export dropdown. + * + * When: the user picks "Mark as exported". + * + * Then: MarkAsExported runs straight away. The "export again" copy warns that the report is about to be + * exported to QuickBooks Online again, which never happens for this action. + */ + renderMenu(); + + mockDropdownProps.current?.onPress?.(undefined, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(exportToIntegration).not.toHaveBeenCalled(); + }); + + it('still shows the export-again confirmation before re-exporting to the integration', async () => { + /** + * Given: the same already-exported report. + * + * When: the user picks the real "Export to QuickBooks Online" option. + * + * Then: the export-again confirmation is shown first and the report is exported only after it is + * confirmed — this path is deliberately unchanged. + */ + renderMenu(); + + mockDropdownProps.current?.onPress?.(undefined, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); + + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'workspace.exportAgainModal.title', + prompt: 'workspace.exportAgainModal.description', + }), + ); + + // Flush the modal promise chain, then confirm the export ran. + await Promise.resolve(); + expect(exportToIntegration).toHaveBeenCalledWith(REPORT_ID, CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(markAsManuallyExported).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 6757b1e86588..e441f92345cf 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -3,17 +3,27 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-na import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import * as ConfirmAction from '@components/MoneyRequestConfirmationList/confirmAction'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {ParticipantPickerProps} from '@components/ParticipantPicker/types'; import ScreenWrapper from '@components/ScreenWrapper'; import {startSplitBill} from '@libs/actions/IOU/Split'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as IOUUtils from '@libs/IOUUtils'; +import * as SubmitWithDismissFirst from '@libs/Navigation/helpers/submitWithDismissFirst'; +import Navigation from '@libs/Navigation/Navigation'; +// 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 IOURequestStepConfirmationWithWritableReportOrNotFound, {IOURequestStepConfirmationContentWithWritableReportOrNotFound} from '@pages/iou/request/step/IOURequestStepConfirmation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Policy, TaxRatesWithDefault} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type Transaction from '@src/types/onyx/Transaction'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -27,7 +37,8 @@ import * as MoneyRequest from '../../../src/libs/actions/IOU/MoneyRequest'; import * as Split from '../../../src/libs/actions/IOU/Split'; import * as TrackExpense from '../../../src/libs/actions/IOU/TrackExpense'; import createRandomPolicy from '../../utils/collections/policies'; -import {signInWithTestUser, translateLocal} from '../../utils/TestHelper'; +import createMockScreenNavigation from '../../utils/createMockScreenNavigation'; +import {setupGlobalFetchMock, signInWithTestUser, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; jest.mock('@rnmapbox/maps', () => { @@ -100,22 +111,39 @@ jest.mock('@components/ProductTrainingContext', () => ({ // The picker is only rendered under the new manual expense flow beta, so this is inert for every other test here. let mockSelectedParticipants: Participant[] = []; let mockSelectedPolicy: OnyxEntry; +type MockParticipantPickerProps = Pick; jest.mock('@components/ParticipantPicker', () => { const ReactModule = jest.requireActual('react'); const {Text, TouchableOpacity} = jest.requireActual<{ - Text: React.ComponentType<{children?: React.ReactNode}>; + Text: React.ComponentType<{testID?: string; children?: React.ReactNode}>; TouchableOpacity: React.ComponentType<{testID: string; onPress: () => void; children?: React.ReactNode}>; }>('react-native'); return { __esModule: true, - default: ({onParticipantsAdded}: {onParticipantsAdded: (participants: Participant[], selectedPolicy?: OnyxEntry) => void}) => + // `MockParticipantPickerVisible` stands in for the docked overlay itself, so tests can assert whether the picker + // is showing. `MockParticipantPickerReferralBanner` stands in for the referral CTA inside it, and + // `MockParticipantPickerDismiss` for a real dismissal (the back button), which must not arm the reopen. + default: ({onParticipantsAdded, isVisible, onClose, onCloseForReferralNavigation}: MockParticipantPickerProps) => ReactModule.createElement( - TouchableOpacity, - {testID: 'MockParticipantPicker', onPress: () => onParticipantsAdded(mockSelectedParticipants, mockSelectedPolicy)}, - ReactModule.createElement(Text, null, 'Select participant'), + ReactModule.Fragment, + null, + ReactModule.createElement( + TouchableOpacity, + {testID: 'MockParticipantPicker', onPress: () => onParticipantsAdded(mockSelectedParticipants, mockSelectedPolicy)}, + ReactModule.createElement(Text, null, 'Select participant'), + ), + ReactModule.createElement( + TouchableOpacity, + {testID: 'MockParticipantPickerReferralBanner', onPress: () => onCloseForReferralNavigation?.()}, + ReactModule.createElement(Text, null, 'Referral banner'), + ), + ReactModule.createElement(TouchableOpacity, {testID: 'MockParticipantPickerDismiss', onPress: () => onClose?.()}, ReactModule.createElement(Text, null, 'Dismiss picker')), + isVisible ? ReactModule.createElement(Text, {testID: 'MockParticipantPickerVisible'}, 'Participant picker is open') : null, ), }; }); + +const {navigation: mockNavigation, emitScreenFocus, resetScreenFocusListeners} = createMockScreenNavigation(); jest.mock('@src/hooks/useResponsiveLayout'); jest.mock('@libs/getCurrentPosition'); jest.mock('@libs/getIsNarrowLayout', () => jest.fn(() => false)); @@ -194,6 +222,14 @@ const TRANSACTION_ID = '1'; const POLICY_ID = 'test-policy-id'; const POLICY_CHAT_REPORT_ID = '595'; +const mockSendMoneyElsewhere = jest.fn(); +jest.mock('@userActions/IOU/SendMoney', () => ({ + sendMoneyElsewhere: (...args: unknown[]) => { + mockSendMoneyElsewhere(...args); + }, + sendMoneyWithWallet: jest.fn(), +})); + // Helper to create a policy with tax and distance enabled function createPolicyWithTaxAndDistance(): Policy { const taxRates: TaxRatesWithDefault = { @@ -301,8 +337,12 @@ const DEFAULT_SPLIT_TRANSACTION: Transaction = { }; describe('IOURequestStepConfirmationPageTest', () => { + // Writes fired during render (e.g. UpdatePreferredLocale) must not hit the real network and leave retry backoff across tests + setupGlobalFetchMock(); + beforeEach(() => { jest.clearAllMocks(); + resetScreenFocusListeners(); Onyx.init({ keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], @@ -350,8 +390,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: routeReportID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -392,8 +431,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -441,8 +479,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -520,8 +557,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -579,8 +615,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -647,8 +682,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -687,8 +721,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -794,8 +827,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -897,8 +929,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -955,8 +986,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -991,6 +1021,166 @@ describe('IOURequestStepConfirmationPageTest', () => { return /^Create .*expense/i; } + it('uses the transaction optimistic report ID for a brand-new P2P pre-mount and pay destination', async () => { + // Given a brand-new P2P recipient with no existing chat, so the screen must reuse the + // transaction's optimistic report ID rather than one a builder would otherwise mint + const optimisticP2PReportID = 'optimistic-p2p-report-1'; + const transactionID = 'tx-new-p2p'; + let sendMoney: ((paymentMethod: PaymentMethodType | undefined) => void) | undefined; + const originalBuildConfirmAction = ConfirmAction.default; + const buildConfirmActionSpy = jest.spyOn(ConfirmAction, 'default').mockImplementation((params) => { + sendMoney = params.onSendMoney; + return originalBuildConfirmAction(params); + }); + const submitWithDismissFirstSpy = jest.spyOn(SubmitWithDismissFirst, 'submitWithDismissFirst').mockImplementation((params) => { + params.executeWrite({shouldHandleNavigation: false}); + }); + const getChatByParticipantsSpy = jest.spyOn(ReportUtils, 'getChatByParticipants').mockReturnValue(undefined); + const getReusableP2PReportIDSpy = jest.spyOn(IOUUtils, 'getReusableP2PReportID').mockReturnValue(optimisticP2PReportID); + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + + try { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + transactionID, + reportID: optimisticP2PReportID, + amount: 1000, + isAmountSet: true, + currency: 'USD', + merchant: 'Test', + created: '2025-01-15', + isFromGlobalCreate: true, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + participants: [{accountID: PARTICIPANT_ACCOUNT_ID, selected: true}], + }); + }); + + render( + + + + + + + + + , + ); + + // When the screen renders and resolves the P2P destination + await waitForBatchedUpdatesWithAct(); + + expect(getChatByParticipantsSpy).toHaveBeenCalled(); + expect(getReusableP2PReportIDSpy).toHaveBeenCalledWith(expect.objectContaining({accountID: PARTICIPANT_ACCOUNT_ID}), optimisticP2PReportID); + // Then it pre-mounts the report at the transaction's own optimistic ID, not a different one + await waitFor( + () => + expect(Navigation.preInsertFullscreenUnderRHP).toHaveBeenCalledWith( + ROUTES.REPORT_WITH_ID.getRoute(optimisticP2PReportID, undefined, undefined, undefined, undefined, true), + ), + {timeout: 2000}, + ); + + // When the user sends money + act(() => sendMoney?.(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)); + + // Then submission also uses that same optimistic report ID, so the pre-mounted screen ends up + // subscribed to the report that actually gets created + expect(submitWithDismissFirstSpy).toHaveBeenCalledWith(expect.objectContaining({destinationReportID: optimisticP2PReportID})); + expect(mockSendMoneyElsewhere).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: optimisticP2PReportID})); + } finally { + buildConfirmActionSpy.mockRestore(); + submitWithDismissFirstSpy.mockRestore(); + getChatByParticipantsSpy.mockRestore(); + getReusableP2PReportIDSpy.mockRestore(); + jest.mocked(getIsNarrowLayout).mockReturnValue(false); + } + }); + + it('keeps the IOU report as pre-mount destination when the flow starts from it, instead of the participant chat', async () => { + // Given an existing 1:1 chat and an IOU report under it, and a flow started from that IOU report to add another expense + const chatReportID = 'p2p-chat-1'; + const iouReportID = 'p2p-iou-report-1'; + const transactionID = 'tx-from-iou-report'; + const getChatByParticipantsSpy = jest.spyOn(ReportUtils, 'getChatByParticipants').mockReturnValue({reportID: chatReportID}); + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + + try { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[ACCOUNT_ID]: {}, [PARTICIPANT_ACCOUNT_ID]: {}}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + chatReportID, + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: ACCOUNT_ID, + managerID: PARTICIPANT_ACCOUNT_ID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + transactionID, + reportID: iouReportID, + amount: 1000, + isAmountSet: true, + currency: 'USD', + merchant: 'Test', + created: '2025-01-15', + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + participants: [{accountID: PARTICIPANT_ACCOUNT_ID, reportID: chatReportID, selected: true}], + }); + }); + + render( + + + + + + + + + , + ); + + // When the screen renders and resolves the pre-mount destination + await waitForBatchedUpdatesWithAct(); + + // Then the IOU report the flow started from is pre-inserted, not the participant chat the lookup resolved + expect(getChatByParticipantsSpy).toHaveBeenCalled(); + await waitFor(() => expect(Navigation.preInsertFullscreenUnderRHP).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(iouReportID)), {timeout: 2000}); + expect(Navigation.preInsertFullscreenUnderRHP).not.toHaveBeenCalledWith(expect.stringContaining(chatReportID)); + } finally { + getChatByParticipantsSpy.mockRestore(); + jest.mocked(getIsNarrowLayout).mockReturnValue(false); + } + }); + it('should not fallback to route report when transaction report differs and is not usable', async () => { const routeReportID = '100'; const transactionReportID = '200'; @@ -1044,8 +1234,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: routeReportID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1111,8 +1300,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: routeReportID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1140,7 +1328,7 @@ describe('IOURequestStepConfirmationPageTest', () => { harvesting: {enabled: false}, }; - const isReportOutstandingSpy = jest.spyOn(require('@libs/ReportUtils'), 'isReportOutstanding').mockReturnValue(true); + const canAddTransactionSpy = jest.spyOn(require('@libs/ReportUtils'), 'canAddTransaction').mockReturnValue(true); try { await act(async () => { @@ -1187,8 +1375,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: routeReportID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1204,7 +1391,7 @@ describe('IOURequestStepConfirmationPageTest', () => { const params = requestMoneyMock.mock.calls.at(0)?.at(0); expect(params?.report?.reportID).toBe(transactionReportID); } finally { - isReportOutstandingSpy.mockRestore(); + canAddTransactionSpy.mockRestore(); } }); }); @@ -1247,8 +1434,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1319,8 +1505,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1405,8 +1590,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1462,8 +1646,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error we don't need navigation param here. - navigation={undefined} + navigation={mockNavigation} /> @@ -1494,6 +1677,132 @@ describe('IOURequestStepConfirmationPageTest', () => { }); }); + describe('Referral banner inside the participant picker', () => { + beforeEach(async () => { + mockSelectedParticipants = []; + mockSelectedPolicy = undefined; + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + function confirmationScreen() { + return ( + + + + + + + + + + ); + } + + /** Renders the confirmation for a brand-new manual expense with no recipient yet, which auto-opens the picker. */ + async function renderConfirmationWithOpenPicker() { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + amount: 1000, + currency: 'USD', + created: '2025-08-29', + merchant: '(none)', + isFromGlobalCreate: true, + participants: [], + }); + }); + + render(confirmationScreen()); + await waitForBatchedUpdatesWithAct(); + } + + /** Replays what returning from another RHP does to this screen. */ + async function returnToScreen() { + act(() => emitScreenFocus()); + await waitForBatchedUpdatesWithAct(); + } + + it('reopens the picker when returning from the referral page, so back does not land on the expense form (#96562)', async () => { + // Given a new manual expense whose participant picker is open + await renderConfirmationWithOpenPicker(); + expect(screen.getByTestId('MockParticipantPickerVisible')).toBeOnTheScreen(); + + // When the referral banner navigates away, the picker closes so it doesn't cover the referral RHP + fireEvent.press(screen.getByTestId('MockParticipantPickerReferralBanner')); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + + // Then pressing back on the referral page refocuses this screen and brings the picker back + await returnToScreen(); + expect(screen.getByTestId('MockParticipantPickerVisible')).toBeOnTheScreen(); + }); + + it('leaves the picker closed on refocus when it was dismissed normally rather than by the referral banner', async () => { + // Given a new manual expense whose participant picker the user dismissed with the back button + await renderConfirmationWithOpenPicker(); + expect(screen.getByTestId('MockParticipantPickerVisible')).toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId('MockParticipantPickerDismiss')); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + + // When the screen regains focus after some unrelated navigation + await returnToScreen(); + + // Then the picker stays closed, since only the referral navigation arms the reopen + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + }); + + it('leaves the picker closed on refocus once a recipient was picked', async () => { + // Given a new manual expense whose participant picker closed because the user picked a recipient + await renderConfirmationWithOpenPicker(); + expect(screen.getByTestId('MockParticipantPickerVisible')).toBeOnTheScreen(); + + mockSelectedParticipants = [{accountID: PARTICIPANT_ACCOUNT_ID, selected: true}]; + fireEvent.press(screen.getByTestId('MockParticipantPicker')); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + + // When the screen regains focus after some unrelated navigation + await returnToScreen(); + + // Then the picker stays closed rather than covering a form the user is already filling in + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + }); + + it('does not reopen the picker on refocus when a recipient was resolved while the referral page was open', async () => { + // Given a new manual expense whose picker closed because the referral banner navigated away + await renderConfirmationWithOpenPicker(); + fireEvent.press(screen.getByTestId('MockParticipantPickerReferralBanner')); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + + // When the expense gains a recipient in the meantime (a deep link, or default participant resolution) + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + participants: [{accountID: PARTICIPANT_ACCOUNT_ID, selected: true}], + }); + }); + + // Then coming back leaves the picker closed, because there is nothing left to pick + await returnToScreen(); + expect(screen.queryByTestId('MockParticipantPickerVisible')).toBeNull(); + }); + }); + describe('Participant switch field resets', () => { const SOURCE_POLICY_ID = 'sourcePolicy'; const DESTINATION_POLICY_ID = 'destinationPolicy'; @@ -1520,7 +1829,6 @@ describe('IOURequestStepConfirmationPageTest', () => { mockSelectedPolicy = undefined; await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW]); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${SOURCE_POLICY_ID}`, {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Source policy'), id: SOURCE_POLICY_ID}); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${DESTINATION_POLICY_ID}`, { ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE, 'Destination policy'), @@ -1578,8 +1886,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: SOURCE_CHAT_REPORT_ID, }, }} - // @ts-expect-error only setParams is used by the participant selection handler. - navigation={{setParams: jest.fn()}} + navigation={mockNavigation} /> @@ -1639,6 +1946,27 @@ describe('IOURequestStepConfirmationPageTest', () => { expect(draftTransaction?.tag).toBe(''); }); + it('restores the cleared category when the destination workspace still has it enabled', async () => { + const SHARED_CATEGORY = 'Shared category'; + + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: SOURCE_CHAT_REPORT_ID, + category: SHARED_CATEGORY, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${DESTINATION_POLICY_ID}`, { + [SHARED_CATEGORY]: {name: SHARED_CATEGORY, enabled: true}, + }); + }); + await renderConfirmationOnSourceWorkspace({category: SHARED_CATEGORY}); + + await selectParticipants([createWorkspaceParticipant(DESTINATION_CHAT_REPORT_ID, DESTINATION_POLICY_ID)]); + + const draftTransaction = await getDraftTransaction(); + expect(draftTransaction?.category).toBe(SHARED_CATEGORY); + }); + it('keeps the category and the tag when the same workspace is selected again', async () => { // Given a manual expense assigned to the source workspace with one of its categories and tags selected await renderConfirmationOnSourceWorkspace(); @@ -1697,9 +2025,6 @@ describe('IOURequestStepConfirmationPageTest', () => { beforeEach(async () => { mockSelectedParticipants = []; await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); - await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW]); - }); }); /** @@ -1728,8 +2053,7 @@ describe('IOURequestStepConfirmationPageTest', () => { reportID: REPORT_ID, }, }} - // @ts-expect-error only setParams is used by the participant selection handler. - navigation={{setParams: jest.fn()}} + navigation={mockNavigation} shouldHideHeader={isEmbedded} /> ); diff --git a/tests/ui/components/LinkButton.tsx b/tests/ui/components/LinkButton.tsx index cf0c00f445bc..6b11190e9f82 100644 --- a/tests/ui/components/LinkButton.tsx +++ b/tests/ui/components/LinkButton.tsx @@ -1,11 +1,11 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; -import LinkButton from '@src/components/ButtonComposed/composed/LinkButton'; -import ButtonIcon from '@src/components/ButtonComposed/primitives/ButtonIcon'; -import ButtonKeyboardShortcut from '@src/components/ButtonComposed/primitives/ButtonKeyboardShortcut'; +import LinkButton from '@src/components/Button/composed/LinkButton'; +import ButtonIcon from '@src/components/Button/primitives/ButtonIcon'; +import ButtonKeyboardShortcut from '@src/components/Button/primitives/ButtonKeyboardShortcut'; import CONST from '@src/CONST'; import colors from '@src/styles/theme/colors'; -import variables from '@src/styles/variables'; +import {fontScale} from '@src/styles/typography'; import React from 'react'; @@ -24,7 +24,7 @@ const renderLinkButton = (innerStyles?: React.ComponentProps[ const getButton = () => screen.getByRole(CONST.ROLE.BUTTON, {name: LABEL}); -describe('ButtonComposed — LinkButton', () => { +describe('Button — LinkButton', () => { describe('transparent background invariant', () => { it('renders with a transparent background by default', () => { // Link buttons must not obscure underlying content. @@ -57,7 +57,7 @@ describe('ButtonComposed — LinkButton', () => { // theme.link is blue300 in the default dark theme. expect(text).toHaveStyle({color: colors.blue300}); expect(text).toHaveStyle({fontWeight: '400'}); - expect(text).toHaveStyle({fontSize: variables.fontSizeLabel}); + expect(text).toHaveStyle({fontSize: fontScale.label}); }); it('swaps the text color to linkHover when the button is hovered', () => { @@ -80,9 +80,9 @@ describe('ButtonComposed — LinkButton', () => { describe('composable primitives', () => { // Smoke check that Object.assign-ed primitives are actually exposed and - // point at the underlying ButtonComposed primitives (no link-specific override + // point at the underlying Button primitives (no link-specific override // beyond Text). If a refactor accidentally drops one of these, this fails fast. - it('exposes Icon, DoubleLineText, and KeyboardShortcut from the ButtonComposed primitives', () => { + it('exposes Icon, DoubleLineText, and KeyboardShortcut from the Button primitives', () => { expect(LinkButton.Icon).toBe(ButtonIcon); expect(LinkButton.KeyboardShortcut).toBe(ButtonKeyboardShortcut); }); diff --git a/tests/ui/components/MenuItem/MenuItemFieldTest.tsx b/tests/ui/components/MenuItem/MenuItemFieldTest.tsx new file mode 100644 index 000000000000..d6e08d53021c --- /dev/null +++ b/tests/ui/components/MenuItem/MenuItemFieldTest.tsx @@ -0,0 +1,201 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import Text from '@components/Text'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + ArrowRight: () => null, + })), +})); + +const CHEVRON_TEST_ID = 'menu-item-chevron'; +const pressEvent = {nativeEvent: {}}; +const NAME = 'Legal first name'; +const VALUE = 'John'; + +function Wrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('MenuItemField', () => { + describe('filled shape', () => { + it('renders the name and the value', () => { + render( + + + , + ); + + expect(screen.getByText(NAME)).toBeOnTheScreen(); + expect(screen.getByText(VALUE)).toBeOnTheScreen(); + }); + + it('announces the name first, then the value', async () => { + render( + + + , + ); + + expect(await screen.findByLabelText(`${NAME}, ${VALUE}`)).toBeOnTheScreen(); + }); + }); + + describe('empty shape', () => { + it.each([ + ['no value prop', undefined], + ['an empty value', ''], + ])('renders the name as a placeholder and nothing else given %s', async (_case, value) => { + render( + + + , + ); + + expect(screen.getByText(NAME)).toBeOnTheScreen(); + expect(screen.queryByText(VALUE)).not.toBeOnTheScreen(); + // The name stands in for the missing value, so it is the whole announced label + expect(await screen.findByLabelText(NAME)).toBeOnTheScreen(); + }); + }); + + describe('trailing cell', () => { + it('renders no chevron when the row is not pressable', () => { + render( + + + , + ); + + expect(screen.queryByTestId(CHEVRON_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('renders a chevron when the row is pressable', () => { + render( + + {}} + /> + , + ); + + expect(screen.getByTestId(CHEVRON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders children without a chevron when the row is not pressable', () => { + render( + + + Badge + + , + ); + + expect(screen.getByText('Badge')).toBeOnTheScreen(); + expect(screen.queryByTestId(CHEVRON_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('renders children alongside the chevron when the row is pressable', () => { + render( + + {}} + > + Badge + + , + ); + + expect(screen.getByText('Badge')).toBeOnTheScreen(); + expect(screen.getByTestId(CHEVRON_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('press handling', () => { + it('takes the button role only when pressable', async () => { + const {unmount} = render( + + + , + ); + + expect(await screen.findByLabelText(`${NAME}, ${VALUE}`)).not.toHaveProp('role', CONST.ROLE.BUTTON); + unmount(); + + render( + + {}} + /> + , + ); + + expect(await screen.findByRole(CONST.ROLE.BUTTON, {name: `${NAME}, ${VALUE}`})).toBeOnTheScreen(); + }); + + it('calls onPress when pressed', async () => { + const onPress = jest.fn(); + render( + + + , + ); + + fireEvent.press(await screen.findByLabelText(`${NAME}, ${VALUE}`), pressEvent); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not call onPress when disabled', async () => { + const onPress = jest.fn(); + render( + + + , + ); + + fireEvent.press(await screen.findByLabelText(`${NAME}, ${VALUE}`), pressEvent); + + expect(onPress).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index ce0adf2fbacb..e5325f7452c7 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -196,6 +196,16 @@ const transactionWithReceipt: Transaction = { }, }; +const transactionWithMultiPagePDFReceipt: Transaction = { + ...transactionWithoutReceipt, + receipt: { + state: CONST.IOU.RECEIPT_STATE.OPEN, + source: 'https://example.com/receipt.pdf', + filename: 'receipt.pdf', + pageCount: 3, + }, +}; + const transactionWithScanningReceipt: Transaction = { ...transactionWithoutReceipt, receipt: { @@ -296,6 +306,103 @@ describe('MoneyRequestReceiptView', () => { }); }); + describe('receipt page count badge', () => { + it('shows the page count for a multi-page PDF receipt', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithMultiPagePDFReceipt); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeTruthy(); + }); + + it('does not show the page count for a single page PDF receipt', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + ...transactionWithMultiPagePDFReceipt, + receipt: {...transactionWithMultiPagePDFReceipt.receipt, pageCount: 1}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 1}))).toBeNull(); + }); + + // An optimistic merge that swaps a PDF for an image can leave the PDF's count behind, so the + // badge has to follow the current file type rather than the leftover count + it('does not show the page count when a stale count is left on an image receipt', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + ...transactionWithMultiPagePDFReceipt, + receipt: {...transactionWithMultiPagePDFReceipt.receipt, source: 'https://example.com/photo.jpg', filename: 'photo.jpg'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + }); + + // An image receipt carries no page count at all, which is also what a PDF uploaded before the + // backend started reporting one looks like + it('does not show the page count for a receipt without one', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithReceipt); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + }); + + // The regenerated receipt makes the old count stale + it('does not show the page count while a map distance receipt is regenerating', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + ...transactionWithMultiPagePDFReceipt, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + pendingFields: {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + }); + }); + describe('receipt action buttons visibility', () => { it('does not show action buttons when transaction has no receipt', async () => { render( diff --git a/tests/ui/components/PayActionButtonTest.tsx b/tests/ui/components/PayActionButtonTest.tsx index c0738fc52c6d..a5e9ce7bc85b 100644 --- a/tests/ui/components/PayActionButtonTest.tsx +++ b/tests/ui/components/PayActionButtonTest.tsx @@ -218,7 +218,7 @@ describe('PayActionButton', () => { }); }); - expect(onHoldMenuOpen).toHaveBeenCalledWith(CONST.IOU.REPORT_ACTION_TYPE.PAY, CONST.IOU.PAYMENT_TYPE.VBBA, expect.anything(), SELECTED_BANK_ACCOUNT_ID); + expect(onHoldMenuOpen).toHaveBeenCalledWith(CONST.IOU.PAYMENT_TYPE.VBBA, expect.anything(), SELECTED_BANK_ACCOUNT_ID); expect(mockedPayMoneyRequest).not.toHaveBeenCalled(); }); }); diff --git a/tests/ui/components/ReportPreviewActionButtonTest.tsx b/tests/ui/components/ReportPreviewActionButtonTest.tsx index 346d80edccbe..ea9221f37cf7 100644 --- a/tests/ui/components/ReportPreviewActionButtonTest.tsx +++ b/tests/ui/components/ReportPreviewActionButtonTest.tsx @@ -8,9 +8,8 @@ import type {ConnectionName} from '@src/types/onyx/Policy'; import type {ValueOf} from 'type-fest'; import React from 'react'; +import {View} from 'react-native'; -// The dispatcher owns no props and reads its decision from context, so drive the decision through the mocked context -// slice and stub each branch component with a spy so we can assert which one gets rendered for a given action. const mockActionState: {reportPreviewAction: ValueOf; connectedIntegration: ConnectionName | undefined} = { reportPreviewAction: CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, connectedIntegration: undefined, @@ -65,7 +64,7 @@ jest.mock('@components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActi return null; }, })); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { function MockButton() { mockView(); return null; @@ -79,9 +78,17 @@ jest.mock('@components/ButtonComposed', () => { }; }); -jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({flex1: {}})})); +// These must stay distinct objects: with `{}` values the style assertions below pass even when the styles are missing. +const mockStyles = {flex1: {flex: 1}, flexRow: {flexDirection: 'row'}, gap2: {gap: 8}}; +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => mockStyles})); jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key})})); +function flattenContainerStyle(rendered: ReturnType): unknown[] { + const container = rendered.UNSAFE_getAllByType(View).at(0); + const style: unknown = container?.props.style; + return Array.isArray(style) ? style : [style]; +} + describe('ReportPreviewActionButton', () => { beforeEach(() => { jest.clearAllMocks(); @@ -106,7 +113,8 @@ describe('ReportPreviewActionButton', () => { mockActionState.connectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; render(); expect(mockExport).toHaveBeenCalled(); - expect(mockView).not.toHaveBeenCalled(); + // View renders alongside the primary action, not instead of it. + expect(mockView).toHaveBeenCalled(); }); it('falls back to the View button for EXPORT_TO_ACCOUNTING when no integration is connected', () => { @@ -116,4 +124,15 @@ describe('ReportPreviewActionButton', () => { expect(mockView).toHaveBeenCalled(); expect(mockExport).not.toHaveBeenCalled(); }); + it('lays the primary action and View out in a row, and keeps a lone View full-width', () => { + mockActionState.reportPreviewAction = CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY; + const withPrimary = render(); + expect(flattenContainerStyle(withPrimary)).toEqual(expect.arrayContaining([mockStyles.flexRow, mockStyles.gap2])); + withPrimary.unmount(); + + jest.clearAllMocks(); + mockActionState.reportPreviewAction = CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; + const viewOnly = render(); + expect(flattenContainerStyle(viewOnly)).not.toEqual(expect.arrayContaining([mockStyles.flexRow])); + }); }); diff --git a/tests/ui/components/ReportPreviewHoldMenuTest.tsx b/tests/ui/components/ReportPreviewHoldMenuTest.tsx index b9674f63cb0b..d743415b3711 100644 --- a/tests/ui/components/ReportPreviewHoldMenuTest.tsx +++ b/tests/ui/components/ReportPreviewHoldMenuTest.tsx @@ -1,6 +1,5 @@ import {act, render} from '@testing-library/react-native'; -import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import {ReportPreviewActionsContext, ReportPreviewDataContext, ReportPreviewHoldMenuContext} from '@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext'; import ReportPreviewHoldMenu from '@components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu'; @@ -20,7 +19,6 @@ const SELECTED_BANK_ACCOUNT_ID = 9999; type CapturedHoldMenuProps = { isVisible?: boolean; - requestType?: string; paymentType?: PaymentMethodType; methodID?: number; nonHeldAmount?: string; @@ -42,7 +40,6 @@ const heldTransaction: Transaction = {...createRandomTransaction(1), comment: {. const nonHeldTransaction: Transaction = {...createRandomTransaction(2), comment: {...createRandomTransaction(2).comment, hold: undefined}}; const defaultHoldMenuState = { - requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY as ActionHandledType, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA as PaymentMethodType, canPay: true, methodID: SELECTED_BANK_ACCOUNT_ID, @@ -112,11 +109,10 @@ describe('ReportPreviewHoldMenu', () => { expect(mockHoldMenuPropsHolder.current).toBeUndefined(); }); - it('forwards the request type, payment type and selected bank account from the context state', () => { + it('forwards the payment type and selected bank account from the context state', () => { renderHoldMenu(); expect(mockHoldMenuPropsHolder.current?.isVisible).toBe(true); - expect(mockHoldMenuPropsHolder.current?.requestType).toBe(CONST.IOU.REPORT_ACTION_TYPE.PAY); expect(mockHoldMenuPropsHolder.current?.paymentType).toBe(CONST.IOU.PAYMENT_TYPE.VBBA); expect(mockHoldMenuPropsHolder.current?.methodID).toBe(SELECTED_BANK_ACCOUNT_ID); }); @@ -136,17 +132,6 @@ describe('ReportPreviewHoldMenu', () => { expect(mockHoldMenuPropsHolder.current?.nonHeldAmount).toBeUndefined(); }); - it('starts the approved animation when confirming an approval', () => { - renderHoldMenu({holdMenu: {...defaultHoldMenuState, requestType: CONST.IOU.REPORT_ACTION_TYPE.APPROVE}}); - - act(() => { - mockHoldMenuPropsHolder.current?.onConfirm?.(true); - }); - - expect(mockActionsValue.startApprovedAnimation).toHaveBeenCalled(); - expect(mockActionsValue.startAnimation).not.toHaveBeenCalled(); - }); - it('starts the paid animation when confirming a payment', () => { renderHoldMenu(); diff --git a/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx b/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx index 8a3d3fd5c811..16d143a735c5 100644 --- a/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx +++ b/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx @@ -2,15 +2,16 @@ import {act, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; - -import useNetwork from '@hooks/useNetwork'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; +import type * as SearchContext from '@components/Search/SearchContext'; +import type {SearchQueryContextValue} from '@components/Search/types'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {getSuggestedSearches, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import StaticSearchTypeMenu from '@pages/Search/SearchPageNarrow/StaticSearchTypeMenu'; import SearchTypeMenuNarrow from '@pages/Search/SearchTypeMenuNarrow'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as ReactNavigation from '@react-navigation/native'; @@ -18,7 +19,6 @@ import type * as ReactNavigation from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; -import {translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; jest.mock('@hooks/useTodoCounts', () => ({ @@ -35,7 +35,28 @@ jest.mock('@react-navigation/native', () => { useIsFocused: jest.fn(() => true), }; }); -jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); + +jest.mock('@components/Search/SearchContext', () => { + const actualSearchContext: typeof SearchContext = jest.requireActual('@components/Search/SearchContext'); + return { + __esModule: true, + ...actualSearchContext, + useSearchQueryContext: jest.fn(), + }; +}); + +const mockedUseSearchQueryContext = jest.mocked(useSearchQueryContext); + +const defaultSearchContext: SearchQueryContextValue = { + currentSearchHash: -1, + currentSimilarSearchHash: -1, + currentSearchKey: undefined, + currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), + suggestedSearches: getSuggestedSearches(), + shouldResetSearchQuery: false, +}; function Wrapper({children}: {children: React.ReactNode}) { return ( @@ -58,7 +79,7 @@ describe('Search saved-search tab highlight', () => { jest.clearAllMocks(); }); - it('keeps saved search selected in static narrow menu when similar hash collides', async () => { + it("keeps saved search selected in static narrow menu when it's selected", async () => { const baseQuery = 'type:expense status:all'; const savedQueryString = `${baseQuery} sortBy:amount`; const savedQueryJSON = buildSearchQueryJSON(savedQueryString); @@ -67,33 +88,7 @@ describe('Search saved-search tab highlight', () => { throw new Error('Failed to build saved query JSON'); } - await act(async () => { - await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { - [savedQueryJSON.hash]: { - name: 'My saved search', - query: savedQueryString, - }, - }); - }); - - render( - - - , - ); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); - }); - - it('keeps saved search selected in interactive narrow menu when similar hash collides', async () => { - const baseQuery = 'type:expense status:all'; - const savedQueryString = `${baseQuery} sortBy:amount`; - const savedQueryJSON = buildSearchQueryJSON(savedQueryString); - - if (!savedQueryJSON) { - throw new Error('Failed to build saved query JSON'); - } + mockedUseSearchQueryContext.mockReturnValue({...defaultSearchContext, currentSearchKey: savedSearchIDToSearchKey(savedQueryJSON.hash.toString())}); await act(async () => { await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { @@ -104,37 +99,6 @@ describe('Search saved-search tab highlight', () => { }); }); - render( - - - , - ); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); - }); - - it('does not show pending-delete saved search as selected in static narrow menu while online', async () => { - const baseQuery = 'type:expense status:all'; - const savedQueryString = `${baseQuery} sortBy:amount`; - const savedQueryJSON = buildSearchQueryJSON(savedQueryString); - - if (!savedQueryJSON) { - throw new Error('Failed to build saved query JSON'); - } - - jest.mocked(useNetwork).mockReturnValue({isOffline: false}); - - await act(async () => { - await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { - [savedQueryJSON.hash]: { - name: 'My saved search', - query: savedQueryString, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }, - }); - }); - render( @@ -142,10 +106,10 @@ describe('Search saved-search tab highlight', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull(); + expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); }); - it('highlights built-in tab when saved search is pending delete while online (interactive narrow)', async () => { + it("keeps saved search selected in interactive narrow menu when it's selected", async () => { const baseQuery = 'type:expense status:all'; const savedQueryString = `${baseQuery} sortBy:amount`; const savedQueryJSON = buildSearchQueryJSON(savedQueryString); @@ -154,14 +118,13 @@ describe('Search saved-search tab highlight', () => { throw new Error('Failed to build saved query JSON'); } - jest.mocked(useNetwork).mockReturnValue({isOffline: false}); + mockedUseSearchQueryContext.mockReturnValue({...defaultSearchContext, currentSearchKey: savedSearchIDToSearchKey(savedQueryJSON.hash.toString())}); await act(async () => { await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { [savedQueryJSON.hash]: { name: 'My saved search', query: savedQueryString, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }, }); }); @@ -173,7 +136,6 @@ describe('Search saved-search tab highlight', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull(); - expect(screen.getByRole('tab', {name: translateLocal('search.tabs.expenses'), selected: true})).toBeTruthy(); + expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); }); }); diff --git a/tests/ui/components/SubStepForms/AgreementsFullStepTest.tsx b/tests/ui/components/SubStepForms/AgreementsFullStepTest.tsx new file mode 100644 index 000000000000..acf96bdd122a --- /dev/null +++ b/tests/ui/components/SubStepForms/AgreementsFullStepTest.tsx @@ -0,0 +1,159 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import type FormWrapper from '@components/Form/FormWrapper'; +import AgreementsFullStep from '@components/SubStepForms/AgreementsFullStep'; + +import getSubStepValues from '@pages/ReimbursementAccount/utils/getSubStepValues'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import GLOBAL_INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; +import REIMBURSEMENT_INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import type HybridAppModuleType from '@expensify/react-native-hybrid-app/src/types'; +import type ReactNative from 'react-native'; + +import React from 'react'; + +import * as TestHelper from '../../../utils/TestHelper'; + +type MockFormWrapperProps = React.ComponentProps; +const mockTranslate = TestHelper.translateLocal; +jest.mock('@components/Form/FormWrapper', () => { + const reactNative = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({children, errors, onSubmit}: MockFormWrapperProps) => ( + + + {JSON.stringify(errors)} + {children} + + ), + }; +}); +jest.mock('@components/InteractiveStepWrapper', () => { + const reactNative = jest.requireActual('react-native'); + return {__esModule: true, default: ({children}: {children: React.ReactNode}) => {children}}; +}); +jest.mock('@components/CheckboxWithLabel', () => ({__esModule: true, default: () => null})); +jest.mock('@components/UploadFile', () => ({__esModule: true, default: () => null})); +jest.mock('@components/RenderHTML', () => ({__esModule: true, default: () => null})); +jest.mock('@expensify/react-native-hybrid-app', () => ({ + __esModule: true, + default: {isHybridApp: jest.fn, Parameters>(() => false)}, +})); +jest.mock('@hooks/useLocalize', () => () => ({translate: mockTranslate, preferredLocale: 'en'})); +jest.mock('@hooks/useThemeStyles', () => () => new Proxy({}, {get: () => ({})})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useIsFocusedRef', () => () => ({current: true})); +jest.mock('@hooks/usePressLoading', () => () => ({isLoading: false, startWithLoading: (callback: () => void) => callback()})); +jest.mock('@src/utils/keyboard', () => ({dismiss: () => Promise.resolve(), dismissKeyboardAndExecute: (callback: () => void) => callback()})); + +const GLOBAL_FORM_ID = ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS; +const REIMBURSEMENT_FORM_ID = ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM; +const BANK_STATEMENT_INPUT_ID = REIMBURSEMENT_INPUT_IDS.ADDITIONAL_DATA.CORPAY.BANK_STATEMENT; +const GLOBAL_AGREEMENT_INPUTS = { + provideTruthfulInformation: GLOBAL_INPUT_IDS.PROVIDE_TRUTHFUL_INFORMATION, + agreeToTermsAndConditions: GLOBAL_INPUT_IDS.AGREE_TO_TERMS_AND_CONDITIONS, + consentToPrivacyNotice: GLOBAL_INPUT_IDS.CONSENT_TO_PRIVACY_NOTICE, + authorizedToBindClientToAgreement: GLOBAL_INPUT_IDS.AUTHORIZED_TO_BIND_CLIENT_TO_AGREEMENT, +}; +const REIMBURSEMENT_AGREEMENT_INPUTS = { + provideTruthfulInformation: REIMBURSEMENT_INPUT_IDS.ADDITIONAL_DATA.CORPAY.PROVIDE_TRUTHFUL_INFORMATION, + agreeToTermsAndConditions: REIMBURSEMENT_INPUT_IDS.ADDITIONAL_DATA.CORPAY.AGREE_TO_TERMS_AND_CONDITIONS, + consentToPrivacyNotice: REIMBURSEMENT_INPUT_IDS.ADDITIONAL_DATA.CORPAY.CONSENT_TO_PRIVACY_NOTICE, + authorizedToBindClientToAgreement: REIMBURSEMENT_INPUT_IDS.ADDITIONAL_DATA.CORPAY.AUTHORIZED_TO_BIND_CLIENT_TO_AGREEMENT, +}; +type AgreementErrorInputID = (typeof GLOBAL_AGREEMENT_INPUTS | typeof REIMBURSEMENT_AGREEMENT_INPUTS)[keyof typeof GLOBAL_AGREEMENT_INPUTS] | typeof BANK_STATEMENT_INPUT_ID; +const ALL_GLOBAL_AGREEMENTS = { + [GLOBAL_AGREEMENT_INPUTS.provideTruthfulInformation]: true, + [GLOBAL_AGREEMENT_INPUTS.agreeToTermsAndConditions]: true, + [GLOBAL_AGREEMENT_INPUTS.consentToPrivacyNotice]: true, + [GLOBAL_AGREEMENT_INPUTS.authorizedToBindClientToAgreement]: true, +} satisfies React.ComponentProps>['defaultValues']; +const ALL_REIMBURSEMENT_AGREEMENTS = {...ALL_GLOBAL_AGREEMENTS} satisfies React.ComponentProps>['defaultValues']; +type AgreementsTestProps = Pick< + React.ComponentProps>, + 'bankStatementDefaultValue' | 'bankStatementInputID' | 'defaultValues' | 'formID' | 'inputIDs' +>; + +function renderAgreements(props: AgreementsTestProps) { + const onSubmit = jest.fn(); + render( + + {...props} + isLoading={false} + onBackButtonPress={() => {}} + onSubmit={onSubmit} + currency="USD" + startStepIndex={1} + />, + ); + return onSubmit; +} + +async function submitAgreementsAndExpectErrors(errors: Partial>>) { + fireEvent.press(screen.getByTestId('submit')); + await waitFor(() => expect(screen.getByTestId('form-errors')).toHaveTextContent(JSON.stringify(errors))); +} + +describe('AgreementsFullStep validation', () => { + it.each([ + [GLOBAL_AGREEMENT_INPUTS.authorizedToBindClientToAgreement, TestHelper.translateLocal('agreementsStep.error.authorized')], + [GLOBAL_AGREEMENT_INPUTS.provideTruthfulInformation, TestHelper.translateLocal('agreementsStep.error.certify')], + [GLOBAL_AGREEMENT_INPUTS.agreeToTermsAndConditions, TestHelper.translateLocal('common.error.acceptTerms')], + [GLOBAL_AGREEMENT_INPUTS.consentToPrivacyNotice, TestHelper.translateLocal('agreementsStep.error.consent')], + ])('returns the specific required message for %s', async (rejectedInput, message) => { + renderAgreements({ + formID: GLOBAL_FORM_ID, + inputIDs: GLOBAL_AGREEMENT_INPUTS, + defaultValues: {...ALL_GLOBAL_AGREEMENTS, [rejectedInput]: false}, + }); + await submitAgreementsAndExpectErrors({[rejectedInput]: message}); + }); + + it('submits when every global-reimbursements agreement is fulfilled', async () => { + const onSubmit = renderAgreements({formID: GLOBAL_FORM_ID, inputIDs: GLOBAL_AGREEMENT_INPUTS, defaultValues: ALL_GLOBAL_AGREEMENTS}); + await submitAgreementsAndExpectErrors({}); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + + it('returns all four specific messages for the real NonUSD absent-value producer', async () => { + const absentValues = getSubStepValues(REIMBURSEMENT_AGREEMENT_INPUTS, undefined, undefined); + renderAgreements({formID: REIMBURSEMENT_FORM_ID, inputIDs: REIMBURSEMENT_AGREEMENT_INPUTS, defaultValues: absentValues}); + await submitAgreementsAndExpectErrors({ + [REIMBURSEMENT_AGREEMENT_INPUTS.authorizedToBindClientToAgreement]: TestHelper.translateLocal('agreementsStep.error.authorized'), + [REIMBURSEMENT_AGREEMENT_INPUTS.provideTruthfulInformation]: TestHelper.translateLocal('agreementsStep.error.certify'), + [REIMBURSEMENT_AGREEMENT_INPUTS.agreeToTermsAndConditions]: TestHelper.translateLocal('common.error.acceptTerms'), + [REIMBURSEMENT_AGREEMENT_INPUTS.consentToPrivacyNotice]: TestHelper.translateLocal('agreementsStep.error.consent'), + }); + }); + + it('keeps the generic required error for an empty bank statement', async () => { + renderAgreements({ + formID: REIMBURSEMENT_FORM_ID, + inputIDs: REIMBURSEMENT_AGREEMENT_INPUTS, + defaultValues: ALL_REIMBURSEMENT_AGREEMENTS, + bankStatementInputID: BANK_STATEMENT_INPUT_ID, + bankStatementDefaultValue: [], + }); + await submitAgreementsAndExpectErrors({[BANK_STATEMENT_INPUT_ID]: TestHelper.translateLocal('common.error.fieldRequired')}); + }); + + it('submits when the required bank statement contains a production-typed file', async () => { + const bankStatement: FileObject[] = [{name: 'statement.pdf', uri: 'file://statement.pdf', type: 'application/pdf'}]; + const onSubmit = renderAgreements({ + formID: REIMBURSEMENT_FORM_ID, + inputIDs: REIMBURSEMENT_AGREEMENT_INPUTS, + defaultValues: ALL_REIMBURSEMENT_AGREEMENTS, + bankStatementInputID: BANK_STATEMENT_INPUT_ID, + bankStatementDefaultValue: bankStatement, + }); + await submitAgreementsAndExpectErrors({}); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx b/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx index 189c7aa4e7eb..494f2a7ebe0d 100644 --- a/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx +++ b/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx @@ -29,7 +29,6 @@ jest.mock('@components/MenuItemWithTopDescription', () => { jest.mock('@components/RenderHTML', () => ({__esModule: true, default: () => null})); jest.mock('@components/DotIndicatorMessage', () => ({__esModule: true, default: () => null})); -jest.mock('@components/Button', () => ({__esModule: true, default: () => null})); jest.mock('@components/Text', () => { const reactNative = jest.requireActual('react-native'); return {__esModule: true, default: reactNative.Text}; diff --git a/tests/ui/components/SubmitActionButtonTest.tsx b/tests/ui/components/SubmitActionButtonTest.tsx index bfe99a4567bb..90bf824e8423 100644 --- a/tests/ui/components/SubmitActionButtonTest.tsx +++ b/tests/ui/components/SubmitActionButtonTest.tsx @@ -5,21 +5,31 @@ import SubmitActionButton from '@components/ReportActionItem/MoneyRequestReportP import useOnyx from '@hooks/useOnyx'; import {isSubmitPolicy} from '@libs/PolicyUtils'; -import {hasOnlyHeldExpenses, hasViolations} from '@libs/ReportUtils'; -import {hasAnyPendingRTERViolation, hasOnlyPendingCardTransactions, showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; +import {hasOnlyHeldExpenses, hasViolations, shouldBlockSubmitDueToPreventSelfApproval, shouldBlockSubmitDueToStrictPolicyRules} from '@libs/ReportUtils'; +import { + getTransactionViolations, + hasAnyPendingRTERViolation, + hasOnlyPendingCardTransactions, + showHeldExpensesBlockModal, + showPendingCardTransactionsBlockModal, +} from '@libs/TransactionUtils'; import {submitReport} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, TransactionViolations} from '@src/types/onyx'; +import type {Report, Transaction, TransactionViolations} from '@src/types/onyx'; import type {OnyxCollection, UseOnyxResult} from 'react-native-onyx'; import React from 'react'; +import createMock from '../../utils/createMock'; + const TEST_IOU_REPORT_ID = '1001'; const TEST_TRANSACTION_ID = '3003'; +const TEST_ACCOUNT_ID = 1; +const TEST_EMAIL = 'submitter@test.com'; const iouReport = { reportID: TEST_IOU_REPORT_ID, @@ -32,19 +42,28 @@ const reportViolations: OnyxCollection = { [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TEST_TRANSACTION_ID}`]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}], }; +const reportTransactions = [createMock({transactionID: TEST_TRANSACTION_ID})]; + // Mutable so each test can back the mocked useReportPreviewTransactionViolations slice with a specific value. let mockTransactionViolations: OnyxCollection = {}; +// Mutable so each test can back the mocked useReportPreviewData slice with a specific transaction list. +let mockTransactions: Transaction[] = []; + +// Mutable so a test can turn on the domain's "strictly enforce workspace rules" setting without standing up security groups. +let mockAreStrictPolicyRulesEnabled = false; + function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { return [value, {status: 'loaded'}]; } -// Capture the onPress (handleSubmit) handler the button passes to AnimatedSubmitButton so submission can be triggered. -const mockOnPressHolder: {current?: () => void} = {current: undefined}; +// Capture the props the button passes to AnimatedSubmitButton so submission can be triggered (onPress) and the disabled +// state asserted (isDisabled). +const mockSubmitButtonPropsHolder: {current?: {onPress?: () => void; isDisabled?: boolean}} = {current: undefined}; jest.mock('@components/AnimatedSubmitButton', () => ({ __esModule: true, - default: (props: {onPress?: () => void}) => { - mockOnPressHolder.current = props.onPress; + default: (props: {onPress?: () => void; isDisabled?: boolean}) => { + mockSubmitButtonPropsHolder.current = props; return null; }, })); @@ -79,9 +98,10 @@ jest.mock('@libs/PolicyUtils', () => { }); // Fully mocked (no requireActual): loading the real TransactionUtils barrel pulls in platform-specific modules that -// are unavailable in the jest environment, and the component only uses these three functions. +// are unavailable in the jest environment, and the component only uses the functions listed here. jest.mock('@libs/TransactionUtils', () => ({ __esModule: true, + getTransactionViolations: jest.fn(), hasOnlyPendingCardTransactions: jest.fn(() => false), hasAnyPendingRTERViolation: jest.fn(() => false), showPendingCardTransactionsBlockModal: jest.fn(), @@ -98,6 +118,12 @@ jest.mock('@libs/ReportUtils', () => { hasViolations: jest.fn(() => false), shouldShowMarkAsDone: jest.fn(() => false), hasOnlyHeldExpenses: jest.fn(() => false), + // These two reach into TransactionUtils (hasViolation / hasNoticeTypeViolation / hasWarningTypeViolation), which + // is fully mocked below with only the exports this component needs, so the real implementations would blow + // up as soon as a test supplies transactions. The violation math itself is covered by ReportUtilsTest; what + // matters here is that the button wires the predicates up to its disabled state and its press handler. + shouldBlockSubmitDueToStrictPolicyRules: jest.fn(() => false), + shouldBlockSubmitDueToPreventSelfApproval: jest.fn(() => false), }; }); @@ -111,14 +137,15 @@ jest.mock('@hooks/useConfirmPendingRTERAndProceed', () => ({ const mockStartSubmittingAnimation = jest.fn(); jest.mock('@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext', () => ({ __esModule: true, - useReportPreviewData: () => ({iouReportID: TEST_IOU_REPORT_ID, transactions: []}), + useReportPreviewData: () => ({iouReportID: TEST_IOU_REPORT_ID, transactions: mockTransactions}), useReportPreviewTransactionViolations: () => ({transactionViolations: mockTransactionViolations}), useReportPreviewAnimationState: () => ({isSubmittingAnimationRunning: false}), useReportPreviewActions: () => ({stopAnimation: jest.fn(), startSubmittingAnimation: mockStartSubmittingAnimation}), })); jest.mock('@hooks/useConfirmModal', () => ({__esModule: true, default: jest.fn(() => ({showConfirmModal: jest.fn()}))})); -jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: jest.fn(() => ({accountID: 1, email: 'submitter@test.com'}))})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: jest.fn(() => ({accountID: TEST_ACCOUNT_ID, email: TEST_EMAIL}))})); +jest.mock('@hooks/useStrictPolicyRules', () => ({__esModule: true, default: jest.fn(() => ({areStrictPolicyRulesEnabled: mockAreStrictPolicyRulesEnabled}))})); jest.mock('@hooks/usePermissions', () => ({__esModule: true, default: jest.fn(() => ({isBetaEnabled: () => false}))})); jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: jest.fn(() => ({translate: (key: string) => key}))})); jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: jest.fn(() => ({isOffline: false}))})); @@ -134,16 +161,28 @@ const mockedHasOnlyHeldExpenses = jest.mocked(hasOnlyHeldExpenses); const mockedShowHeldExpensesBlockModal = jest.mocked(showHeldExpensesBlockModal); const mockedHasViolations = jest.mocked(hasViolations); const mockedHasAnyPendingRTERViolation = jest.mocked(hasAnyPendingRTERViolation); +const mockedGetTransactionViolations = jest.mocked(getTransactionViolations); +const mockedShouldBlockSubmitDueToStrictPolicyRules = jest.mocked(shouldBlockSubmitDueToStrictPolicyRules); +const mockedShouldBlockSubmitDueToPreventSelfApproval = jest.mocked(shouldBlockSubmitDueToPreventSelfApproval); describe('SubmitActionButton', () => { beforeEach(() => { jest.clearAllMocks(); - mockOnPressHolder.current = undefined; + mockSubmitButtonPropsHolder.current = undefined; mockTransactionViolations = {}; + mockTransactions = []; + mockAreStrictPolicyRulesEnabled = false; mockedIsSubmitPolicy.mockReturnValue(false); mockedHasOnlyPendingCardTransactions.mockReturnValue(false); mockedHasOnlyHeldExpenses.mockReturnValue(false); mockedHasViolations.mockReturnValue(false); + // jest.clearAllMocks() only clears recorded calls, so a mockReturnValue set by one test would otherwise leak + // into every test that runs after it. + mockedShouldBlockSubmitDueToStrictPolicyRules.mockReturnValue(false); + mockedShouldBlockSubmitDueToPreventSelfApproval.mockReturnValue(false); + // Default to nothing-dismissed passthrough so the filtered collection mirrors the raw slice; individual tests + // override the implementation to simulate dismissals. + mockedGetTransactionViolations.mockImplementation((transaction, violations) => violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction?.transactionID}`]); mockedUseOnyx.mockImplementation((key) => { if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_IOU_REPORT_ID}`) { return createOnyxResult(iouReport); @@ -156,7 +195,7 @@ describe('SubmitActionButton', () => { render(); act(() => { - mockOnPressHolder.current?.(); + mockSubmitButtonPropsHolder.current?.onPress?.(); }); expect(mockedSubmitReport).toHaveBeenCalledWith( @@ -172,7 +211,7 @@ describe('SubmitActionButton', () => { render(); act(() => { - mockOnPressHolder.current?.(); + mockSubmitButtonPropsHolder.current?.onPress?.(); }); expect(mockOpenReportSubmitToPopover).toHaveBeenCalled(); @@ -184,7 +223,7 @@ describe('SubmitActionButton', () => { render(); act(() => { - mockOnPressHolder.current?.(); + mockSubmitButtonPropsHolder.current?.onPress?.(); }); expect(mockedShowPendingCardTransactionsBlockModal).toHaveBeenCalled(); @@ -196,7 +235,7 @@ describe('SubmitActionButton', () => { render(); act(() => { - mockOnPressHolder.current?.(); + mockSubmitButtonPropsHolder.current?.onPress?.(); }); expect(mockedShowHeldExpensesBlockModal).toHaveBeenCalled(); @@ -209,11 +248,119 @@ describe('SubmitActionButton', () => { render(); act(() => { - mockOnPressHolder.current?.(); + mockSubmitButtonPropsHolder.current?.onPress?.(); }); expect(mockedHasViolations.mock.calls.at(-1)?.[1]).toBe(reportViolations); expect(mockedHasAnyPendingRTERViolation.mock.calls.at(-1)?.[1]).toBe(reportViolations); expect(mockedSubmitReport).toHaveBeenCalledWith(expect.objectContaining({hasViolations: true})); }); + + it('disables the button and blocks submission when strict policy rules are enforced on a violating report', () => { + // Given a domain that strictly enforces workspace rules and a report whose transactions have violations + mockAreStrictPolicyRulesEnabled = true; + mockTransactions = reportTransactions; + mockTransactionViolations = reportViolations; + mockedShouldBlockSubmitDueToStrictPolicyRules.mockReturnValue(true); + + // When the button renders and onPress is called directly, to confirm the handler no-ops on its own — the + // disabled prop is the first layer of defense, the early return in the handler is the second + render(); + + act(() => { + mockSubmitButtonPropsHolder.current?.onPress?.(); + }); + + // Then nothing reaches the backend, because the backend would reject the submission and the user would be left + // with the generic "Unexpected error. Please try again later." instead of the violations they need to fix + expect(mockSubmitButtonPropsHolder.current?.isDisabled).toBe(true); + expect(mockedSubmitReport).not.toHaveBeenCalled(); + + // Then the decision is made from the report preview's own violations and transactions rather than a separate + // Onyx read, which is what let this button drift out of sync with the report header's submit button + expect(mockedShouldBlockSubmitDueToStrictPolicyRules).toHaveBeenCalledWith(TEST_IOU_REPORT_ID, reportViolations, true, TEST_ACCOUNT_ID, TEST_EMAIL, reportTransactions); + }); + + it('passes dismissal-filtered violations to the strict policy rules gate', () => { + // Given a strict-rules domain and a report whose only violation has been dismissed — a dismissal that is only + // detectable with the report/owner/policy context that getTransactionViolations applies + mockAreStrictPolicyRulesEnabled = true; + mockTransactions = reportTransactions; + mockTransactionViolations = reportViolations; + mockedGetTransactionViolations.mockReturnValue([]); + + // When the button renders + render(); + + // Then the filter ran with the full context the report header's filter uses, and the gate received the filtered + // collection instead of the raw context slice, so the two Submit buttons cannot disagree on dismissed violations + expect(mockedGetTransactionViolations).toHaveBeenCalledWith(reportTransactions.at(0), reportViolations, TEST_EMAIL, TEST_ACCOUNT_ID, iouReport, undefined, undefined); + expect(mockedShouldBlockSubmitDueToStrictPolicyRules).toHaveBeenCalledWith( + TEST_IOU_REPORT_ID, + {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TEST_TRANSACTION_ID}`]: []}, + true, + TEST_ACCOUNT_ID, + TEST_EMAIL, + reportTransactions, + ); + }); + + it('does not open the submit-to popover on a submit policy when strict policy rules block the report', () => { + // Given a blocked report on a submit workspace, where pressing submit normally opens the submit-to popover + mockAreStrictPolicyRulesEnabled = true; + mockTransactions = reportTransactions; + mockTransactionViolations = reportViolations; + mockedShouldBlockSubmitDueToStrictPolicyRules.mockReturnValue(true); + mockedIsSubmitPolicy.mockReturnValue(true); + + // When the press handler runs + render(); + + act(() => { + mockSubmitButtonPropsHolder.current?.onPress?.(); + }); + + // Then the popover never opens, because it runs the submission itself and would route around the block, landing + // the user on the same backend rejection one step later + expect(mockOpenReportSubmitToPopover).not.toHaveBeenCalled(); + expect(mockedSubmitReport).not.toHaveBeenCalled(); + }); + + it('keeps the button enabled and submits when strict policy rules are enforced but nothing blocks the report', () => { + // Given a domain that strictly enforces workspace rules and a report with no blocking violations + mockAreStrictPolicyRulesEnabled = true; + mockTransactions = reportTransactions; + + // When the button is pressed + render(); + + act(() => { + mockSubmitButtonPropsHolder.current?.onPress?.(); + }); + + // Then the report still submits, so the new block never stands in the way of a report the backend would accept + expect(mockSubmitButtonPropsHolder.current?.isDisabled).toBe(false); + expect(mockedSubmitReport).toHaveBeenCalledWith(expect.objectContaining({expenseReport: iouReport})); + }); + + it('disables the button and blocks submission when the workspace prevents self-approval', () => { + // Given a workspace that prevents self-approval on a report whose next approver is the submitter + mockedShouldBlockSubmitDueToPreventSelfApproval.mockReturnValue(true); + + // When the press handler runs + render(); + + act(() => { + mockSubmitButtonPropsHolder.current?.onPress?.(); + }); + + // Then the button behaves exactly like the report header's submit button, so the two cannot disagree about + // whether the same report is submittable + expect(mockSubmitButtonPropsHolder.current?.isDisabled).toBe(true); + expect(mockedSubmitReport).not.toHaveBeenCalled(); + + // Then the predicate received the same report and policy the header's gate reads (the policy is not hydrated + // in this environment, so it is undefined) + expect(mockedShouldBlockSubmitDueToPreventSelfApproval).toHaveBeenCalledWith(iouReport, undefined, undefined); + }); }); diff --git a/tests/ui/pages/DynamicReportDetailsExportPageTest.tsx b/tests/ui/pages/DynamicReportDetailsExportPageTest.tsx new file mode 100644 index 000000000000..07e7a8ee5f11 --- /dev/null +++ b/tests/ui/pages/DynamicReportDetailsExportPageTest.tsx @@ -0,0 +1,124 @@ +import {render} from '@testing-library/react-native'; + +import {exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; + +import DynamicReportDetailsExportPage from '@pages/inbox/report/DynamicReportDetailsExportPage'; +import type {ExportType} from '@pages/inbox/report/DynamicReportDetailsExportPage'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import createMock from '../../utils/createMock'; + +type ExportPageProps = PlatformStackScreenProps; + +const REPORT_ID = '1001'; + +// Captures the props the page hands to SelectionScreen so the test can fire `onSelectRow` for a specific +// export option without rendering the real selection list. +const mockSelectionProps: {current: {onSelectRow?: (option: {value: ExportType}) => void} | undefined} = {current: undefined}; +jest.mock('@components/SelectionScreen', () => ({ + __esModule: true, + default: (props: {onSelectRow?: (option: {value: ExportType}) => void}) => { + mockSelectionProps.current = props; + return null; + }, +})); + +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: jest.fn(() => [undefined])})); + +jest.mock('@hooks/useDynamicBackPath', () => ({__esModule: true, default: () => undefined})); + +jest.mock('@libs/actions/Report', () => ({ + exportToIntegration: jest.fn(), + markAsManuallyExported: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {dismissModal: jest.fn(), goBack: jest.fn()}, +})); + +// The report is already exported, which is the state that used to trigger the "export again" confirmation for +// BOTH export options on this page. +jest.mock('@libs/ReportUtils', () => ({ + canBeExported: () => true, + isExported: () => true, + getIntegrationIcon: () => undefined, +})); + +describe('DynamicReportDetailsExportPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectionProps.current = undefined; + mockShowConfirmModal.mockResolvedValue({action: 'CONFIRM'}); + }); + + const renderPage = () => + render( + ({params: {reportID: REPORT_ID, connectionName: CONST.POLICY.CONNECTIONS.NAME.QBO}})} + navigation={createMock({})} + />, + ); + + it('marks an already-exported report as manually exported without showing the export-again modal', () => { + /** + * Given: an already-exported report on a QuickBooks Online workspace, opened on the report details + * export page. + * + * When: the user selects "Mark as exported". + * + * Then: MarkAsExported runs straight away. The "export again" copy warns that the report is about to be + * exported to QuickBooks Online again, which never happens for this action. + */ + renderPage(); + + mockSelectionProps.current?.onSelectRow?.({value: CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED}); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(exportToIntegration).not.toHaveBeenCalled(); + }); + + it('still shows the export-again confirmation before re-exporting to the integration', async () => { + /** + * Given: the same already-exported report. + * + * When: the user selects the real "Export to QuickBooks Online" option. + * + * Then: the export-again confirmation is shown first and the report is exported only after it is + * confirmed — this path is deliberately unchanged. + */ + renderPage(); + + mockSelectionProps.current?.onSelectRow?.({value: CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION}); + + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'workspace.exportAgainModal.title', + prompt: 'workspace.exportAgainModal.description', + }), + ); + + // Flush the modal promise chain, then confirm the export ran. + await Promise.resolve(); + expect(exportToIntegration).toHaveBeenCalledWith(REPORT_ID, CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(markAsManuallyExported).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/APIWriteWhenReadyTest.ts b/tests/unit/APIWriteWhenReadyTest.ts index d6d8335d3a18..755e7020a642 100644 --- a/tests/unit/APIWriteWhenReadyTest.ts +++ b/tests/unit/APIWriteWhenReadyTest.ts @@ -3,7 +3,7 @@ import type {WriteReadyBarrier, WriteWhenReadyOptions} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import {SAFETY_TIMEOUT_MS} from '@libs/API/writeWhenReady'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {push as pushToSequentialQueue} from '@libs/Network/SequentialQueue'; +import {claimReadGateForDeferredWrite, push as pushToSequentialQueue} from '@libs/Network/SequentialQueue'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -18,12 +18,16 @@ jest.mock('@libs/Network/SequentialQueue', () => ({ waitForIdle: jest.fn(() => Promise.resolve()), // Called by the network layer on init; stub so advancing fake timers doesn't hit a missing export. flush: jest.fn(), + // Returns the callback writeWhenReady runs once its write has reached the queue, or definitely won't. + // The gate itself is covered in SequentialQueueReadGateTest. + claimReadGateForDeferredWrite: jest.fn(() => jest.fn()), })); jest.mock('@libs/Navigation/TransitionTracker'); jest.mock('@libs/Pusher'); jest.mock('@libs/NetworkState'); const mockPush = jest.mocked(pushToSequentialQueue); +const mockClaimReadGate = jest.mocked(claimReadGateForDeferredWrite); const mockRunAfterTransitions = jest.mocked(TransitionTracker.runAfterTransitions); // writeWhenReady's deferral behaviour is command-agnostic; UPDATE_PREFERRED_LOCALE is just an arbitrary write command. @@ -307,6 +311,8 @@ describe('API.writeWhenReady', () => { const updateSpy = jest.spyOn(Onyx, 'update').mockImplementationOnce(() => { throw new Error('write boom'); }); + const settleClaim = jest.fn(); + mockClaimReadGate.mockReturnValue(settleClaim); try { const onyxData: DeferWriteOnyxData = { optimisticData: [ @@ -329,8 +335,13 @@ describe('API.writeWhenReady', () => { // error the caller needs to see, not something to paper over await expect(outcome).resolves.toBe('rejected'); expect(mockPush).not.toHaveBeenCalled(); + + // And the claim is released on the way out. This is the only assertion on execute()'s catch; + // without it the gate would stay held and every later READ would sit on the safety timeout. + expect(settleClaim).toHaveBeenCalledTimes(1); } finally { updateSpy.mockRestore(); + mockClaimReadGate.mockImplementation(() => jest.fn()); } }); @@ -589,6 +600,85 @@ describe('API.writeWhenReady', () => { expect(SAFETY_TIMEOUT_MS).toBeGreaterThan(CONST.MAX_TRANSITION_START_WAIT_MS + CONST.MAX_TRANSITION_DURATION_MS); }); + describe('sequential queue read gate', () => { + // clearAllMocks() doesn't clear implementations, so reset the stub up front rather than relying on + // the previous test having cleaned up after itself. + beforeEach(() => { + mockClaimReadGate.mockImplementation(() => jest.fn()); + }); + + // One claim whose settle callback a test can assert on directly. + function stubReadGateClaim() { + const settleClaim = jest.fn(); + mockClaimReadGate.mockReturnValue(settleClaim); + return settleClaim; + } + + it('claims the read gate synchronously, before the barrier settles', () => { + // Given a barrier that has not settled, so nothing has reached the queue yet + const {barrier} = makeAbortableBarrier(); + + // When the write is deferred + deferWrite(barrier); + + // Then the gate is already claimed on this same tick. A READ firing on the next line - the + // destination screen refetching what this write is about to change - has to park behind it, + // which is precisely the ordering `write()` would have given for free. + expect(mockClaimReadGate).toHaveBeenCalledTimes(1); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('settles the claim once the write reaches the queue', async () => { + // Given a deferred write holding the gate + const settleClaim = stubReadGateClaim(); + const {barrier, release} = makeAbortableBarrier(); + deferWrite(barrier); + expect(settleClaim).not.toHaveBeenCalled(); + + // When the barrier releases it onto the queue + release(); + await flushMicrotasks(() => settleClaim.mock.calls.length > 0); + + // Then the claim settles off the write promise, rather than assuming push() ran synchronously + expect(mockPush).toHaveBeenCalledTimes(1); + expect(settleClaim).toHaveBeenCalledTimes(1); + }); + + it('settles the claim even when the write rejects', async () => { + // Given a deferred write whose push() fails. processRequest is async, so this surfaces as a + // rejection and takes the .finally() path, not execute()'s catch - that branch is covered by + // 'rejects the returned promise when the write throws synchronously'. + const settleClaim = stubReadGateClaim(); + mockPush.mockImplementationOnce(() => { + throw new Error('push failed'); + }); + const {barrier, release} = makeAbortableBarrier(); + const deferred = deferWrite(barrier); + + // When the barrier releases it + release(); + await flushMicrotasks(() => settleClaim.mock.calls.length > 0); + + // Then the claim is still settled, rather than parking every READ for good + await expect(deferred).rejects.toThrow('push failed'); + expect(settleClaim).toHaveBeenCalledTimes(1); + }); + + it('skips the claim when the caller opts out', async () => { + // Given a write deferred with the read gate turned off + const {barrier, release} = makeAbortableBarrier(); + deferWriteWithOptions(barrier, {shouldClaimReadGate: false}); + + // When the barrier releases it onto the queue + release(); + await flushMicrotasks(pushHappened); + + // Then no gate was ever claimed, so READs ran freely for the length of the deferral + expect(mockClaimReadGate).not.toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledTimes(1); + }); + }); + describe('onRelease/onWriteStarted options', () => { it('calls onRelease with "success" before write() executes, and onWriteStarted after', async () => { // Given onRelease and onWriteStarted hooks on a write deferred on a resolving barrier - the diff --git a/tests/unit/ActiveSpansBenchmarkTest.ts b/tests/unit/ActiveSpansBenchmarkTest.ts new file mode 100644 index 000000000000..5f222490ff4e --- /dev/null +++ b/tests/unit/ActiveSpansBenchmarkTest.ts @@ -0,0 +1,71 @@ +import {endSpan, startSpan} from '@libs/telemetry/activeSpans'; + +import CONST from '@src/CONST'; + +import type {Span, StartSpanOptions} from '@sentry/core'; + +import {AppState} from 'react-native'; + +type MockInactiveSpan = { + setAttribute: jest.Mock>; + setStatus: jest.Mock>; + end: jest.Mock>; +}; + +const mockLogBenchmarkSpanEnd = jest.fn(); +const mockIsBenchmarkSpanEnabled = jest.fn(() => false); +const mockStartInactiveSpan = jest.fn(() => ({ + setAttribute: jest.fn>(), + setStatus: jest.fn>(), + end: jest.fn>(), +})); + +jest.mock('@libs/telemetry/logBenchmarkSpanEnd', () => ({ + __esModule: true, + default: (spanName: string, durationMs: number) => { + mockLogBenchmarkSpanEnd(spanName, durationMs); + }, + isBenchmarkSpanEnabled: (spanName: string) => mockIsBenchmarkSpanEnabled(spanName), +})); + +jest.mock('@sentry/react-native', () => ({ + startInactiveSpan: (options: StartSpanOptions) => mockStartInactiveSpan(options), + spanToJSON: () => ({data: {}}), +})); + +afterEach(() => { + jest.restoreAllMocks(); + mockLogBenchmarkSpanEnd.mockClear(); + mockIsBenchmarkSpanEnabled.mockReset().mockReturnValue(false); + mockStartInactiveSpan.mockClear(); +}); + +describe('activeSpans benchmark logging', () => { + it('logs the Sentry span name instead of its unique tracking ID', () => { + jest.spyOn(performance, 'now').mockReturnValueOnce(100).mockReturnValueOnce(250); + + startSpan('ManualOpenReport_123', {name: 'ManualOpenReport'}); + endSpan('ManualOpenReport_123'); + + expect(mockLogBenchmarkSpanEnd).toHaveBeenCalledWith('ManualOpenReport', 150); + }); + + it('starts an enabled benchmark span while iOS is still transitioning to active', () => { + jest.spyOn(AppState, 'currentState', 'get').mockReturnValue(CONST.APP_STATE.INACTIVE); + mockIsBenchmarkSpanEnabled.mockReturnValue(true); + + startSpan('ManualAppStartup', {name: 'ManualAppStartup'}); + endSpan('ManualAppStartup'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({name: 'ManualAppStartup'}); + expect(mockLogBenchmarkSpanEnd).toHaveBeenCalledWith('ManualAppStartup', expect.any(Number)); + }); + + it('still skips a regular span while the app is inactive', () => { + jest.spyOn(AppState, 'currentState', 'get').mockReturnValue(CONST.APP_STATE.INACTIVE); + + startSpan('ManualOpenReport', {name: 'ManualOpenReport'}); + + expect(mockStartInactiveSpan).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ActiveSpansTest.ts b/tests/unit/ActiveSpansTest.ts index aa21aa19ca45..742e0339a770 100644 --- a/tests/unit/ActiveSpansTest.ts +++ b/tests/unit/ActiveSpansTest.ts @@ -2,6 +2,11 @@ import {endSpan, getSpanByPrefix, startSpan} from '@libs/telemetry/activeSpans'; import CONST from '@src/CONST'; +jest.mock('@libs/telemetry/logBenchmarkSpanEnd', () => ({ + __esModule: true, + default: jest.fn(), + isBenchmarkSpanEnabled: () => false, +})); jest.mock('@sentry/react-native', () => ({ startInactiveSpan: () => ({ setAttribute: jest.fn(), @@ -32,7 +37,6 @@ describe('activeSpans', () => { expect(consoleDebugSpy).toHaveBeenLastCalledWith(expect.stringContaining('Ending span (750ms)'), expect.objectContaining({durationMs: 750, timestamp: 1_786_362_201_750})); }); - describe('getSpanByPrefix', () => { const prefix = CONST.TELEMETRY.SPAN_STARTUP_DATA.APPLY; diff --git a/tests/unit/AddExistingExpenseSearchTest.ts b/tests/unit/AddExistingExpenseSearchTest.ts index c80c6f0b73bd..0966ef58928a 100644 --- a/tests/unit/AddExistingExpenseSearchTest.ts +++ b/tests/unit/AddExistingExpenseSearchTest.ts @@ -1,4 +1,3 @@ -import {convertToDisplayString} from '@libs/CurrencyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import {getAmount, getCurrency, getDescription, getMerchant} from '@libs/TransactionUtils'; @@ -8,10 +7,9 @@ import type Transaction from '@src/types/onyx/Transaction'; import createMock from '../utils/createMock'; // Mock the dependencies -jest.mock('@libs/CurrencyUtils'); jest.mock('@libs/TransactionUtils'); -const mockConvertToDisplayString = jest.mocked(convertToDisplayString); +const mockConvertToDisplayString = jest.fn(); const mockGetMerchant = jest.mocked(getMerchant); const mockGetDescription = jest.mocked(getDescription); const mockGetAmount = jest.mocked(getAmount); @@ -123,7 +121,7 @@ describe('AddExistingExpense Search Functionality', () => { // Add formatted amount to searchable fields const amount = getAmount(transaction); const currency = getCurrency(transaction); - const formattedAmount = convertToDisplayString(amount, currency); + const formattedAmount = mockConvertToDisplayString(amount, currency); searchableFields.push(formattedAmount); // This allows users to search "2000" and find "$2,000.00" for example diff --git a/tests/unit/AddWorkEmailTest.ts b/tests/unit/AddWorkEmailTest.ts new file mode 100644 index 000000000000..40e2e6590a52 --- /dev/null +++ b/tests/unit/AddWorkEmailTest.ts @@ -0,0 +1,113 @@ +import {act} from '@testing-library/react-native'; + +import {AddWorkEmail} from '@libs/actions/Session'; +import HttpUtils from '@libs/HttpUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/AddWorkEmailForm'; +import type {Response as OnyxResponse} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +TestHelper.setupGlobalFetchMock(); + +const workEmail = 'testuser@privateemail.com'; + +/** Mocks the backend response of the next AddWorkEmail request and restores the original implementation once it settles. */ +function mockAddWorkEmailResponse(mockedResponse: OnyxResponse, formID?: typeof ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM) { + const originalXhr = HttpUtils.xhr; + HttpUtils.xhr = jest.fn().mockImplementation(() => Promise.resolve(mockedResponse)); + AddWorkEmail(workEmail, formID); + return waitForBatchedUpdates().then(() => { + HttpUtils.xhr = originalXhr; + }); +} + +/** The generic backend rejection reported in https://github.com/Expensify/App/issues/98664 */ +const forbiddenResponse: OnyxResponse = { + jsonCode: CONST.JSON_CODE.EXP_ERROR, + message: '403 Forbidden', + title: '', +}; + +describe('AddWorkEmail', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await TestHelper.signInWithTestUser(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + describe('when a workspace card flow submits the form', () => { + it('stops the form loading state and shows the failure inline when the backend rejects the request', async () => { + await mockAddWorkEmailResponse(forbiddenResponse, ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); + + const form = await getOnyxValue(ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); + expect(form?.isLoading).toBe(false); + expect(Object.values(form?.errorFields?.[INPUT_IDS.EMAIL] ?? {})).toEqual([TestHelper.translateLocal('common.genericErrorMessage')]); + }); + + it('shows the specific reason inline when the backend returns one', async () => { + await mockAddWorkEmailResponse( + { + jsonCode: CONST.JSON_CODE.EXP_ERROR, + message: `${workEmail} ${CONST.MERGE_ACCOUNT_2FA_ERROR}`, + title: '401 work account uses 2FA', + }, + ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM, + ); + + const form = await getOnyxValue(ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); + expect(Object.values(form?.errorFields?.[INPUT_IDS.EMAIL] ?? {})).toEqual([TestHelper.translateLocal('onboarding.workEmail2FAError')]); + }); + + it('does not write onboarding state', async () => { + await mockAddWorkEmailResponse(forbiddenResponse, ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); + + expect((await getOnyxValue(ONYXKEYS.NVP_ONBOARDING))?.isMergingAccountBlocked).toBeUndefined(); + expect(await getOnyxValue(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY)).toBeUndefined(); + expect(await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM)).toBeUndefined(); + }); + + it('stops the form loading state when the request succeeds', async () => { + await mockAddWorkEmailResponse({jsonCode: 200}, ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); + + const form = await getOnyxValue(ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM); + expect(form?.isLoading).toBe(false); + expect(form?.errorFields).toBeUndefined(); + }); + }); + + describe('when onboarding submits the form', () => { + it('blocks the merge on a generic failure', async () => { + await mockAddWorkEmailResponse(forbiddenResponse); + + expect((await getOnyxValue(ONYXKEYS.NVP_ONBOARDING))?.isMergingAccountBlocked).toBe(true); + expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.isLoading).toBe(false); + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM)).toBeUndefined(); + }); + + it('surfaces the specific reason on the onboarding error key', async () => { + await mockAddWorkEmailResponse({ + jsonCode: CONST.JSON_CODE.EXP_ERROR, + message: `${workEmail} ${CONST.MERGE_ACCOUNT_2FA_ERROR}`, + title: '401 work account uses 2FA', + }); + + expect(await getOnyxValue(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY)).toBe('onboarding.workEmail2FAError'); + }); + }); +}); diff --git a/tests/unit/AgentActionTest.ts b/tests/unit/AgentActionTest.ts index 1b6df9bdc8e9..59536a85e34e 100644 --- a/tests/unit/AgentActionTest.ts +++ b/tests/unit/AgentActionTest.ts @@ -329,16 +329,14 @@ describe('createAgent', () => { expect(failureData.some((u) => u.key === ONYXKEYS.FORMS.ADD_AGENT_FORM)).toBe(false); }); - it('success data nulls out both optimistic entries', () => { + it('success data leaves both optimistic entries in place so replaceOptimisticAgentWithActualAgent can clear them after redirecting', () => { createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); const {optimisticData, successData} = getWriteOptions(); const accountID = getOptimisticAccountID(optimisticData); - const promptRollback = findUpdate(successData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); - - expect(getPersonalDetailValue(successData, accountID)).toBeNull(); - expect(promptRollback?.value).toBeNull(); + expect(findUpdate(successData, ONYXKEYS.PERSONAL_DETAILS_LIST)).toBeUndefined(); + expect(findUpdate(successData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`)).toBeUndefined(); }); it('passes the optimistic accountID through to the server so it can echo a real-ID mapping', () => { diff --git a/tests/unit/AgreementsFullStepTest.tsx b/tests/unit/AgreementsFullStepTest.tsx new file mode 100644 index 000000000000..a89c3bae49c4 --- /dev/null +++ b/tests/unit/AgreementsFullStepTest.tsx @@ -0,0 +1,70 @@ +import {render, screen} from '@testing-library/react-native'; + +import AgreementsFullStep from '@components/SubStepForms/AgreementsFullStep'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +// RenderHTML needs a TRenderEngineProvider that unit tests don't set up, and the two agreement labels that use it aren't +// what these assertions are about. +jest.mock('@components/RenderHTML', () => () => null); + +// Without a LocaleContextProvider every translate() call resolves to an empty string, which would make text queries +// pass or fail regardless of what rendered. Returning the key keeps the assertions about rendering, not translation. +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +const inputIDs = { + provideTruthfulInformation: INPUT_IDS.PROVIDE_TRUTHFUL_INFORMATION, + agreeToTermsAndConditions: INPUT_IDS.AGREE_TO_TERMS_AND_CONDITIONS, + consentToPrivacyNotice: INPUT_IDS.CONSENT_TO_PRIVACY_NOTICE, + authorizedToBindClientToAgreement: INPUT_IDS.AUTHORIZED_TO_BIND_CLIENT_TO_AGREEMENT, +}; + +const defaultValues = { + provideTruthfulInformation: true, + agreeToTermsAndConditions: true, + consentToPrivacyNotice: true, + authorizedToBindClientToAgreement: true, +}; + +function renderStep(bankStatementInputID?: typeof INPUT_IDS.BANK_STATEMENT) { + return render( + , + ); +} + +describe('AgreementsFullStep', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + it('renders the bank statement upload when bankStatementInputID is passed', () => { + renderStep(INPUT_IDS.BANK_STATEMENT); + expect(screen.getByText('agreementsStep.bankStatement')).toBeOnTheScreen(); + }); + + it('omits the bank statement upload when bankStatementInputID is not passed', () => { + renderStep(undefined); + expect(screen.queryByText('agreementsStep.bankStatement')).not.toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/ApiUtilsEnvironmentOrderingTest.ts b/tests/unit/ApiUtilsEnvironmentOrderingTest.ts new file mode 100644 index 000000000000..20d79b9e8330 --- /dev/null +++ b/tests/unit/ApiUtilsEnvironmentOrderingTest.ts @@ -0,0 +1,67 @@ +/** + * ApiUtils derives the staging flag on demand rather than caching it, so that what is known before the + * environment resolves is not discarded once it does. + * + * The other ApiUtils suites drain the environment promise before their first assertion, so none of them can + * exercise this. Here the mocked getEnvironment is deliberately left unsettled until the test resolves it. + */ +import type * as ApiUtilsModule from '@libs/ApiUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const PRODUCTION_API_ROOT = 'https://www.expensify.com/'; +const STAGING_API_ROOT = 'https://staging.expensify.com/'; + +const mockConfig = { + IS_USING_LOCAL_WEB: false, + IS_USING_WEB_PROXY: false, + EXPENSIFY: { + DEFAULT_API_ROOT: PRODUCTION_API_ROOT, + DEFAULT_SECURE_API_ROOT: 'https://secure.expensify.com/', + STAGING_API_ROOT, + STAGING_SECURE_API_ROOT: 'https://staging-secure.expensify.com/', + EXPENSIFY_URL: PRODUCTION_API_ROOT, + SECURE_EXPENSIFY_URL: 'https://secure.expensify.com/', + }, +}; + +// Held open so the test controls exactly when the environment becomes known. +let mockResolveEnvironment: (environment: string) => void = () => {}; +const mockEnvironmentPromise = new Promise((resolve) => { + mockResolveEnvironment = resolve; +}); + +jest.mock('@src/libs/Environment/getEnvironment', () => ({ + __esModule: true, + default: () => mockEnvironmentPromise, +})); + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: mockConfig})); + +Onyx.init({keys: ONYXKEYS}); + +const ApiUtils = require('@libs/ApiUtils'); + +describe('ApiUtils when the stored preference arrives before the environment', () => { + it('keeps a preference stored before the environment resolves', async () => { + // Stored first, while ENV_NAME is still the PRODUCTION default + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, CONST.SERVER.STAGING); + await waitForBatchedUpdates(); + + // A deliberate choice needs no environment to interpret it, so it applies straight away + expect(ApiUtils.getApiRoot()).toBe(STAGING_API_ROOT); + + // The literal matches CONST.ENVIRONMENT.STAGING; hardcoded so the mock factory stays self-contained + mockResolveEnvironment('staging'); + await waitForBatchedUpdates(); + + // The preference was stored before anyone could interpret it, and must not have been discarded + expect(ApiUtils.getApiRoot()).toBe(STAGING_API_ROOT); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.STAGING); + }); +}); diff --git a/tests/unit/ApiUtilsProductionEnvTest.ts b/tests/unit/ApiUtilsProductionEnvTest.ts new file mode 100644 index 000000000000..0ee4dced94d4 --- /dev/null +++ b/tests/unit/ApiUtilsProductionEnvTest.ts @@ -0,0 +1,81 @@ +import type * as ApiUtilsModule from '@libs/ApiUtils'; + +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 mockConfig = { + IS_USING_LOCAL_WEB: false, + IS_USING_WEB_PROXY: false, + EXPENSIFY: { + DEFAULT_API_ROOT: 'https://www.expensify.com/', + DEFAULT_SECURE_API_ROOT: 'https://secure.expensify.com/', + STAGING_API_ROOT: 'https://staging.expensify.com/', + STAGING_SECURE_API_ROOT: 'https://staging-secure.expensify.com/', + QA_API_ROOT: 'https://qa.exops.io/', + QA_SECURE_API_ROOT: 'https://qa-secure.exops.io/', + EXPENSIFY_URL: 'https://www.expensify.com/', + SECURE_EXPENSIFY_URL: 'https://secure.expensify.com/', + }, +}; + +jest.mock('@src/libs/Environment/getEnvironment', () => ({ + __esModule: true, + // The literal value matches CONST.ENVIRONMENT.PRODUCTION. Hardcoded so the factory doesn't depend + // on any `mock*`-prefixed module-scope binding (which would require a let-and-mutate dance). + default: () => Promise.resolve('production'), +})); + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: mockConfig})); + +Onyx.init({keys: ONYXKEYS}); + +// Lazy-require so the @src/CONFIG mock factory sees an initialized mockConfig. Otherwise the +// hoisted import order would resolve CONFIG.default while mockConfig was still in the TDZ. +const ApiUtils = require('@libs/ApiUtils'); + +async function setActiveServer(value: ValueOf | null) { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, value); + await waitForBatchedUpdates(); +} + +beforeAll(async () => { + // Drain the initial getEnvironment().then(...) so ApiUtils subscribes to ACTIVE_SERVER. + await waitForBatchedUpdates(); +}); + +beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); +}); + +describe('a production build', () => { + it('stays on production when nothing is stored', async () => { + await setActiveServer(null); + + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.PRODUCTION); + expect(ApiUtils.getApiRoot()).toBe('https://www.expensify.com/'); + }); + + it('honors a stored staging', async () => { + // Support hands staging to customers on store builds, and testers on a Play testing track have no + // other way in, so a deliberate choice outranks the build's own default + await setActiveServer(CONST.SERVER.STAGING); + + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.STAGING); + }); + + it('ignores a stored qa and stays on production', async () => { + // Unlike staging, QA has no host a production build could reach + await setActiveServer(CONST.SERVER.QA); + + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.PRODUCTION); + expect(ApiUtils.isQAServerActive()).toBe(false); + expect(ApiUtils.getApiRoot()).toBe('https://www.expensify.com/'); + }); +}); diff --git a/tests/unit/ApiUtilsProductionEnvironmentTest.ts b/tests/unit/ApiUtilsProductionEnvironmentTest.ts new file mode 100644 index 000000000000..49e95c762161 --- /dev/null +++ b/tests/unit/ApiUtilsProductionEnvironmentTest.ts @@ -0,0 +1,97 @@ +/** + * Covers how ApiUtils resolves the staging server toggle when the environment resolves to PRODUCTION. + * + * This lives in its own file because ApiUtils captures the environment once at module load, so a single test file can + * only exercise one environment. ApiUtilsTest.ts covers ADHOC and ApiUtilsStagingEnvironmentTest.ts covers STAGING. + */ +import type * as ApiUtilsModule from '@libs/ApiUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const PRODUCTION_API_ROOT = 'https://www.expensify.com/'; +const STAGING_API_ROOT = 'https://staging.expensify.com/'; + +const mockConfig = { + IS_USING_LOCAL_WEB: false, + IS_USING_WEB_PROXY: false, + EXPENSIFY: { + DEFAULT_API_ROOT: PRODUCTION_API_ROOT, + DEFAULT_SECURE_API_ROOT: 'https://secure.expensify.com/', + STAGING_API_ROOT, + STAGING_SECURE_API_ROOT: 'https://staging-secure.expensify.com/', + EXPENSIFY_URL: PRODUCTION_API_ROOT, + SECURE_EXPENSIFY_URL: 'https://secure.expensify.com/', + }, +}; + +jest.mock('@src/libs/Environment/getEnvironment', () => ({ + __esModule: true, + // The literal value matches CONST.ENVIRONMENT.PRODUCTION. Hardcoded so the factory doesn't depend + // on any `mock*`-prefixed module-scope binding (which would require a let-and-mutate dance). + default: () => Promise.resolve('production'), +})); + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: mockConfig})); + +Onyx.init({keys: ONYXKEYS}); + +// Lazy-require so the @src/CONFIG mock factory sees an initialized mockConfig — otherwise the +// hoisted import order would resolve CONFIG.default while mockConfig was still in the TDZ. +const ApiUtils = require('@libs/ApiUtils'); + +async function setStagingToggle(value: boolean | null) { + if (value === null) { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, null); + } else { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, value ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION); + } + await waitForBatchedUpdates(); +} + +beforeAll(async () => { + // Drain the initial getEnvironment().then(...) so ENV_NAME is set + await waitForBatchedUpdates(); +}); + +beforeEach(async () => { + mockConfig.IS_USING_LOCAL_WEB = false; + // Clear so every test starts from the same Onyx state — otherwise same-value writes are deduped + await Onyx.clear(); + await waitForBatchedUpdates(); +}); + +describe('ApiUtils in a production environment', () => { + it('routes to production when the toggle was never set', async () => { + await setStagingToggle(null); + + expect(ApiUtils.getApiRoot()).toBe(PRODUCTION_API_ROOT); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.PRODUCTION); + }); + + it('routes to production when the toggle is off', async () => { + await setStagingToggle(false); + + expect(ApiUtils.getApiRoot()).toBe(PRODUCTION_API_ROOT); + }); + + it('honors the toggle when it is on', async () => { + // A production build defaults to production, but a deliberate choice still wins: support hands staging + // to customers on store builds, and testers on a Play testing track have no other way in. + await setStagingToggle(true); + + expect(ApiUtils.getApiRoot()).toBe(STAGING_API_ROOT); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.STAGING); + }); + + it('ignores the toggle on the internal dev environment', async () => { + await setStagingToggle(true); + mockConfig.IS_USING_LOCAL_WEB = true; + + expect(ApiUtils.getApiRoot()).toBe(PRODUCTION_API_ROOT); + }); +}); diff --git a/tests/unit/ApiUtilsQAEnvTest.ts b/tests/unit/ApiUtilsQAEnvTest.ts new file mode 100644 index 000000000000..1e5ad7ee20b9 --- /dev/null +++ b/tests/unit/ApiUtilsQAEnvTest.ts @@ -0,0 +1,74 @@ +import type * as ApiUtilsModule from '@libs/ApiUtils'; + +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 mockConfig = { + IS_USING_LOCAL_WEB: false, + IS_USING_WEB_PROXY: false, + EXPENSIFY: { + DEFAULT_API_ROOT: 'https://www.expensify.com/', + DEFAULT_SECURE_API_ROOT: 'https://secure.expensify.com/', + STAGING_API_ROOT: 'https://staging.expensify.com/', + STAGING_SECURE_API_ROOT: 'https://staging-secure.expensify.com/', + QA_API_ROOT: 'https://qa.exops.io/', + QA_SECURE_API_ROOT: 'https://qa-secure.exops.io/', + EXPENSIFY_URL: 'https://www.expensify.com/', + SECURE_EXPENSIFY_URL: 'https://secure.expensify.com/', + }, +}; + +jest.mock('@src/libs/Environment/getEnvironment', () => ({ + __esModule: true, + // The literal value matches CONST.ENVIRONMENT.QA. Hardcoded so the factory doesn't depend + // on any `mock*`-prefixed module-scope binding (which would require a let-and-mutate dance). + default: () => Promise.resolve('qa'), +})); + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: mockConfig})); + +Onyx.init({keys: ONYXKEYS}); + +// Lazy-require so the @src/CONFIG mock factory sees an initialized mockConfig. Otherwise the +// hoisted import order would resolve CONFIG.default while mockConfig was still in the TDZ. +const ApiUtils = require('@libs/ApiUtils'); + +async function setActiveServer(value: ValueOf | null) { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, value); + await waitForBatchedUpdates(); +} + +beforeAll(async () => { + // Drain the initial getEnvironment().then(...) so ApiUtils subscribes to ACTIVE_SERVER. + await waitForBatchedUpdates(); +}); + +beforeEach(async () => { + mockConfig.EXPENSIFY.QA_API_ROOT = 'https://qa.exops.io/'; + await Onyx.clear(); + await waitForBatchedUpdates(); +}); + +describe('a QA build', () => { + it.each([CONST.SERVER.PRODUCTION, CONST.SERVER.STAGING])('ignores a stored %s and stays on QA', async (storedServer) => { + await setActiveServer(storedServer); + + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.QA); + expect(ApiUtils.isQAServerActive()).toBe(true); + expect(ApiUtils.getApiRoot()).toBe('https://qa.exops.io/'); + }); + + it('falls back to the environment default when QA_EXPENSIFY_URL is unset, rather than routing to an empty root', async () => { + mockConfig.EXPENSIFY.QA_API_ROOT = ''; + await setActiveServer(CONST.SERVER.STAGING); + + expect(ApiUtils.isQAServerActive()).toBe(false); + expect(ApiUtils.getApiRoot()).toBe('https://staging.expensify.com/'); + }); +}); diff --git a/tests/unit/ApiUtilsStagingEnvironmentTest.ts b/tests/unit/ApiUtilsStagingEnvironmentTest.ts new file mode 100644 index 000000000000..13455effd2dc --- /dev/null +++ b/tests/unit/ApiUtilsStagingEnvironmentTest.ts @@ -0,0 +1,96 @@ +/** + * Covers how ApiUtils resolves the staging server toggle when the environment resolves to STAGING. + * + * This lives in its own file because ApiUtils captures the environment once at module load, so a single test file can + * only exercise one environment. ApiUtilsTest.ts covers ADHOC and ApiUtilsProductionEnvironmentTest.ts covers PRODUCTION. + */ +import type * as ApiUtilsModule from '@libs/ApiUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const PRODUCTION_API_ROOT = 'https://www.expensify.com/'; +const STAGING_API_ROOT = 'https://staging.expensify.com/'; + +const mockConfig = { + IS_USING_LOCAL_WEB: false, + IS_USING_WEB_PROXY: false, + EXPENSIFY: { + DEFAULT_API_ROOT: PRODUCTION_API_ROOT, + DEFAULT_SECURE_API_ROOT: 'https://secure.expensify.com/', + STAGING_API_ROOT, + STAGING_SECURE_API_ROOT: 'https://staging-secure.expensify.com/', + EXPENSIFY_URL: PRODUCTION_API_ROOT, + SECURE_EXPENSIFY_URL: 'https://secure.expensify.com/', + }, +}; + +jest.mock('@src/libs/Environment/getEnvironment', () => ({ + __esModule: true, + // The literal value matches CONST.ENVIRONMENT.STAGING. Hardcoded so the factory doesn't depend + // on any `mock*`-prefixed module-scope binding (which would require a let-and-mutate dance). + default: () => Promise.resolve('staging'), +})); + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: mockConfig})); + +Onyx.init({keys: ONYXKEYS}); + +// Lazy-require so the @src/CONFIG mock factory sees an initialized mockConfig — otherwise the +// hoisted import order would resolve CONFIG.default while mockConfig was still in the TDZ. +const ApiUtils = require('@libs/ApiUtils'); + +async function setStagingToggle(value: boolean | null) { + if (value === null) { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, null); + } else { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, value ? CONST.SERVER.STAGING : CONST.SERVER.PRODUCTION); + } + await waitForBatchedUpdates(); +} + +beforeAll(async () => { + // Drain the initial getEnvironment().then(...) so ENV_NAME is set + await waitForBatchedUpdates(); +}); + +beforeEach(async () => { + mockConfig.IS_USING_LOCAL_WEB = false; + // Clear so every test starts from the same Onyx state — otherwise same-value writes are deduped + await Onyx.clear(); + await waitForBatchedUpdates(); +}); + +describe('ApiUtils in a staging environment', () => { + it('defaults to staging when the toggle was never set', async () => { + await setStagingToggle(null); + + expect(ApiUtils.getApiRoot()).toBe(STAGING_API_ROOT); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.STAGING); + }); + + it('honours a deliberate opt-out', async () => { + // A stored false must not be overridden by the staging default + await setStagingToggle(false); + + expect(ApiUtils.getApiRoot()).toBe(PRODUCTION_API_ROOT); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.PRODUCTION); + }); + + it('keeps the toggle on when it is explicitly set', async () => { + await setStagingToggle(true); + + expect(ApiUtils.getApiRoot()).toBe(STAGING_API_ROOT); + }); + + it('ignores the toggle on the internal dev environment', async () => { + await setStagingToggle(true); + mockConfig.IS_USING_LOCAL_WEB = true; + + expect(ApiUtils.getApiRoot()).toBe(PRODUCTION_API_ROOT); + }); +}); diff --git a/tests/unit/ApiUtilsTest.ts b/tests/unit/ApiUtilsTest.ts index 698541192f8b..daebb04c5d6d 100644 --- a/tests/unit/ApiUtilsTest.ts +++ b/tests/unit/ApiUtilsTest.ts @@ -1,7 +1,10 @@ import type * as ApiUtilsModule from '@libs/ApiUtils'; +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'; @@ -14,6 +17,8 @@ const mockConfig = { DEFAULT_SECURE_API_ROOT: 'https://secure.expensify.com/', STAGING_API_ROOT: 'https://staging.expensify.com/', STAGING_SECURE_API_ROOT: 'https://staging-secure.expensify.com/', + QA_API_ROOT: 'https://qa.exops.io/', + QA_SECURE_API_ROOT: 'https://qa-secure.exops.io/', EXPENSIFY_URL: 'https://www.expensify.com/', SECURE_EXPENSIFY_URL: 'https://secure.expensify.com/', }, @@ -30,27 +35,27 @@ jest.mock('@src/CONFIG', () => ({__esModule: true, default: mockConfig})); Onyx.init({keys: ONYXKEYS}); -// Lazy-require so the @src/CONFIG mock factory sees an initialized mockConfig — otherwise the +// Lazy-require so the @src/CONFIG mock factory sees an initialized mockConfig. Otherwise the // hoisted import order would resolve CONFIG.default while mockConfig was still in the TDZ. const ApiUtils = require('@libs/ApiUtils'); type CommandRequest = Parameters[0]; -async function setStagingToggle(value: boolean | null) { - await Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, value); +async function setActiveServer(value: ValueOf | null) { + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, value); await waitForBatchedUpdates(); } beforeAll(async () => { - // Drain the initial getEnvironment().then(...) so ApiUtils subscribes to SHOULD_USE_STAGING_SERVER. + // Drain the initial getEnvironment().then(...) so ApiUtils subscribes to ACTIVE_SERVER. await waitForBatchedUpdates(); }); beforeEach(async () => { mockConfig.IS_USING_WEB_PROXY = false; mockConfig.IS_USING_LOCAL_WEB = false; + mockConfig.EXPENSIFY.QA_API_ROOT = 'https://qa.exops.io/'; // Clear so every test starts from the same Onyx state — otherwise same-value writes are deduped - // and the ApiUtils subscription never re-runs with the updated CONFIG flags. await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -59,17 +64,17 @@ describe('ApiUtils', () => { describe('getApiRoot — URL resolution (env=ADHOC)', () => { describe('staging toggle', () => { it('toggle on → STAGING_API_ROOT', async () => { - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot()).toBe('https://staging.expensify.com/'); }); it('toggle off → DEFAULT_API_ROOT', async () => { - await setStagingToggle(false); + await setActiveServer(CONST.SERVER.PRODUCTION); expect(ApiUtils.getApiRoot()).toBe('https://www.expensify.com/'); }); it('toggle cleared → defaults to staging (adhoc default)', async () => { - await setStagingToggle(null); + await setActiveServer(null); expect(ApiUtils.getApiRoot()).toBe('https://staging.expensify.com/'); }); }); @@ -77,53 +82,53 @@ describe('ApiUtils', () => { describe('web proxy', () => { it('proxy on + toggle on → proxyConfig.STAGING', async () => { mockConfig.IS_USING_WEB_PROXY = true; - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot()).toBe('/staging/'); }); it('shouldSkipWebProxy + proxy on + toggle on → STAGING_API_ROOT (proxy bypassed)', async () => { mockConfig.IS_USING_WEB_PROXY = true; - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot({shouldSkipWebProxy: true})).toBe('https://staging.expensify.com/'); }); it('shouldSkipWebProxy on non-staging path → EXPENSIFY_URL', async () => { - await setStagingToggle(false); + await setActiveServer(CONST.SERVER.PRODUCTION); expect(ApiUtils.getApiRoot({shouldSkipWebProxy: true})).toBe('https://www.expensify.com/'); }); }); describe('secure variants', () => { it('toggle off + secure → DEFAULT_SECURE_API_ROOT', async () => { - await setStagingToggle(false); + await setActiveServer(CONST.SERVER.PRODUCTION); expect(ApiUtils.getApiRoot({shouldUseSecure: true})).toBe('https://secure.expensify.com/'); }); it('toggle on + secure → STAGING_SECURE_API_ROOT', async () => { - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot({shouldUseSecure: true})).toBe('https://staging-secure.expensify.com/'); }); it('toggle on + secure + proxy → proxyConfig.STAGING_SECURE', async () => { mockConfig.IS_USING_WEB_PROXY = true; - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot({shouldUseSecure: true})).toBe('/staging-secure/'); }); it('shouldSkipWebProxy + secure (non-staging) → SECURE_EXPENSIFY_URL', async () => { - await setStagingToggle(false); + await setActiveServer(CONST.SERVER.PRODUCTION); expect(ApiUtils.getApiRoot({shouldUseSecure: true, shouldSkipWebProxy: true})).toBe('https://secure.expensify.com/'); }); }); describe('forceProduction', () => { it('forceProduction + toggle on → DEFAULT_API_ROOT', async () => { - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot(undefined, true)).toBe('https://www.expensify.com/'); }); it('forceProduction + toggle on + secure → DEFAULT_SECURE_API_ROOT', async () => { - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot({shouldUseSecure: true}, true)).toBe('https://secure.expensify.com/'); }); }); @@ -131,7 +136,7 @@ describe('ApiUtils', () => { describe('env clamping (via IS_USING_LOCAL_WEB)', () => { it('IS_USING_LOCAL_WEB + toggle on → DEFAULT_API_ROOT (toggle force-disabled on local web)', async () => { mockConfig.IS_USING_LOCAL_WEB = true; - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getApiRoot()).toBe('https://www.expensify.com/'); }); }); @@ -139,36 +144,88 @@ describe('ApiUtils', () => { describe('getCommandURL', () => { it('staging Ping → {root}api/Ping?', async () => { - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getCommandURL({command: 'Ping'} as CommandRequest)).toBe('https://staging.expensify.com/api/Ping?'); }); it('production Ping → {root}api/Ping?', async () => { - await setStagingToggle(false); + await setActiveServer(CONST.SERVER.PRODUCTION); expect(ApiUtils.getCommandURL({command: 'Ping'} as CommandRequest)).toBe('https://www.expensify.com/api/Ping?'); }); it('omits extra ? when command already contains one', async () => { - await setStagingToggle(false); + await setActiveServer(CONST.SERVER.PRODUCTION); expect(ApiUtils.getCommandURL({command: 'Ping?accountID=42'} as CommandRequest)).toBe('https://www.expensify.com/api/Ping?accountID=42'); }); it('routes through proxy when IS_USING_WEB_PROXY + toggle on', async () => { mockConfig.IS_USING_WEB_PROXY = true; - await setStagingToggle(true); + await setActiveServer(CONST.SERVER.STAGING); expect(ApiUtils.getCommandURL({command: 'Ping'} as CommandRequest)).toBe('/staging/api/Ping?'); }); }); - describe('isUsingStagingApi', () => { - it('returns true when toggle is on', async () => { - await setStagingToggle(true); - expect(ApiUtils.isUsingStagingApi()).toBe(true); + describe('QA server routing', () => { + it('is inactive by default', async () => { + await setActiveServer(null); + + expect(ApiUtils.isQAServerActive()).toBe(false); + expect(ApiUtils.getApiRoot()).toBe('https://staging.expensify.com/'); + }); + + it('routes both roots to QA when activeServer is qa', async () => { + await setActiveServer(CONST.SERVER.QA); + + expect(ApiUtils.isQAServerActive()).toBe(true); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.QA); + expect(ApiUtils.getApiRoot()).toBe('https://qa.exops.io/'); + expect(ApiUtils.getApiRoot({shouldUseSecure: true})).toBe('https://qa-secure.exops.io/'); + }); + + it('never routes to QA when forceProduction is set', async () => { + await setActiveServer(CONST.SERVER.QA); + + expect(ApiUtils.getApiRoot(undefined, true)).toBe('https://www.expensify.com/'); + }); + + it('bypasses the web proxy for QA \u2014 Cloudflare matches CORS and the bearer against the real origin', async () => { + mockConfig.IS_USING_WEB_PROXY = true; + await setActiveServer(CONST.SERVER.QA); + + expect(ApiUtils.getApiRoot({shouldSkipWebProxy: false})).toBe('https://qa.exops.io/'); + }); + + it('drops a stored qa once QA_EXPENSIFY_URL is cleared, rather than routing to an empty root', async () => { + mockConfig.EXPENSIFY.QA_API_ROOT = ''; + await setActiveServer(CONST.SERVER.QA); + + expect(ApiUtils.isQAServerActive()).toBe(false); + expect(ApiUtils.getApiRoot()).toBe('https://staging.expensify.com/'); + }); + + it('stays on QA for an internal dev build \u2014 the one case IS_USING_LOCAL_WEB does not win', async () => { + mockConfig.IS_USING_LOCAL_WEB = true; + await setActiveServer(CONST.SERVER.QA); + + expect(ApiUtils.isQAServerActive()).toBe(true); + expect(ApiUtils.getApiRoot()).toBe('https://qa.exops.io/'); + }); + }); + + describe('getActiveServer', () => { + it('returns staging when the staging server is active', async () => { + await setActiveServer(CONST.SERVER.STAGING); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.STAGING); + }); + + it('returns production when the production server is active', async () => { + await setActiveServer(CONST.SERVER.PRODUCTION); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.PRODUCTION); }); - it('returns false when toggle is off', async () => { - await setStagingToggle(false); - expect(ApiUtils.isUsingStagingApi()).toBe(false); + it('returns qa when the QA server is active', async () => { + await setActiveServer(CONST.SERVER.QA); + expect(ApiUtils.getActiveServer()).toBe(CONST.SERVER.QA); }); }); }); diff --git a/tests/unit/AttachmentPickerAssetProcessingTest.ts b/tests/unit/AttachmentPickerAssetProcessingTest.ts new file mode 100644 index 000000000000..f3d4056d2863 --- /dev/null +++ b/tests/unit/AttachmentPickerAssetProcessingTest.ts @@ -0,0 +1,237 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import processPickedAssetsSequentially from '@libs/fileDownload/processPickedAssets'; + +import type {Asset} from 'react-native-image-picker'; + +const mockVerifyFileFormat = jest.fn(); +const mockRenderAsync = jest.fn(); +const mockSaveAsync = jest.fn(); +const mockRelease = jest.fn(); +const mockImageRelease = jest.fn(); + +jest.mock('@libs/fileDownload/FileUtils', () => ({ + getFileName: (url: string) => url.split('/').pop()?.split('?').at(0) ?? '', + verifyFileFormat: () => mockVerifyFileFormat() as unknown, +})); + +jest.mock('expo-image-manipulator', () => ({ + ImageManipulator: { + manipulate: () => ({ + renderAsync: () => mockRenderAsync() as unknown, + release: () => { + mockRelease(); + }, + }), + }, + SaveFormat: {JPEG: 'jpeg'}, +})); + +jest.mock('@libs/Log', () => ({ + info: jest.fn(), + warn: jest.fn(), +})); + +const buildHeicAssets = (count: number): Asset[] => + Array.from({length: count}, (value, index) => ({ + uri: `file:///photo-${index}.heic`, + fileName: `photo-${index}.heic`, + type: 'image/heic', + })); + +const showGeneralAlert = jest.fn(); +// Returns the key itself so assertions can tell the different failure messages apart. +const translate: LocaleContextProps['translate'] = (path, ...parameters): string => (parameters.length > 0 ? `${path}:${parameters.length}` : path); + +describe('processPickedAssetsSequentially', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockVerifyFileFormat.mockResolvedValue(true); + mockSaveAsync.mockResolvedValue({uri: 'file:///photo.jpg', width: 100, height: 200}); + mockRenderAsync.mockImplementation(() => + Promise.resolve({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }), + ); + }); + + it('decodes only one image at a time', async () => { + let inFlight = 0; + let peakInFlight = 0; + + // Hold each decode open long enough that any overlap would be observable in `peakInFlight`. + mockRenderAsync.mockImplementation(() => { + inFlight++; + peakInFlight = Math.max(peakInFlight, inFlight); + return new Promise((resolve) => { + setImmediate(() => { + inFlight--; + resolve({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }); + }); + }); + }); + + const result = await processPickedAssetsSequentially(buildHeicAssets(30), showGeneralAlert, translate); + + expect(peakInFlight).toBe(1); + expect(mockRenderAsync).toHaveBeenCalledTimes(30); + expect(result).toHaveLength(30); + }); + + it('releases the native image resources for every converted asset', async () => { + await processPickedAssetsSequentially(buildHeicAssets(5), showGeneralAlert, translate); + + expect(mockRelease).toHaveBeenCalledTimes(5); + expect(mockImageRelease).toHaveBeenCalledTimes(5); + }); + + it('releases the manipulator context even when the conversion fails', async () => { + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate); + + expect(mockRelease).toHaveBeenCalledTimes(3); + }); + + it('skips assets that fail to convert instead of uploading the raw HEIC', async () => { + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + const result = await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate); + + expect(result).toBeUndefined(); + }); + + it('shows a single alert when the whole selection fails the same way', async () => { + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + await processPickedAssetsSequentially(buildHeicAssets(30), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledTimes(1); + }); + + it('passes non-HEIC images through without decoding them', async () => { + mockVerifyFileFormat.mockResolvedValue(false); + + const result = await processPickedAssetsSequentially([{uri: 'file:///photo.jpg', fileName: 'photo.jpg', type: 'image/jpeg'}], showGeneralAlert, translate); + + expect(mockRenderAsync).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + it('preserves selection order across mixed HEIC and non-HEIC assets', async () => { + mockVerifyFileFormat.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockSaveAsync.mockResolvedValueOnce({uri: 'file:///a-converted.jpg', width: 1, height: 1}).mockResolvedValueOnce({uri: 'file:///c-converted.jpg', width: 1, height: 1}); + + const result = await processPickedAssetsSequentially( + [ + {uri: 'file:///a.heic', fileName: 'a.heic', type: 'image/heic'}, + {uri: 'file:///b.jpg', fileName: 'b.jpg', type: 'image/jpeg'}, + {uri: 'file:///c.heic', fileName: 'c.heic', type: 'image/heic'}, + ], + showGeneralAlert, + translate, + ); + + expect(result?.map((asset) => asset.fileName)).toEqual(['a-converted.jpg', 'b.jpg', 'c-converted.jpg']); + }); + + it('skips assets that have no uri', async () => { + const result = await processPickedAssetsSequentially([{fileName: 'no-uri.heic', type: 'image/heic'}], showGeneralAlert, translate); + + expect(mockVerifyFileFormat).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('passes non-image assets through without checking the file format', async () => { + const result = await processPickedAssetsSequentially([{uri: 'file:///doc.pdf', fileName: 'doc.pdf', type: 'application/pdf'}], showGeneralAlert, translate); + + expect(mockVerifyFileFormat).not.toHaveBeenCalled(); + expect(result?.at(0)?.fileName).toBe('doc.pdf'); + }); + + it('surfaces the underlying message when the format check fails', async () => { + mockVerifyFileFormat.mockRejectedValueOnce(new Error('format check failed')); + + await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledWith('format check failed'); + }); + + it('falls back to localized copy when the failure is not an Error', async () => { + mockVerifyFileFormat.mockRejectedValueOnce('not an error object'); + + await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledWith('attachmentPicker.errorWhileSelectingAttachment'); + }); + + it('shows one alert even when the selection fails in different ways', async () => { + mockVerifyFileFormat.mockRejectedValueOnce(new Error('format check failed')); + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + await processPickedAssetsSequentially(buildHeicAssets(2), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledTimes(1); + expect(showGeneralAlert).toHaveBeenCalledWith('format check failed\nattachmentPicker.errorWhileConvertingHeic'); + }); + it.each([ + ['the rendered image', () => mockImageRelease], + ['the manipulator context', () => mockRelease], + ])('keeps a converted asset even if releasing %s throws', async (name, getMock) => { + getMock().mockImplementation(() => { + throw new Error('release blew up'); + }); + + const result = await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(result).toHaveLength(1); + expect(showGeneralAlert).not.toHaveBeenCalled(); + }); + it('derives fileName from the uri and defaults type when the picker omits them', async () => { + const result = await processPickedAssetsSequentially([{uri: 'file:///scan.pdf'}], showGeneralAlert, translate); + + expect(result?.at(0)?.fileName).toBe('scan.pdf'); + expect(result?.at(0)?.type).toBe('image/jpeg'); + }); + + it('releases the rendered image and skips the asset when saving fails', async () => { + mockSaveAsync.mockRejectedValue(new Error('encode failed')); + + const result = await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(result).toBeUndefined(); + expect(mockImageRelease).toHaveBeenCalledTimes(1); + expect(mockRelease).toHaveBeenCalledTimes(1); + expect(showGeneralAlert).toHaveBeenCalledWith('attachmentPicker.errorWhileConvertingHeic'); + }); + + it('returns the successful assets and still alerts when some fail', async () => { + mockRenderAsync + .mockResolvedValueOnce({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }) + .mockRejectedValueOnce(new Error('decode failed')) + .mockResolvedValueOnce({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }); + + const result = await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate); + + expect(result).toHaveLength(2); + expect(showGeneralAlert).toHaveBeenCalledTimes(1); + expect(showGeneralAlert).toHaveBeenCalledWith('attachmentPicker.errorWhileConvertingHeic'); + }); +}); diff --git a/tests/unit/AttachmentViewTest.ts b/tests/unit/AttachmentViewTest.ts new file mode 100644 index 000000000000..10d228a99073 --- /dev/null +++ b/tests/unit/AttachmentViewTest.ts @@ -0,0 +1,105 @@ +import {checkIsFileImage} from '@components/Attachments/AttachmentView'; + +describe('checkIsFileImage', () => { + describe('file type', () => { + it('returns true when the file type is a renderable image MIME type and the file name has no image extension', () => { + // Given a pasted file with an image MIME type but a name without an image extension + const source = 'blob:https://new.expensify.com/1234-5678'; + const fileName = 'image'; + const fileType = 'image/png'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, fileName, fileType); + + // Then it is detected as an image + expect(result).toBe(true); + }); + + it('returns false when the file type is an image MIME type that React Native cannot render', () => { + // Given a file with an image MIME type outside the renderable list + const source = 'blob:https://new.expensify.com/1234-5678'; + const fileName = 'image'; + const fileType = 'image/vnd.adobe.photoshop'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, fileName, fileType); + + // Then it is not detected as an image + expect(result).toBe(false); + }); + + it('returns false when the file type is not an image MIME type', () => { + // Given a file with a non-image MIME type + const source = 'blob:https://new.expensify.com/1234-5678'; + const fileName = 'document'; + const fileType = 'text/plain'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, fileName, fileType); + + // Then it is not detected as an image + expect(result).toBe(false); + }); + }); + + describe('file name', () => { + it('returns true when the file name has an image extension and no file type is provided', () => { + // Given a file named with an image extension and no MIME type + const source = 'blob:https://new.expensify.com/1234-5678'; + const fileName = 'receipt.png'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, fileName); + + // Then it is detected as an image + expect(result).toBe(true); + }); + + it('returns false when neither the file name nor the source has an image extension', () => { + // Given a file named with a non-image extension and no MIME type + const source = 'blob:https://new.expensify.com/1234-5678'; + const fileName = 'receipt.pdf'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, fileName); + + // Then it is not detected as an image + expect(result).toBe(false); + }); + }); + + describe('source', () => { + it('returns true when the source URL has an image extension', () => { + // Given a source URL pointing to an image and no file name or type + const source = 'https://new.expensify.com/receipt.jpg'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, undefined); + + // Then it is detected as an image + expect(result).toBe(true); + }); + + it('returns true when the source is a number, which is how static images are represented in React Native', () => { + // Given a numeric source + const source = 42; + + // When checking whether the file is an image + const result = checkIsFileImage(source, undefined); + + // Then it is detected as an image + expect(result).toBe(true); + }); + + it('returns false when the source is a blob URL with no file name or type', () => { + // Given a blob source with no file name or type + const source = 'blob:https://new.expensify.com/1234-5678'; + + // When checking whether the file is an image + const result = checkIsFileImage(source, undefined); + + // Then it is not detected as an image + expect(result).toBe(false); + }); + }); +}); diff --git a/tests/unit/AvatarTest.tsx b/tests/unit/AvatarTest.tsx index 9dc3dfc086e6..bfdf2198d345 100644 --- a/tests/unit/AvatarTest.tsx +++ b/tests/unit/AvatarTest.tsx @@ -322,6 +322,26 @@ describe('Avatar', () => { expect(getHiddenTestId(workspaceFallbackTestID)).toBeTruthy(); }); + it('WorkspaceAvatar still renders the default workspace icon when avatarID is missing', async () => { + const workspaceFallbackTestID = getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME); + + // A `policy_` Onyx record is merged field-by-field, so `policy.id` can still be undefined on the + // fallback-icon branch. That used to throw out of render and white-screen the whole app. + render( + + + , + ); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(workspaceFallbackTestID)).toBeTruthy(); + }); + it('assigns different workspace avatar colors for distinct hex policy IDs', () => { const naNColor = getDefaultWorkspaceAvatarColor('NaN'); const hexPolicyColor = getDefaultWorkspaceAvatarColor(HEX_POLICY_ID_STARTING_WITH_LETTER); diff --git a/tests/unit/BenchmarkSpanLoggerIOSTest.ts b/tests/unit/BenchmarkSpanLoggerIOSTest.ts new file mode 100644 index 000000000000..8e6f2b8d141c --- /dev/null +++ b/tests/unit/BenchmarkSpanLoggerIOSTest.ts @@ -0,0 +1,27 @@ +import writeBenchmarkLog from '@libs/telemetry/writeBenchmarkLog/index.ios'; + +const mockMkdir = jest.fn, [string]>(() => Promise.resolve()); +const mockWriteFile = jest.fn, [string, string, string]>(() => Promise.resolve()); + +jest.mock('react-native-fs', () => ({ + CachesDirectoryPath: '/Library/Caches', + mkdir: (...args: [string]) => mockMkdir(...args), + writeFile: (...args: [string, string, string]) => mockWriteFile(...args), +})); + +describe('iOS benchmark span logging', () => { + it('persists the tagged event to a span-specific app-container marker', async () => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + writeBenchmarkLog('[EXPENSIFY_BENCHMARK] {"durationMs":250}', 'Manual/App Startup'); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockMkdir).toHaveBeenCalledWith('/Library/Caches/ExpensifyBenchmark'); + expect(mockWriteFile).toHaveBeenCalledWith('/Library/Caches/ExpensifyBenchmark/Manual%2FApp%20Startup.log', '[EXPENSIFY_BENCHMARK] {"durationMs":250}', 'utf8'); + } finally { + consoleWarn.mockRestore(); + } + }); +}); diff --git a/tests/unit/BenchmarkSpanLoggerTest.ts b/tests/unit/BenchmarkSpanLoggerTest.ts new file mode 100644 index 000000000000..0130562a63e2 --- /dev/null +++ b/tests/unit/BenchmarkSpanLoggerTest.ts @@ -0,0 +1,20 @@ +import {BENCHMARK_LOG_TAG, createBenchmarkSpanEndLogger, parseBenchmarkSpanNames} from '@libs/telemetry/logBenchmarkSpanEnd'; + +describe('benchmark span logging', () => { + it('parses and deduplicates configured span names', () => { + expect(parseBenchmarkSpanNames(' ManualAppStartup,ManualOpenReport,ManualAppStartup, ')).toEqual(['ManualAppStartup', 'ManualOpenReport']); + expect(parseBenchmarkSpanNames(undefined)).toEqual([]); + }); + + it('writes a structured event only for enabled spans', () => { + jest.spyOn(Date, 'now').mockReturnValue(1_234); + const writeLog = jest.fn(); + const logSpanEnd = createBenchmarkSpanEndLogger(['ManualAppStartup'], writeLog); + + logSpanEnd('ManualOpenReport', 100); + logSpanEnd('ManualAppStartup', 250); + + expect(writeLog).toHaveBeenCalledTimes(1); + expect(writeLog).toHaveBeenCalledWith(`${BENCHMARK_LOG_TAG} ${JSON.stringify({event: 'span_end', span: 'ManualAppStartup', durationMs: 250, timestamp: 1_234})}`, 'ManualAppStartup'); + }); +}); diff --git a/tests/unit/BetaCheckerAndroidTest.ts b/tests/unit/BetaCheckerAndroidTest.ts new file mode 100644 index 000000000000..dcdb6822fcac --- /dev/null +++ b/tests/unit/BetaCheckerAndroidTest.ts @@ -0,0 +1,55 @@ +import type betaCheckerModule from '@libs/Environment/betaChecker/index.android'; + +import CONST from '@src/CONST'; + +import DeviceInfo from 'react-native-device-info'; + +const PLAY_STORE_INSTALLER = 'com.android.vending'; + +// Required by path: jest-expo resolves `@libs/Environment/betaChecker` to the iOS file, so this suite is the only +// coverage the Android implementation can ever get. +const betaChecker = require<{default: typeof betaCheckerModule}>('@libs/Environment/betaChecker/index.android').default; + +function mockInstaller(installerPackageName: string) { + jest.spyOn(DeviceInfo, 'getInstallerPackageNameSync').mockReturnValue(installerPackageName); +} + +describe('betaChecker (android)', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + describe('sideloaded builds', () => { + // 'unknown' is what react-native-device-info reports when the installer is null, e.g. a plain `adb install` + it.each([['com.google.android.packageinstaller'], ['com.android.packageinstaller'], ['com.android.shell'], ['unknown']])( + 'reports a beta build when the installer is %s', + async (installer) => { + mockInstaller(installer); + + await expect(betaChecker.isBetaBuild()).resolves.toBe(true); + }, + ); + }); + + describe('Play Store builds', () => { + it('recognizes the Play Store installer', () => { + // Pinned here so the test below cannot pass by comparing a typo against itself + expect(CONST.PLAY_STORE_INSTALLER_PACKAGE_NAME).toBe(PLAY_STORE_INSTALLER); + }); + + it('reports a production build', async () => { + mockInstaller(PLAY_STORE_INSTALLER); + + await expect(betaChecker.isBetaBuild()).resolves.toBe(false); + }); + + it('reports a production build when the native call throws', async () => { + jest.spyOn(DeviceInfo, 'getInstallerPackageNameSync').mockImplementation(() => { + throw new Error('RNDeviceInfo is not available'); + }); + + // Anything that awaits getEnvironment() hangs forever if this rejects instead of resolving + await expect(betaChecker.isBetaBuild()).resolves.toBe(false); + }); + }); +}); diff --git a/tests/unit/BetaOverridesProductionTest.ts b/tests/unit/BetaOverridesProductionTest.ts new file mode 100644 index 000000000000..08abbee02f71 --- /dev/null +++ b/tests/unit/BetaOverridesProductionTest.ts @@ -0,0 +1,66 @@ +import defaultPermissions from '@libs/Permissions'; + +import CONST from '@src/CONST'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type PermissionsModule = typeof defaultPermissions; + +/** + * The overrides key survives app resets and is included in exported Onyx state, so it can reach a production build. + * Permissions resolves the environment once on import, so each case loads it in a fresh module registry. `configEnvironment` + * is what the build was compiled with and `resolvedEnvironment` is what getEnvironment reports, which differ on TestFlight. + */ +async function loadPermissionsForEnvironment(resolvedEnvironment: string, configEnvironment = resolvedEnvironment) { + jest.resetModules(); + jest.doMock('@libs/Environment/getEnvironment', () => ({ + __esModule: true, + default: () => Promise.resolve(resolvedEnvironment), + })); + jest.doMock('@src/CONFIG', () => ({ + __esModule: true, + default: {...jest.requireActual<{default: Record}>('@src/CONFIG').default, ENVIRONMENT: configEnvironment}, + })); + + let permissions: PermissionsModule = defaultPermissions; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + permissions = require('@libs/Permissions').default; + }); + + // the environment is resolved through two promises before Permissions stores it + await waitForBatchedUpdates(); + + return permissions; +} + +describe('beta overrides in production', () => { + it('ignores overrides when the environment is production', async () => { + // Given a production build + const Permissions = await loadPermissionsForEnvironment(CONST.ENVIRONMENT.PRODUCTION); + + // When a beta is resolved with an override pinned against the server betas + // Then the server betas win + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: true})).toBe(false); + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [CONST.BETAS.DEFAULT_ROOMS], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: false})).toBe(true); + }); + + it('applies overrides when the environment is staging', async () => { + // Given a staging build + const Permissions = await loadPermissionsForEnvironment(CONST.ENVIRONMENT.STAGING); + + // When a beta is resolved with an override pinned against the server betas + // Then the override wins + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: true})).toBe(true); + expect(Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, [CONST.BETAS.ASAP_SUBMIT], undefined, {[CONST.BETAS.ASAP_SUBMIT]: false})).toBe(false); + }); + + it('applies overrides on a TestFlight build, which is compiled as production but resolves to staging', async () => { + // Given a build compiled as production that the resolved environment downgrades to staging + const Permissions = await loadPermissionsForEnvironment(CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.PRODUCTION); + + // When a beta is resolved with an override pinned against the server betas + // Then the override wins, since only the resolved environment counts + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: true})).toBe(true); + }); +}); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index b836ad73d82a..3f1a88deb292 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -56,6 +56,7 @@ import { getPlaidInstitutionId, getSelectedFeed, getTranslationKeyForCardStatus, + getWalletProviderNameKey, getYearFromExpirationDateString, hasAssignedCardMatching, hasIssuedExpensifyCard, @@ -5022,3 +5023,17 @@ describe('getDomainByFundID', () => { expect(getDomainByFundID(domains, FUND_ID)).toBe(domain); }); }); + +describe('getWalletProviderNameKey', () => { + it('maps APPLE_PAY to the Apple Wallet key', () => { + expect(getWalletProviderNameKey(CONST.EXPENSIFY_CARD.WALLET_PROVIDER.APPLE_PAY)).toBe('appleWallet'); + }); + + it('maps ANDROID_PAY to the Google Wallet key, since that is how the card provider names Google Wallet', () => { + expect(getWalletProviderNameKey(CONST.EXPENSIFY_CARD.WALLET_PROVIDER.ANDROID_PAY)).toBe('googleWallet'); + }); + + it('falls back to the generic key when the provider is missing, which happens when the card provider reports UNKNOWN', () => { + expect(getWalletProviderNameKey(undefined)).toBe('digitalWallet'); + }); +}); diff --git a/tests/unit/CategoryContextualRulesUtilsTest.ts b/tests/unit/CategoryContextualRulesUtilsTest.ts index ddb67f4ef576..cefaba0f7a93 100644 --- a/tests/unit/CategoryContextualRulesUtilsTest.ts +++ b/tests/unit/CategoryContextualRulesUtilsTest.ts @@ -1,5 +1,4 @@ import getCategoryContextualRules from '@libs/CategoryContextualRulesUtils'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -7,7 +6,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {PolicyCategory} from '@src/types/onyx'; import createRandomPolicy from '../utils/collections/policies'; -import {translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const policy = { diff --git a/tests/unit/CategoryTaxRulesUtilsTest.ts b/tests/unit/CategoryTaxRulesUtilsTest.ts new file mode 100644 index 000000000000..4e6f31a5de7e --- /dev/null +++ b/tests/unit/CategoryTaxRulesUtilsTest.ts @@ -0,0 +1,143 @@ +import {getCategoryTaxRulesTableData, hasSelectableCategoryTaxRate, isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {Policy} from '@src/types/onyx'; +import type {ExpenseRule, TaxRate} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const CATEGORY_NAME = 'Travel'; +const TAX_ID = 'id_TAX_RATE_1'; +const DEFAULT_TAX_ID = 'id_TAX_EXEMPT'; + +/** A saved category tax rule, which stores the rate as an ID with no label of its own. */ +const buildCategoryTaxRule = (taxID: string): ExpenseRule => ({ + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: {externalID: taxID}, + }, + applyWhen: [ + { + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + field: CONST.POLICY.FIELDS.CATEGORY, + value: CATEGORY_NAME, + }, + ], +}); + +/** A policy holding one category tax rule, with `taxes` describing which rates the workspace still has. */ +const buildPolicy = (taxes: Record, expenseRules: ExpenseRule[] = [buildCategoryTaxRule(TAX_ID)]): Policy => + createMock({ + ...createRandomPolicy(0), + taxRates: { + name: 'Taxes', + defaultExternalID: DEFAULT_TAX_ID, + defaultValue: '0%', + foreignTaxDefault: DEFAULT_TAX_ID, + taxes, + }, + rules: {expenseRules}, + }); + +const buildTableData = (policy: Policy) => + getCategoryTaxRulesTableData({ + policy, + policyCategories: {[CATEGORY_NAME]: createMock({name: CATEGORY_NAME, enabled: true})}, + translate: translateLocal, + isOffline: false, + onNavigate: () => {}, + }); + +describe('CategoryTaxRulesUtils', () => { + beforeAll(() => { + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + describe('hasSelectableCategoryTaxRate', () => { + /** `hasSelectableCategoryTaxRate` reads tracking as well as the rates, so this states it rather than leaving it to the random policy. */ + const buildTaxTrackingPolicy = (taxes: Record, trackingEnabled = true): Policy => + createMock({ + ...buildPolicy(taxes), + tax: {trackingEnabled}, + }); + + it('is true when a rate other than the workspace default is enabled', () => { + const policy = buildTaxTrackingPolicy({ + [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), + [TAX_ID]: createMock({name: 'VAT', value: '5%'}), + }); + expect(hasSelectableCategoryTaxRate(policy)).toBe(true); + }); + + it('is false when the workspace default is the only rate left', () => { + const policy = buildTaxTrackingPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}); + expect(hasSelectableCategoryTaxRate(policy)).toBe(false); + }); + + it('is false when every other rate is disabled', () => { + const policy = buildTaxTrackingPolicy({ + [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), + [TAX_ID]: createMock({name: 'VAT', value: '5%', isDisabled: true}), + }); + expect(hasSelectableCategoryTaxRate(policy)).toBe(false); + }); + + it('is false when every other rate is being deleted', () => { + const policy = buildTaxTrackingPolicy({ + [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), + [TAX_ID]: createMock({name: 'VAT', value: '5%', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + }); + expect(hasSelectableCategoryTaxRate(policy)).toBe(false); + }); + + it('is false with tax tracking off, whatever rates the workspace holds', () => { + const policy = buildTaxTrackingPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%'})}, false); + expect(hasSelectableCategoryTaxRate(policy)).toBe(false); + }); + }); + + describe('isSelectableTaxRate', () => { + it('offers the workspace default to a merchant rule but not to a category rule', () => { + const policy = buildPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}); + const defaultRate = createMock({name: 'Tax exempt', value: '0%'}); + expect(isSelectableTaxRate(policy, DEFAULT_TAX_ID, defaultRate, false)).toBe(true); + expect(isSelectableTaxRate(policy, DEFAULT_TAX_ID, defaultRate, true)).toBe(false); + }); + }); + + describe('getCategoryTaxRulesTableData', () => { + it('names the rate the rule holds', () => { + const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%'})}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT (5%)'); + }); + + it('reads the rename rather than the label saved when the rule was created', () => { + const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT renamed', value: '7%'})}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT renamed (7%)'); + }); + + it('never shows the raw tax ID once the rate has left the workspace', () => { + // A category tax rule stores only the ID, so with the rate gone there is nothing to name it from. The row + // has to read as unset, the way the editor it opens does, rather than printing the ID at the admin. + const row = buildTableData(buildPolicy({})).at(0); + expect(row).toBeDefined(); + expect(row?.ruleDescription).not.toContain(TAX_ID); + expect(row?.searchTokens).not.toContain(TAX_ID); + }); + + it('still lists the rule when its rate is disabled, since the rule keeps applying it', () => { + const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%', isDisabled: true})}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT (5%)'); + }); + + it('still lists the rule when its rate has become the workspace default', () => { + const policy = buildPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}, [buildCategoryTaxRule(DEFAULT_TAX_ID)]); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Tax exempt (0%)'); + }); + }); +}); diff --git a/tests/unit/CategoryUtilsTest.ts b/tests/unit/CategoryUtilsTest.ts index 449daa474c34..765e3a3871a8 100644 --- a/tests/unit/CategoryUtilsTest.ts +++ b/tests/unit/CategoryUtilsTest.ts @@ -1,6 +1,7 @@ import { formatRequireItemizedReceiptsOverText, getAvailableNonPersonalPolicyCategories, + getCategoryDefaultTaxRate, getCategoryGLCode, getDecodedFullCategoryName, getDecodedLeafCategoryName, @@ -13,6 +14,7 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {ExpenseRule} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; @@ -471,3 +473,30 @@ describe('getCategoryGLCode', () => { expect(getCategoryGLCode(categories, 'Meals')).toBe('1200'); }); }); + +describe('getCategoryDefaultTaxRate', () => { + const buildCategoryTaxRule = (categoryName: string, taxID: string): ExpenseRule => ({ + applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: categoryName}], + // eslint-disable-next-line @typescript-eslint/naming-convention + tax: {field_id_TAX: {externalID: taxID}}, + }); + + it("returns the category's own rate", () => { + expect(getCategoryDefaultTaxRate([buildCategoryTaxRule('Travel', 'id_TAX_A')], 'Travel', 'id_TAX_DEFAULT')).toBe('id_TAX_A'); + }); + + it('falls back to the workspace rate when the category has no rule', () => { + expect(getCategoryDefaultTaxRate([buildCategoryTaxRule('Travel', 'id_TAX_A')], 'Meals', 'id_TAX_DEFAULT')).toBe('id_TAX_DEFAULT'); + }); + + it('ignores a rule that carries the name on some other condition', () => { + // Matching on the value alone would read a rule that a save or delete never targets, handing the expense a + // rate the admin never set for this category. + const tagNamedAfterTheCategory: ExpenseRule = { + applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.TAG, value: 'Travel'}], + // eslint-disable-next-line @typescript-eslint/naming-convention + tax: {field_id_TAX: {externalID: 'id_TAX_B'}}, + }; + expect(getCategoryDefaultTaxRate([tagNamedAfterTheCategory], 'Travel', 'id_TAX_DEFAULT')).toBe('id_TAX_DEFAULT'); + }); +}); diff --git a/tests/unit/ConfirmContentTest.tsx b/tests/unit/ConfirmContentTest.tsx index 0a1508fd450d..49e70da6af81 100644 --- a/tests/unit/ConfirmContentTest.tsx +++ b/tests/unit/ConfirmContentTest.tsx @@ -17,7 +17,7 @@ type ButtonProps = { const mockButtonSpy = jest.fn(); const mockImageSVGSpy = jest.fn(); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { const ReactLib = jest.requireActual('react'); const MockButton = (props: ButtonProps) => { mockButtonSpy(props); diff --git a/tests/unit/CopyPolicySettingsConfirmTest.tsx b/tests/unit/CopyPolicySettingsConfirmTest.tsx index cdfa798df2af..b38d9ba2f5d5 100644 --- a/tests/unit/CopyPolicySettingsConfirmTest.tsx +++ b/tests/unit/CopyPolicySettingsConfirmTest.tsx @@ -26,10 +26,10 @@ const TEST_USER_ACCOUNT_ID = 12345; // jest.mock factories can't reference imported bindings, but `mock`-prefixed locals are allowed. const MockView = View; -// Capture the onPress handler the copy button passes to ButtonComposed so the test can drive it. +// Capture the onPress handler the copy button passes to Button so the test can drive it. const mockOnPressHolder: {current?: () => void} = {current: undefined}; -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { function MockButton(props: {onPress?: () => void}) { mockOnPressHolder.current = props.onPress; return ; @@ -172,7 +172,7 @@ describe('CopyPolicySettingsConfirmPage', () => { await Onyx.set(ONYXKEYS.COPY_POLICY_SETTINGS, { sourcePolicyID: SOURCE_POLICY_ID, targetPolicyIDs: [TARGET_POLICY_ID], - parts: ['rules'] as Part[], + parts: ['perDiem'] as Part[], }); await waitForBatchedUpdates(); @@ -195,7 +195,7 @@ describe('CopyPolicySettingsConfirmPage', () => { await Onyx.set(ONYXKEYS.COPY_POLICY_SETTINGS, { sourcePolicyID: SOURCE_POLICY_ID, targetPolicyIDs: [TARGET_POLICY_ID], - parts: ['rules'] as Part[], + parts: ['perDiem'] as Part[], }); await waitForBatchedUpdates(); diff --git a/tests/unit/CopyPolicySettingsUpgradeTest.tsx b/tests/unit/CopyPolicySettingsUpgradeTest.tsx index 2564236b1eee..1d2ac36edab8 100644 --- a/tests/unit/CopyPolicySettingsUpgradeTest.tsx +++ b/tests/unit/CopyPolicySettingsUpgradeTest.tsx @@ -137,7 +137,7 @@ describe('CopyPolicySettingsUpgradePage', () => { await Onyx.set(ONYXKEYS.COPY_POLICY_SETTINGS, { sourcePolicyID: SOURCE_POLICY_ID, targetPolicyIDs: [TARGET_POLICY_ID], - parts: ['rules'] as Part[], + parts: ['perDiem'] as Part[], }); await waitForBatchedUpdates(); }); diff --git a/tests/unit/CopyPolicySettingsUtilsTest.ts b/tests/unit/CopyPolicySettingsUtilsTest.ts index c112f8013860..e3747879931e 100644 --- a/tests/unit/CopyPolicySettingsUtilsTest.ts +++ b/tests/unit/CopyPolicySettingsUtilsTest.ts @@ -435,8 +435,8 @@ describe('CopyPolicySettingsUtils', () => { describe('getControlOnlySelectedParts', () => { it('returns the selected parts a Collect target cannot access', () => { - const result = getControlOnlySelectedParts([collectTarget(1)], ['rules', 'perDiem', 'categories'] as Part[]); - expect(result).toEqual(expect.arrayContaining(['rules', 'perDiem'])); + const result = getControlOnlySelectedParts([collectTarget(1)], ['perDiem', 'categories'] as Part[]); + expect(result).toEqual(expect.arrayContaining(['perDiem'])); expect(result).not.toContain('categories'); }); @@ -444,8 +444,12 @@ describe('CopyPolicySettingsUtils', () => { expect(getControlOnlySelectedParts([collectTarget(1)], ['categories', 'tags'] as Part[])).toEqual([]); }); + it('does not treat rules as Control-only, since Collect can access them', () => { + expect(getControlOnlySelectedParts([collectTarget(1)], ['rules'] as Part[])).toEqual([]); + }); + it('returns nothing when there are no Collect targets', () => { - expect(getControlOnlySelectedParts([controlTarget(1)], ['rules'] as Part[])).toEqual([]); + expect(getControlOnlySelectedParts([controlTarget(1)], ['perDiem'] as Part[])).toEqual([]); }); }); @@ -453,7 +457,7 @@ describe('CopyPolicySettingsUtils', () => { it('returns every Collect target when a Control-only part is selected', () => { const collectA = collectTarget(1); const collectB = collectTarget(2); - const result = getCollectTargetsToUpgrade([collectA, collectB, controlTarget(3)], ['rules'] as Part[]); + const result = getCollectTargetsToUpgrade([collectA, collectB, controlTarget(3)], ['perDiem'] as Part[]); expect(result).toHaveLength(2); expect(result.map((policy) => policy.id)).toEqual(expect.arrayContaining([collectA.id, collectB.id])); }); @@ -463,17 +467,17 @@ describe('CopyPolicySettingsUtils', () => { }); it('ignores unresolved targets', () => { - expect(getCollectTargetsToUpgrade([undefined, controlTarget(1)], ['rules'] as Part[])).toEqual([]); + expect(getCollectTargetsToUpgrade([undefined, controlTarget(1)], ['perDiem'] as Part[])).toEqual([]); }); }); describe('shouldShowCopyPolicySettingsUpgradeStep', () => { it('is true when a Control-only part targets a Collect workspace', () => { - expect(shouldShowCopyPolicySettingsUpgradeStep([collectTarget(1)], ['rules'] as Part[])).toBe(true); + expect(shouldShowCopyPolicySettingsUpgradeStep([collectTarget(1)], ['perDiem'] as Part[])).toBe(true); }); it('is false when every target is already Control', () => { - expect(shouldShowCopyPolicySettingsUpgradeStep([controlTarget(1)], ['rules'] as Part[])).toBe(false); + expect(shouldShowCopyPolicySettingsUpgradeStep([controlTarget(1)], ['perDiem'] as Part[])).toBe(false); }); it('is false when no Control-only part is selected', () => { diff --git a/tests/unit/CurrencyUtilsTest.ts b/tests/unit/CurrencyUtilsTest.ts index 56c47c2a8777..425e70f573ff 100644 --- a/tests/unit/CurrencyUtilsTest.ts +++ b/tests/unit/CurrencyUtilsTest.ts @@ -55,22 +55,6 @@ describe('CurrencyUtils', () => { ); }); - describe('getCurrencyDecimals', () => { - test('Currency decimals smaller than or equal 2', () => { - expect(CurrencyUtils.getCurrencyDecimals('JPY')).toBe(0); - expect(CurrencyUtils.getCurrencyDecimals('USD')).toBe(2); - expect(CurrencyUtils.getCurrencyDecimals('RSD')).toBe(2); - }); - - test('Currency decimals larger than 2 should return 2', () => { - // Actual: 3 - expect(CurrencyUtils.getCurrencyDecimals('LYD')).toBe(2); - - // Actual: 4 - expect(CurrencyUtils.getCurrencyDecimals('UYW')).toBe(2); - }); - }); - describe('convertToBackendAmount', () => { test.each([ [25, 2500], @@ -131,33 +115,6 @@ describe('CurrencyUtils', () => { }); }); - describe('convertToDisplayString', () => { - test.each([ - [CONST.CURRENCY.USD, 25, '$0.25'], - [CONST.CURRENCY.USD, 2500, '$25.00'], - [CONST.CURRENCY.USD, 150, '$1.50'], - [CONST.CURRENCY.USD, 250000, '$2,500.00'], - ['JPY', 2500, '¥25'], - ['JPY', 250000, '¥2,500'], - ['JPY', 2500.5, '¥25'], - ['RSD', 100, 'RSD\xa01.00'], - ['RSD', 145, 'RSD\xa01.45'], - ['BHD', 12345, 'BHD\xa0123.45'], - ['BHD', 1, 'BHD\xa00.01'], - ])('Correctly displays %s', (currency, amount, expectedResult) => { - expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult); - }); - - test.each([ - ['EUR', 25, '0,25\xa0€'], - ['EUR', 2500, '25,00\xa0€'], - ['EUR', 250000, '2500,00\xa0€'], - ['EUR', 250000000, '2.500.000,00\xa0€'], - ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) => - IntlStore.load(CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult)), - ); - }); - describe('convertToShortDisplayString', () => { test.each([ [CONST.CURRENCY.USD, 25, '$0'], @@ -263,7 +220,7 @@ describe('CurrencyUtils', () => { test('shares the throttle across helpers that go through sanitizeCurrencyCode', () => { const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => undefined); try { - CurrencyUtils.convertToDisplayString(2500, 'XX'); + CurrencyUtils.convertAmountToDisplayString(2500, 'XX'); CurrencyUtils.getLocalizedCurrencySymbol(CONST.LOCALES.EN, 'XX'); CurrencyUtils.convertToShortDisplayString(2500, 'XX'); expect(warnSpy).toHaveBeenCalledTimes(1); @@ -273,27 +230,6 @@ describe('CurrencyUtils', () => { }); }); - describe('convertToDisplayString with malformed currency', () => { - test.each(['', 'XX', 'USDD', '???'])('does not throw and falls back to USD formatting for %p', (input) => { - expect(() => CurrencyUtils.convertToDisplayString(2500, input)).not.toThrow(); - expect(CurrencyUtils.convertToDisplayString(2500, input)).toBe('$25.00'); - }); - - test('normalizes case-only variations to the intended currency instead of USD', () => { - expect(CurrencyUtils.convertToDisplayString(2500, 'eur')).toBe(CurrencyUtils.convertToDisplayString(2500, 'EUR')); - }); - - test('falls back to USD when shouldUseLocalCurrencySymbol is true and currency is malformed', () => { - expect(() => CurrencyUtils.convertToDisplayString(2500, 'invalid', true)).not.toThrow(); - // USD has a known local symbol in the currencyList, so the local-symbol branch should produce a $-prefixed result. - expect(CurrencyUtils.convertToDisplayString(2500, 'invalid', true)).toMatch(/\$/); - }); - - test('handles undefined currency via the default parameter', () => { - expect(CurrencyUtils.convertToDisplayString(2500, undefined)).toBe('$25.00'); - }); - }); - describe('convertToShortDisplayString with malformed currency', () => { test.each(['', 'XX', 'USDD', '???'])('does not throw and falls back to USD formatting for %p', (input) => { expect(() => CurrencyUtils.convertToShortDisplayString(2500, input)).not.toThrow(); @@ -309,29 +245,6 @@ describe('CurrencyUtils', () => { }); }); - describe('convertToDisplayStringWithoutCurrency with malformed currency', () => { - test.each(['', 'XX', 'USDD', '???'])('does not throw and produces a numeric output for %p', (input) => { - expect(() => CurrencyUtils.convertToDisplayStringWithoutCurrency(2500, input)).not.toThrow(); - // Output should not contain a currency symbol but should contain the numeric portion. - const result = CurrencyUtils.convertToDisplayStringWithoutCurrency(2500, input); - expect(result).not.toMatch(/\$/); - expect(result).toContain('25'); - }); - }); - - describe('convertToDisplayStringWithExplicitCurrency with malformed currency', () => { - test.each(['XX', 'USDD', '???'])('does not throw and falls back to USD formatting for truthy malformed %p', (input) => { - expect(() => CurrencyUtils.convertToDisplayStringWithExplicitCurrency(2500, input)).not.toThrow(); - expect(CurrencyUtils.convertToDisplayStringWithExplicitCurrency(2500, input)).toBe('$25.00'); - }); - - test.each([undefined, ''])('returns the symbol-less form for falsy currency %p (delegates to convertToDisplayStringWithoutCurrency)', (input) => { - const result = CurrencyUtils.convertToDisplayStringWithExplicitCurrency(2500, input); - expect(result).not.toMatch(/\$/); - expect(result).toContain('25'); - }); - }); - describe('getLocalizedCurrencySymbol with malformed currency', () => { test.each(['', 'XX', 'USDD'])('returns the USD symbol without throwing for %p', (input) => { expect(() => CurrencyUtils.getLocalizedCurrencySymbol(CONST.LOCALES.EN, input)).not.toThrow(); diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts index 771cc8309b04..2ff13173c7fe 100644 --- a/tests/unit/DebugUtilsTest.ts +++ b/tests/unit/DebugUtilsTest.ts @@ -394,6 +394,50 @@ describe('DebugUtils', () => { }); }); + describe('validateStringRecord', () => { + it('does not throw SyntaxError when value is "undefined"', () => { + expect(() => { + DebugUtils.validateStringRecord('undefined'); + }).not.toThrow(); + }); + + it('does not throw SyntaxError when value is a string representation of an empty object', () => { + expect(() => { + DebugUtils.validateStringRecord('{}'); + }).not.toThrow(); + }); + + it('does not throw SyntaxError when value is a valid string representation of a string-to-string record', () => { + expect(() => { + DebugUtils.validateStringRecord('{"0":"CostCenterB","1":"IndicationZ"}'); + }).not.toThrow(); + }); + + it('throws SyntaxError when value is just a string', () => { + expect(() => { + DebugUtils.validateStringRecord('a'); + }).toThrow(); + }); + + it('throws SyntaxError when value is a string representation of an array', () => { + expect(() => { + DebugUtils.validateStringRecord('["a"]'); + }).toThrow(); + }); + + it('does not throw SyntaxError when value is "null" (treated as an empty value)', () => { + expect(() => { + DebugUtils.validateStringRecord('null'); + }).not.toThrow(); + }); + + it('throws SyntaxError when a value in the record is not a string', () => { + expect(() => { + DebugUtils.validateStringRecord('{"0":1}'); + }).toThrow(); + }); + }); + describe('validateObject', () => { describe('value is undefined', () => { it('does not throw SyntaxError', () => { diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index 34a1d6d81ce9..bc4168587e75 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -8,6 +8,7 @@ import { accountLockSelector, adminAccountIDsSelector, adminPendingActionSelector, + adminshipRequesterPendingActionSelector, defaultSecurityGroupIDSelector, domainEmailSelector, domainSecurityGroupSettingErrorsSelector, @@ -19,6 +20,7 @@ import { isSecurityGroupEntry, isSecurityGroupPendingDeleteSelector, memberAccountIDsSelector, + pendingAdminRequesterAccountIDsSelector, selectRestrictedPrimaryPolicyID, selectSecurityGroupForAccount, technicalContactSettingsSelector, @@ -140,6 +142,18 @@ describe('domainSelectors', () => { const domain = createDomainFixture({empty: true}); expect(adminAccountIDsSelector(domain)).toEqual([]); }); + + it('Should list an account once when several permission keys point at it', () => { + const domain = createDomainFixture({ + admins: [ + ['0', 123], + ['123', 123], + ['1', 321], + ], + }); + + expect(adminAccountIDsSelector(domain)).toEqual([123, 321]); + }); }); describe('technicalContactSettingsSelector', () => { @@ -875,6 +889,55 @@ describe('domainSelectors', () => { }); }); + describe('pendingAdminRequesterAccountIDsSelector', () => { + it('Should return an empty array if the domain object is undefined', () => { + expect(pendingAdminRequesterAccountIDsSelector(undefined)).toEqual([]); + }); + + it('Should return an empty array when domain_adminRequesters is missing entirely', () => { + const domain = createDomainFixture(); + expect(pendingAdminRequesterAccountIDsSelector(domain)).toEqual([]); + }); + + it('Should return accountIDs with truthy values and skip null tombstones', () => { + const domain = createDomainFixture({ + boundaryEntries: { + domain_adminRequesters: {[userID1]: 'read', [userID2]: null}, + }, + }); + + expect(pendingAdminRequesterAccountIDsSelector(domain)).toEqual([userID1]); + }); + + it('Should return an empty array when domain_adminRequesters is empty', () => { + const domain = createDomainFixture({boundaryEntries: {domain_adminRequesters: {}}}); + expect(pendingAdminRequesterAccountIDsSelector(domain)).toEqual([]); + }); + }); + + describe('adminshipRequesterPendingActionSelector', () => { + it.each([ + ['undefined', undefined, {}], + ['empty object', createFixture(domainPendingActionsFixture), {}], + ])('Should return empty object when pendingAction is %s', (_description, pendingAction, expected) => { + expect(adminshipRequesterPendingActionSelector(pendingAction)).toEqual(expected); + }); + + it('Should return the adminship requester pending actions when they exist', () => { + const pendingAction: OnyxEntry = { + adminshipRequester: { + [userID1]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }; + + expect(adminshipRequesterPendingActionSelector(pendingAction)).toEqual({ + [userID1]: {pendingAction: 'delete'}, + }); + }); + }); + describe('accountLockSelector', () => { it('Should return lock state for the given account ID', () => { const accountID = 123; diff --git a/tests/unit/DomainUtilsTest.ts b/tests/unit/DomainUtilsTest.ts index fd38a7e3c034..9607003e5683 100644 --- a/tests/unit/DomainUtilsTest.ts +++ b/tests/unit/DomainUtilsTest.ts @@ -7,9 +7,11 @@ import { hasDomainGroupsErrors, hasDomainMembersErrors, hasDomainMembersSettingsErrors, + hasPendingDomainAdminRequestsToReview, } from '@libs/DomainUtils'; import CONST from '@src/CONST'; +import type {Domain} from '@src/types/onyx'; import type DomainErrors from '@src/types/onyx/DomainErrors'; import type DomainPendingAction from '@src/types/onyx/DomainPendingActions'; @@ -113,6 +115,146 @@ describe('DomainUtils', () => { }; expect(hasDomainAdminsErrors(domainErrors)).toBe(true); }); + + it('should return false when adminshipRequesterErrors exist but have no errors inside', () => { + const domainErrors: DomainErrors = { + errors: {}, + adminshipRequesterErrors: {[adminID]: {errors: {}}}, + }; + expect(hasDomainAdminsErrors(domainErrors)).toBe(false); + }); + + it('should return true when an adminship requester has errors', () => { + const domainErrors: DomainErrors = { + errors: {}, + adminshipRequesterErrors: {[adminID]: {errors: {timestamp1: 'Approve/deny error'}}}, + }; + expect(hasDomainAdminsErrors(domainErrors)).toBe(true); + }); + + describe('when the domain is passed', () => { + const requesterDomain = (domainAdminRequesters: Domain['domain_adminRequesters']): Domain => ({ + validated: true, + accountID: 1, + email: 'test@example.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: domainAdminRequesters, + }); + const domainErrors: DomainErrors = { + errors: {}, + adminshipRequesterErrors: {[adminID]: {errors: {timestamp1: 'Approve/deny error'}}}, + }; + + it('should return true while that request is still pending', () => { + expect(hasDomainAdminsErrors(domainErrors, requesterDomain({[adminID]: 'read'}))).toBe(true); + }); + + it('should return false once that request is no longer pending, since no row can dismiss the error', () => { + expect(hasDomainAdminsErrors(domainErrors, requesterDomain({}))).toBe(false); + expect(hasDomainErrors(domainErrors, requesterDomain({}))).toBe(false); + }); + + it('should still report an admin error for an account that is no longer a requester', () => { + const errorsOnAdmin: DomainErrors = {...domainErrors, adminErrors: {[adminID]: {errors: {timestamp1: 'Admin error'}}}}; + expect(hasDomainAdminsErrors(errorsOnAdmin, requesterDomain({}))).toBe(true); + }); + }); + }); + + describe('hasPendingDomainAdminRequestsToReview', () => { + const currentUserAccountID = 42; + const requesterAccountID = 99; + const adminPermissionKey = `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${currentUserAccountID}` as const; + const baseDomain: Domain = { + validated: true, + accountID: 1, + email: 'test@example.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + }; + + it('should return false when domain is undefined', () => { + expect(hasPendingDomainAdminRequestsToReview(undefined, currentUserAccountID)).toBe(false); + }); + + it('should return false when currentUserAccountID is undefined', () => { + const domain: Domain = { + ...baseDomain, + [adminPermissionKey]: currentUserAccountID, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, undefined)).toBe(false); + }); + + it('should return true when the current user is an admin and there is a pending request', () => { + const domain: Domain = { + ...baseDomain, + [adminPermissionKey]: currentUserAccountID, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID)).toBe(true); + }); + + it('should return false when the current user is only a requester, not an admin', () => { + const domain: Domain = { + ...baseDomain, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[currentUserAccountID]: 'read'}, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID)).toBe(false); + }); + + it('should return false when the current user is an admin but there are no pending requests', () => { + const domain: Domain = { + ...baseDomain, + [adminPermissionKey]: currentUserAccountID, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID)).toBe(false); + }); + + it('should return false when the only pending request has already been denied', () => { + const domain: Domain = { + ...baseDomain, + [adminPermissionKey]: currentUserAccountID, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + const domainPendingActions: DomainPendingAction = { + adminshipRequester: {[requesterAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, domainPendingActions)).toBe(false); + }); + + it('should return true when a denied request leaves another one to review', () => { + const otherRequesterAccountID = 100; + const domain: Domain = { + ...baseDomain, + [adminPermissionKey]: currentUserAccountID, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read', [otherRequesterAccountID]: 'read'}, + }; + const domainPendingActions: DomainPendingAction = { + adminshipRequester: {[requesterAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, domainPendingActions)).toBe(true); + }); + + it('should return true when a denied request failed, since its pending action is cleared', () => { + const domain: Domain = { + ...baseDomain, + [adminPermissionKey]: currentUserAccountID, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + const domainPendingActions: DomainPendingAction = { + adminshipRequester: {[requesterAccountID]: {pendingAction: null}}, + }; + expect(hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, domainPendingActions)).toBe(true); + }); }); describe('hasDomainAdminsSettingsErrors', () => { diff --git a/tests/unit/EmojiSuggestionItemTest.tsx b/tests/unit/EmojiSuggestionItemTest.tsx new file mode 100644 index 000000000000..38a56a69f95a --- /dev/null +++ b/tests/unit/EmojiSuggestionItemTest.tsx @@ -0,0 +1,40 @@ +import {render, screen} from '@testing-library/react-native'; + +import type {Emoji} from '@assets/emojis/types'; + +import EmojiSuggestionItem from '@components/EmojiSuggestions/EmojiSuggestionItem'; + +import React from 'react'; + +const SMILE: Emoji = {code: '😄', name: 'smile', hexcode: '1F604'}; +const WAVE: Emoji = {code: '👋', name: 'wave', hexcode: '1F44B', types: ['👋🏿', '👋🏾', '👋🏽']}; + +describe('EmojiSuggestionItem', () => { + it.each([ + ['renders the plain emoji code when there are no skin tone variants', SMILE, 1, '😄'], + ['renders the preferred skin tone variant when available', WAVE, 1, '👋🏾'], + ['falls back to the plain code when the preferred skin tone has no variant', WAVE, 9, '👋'], + ])('%s', (_, item, preferredSkinToneIndex, expectedCode) => { + render( + , + ); + + expect(screen.getByText(expectedCode)).toBeOnTheScreen(); + }); + + it('renders the emoji name wrapped in colons', () => { + render( + , + ); + + expect(screen.getByText(':smile:')).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/EnvironmentContextTest.tsx b/tests/unit/EnvironmentContextTest.tsx index 3c06e13a3adc..12e07cbebdf3 100644 --- a/tests/unit/EnvironmentContextTest.tsx +++ b/tests/unit/EnvironmentContextTest.tsx @@ -1,6 +1,7 @@ import {render, waitFor} from '@testing-library/react-native'; import EnvironmentProvider, {EnvironmentActionsContext} from '@components/EnvironmentContextProvider'; +import {defaultEnvironmentActionsContextValue} from '@components/EnvironmentContextProvider/default'; import CONST from '@src/CONST'; @@ -166,5 +167,106 @@ describe('EnvironmentProvider', () => { const inputHtml = '

No links here

Just text
'; expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(inputHtml); }); + + // The backend writes anchors with single-quoted attributes, so these are the cases that matter most in practice. + it('should replace the origin in a single-quoted href generated by the backend', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = + "Connect your card to sync transactions and auto-match receipts next time!"; + const expectedOutput = + "Connect your card to sync transactions and auto-match receipts next time!"; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should replace the origin in a single-quoted href on dev', async () => { + await setupTest(CONST.ENVIRONMENT.DEV, 'https://dev.new.expensify.com:8082'); + const inputHtml = "Wallet"; + const expectedOutput = "Wallet"; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should replace the origin in an unquoted href and leave it unquoted', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = 'Wallet'; + const expectedOutput = 'Wallet'; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should preserve each anchor own quoting style when they are mixed', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = `Subscription and Wallet`; + const expectedOutput = `Subscription and Wallet`; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should replace every occurrence when two anchors share the same href', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = "firstsecond"; + const expectedOutput = "firstsecond"; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should not modify custom tags when the anchor is single-quoted', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = `😃Help
`; + const expectedOutput = `😃Help
`; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should not modify data-raw-href, which holds the text the user typed', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = + 'link'; + const expectedOutput = + 'link'; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should preserve HTML entities in the href', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = 'Search'; + const expectedOutput = 'Search'; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should not modify a host that merely starts with the production origin', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = 'Lookalike'; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(inputHtml); + }); + + it('should replace a bare production origin with no path', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = "Home"; + const expectedOutput = "Home"; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(expectedOutput); + }); + + it('should only rewrite anchor tags', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = ''; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(inputHtml); + }); + + // We deliberately only rewrite the production origin. Staging links pasted into production chats must keep + // pointing at staging, otherwise clicking them would silently land the reader on a different environment. + it('should not rewrite staging links when running on production', async () => { + await setupTest(CONST.ENVIRONMENT.PRODUCTION, CONST.NEW_EXPENSIFY_URL); + const inputHtml = "Staging report"; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(inputHtml); + }); + + it('should not rewrite staging links when running on staging', async () => { + await setupTest(CONST.ENVIRONMENT.STAGING, 'https://staging.new.expensify.com'); + const inputHtml = "Staging report"; + expect(adjustExpensifyLinksForEnv(inputHtml)).toBe(inputHtml); + }); + }); + + describe('defaultEnvironmentActionsContextValue', () => { + it('should return the HTML unchanged when rendered outside of EnvironmentProvider', () => { + const inputHtml = "Wallet"; + expect(defaultEnvironmentActionsContextValue.adjustExpensifyLinksForEnv(inputHtml)).toBe(inputHtml); + }); }); }); diff --git a/tests/unit/ExportOnyxStateTest.ts b/tests/unit/ExportOnyxStateTest.ts index c0e06ba29b9d..9d2d15315d74 100644 --- a/tests/unit/ExportOnyxStateTest.ts +++ b/tests/unit/ExportOnyxStateTest.ts @@ -211,6 +211,30 @@ describe('maskOnyxState', () => { }); }); + it('should mask policy vendor names while preserving the matching fields', () => { + const vendorKey = `${ONYXKEYS.COLLECTION.POLICY_VENDORS}123`; + const externalID = 'vendor-1'; + const input = { + [vendorKey]: { + [externalID]: { + externalID, + name: 'Acme Supplies', + enabled: true, + origin: 'quickbooksOnline', + }, + }, + }; + + const result = maskOnyxState(input); + const vendors = result[vendorKey]; + if (!isRecord(vendors) || !isRecord(vendors[externalID])) { + throw new Error('Expected policy vendors record'); + } + + expect(vendors[externalID]).toMatchObject({externalID, enabled: true, origin: 'quickbooksOnline'}); + expect(vendors[externalID].name).not.toBe('Acme Supplies'); + }); + describe('full pass-through safe collection keys', () => { it('should pass through data as-is for safe collection keys', () => { const mockViolations = [ diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 96c3d67a4d0f..da582906c23e 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -526,6 +526,23 @@ describe('FileUtils', () => { expect(result.reason).toBe('attachmentPicker.imageDimensionsTooLarge'); }); + it('should return the folder-specific error text for a single folder', () => { + const result = getFileValidationErrorText(mockTranslate, {error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED}); + + expect(result.title).toBe('attachmentPicker.attachmentError'); + expect(result.reason).toBe('attachmentPicker.folderNotAllowedMessage'); + }); + + it('should return the folder-specific error text when multiple items are selected', () => { + const result = getFileValidationErrorText(mockTranslate, { + error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED, + isValidatingMultipleFiles: true, + }); + + expect(result.title).toBe('attachmentPicker.someFilesCantBeUploaded'); + expect(result.reason).toBe('attachmentPicker.folderNotAllowedMessage'); + }); + it('should return empty strings for null validation error', () => { const result = getFileValidationErrorText(mockTranslate, null); diff --git a/tests/unit/GetEnvironmentNativeTest.ts b/tests/unit/GetEnvironmentNativeTest.ts new file mode 100644 index 000000000000..2658fe6821f9 --- /dev/null +++ b/tests/unit/GetEnvironmentNativeTest.ts @@ -0,0 +1,50 @@ +/** + * getEnvironment() is awaited by ApiUtils and NetworkState at startup, so it must always settle. A beta check + * that rejects — a missing native module, a throwing bridge call — would otherwise leave everything waiting on + * the environment frozen, with nothing to show why. + * + * Required by path because jest-expo resolves the platform-agnostic specifier to the web implementation. + */ +import type getEnvironmentModule from '@libs/Environment/getEnvironment/index.native'; + +import CONST from '@src/CONST'; + +const mockIsBetaBuild = jest.fn, []>(); + +jest.mock('@libs/Environment/betaChecker', () => ({ + __esModule: true, + default: {isBetaBuild: () => mockIsBetaBuild()}, +})); + +jest.mock('react-native-config', () => ({ + __esModule: true, + default: {ENVIRONMENT: 'production'}, +})); + +describe('getEnvironment (native)', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('resolves to production when the beta check rejects', async () => { + mockIsBetaBuild.mockRejectedValue(new Error('EnvironmentChecker is not available')); + const getEnvironment = require<{default: typeof getEnvironmentModule}>('@libs/Environment/getEnvironment/index.native').default; + + await expect(getEnvironment()).resolves.toBe(CONST.ENVIRONMENT.PRODUCTION); + }); + + it('resolves to staging when the beta check reports a beta build', async () => { + mockIsBetaBuild.mockResolvedValue(true); + const getEnvironment = require<{default: typeof getEnvironmentModule}>('@libs/Environment/getEnvironment/index.native').default; + + await expect(getEnvironment()).resolves.toBe(CONST.ENVIRONMENT.STAGING); + }); + + it('resolves to production when the beta check reports a production build', async () => { + mockIsBetaBuild.mockResolvedValue(false); + const getEnvironment = require<{default: typeof getEnvironmentModule}>('@libs/Environment/getEnvironment/index.native').default; + + await expect(getEnvironment()).resolves.toBe(CONST.ENVIRONMENT.PRODUCTION); + }); +}); diff --git a/tests/unit/GlobalReimbursementPayErrorTest.ts b/tests/unit/GlobalReimbursementPayErrorTest.ts new file mode 100644 index 000000000000..ff8213066656 --- /dev/null +++ b/tests/unit/GlobalReimbursementPayErrorTest.ts @@ -0,0 +1,177 @@ +import {WRITE_COMMANDS} from '@libs/API/types'; +import globalReimbursementPayError from '@libs/Middleware/GlobalReimbursementPayError'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; +import type Request from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import type {OnyxKey} from 'react-native-onyx'; + +const IOU_REPORT_ID = '12345'; +const REPORT_ACTION_ID = '67890'; +const OTHER_REPORT_ACTION_ID = '11111'; + +type PayRequestOverrides = Partial, 'failureData'>> & { + failureData?: AnyOnyxUpdate[]; +}; + +function buildPayRequest(overrides: PayRequestOverrides = {}): Request { + return { + command: WRITE_COMMANDS.PAY_MONEY_REQUEST, + data: { + iouReportID: IOU_REPORT_ID, + reportActionID: REPORT_ACTION_ID, + }, + failureData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, + value: { + [REPORT_ACTION_ID]: { + errors: {error: 'Failed to pay'}, + }, + [OTHER_REPORT_ACTION_ID]: { + errors: {error: 'Other error'}, + }, + }, + }, + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT}${IOU_REPORT_ID}`, + value: { + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }, + }, + ], + ...overrides, + } as Request; +} + +function buildCorpayPayModalResponse(jsonCode: number | string = CONST.JSON_CODE.EXP_ERROR): Response { + return { + jsonCode, + onyxData: [ + { + onyxMethod: 'set', + key: ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, + value: { + bankAccountID: 100, + bankCountry: CONST.COUNTRY.CA, + bankCurrency: CONST.CURRENCY.CAD, + }, + }, + ], + } as Response; +} + +function findFailureUpdate(request: Request, key: string) { + return request.failureData?.find((update) => update.key === key); +} + +describe('GlobalReimbursementPayError middleware', () => { + it('replaces optimistic PAY action error with null in failureData when payment fails with corpayPayModal', async () => { + const request = buildPayRequest(); + const response = buildCorpayPayModalResponse(); + + const result = await globalReimbursementPayError(Promise.resolve(response), request, false); + + expect(result).toBe(response); + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: null, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('works identically for PAY_MONEY_REQUEST_WITH_WALLET command', async () => { + const request = buildPayRequest({command: WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET}); + const response = buildCorpayPayModalResponse(); + + const result = await globalReimbursementPayError(Promise.resolve(response), request, false); + + expect(result).toBe(response); + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: null, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('leaves failureData untouched if response is successful (jsonCode 200)', async () => { + const request = buildPayRequest(); + const response = buildCorpayPayModalResponse(CONST.JSON_CODE.SUCCESS); + + await globalReimbursementPayError(Promise.resolve(response), request, false); + + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: {errors: {error: 'Failed to pay'}}, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('leaves failureData untouched for non-payment commands', async () => { + const request = buildPayRequest({command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST}); + const response = buildCorpayPayModalResponse(); + + await globalReimbursementPayError(Promise.resolve(response), request, false); + + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: {errors: {error: 'Failed to pay'}}, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('leaves failureData untouched if response does not contain RAM_ONLY_CORPAY_PAY_MODAL', async () => { + const request = buildPayRequest(); + const response: Response = { + jsonCode: CONST.JSON_CODE.EXP_ERROR, + onyxData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT}${IOU_REPORT_ID}`, + value: {statusNum: CONST.REPORT.STATUS_NUM.OPEN}, + }, + ], + } as Response; + + await globalReimbursementPayError(Promise.resolve(response), request, false); + + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: {errors: {error: 'Failed to pay'}}, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('handles missing or corrupt data fields gracefully', async () => { + const response = buildCorpayPayModalResponse(); + + // Missing iouReportID + const requestWithoutIou = buildPayRequest({data: {reportActionID: REPORT_ACTION_ID}}); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithoutIou, false)).resolves.toBe(response); + + // Missing reportActionID + const requestWithoutAction = buildPayRequest({data: {iouReportID: IOU_REPORT_ID}}); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithoutAction, false)).resolves.toBe(response); + + // Missing failureData + const requestWithoutFailureData = buildPayRequest({failureData: undefined}); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithoutFailureData, false)).resolves.toBe(response); + + // Non-object update value in failureData + const requestWithCorruptUpdate = buildPayRequest({ + failureData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, + value: 'invalid', + }, + ], + }); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithCorruptUpdate, false)).resolves.toBe(response); + }); +}); diff --git a/tests/unit/GoogleTagManagerTest.tsx b/tests/unit/GoogleTagManagerTest.tsx index 38d70c800e5d..f1c5df16da74 100644 --- a/tests/unit/GoogleTagManagerTest.tsx +++ b/tests/unit/GoogleTagManagerTest.tsx @@ -179,6 +179,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdatesWithAct(); createWorkspace({ @@ -192,6 +193,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: true, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdatesWithAct(); createWorkspace({ @@ -205,6 +207,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: true, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdatesWithAct(); @@ -226,6 +229,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, companySize: CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, }); @@ -249,6 +253,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, companySize: CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, }); @@ -267,6 +272,7 @@ describe('GoogleTagManagerTest', () => { const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; trackExpense({ + isDraftChatReport: false, conciergeChat: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, report: {reportID: '123'}, @@ -300,6 +306,7 @@ describe('GoogleTagManagerTest', () => { currentUserLocalCurrency: undefined, delegateAccountID: undefined, reportActionsList: undefined, + rules: undefined, }); await waitForBatchedUpdatesWithAct(); diff --git a/tests/unit/HandleMovedScanFailedExpensesTest.ts b/tests/unit/HandleMovedScanFailedExpensesTest.ts new file mode 100644 index 000000000000..1ba7c6901488 --- /dev/null +++ b/tests/unit/HandleMovedScanFailedExpensesTest.ts @@ -0,0 +1,401 @@ +import {replaceReportIDInPath} from '@libs/actions/IOU/reconcileMovedScanFailedReport'; +import handleMovedScanFailedExpenses from '@libs/Middleware/HandleMovedScanFailedExpenses'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; +import type Request from '@src/types/onyx/Request'; +import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import type {OnyxKey} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const CHAT_REPORT_ID = '100'; +const PAID_REPORT_ID = '200'; +const OPTIMISTIC_REPORT_ID = '300'; +const REAL_REPORT_ID = '400'; +const THREAD_REPORT_ID = '500'; +const TRANSACTION_ID = '600'; +const PREVIEW_ACTION_ID = '700'; +const OPTIMISTIC_IOU_ACTION_ID = '800'; +const REAL_IOU_ACTION_ID = '900'; + +const mockSetParams = jest.fn(); +const mockRootState = { + routes: [ + {key: 'report-route', name: 'Report', params: {reportID: OPTIMISTIC_REPORT_ID}}, + {key: 'rhp-route', name: 'Report_Details', params: {backTo: `/r/${OPTIMISTIC_REPORT_ID}`}}, + ], +}; + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: (...args: unknown[]): void => { + mockSetParams(...args); + }, + }, + navigationRef: { + isReady: () => true, + getRootState: () => mockRootState, + }, +})); + +function buildIOUAction(reportActionID: string, reportID: string, transactionID = TRANSACTION_ID): ReportAction { + return { + reportActionID, + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2026-08-24 10:00:00.000', + originalMessage: {IOUTransactionID: transactionID, IOUReportID: reportID, amount: 0, currency: CONST.CURRENCY.USD, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE}, + message: [], + }; +} + +/** Mirrors the pending-state cleanup that `getReportFromHoldRequestsOnyxData` already puts in successData for this flow. */ +function buildSuccessData(): AnyOnyxUpdate[] { + return [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHAT_REPORT_ID}`, + value: {[PREVIEW_ACTION_ID]: {pendingAction: null}}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OPTIMISTIC_REPORT_ID}`, + value: {[OPTIMISTIC_IOU_ACTION_ID]: {pendingAction: null}}, + }, + ]; +} + +function buildPayRequest(overrides: Record = {}, command = 'PayMoneyRequest'): Request { + return { + command, + data: { + full: true, + chatReportID: CHAT_REPORT_ID, + iouReportID: PAID_REPORT_ID, + optimisticHoldReportID: OPTIMISTIC_REPORT_ID, + ...overrides, + }, + successData: buildSuccessData(), + } as Request; +} + +function buildResponse(onyxData: AnyOnyxUpdate[]): Response { + return {jsonCode: 200, onyxData} as Response; +} + +function backendReportUpdate(): AnyOnyxUpdate { + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${REAL_REPORT_ID}`, + value: {reportID: REAL_REPORT_ID, chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE}, + }; +} + +function backendReportActionsUpdate(transactionID = TRANSACTION_ID): AnyOnyxUpdate { + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REAL_REPORT_ID}`, + value: {[REAL_IOU_ACTION_ID]: buildIOUAction(REAL_IOU_ACTION_ID, REAL_REPORT_ID, transactionID)}, + }; +} + +function backendResponse(): Response { + return buildResponse([backendReportUpdate(), backendReportActionsUpdate()]); +} + +function getSuccessData(request: Request): AnyOnyxUpdate[] { + return (request.successData ?? []) as AnyOnyxUpdate[]; +} + +/** The reconciliation appends its updates, so the last one for a key is the one it added. */ +function findAppended(request: Request, key: string) { + return getSuccessData(request).findLast((update) => update.key === key); +} + +describe('HandleMovedScanFailedExpenses middleware', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockSetParams.mockClear(); + await Onyx.clear(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${OPTIMISTIC_REPORT_ID}`, { + reportID: OPTIMISTIC_REPORT_ID, + chatReportID: CHAT_REPORT_ID, + parentReportID: CHAT_REPORT_ID, + parentReportActionID: PREVIEW_ACTION_ID, + type: CONST.REPORT.TYPE.EXPENSE, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${THREAD_REPORT_ID}`, { + reportID: THREAD_REPORT_ID, + parentReportID: OPTIMISTIC_REPORT_ID, + parentReportActionID: OPTIMISTIC_IOU_ACTION_ID, + chatReportID: OPTIMISTIC_REPORT_ID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OPTIMISTIC_REPORT_ID}`, { + [OPTIMISTIC_IOU_ACTION_ID]: buildIOUAction(OPTIMISTIC_IOU_ACTION_ID, OPTIMISTIC_REPORT_ID), + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, { + reportID: CHAT_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + iouReportID: OPTIMISTIC_REPORT_ID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: OPTIMISTIC_REPORT_ID, + }); + await waitForBatchedUpdates(); + }); + + it('leaves a response for another command untouched', async () => { + const request = {command: 'ApproveMoneyRequest', data: {}, successData: buildSuccessData()} as Request; + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(getSuccessData(request)).toHaveLength(2); + expect(mockSetParams).not.toHaveBeenCalled(); + }); + + it('reconciles a wallet payment too, which moves scan-failed expenses the same way', async () => { + const request = buildPayRequest({}, 'PayMoneyRequestWithWallet'); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(mockSetParams).toHaveBeenCalledWith({reportID: REAL_REPORT_ID}, 'report-route'); + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${OPTIMISTIC_REPORT_ID}`)?.value).toBeNull(); + }); + + it('leaves a hold split untouched, since it pays only part of the report and the backend reuses its optimistic report', async () => { + const request = buildPayRequest({full: false}); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(getSuccessData(request)).toHaveLength(2); + expect(mockSetParams).not.toHaveBeenCalled(); + }); + + it('leaves a failed payment to failureData, which rolls the move back on its own', async () => { + const request = buildPayRequest(); + const response = backendResponse(); + response.jsonCode = 400; + + await handleMovedScanFailedExpenses(Promise.resolve(response), request, false); + + expect(getSuccessData(request)).toHaveLength(2); + expect(mockSetParams).not.toHaveBeenCalled(); + }); + + it('retires the optimistic report and its report preview once the backend report arrives', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${OPTIMISTIC_REPORT_ID}`)?.value).toBeNull(); + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OPTIMISTIC_REPORT_ID}`)?.value).toBeNull(); + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHAT_REPORT_ID}`)?.value).toEqual({[PREVIEW_ACTION_ID]: null}); + }); + + it('removes the report actions after successData clears their pending state, never before', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + const successData = getSuccessData(request); + for (const key of [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHAT_REPORT_ID}`, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OPTIMISTIC_REPORT_ID}`]) { + const clearsPendingState = successData.findIndex((update) => update.key === key); + const removes = successData.findLastIndex((update) => update.key === key); + expect(clearsPendingState).toBeGreaterThanOrEqual(0); + expect(removes).toBeGreaterThan(clearsPendingState); + } + }); + + it("re-parents the moved expense's transaction thread onto the backend report action", async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${THREAD_REPORT_ID}`)?.value).toEqual({ + parentReportID: REAL_REPORT_ID, + parentReportActionID: REAL_IOU_ACTION_ID, + chatReportID: REAL_REPORT_ID, + }); + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REAL_REPORT_ID}`)?.value).toEqual({ + [REAL_IOU_ACTION_ID]: {childReportID: THREAD_REPORT_ID}, + }); + }); + + it('moves the expense itself onto the backend report, so the report it now shows is not empty', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(findAppended(request, `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`)?.value).toEqual({reportID: REAL_REPORT_ID}); + }); + + it('leaves the expense alone when the response already puts it on a report', async () => { + const request = buildPayRequest(); + const response = buildResponse([ + backendReportUpdate(), + backendReportActionsUpdate(), + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, + value: {transactionID: TRANSACTION_ID, reportID: REAL_REPORT_ID}, + }, + ]); + + await handleMovedScanFailedExpenses(Promise.resolve(response), request, false); + + expect(findAppended(request, `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`)).toBeUndefined(); + }); + + it('points the workspace chat at the backend report, so it does not keep the report that was removed', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`)?.value).toEqual({iouReportID: REAL_REPORT_ID}); + }); + + it('leaves the workspace chat alone when the response already says which report it points at', async () => { + const request = buildPayRequest(); + const response = buildResponse([ + backendReportUpdate(), + backendReportActionsUpdate(), + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, + value: {reportID: CHAT_REPORT_ID, iouReportID: '999'}, + }, + ]); + + await handleMovedScanFailedExpenses(Promise.resolve(response), request, false); + + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`)).toBeUndefined(); + }); + + it('moves every route showing the optimistic report onto the backend report before it is removed', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(mockSetParams).toHaveBeenCalledWith({reportID: REAL_REPORT_ID}, 'report-route'); + expect(mockSetParams).toHaveBeenCalledWith({backTo: `/r/${REAL_REPORT_ID}`}, 'rhp-route'); + }); + + it('still reconciles when the response carries the backend report but none of its actions', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(buildResponse([backendReportUpdate()])), request, false); + + expect(mockSetParams).toHaveBeenCalledWith({reportID: REAL_REPORT_ID}, 'report-route'); + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${OPTIMISTIC_REPORT_ID}`)?.value).toBeNull(); + // Without the backend actions there is nothing to re-parent the thread onto, so it is left for the next fetch. + expect(findAppended(request, `${ONYXKEYS.COLLECTION.REPORT}${THREAD_REPORT_ID}`)).toBeUndefined(); + }); + + it('does nothing at all when the response does not identify the backend report', async () => { + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(buildResponse([])), request, false); + + expect(getSuccessData(request)).toHaveLength(2); + expect(mockSetParams).not.toHaveBeenCalled(); + }); + + it('never sends a route to the workspace chat, which would render an expense report screen with no expenses', async () => { + const request = buildPayRequest(); + const chatOnlyResponse = buildResponse([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, + value: {reportID: CHAT_REPORT_ID, chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}, + }, + ]); + + await handleMovedScanFailedExpenses(Promise.resolve(chatOnlyResponse), request, false); + + expect(mockSetParams).not.toHaveBeenCalled(); + expect(getSuccessData(request)).toHaveLength(2); + }); + + it('does nothing when the response introduces more than one candidate report, rather than guessing', async () => { + const request = buildPayRequest(); + const ambiguousResponse = buildResponse([ + backendReportUpdate(), + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}999`, + value: {reportID: '999', chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE}, + }, + ]); + + await handleMovedScanFailedExpenses(Promise.resolve(ambiguousResponse), request, false); + + expect(mockSetParams).not.toHaveBeenCalled(); + expect(getSuccessData(request)).toHaveLength(2); + }); + + it('prefers the report that already carries a moved expense over any other new report', async () => { + const request = buildPayRequest(); + const response = buildResponse([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}999`, + value: {reportID: '999', chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE}, + }, + backendReportUpdate(), + backendReportActionsUpdate(), + ]); + + await handleMovedScanFailedExpenses(Promise.resolve(response), request, false); + + expect(mockSetParams).toHaveBeenCalledWith({reportID: REAL_REPORT_ID}, 'report-route'); + }); + + it('does nothing when the optimistic report is already gone', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${OPTIMISTIC_REPORT_ID}`, null); + await waitForBatchedUpdates(); + const request = buildPayRequest(); + + await handleMovedScanFailedExpenses(Promise.resolve(backendResponse()), request, false); + + expect(getSuccessData(request)).toHaveLength(2); + expect(mockSetParams).not.toHaveBeenCalled(); + }); +}); + +describe('replaceReportIDInPath', () => { + it('replaces a whole report route segment', () => { + expect(replaceReportIDInPath('/r/123', '123', '456')).toBe('/r/456'); + expect(replaceReportIDInPath('/e/123', '123', '456')).toBe('/e/456'); + expect(replaceReportIDInPath('/search/r/123', '123', '456')).toBe('/search/r/456'); + expect(replaceReportIDInPath('/search/view/123', '123', '456')).toBe('/search/view/456'); + expect(replaceReportIDInPath('/r/123/details', '123', '456')).toBe('/r/456/details'); + }); + + it('replaces a segment in a path the route helpers build without a leading slash', () => { + expect(replaceReportIDInPath('r/123', '123', '456')).toBe('r/456'); + expect(replaceReportIDInPath('e/123', '123', '456')).toBe('e/456'); + expect(replaceReportIDInPath('search/r/123', '123', '456')).toBe('search/r/456'); + expect(replaceReportIDInPath('search/view/123', '123', '456')).toBe('search/view/456'); + }); + + it('leaves a longer report ID that merely starts with the same digits alone', () => { + expect(replaceReportIDInPath('/r/1234', '123', '456')).toBe('/r/1234'); + expect(replaceReportIDInPath('/e/1234', '123', '456')).toBe('/e/1234'); + expect(replaceReportIDInPath('/search/r/1234', '123', '456')).toBe('/search/r/1234'); + }); + + it('leaves an unrelated path alone', () => { + expect(replaceReportIDInPath('/settings/123', '123', '456')).toBe('/settings/123'); + }); +}); diff --git a/tests/unit/HomeAddressRequiredContentTest.tsx b/tests/unit/HomeAddressRequiredContentTest.tsx index 9ddb2f10d00a..5be5ed523846 100644 --- a/tests/unit/HomeAddressRequiredContentTest.tsx +++ b/tests/unit/HomeAddressRequiredContentTest.tsx @@ -21,7 +21,7 @@ jest.mock('@components/RenderHTML', () => ({ })); jest.mock('@components/ReportActionItem/ActionableItemButtons', () => () => null); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { function MockButton() { return null; } diff --git a/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx b/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx index d54c4c976892..fd2d4a4ab22e 100644 --- a/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx +++ b/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx @@ -309,8 +309,8 @@ describe('YourSpendSection — third-party rows', () => { render(); const row = screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`); // The mock MenuItem renders as a Pressable wrapping a description Text. fireEvent.press - // requires a Pressable target, so we press the description text inside the row. - const description = within(row).getByText('homePage.yourSpend.recentTransactions'); + // requires a Pressable target, so we press the description text (the card's last four) inside the row. + const description = within(row).getByText('9876'); fireEvent.press(description); expect(Navigation.navigate).toHaveBeenLastCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: THIRD_PARTY_QUERY})); }); diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index 1fcec1cbb4bc..9e9b928cf2d3 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -26,7 +26,7 @@ import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {YOUR_SPEND_ROW_STATE} from '@pages/home/YourSpendSection/const'; -import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; +import {buildAwaitingApprovalQuery, buildCardGroupQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; import {useYourSpendData} from '@pages/home/YourSpendSection/useYourSpendData'; import CONST from '@src/CONST'; @@ -65,9 +65,12 @@ const CARD_QUERY_2 = `type:expense from:${ACCOUNT_ID} cardID:${CARD_ID_2}`; const THIRD_PARTY_QUERY_1 = `type:expense from:${ACCOUNT_ID} cardID:${THIRD_PARTY_CARD_ID_1}`; const THIRD_PARTY_QUERY_2 = `type:expense from:${ACCOUNT_ID} cardID:${THIRD_PARTY_CARD_ID_2}`; +const CARD_GROUP_QUERY = buildCardGroupQuery(ACCOUNT_ID); + // Module mocks jest.mock('@pages/home/YourSpendSection/queries', () => ({ + ...jest.requireActual>('@pages/home/YourSpendSection/queries'), buildAwaitingApprovalQuery: jest.fn(), buildRepaidLast30DaysQuery: jest.fn(), buildRecentCardTransactionsQuery: jest.fn(), @@ -181,36 +184,28 @@ function setupPaymentSnapshot(results: SearchResults | undefined) { onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`] = results; } -/** Seeds the allSnapshots collection with a card snapshot so the hook can read count/total/currency. */ -function setupCardSnapshot(cardID: number, results: SearchResults | undefined) { - let cardQuery: string; - switch (cardID) { - case CARD_ID_1: - cardQuery = CARD_QUERY_1; - break; - case CARD_ID_2: - cardQuery = CARD_QUERY_2; - break; - case THIRD_PARTY_CARD_ID_1: - cardQuery = THIRD_PARTY_QUERY_1; - break; - case THIRD_PARTY_CARD_ID_2: - cardQuery = THIRD_PARTY_QUERY_2; - break; - default: - cardQuery = CARD_QUERY_2; - break; - } - const hash = buildSearchQueryJSON(cardQuery)?.hash; - if (!onyxData[ONYXKEYS.COLLECTION.SNAPSHOT]) { - const snapshotCollection: OnyxCollection = {}; - onyxData[ONYXKEYS.COLLECTION.SNAPSHOT] = snapshotCollection; - snapshotCollection[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`] = results; - return; +type CardGroupFixture = {cardID: number; count: number; total?: number; currency?: string}; + +function setupCardGroups(groups: CardGroupFixture[], searchOverrides: Partial = {}) { + const hash = buildSearchQueryJSON(CARD_GROUP_QUERY)?.hash; + const data: SearchResults['data'] = {}; + for (const {cardID, count, total, currency} of groups) { + if (!count) { + continue; + } + data[`${CONST.SEARCH.GROUP_PREFIX}${cardID}`] = { + accountID: ACCOUNT_ID, + cardID, + count, + total: total ?? 0, + currency: currency ?? CONST.CURRENCY.USD, + bank: 'Visa', + cardName: 'card', + lastFourPAN: '', + }; } - const snapshotCollection = onyxData[ONYXKEYS.COLLECTION.SNAPSHOT] ?? {}; - snapshotCollection[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`] = results; - onyxData[ONYXKEYS.COLLECTION.SNAPSHOT] = snapshotCollection; + const results = makeSearchResultsWithCount(1); + onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`] = {...results, search: {...results.search, ...searchOverrides}, data}; } /** Builds a fully-populated `CardFeedErrors` value for `onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS]`. */ @@ -409,16 +404,16 @@ describe('useYourSpendData — cardRows', () => { expect(result.current.cardRows).toHaveLength(0); }); - it('excludes a card whose snapshot has count === 0 (no transactions in last 30 days)', () => { + it('excludes a card the grouped snapshot returns no group for (no transactions in last 30 days)', () => { mockedGetDisplayableExpensifyCards.mockReturnValue(makeDisplayableCards([{cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}])); - setupCardSnapshot(CARD_ID_1, makeSearchResultsWithCount(0)); + setupCardGroups([{cardID: CARD_ID_1, count: 0}]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(0); }); it('returns one row with correct cardID, lastFour, and query when snapshot confirms recent transactions', () => { mockedGetDisplayableExpensifyCards.mockReturnValue(makeDisplayableCards([{cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}])); - setupCardSnapshot(CARD_ID_1, makeSearchResultsWithCount(3)); + setupCardGroups([{cardID: CARD_ID_1, count: 3}]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(1); expect(result.current.cardRows.at(0)).toMatchObject({ @@ -435,8 +430,10 @@ describe('useYourSpendData — cardRows', () => { {cardID: CARD_ID_2, lastFourPAN: CARD_LAST_FOUR_2}, ]), ); - setupCardSnapshot(CARD_ID_1, makeSearchResultsWithCount(5)); - setupCardSnapshot(CARD_ID_2, makeSearchResultsWithCount(2)); + setupCardGroups([ + {cardID: CARD_ID_1, count: 5}, + {cardID: CARD_ID_2, count: 2}, + ]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(2); expect(result.current.cardRows.at(0)).toMatchObject({cardID: CARD_ID_1, lastFour: CARD_LAST_FOUR_1, query: CARD_QUERY_1}); @@ -450,12 +447,37 @@ describe('useYourSpendData — cardRows', () => { {cardID: CARD_ID_2, lastFourPAN: CARD_LAST_FOUR_2}, ]), ); - setupCardSnapshot(CARD_ID_1, makeSearchResultsWithCount(0)); - setupCardSnapshot(CARD_ID_2, makeSearchResultsWithCount(4)); + setupCardGroups([ + {cardID: CARD_ID_1, count: 0}, + {cardID: CARD_ID_2, count: 4}, + ]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(1); expect(result.current.cardRows.at(0)).toMatchObject({cardID: CARD_ID_2, lastFour: CARD_LAST_FOUR_2}); }); + + it('resolves each card row total from its own group in the one grouped snapshot', () => { + // Given a grouped snapshot carrying a group per card + mockedGetDisplayableExpensifyCards.mockReturnValue( + makeDisplayableCards([ + {cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}, + {cardID: CARD_ID_2, lastFourPAN: CARD_LAST_FOUR_2}, + ]), + ); + setupCardGroups([ + {cardID: CARD_ID_1, count: 5, total: 1500, currency: 'USD'}, + {cardID: CARD_ID_2, count: 2, total: 700, currency: 'USD'}, + ]); + + // When the hook renders + const {result} = renderHook(() => useYourSpendData()); + + // Then each row carries its own card's total and its own tap-through query + expect(result.current.cardRows).toEqual([ + expect.objectContaining({cardID: CARD_ID_1, total: 1500, currency: 'USD', query: CARD_QUERY_1}), + expect.objectContaining({cardID: CARD_ID_2, total: 700, currency: 'USD', query: CARD_QUERY_2}), + ]); + }); }); // query builder integration @@ -528,6 +550,72 @@ describe('useYourSpendData — search dispatch', () => { expect(search).not.toHaveBeenCalled(); }); + it('costs one search() for a multi-card account, not one per card', () => { + // Given an account with two Expensify cards and no paid group workspace, so no + // approval or payment search is fired alongside them + mockedIsPaidGroupPolicy.mockReturnValue(false); + mockedGetDisplayableExpensifyCards.mockReturnValue( + makeDisplayableCards([ + {cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}, + {cardID: CARD_ID_2, lastFourPAN: CARD_LAST_FOUR_2}, + ]), + ); + + // When Home renders focused and online + renderHook(() => useYourSpendData()); + + // Then the whole card set costs a single grouped request + expect(search).toHaveBeenCalledTimes(1); + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ + queryJSON: expect.objectContaining({hash: buildSearchQueryJSON(CARD_GROUP_QUERY)?.hash}), + }), + ); + }); + + it('fires no card search when the account has no displayable cards', () => { + // Given an account with no displayable cards and no paid group workspace + mockedIsPaidGroupPolicy.mockReturnValue(false); + mockedGetDisplayableExpensifyCards.mockReturnValue([]); + mockedGetDisplayableThirdPartyCards.mockReturnValue([]); + + // When Home renders focused and online + renderHook(() => useYourSpendData()); + + // Then nothing is sent. An unfiltered grouped query would return every card the user has. + expect(search).not.toHaveBeenCalled(); + }); + + it('keeps the surviving rows when a card is deleted, without refetching', () => { + // Given Home has totals for two cards + mockedIsPaidGroupPolicy.mockReturnValue(false); + onyxData[ONYXKEYS.CARD_LIST] = {[CARD_ID_1]: {cardID: CARD_ID_1}, [CARD_ID_2]: {cardID: CARD_ID_2}}; + mockedGetDisplayableExpensifyCards.mockReturnValue( + makeDisplayableCards([ + {cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}, + {cardID: CARD_ID_2, lastFourPAN: CARD_LAST_FOUR_2}, + ]), + ); + setupCardGroups([ + {cardID: CARD_ID_1, count: 5, total: 1500, currency: 'USD'}, + {cardID: CARD_ID_2, count: 2, total: 700, currency: 'USD'}, + ]); + const {result, rerender} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(2); + expect(search).toHaveBeenCalledTimes(1); + + // When one card is optimistically deleted, as `deletePersonalCard` does even while offline + act(() => { + onyxData[ONYXKEYS.CARD_LIST] = {[CARD_ID_1]: {cardID: CARD_ID_1}}; + }); + mockedGetDisplayableExpensifyCards.mockReturnValue(makeDisplayableCards([{cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}])); + rerender(undefined); + + // Then the remaining row keeps its total off the same snapshot and nothing is refetched + expect(result.current.cardRows).toEqual([expect.objectContaining({cardID: CARD_ID_1, total: 1500, currency: 'USD'})]); + expect(search).toHaveBeenCalledTimes(1); + }); + it('dispatches search() with the approval queryJSON hash', () => { mockedIsPaidGroupPolicy.mockReturnValue(true); renderHook(() => useYourSpendData()); @@ -540,19 +628,14 @@ describe('useYourSpendData — search dispatch', () => { }); }); -// third-party card rows -// -// These tests exercise the third-party card branch end-to-end via the hook. -// `getDisplayableThirdPartyCards` and `getDisplayableExpensifyCards` are both mocked, -// so each test seeds the displayable cards explicitly. Snapshot results are seeded -// through the same `setupCardSnapshot` helper used by the Expensify cardRows block. - describe('useYourSpendData — third-party cardRows', () => { it('orders Expensify Card rows before third-party card rows when both exist', () => { mockedGetDisplayableExpensifyCards.mockReturnValue(makeDisplayableCards([{cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}])); mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); - setupCardSnapshot(CARD_ID_1, makeSearchResultsWithCount(2)); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(3)); + setupCardGroups([ + {cardID: CARD_ID_1, count: 2}, + {cardID: THIRD_PARTY_CARD_ID_1, count: 3}, + ]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(2); expect(result.current.cardRows.at(0)?.cardID).toBe(CARD_ID_1); @@ -561,22 +644,22 @@ describe('useYourSpendData — third-party cardRows', () => { it('produces a row for a third-party card with snapshot count > 0', () => { mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(5)); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 5}]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(1); expect(result.current.cardRows.at(0)).toMatchObject({cardID: THIRD_PARTY_CARD_ID_1, lastFour: THIRD_PARTY_LAST_FOUR_1, query: THIRD_PARTY_QUERY_1}); }); - it('produces no row for a third-party card with snapshot count === 0', () => { + it('produces no row for a third-party card the grouped snapshot returns no group for', () => { mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(0)); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 0}]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(0); }); it('tags the third-party row with kind=thirdParty and leaves spentFraction undefined', () => { mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 1}]); const {result} = renderHook(() => useYourSpendData()); const row = result.current.cardRows.at(0); expect(row?.kind).toBe('thirdParty'); @@ -585,7 +668,7 @@ describe('useYourSpendData — third-party cardRows', () => { it('resolves lastFour from cardName ending in 4 digits when lastFourPAN is empty', () => { mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: '', cardName: 'Chase 9876'}])); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 1}]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(1); expect(result.current.cardRows.at(0)?.lastFour).toBe('9876'); @@ -593,7 +676,7 @@ describe('useYourSpendData — third-party cardRows', () => { it('suppresses the row when lastFourPAN is empty and cardName has no trailing 4 digits', () => { mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: '', cardName: 'Chase'}])); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 1}]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(0); }); @@ -602,69 +685,24 @@ describe('useYourSpendData — third-party cardRows', () => { // The selector receives `cardFeedErrors` and is unit-tested separately. Here we just verify // the hook respects whatever set the selector returns: when the selector returns [], no row. mockedGetDisplayableThirdPartyCards.mockReturnValue([]); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(5)); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(0); }); - it('persists cached READY totals for a third-party card when the snapshot count is wiped', () => { + it('reads per-card totals from `data`, so a wipe of the snapshot-level totals cannot drop a row', () => { + // Given a third-party card with a group in the current snapshot mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); - // First render: READY snapshot with count > 0 → row produced and total cached. - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, { - search: { - type: 'expense', - offset: 0, - hash: 0, - sortBy: 'date', - sortOrder: 'desc', - hasMoreResults: false, - hasResults: true, - isLoading: false, - count: 3, - total: 1234, - currency: 'USD', - }, - data: {}, - }); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 3, total: 1234, currency: 'USD'}]); const {result, rerender} = renderHook(() => useYourSpendData()); expect(result.current.cardRows.at(0)?.total).toBe(1234); - // Search screen wipes count/total/currency on the shared snapshot. - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, { - search: { - type: 'expense', - offset: 0, - hash: 0, - sortBy: 'date', - sortOrder: 'desc', - hasMoreResults: false, - hasResults: true, - isLoading: false, - count: undefined, - total: undefined, - currency: undefined, - }, - data: {}, - }); + // When a `shouldCalculateTotals: false` search nulls the snapshot-level count/total/currency + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 3, total: 1234, currency: 'USD'}], {count: undefined, total: undefined, currency: undefined}); rerender(undefined); - // Cached total/currency must survive the wipe so the row stays. - expect(result.current.cardRows).toHaveLength(1); - expect(result.current.cardRows.at(0)?.total).toBe(1234); - expect(result.current.cardRows.at(0)?.currency).toBe('USD'); - }); - it('fires search() for each third-party card snapshot when focused and online', () => { - mockedGetDisplayableThirdPartyCards.mockReturnValue( - makeThirdPartyCards([ - {cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}, - {cardID: THIRD_PARTY_CARD_ID_2, lastFourPAN: THIRD_PARTY_LAST_FOUR_2}, - ]), - ); - renderHook(() => useYourSpendData()); - const hash1 = buildSearchQueryJSON(THIRD_PARTY_QUERY_1)?.hash; - const hash2 = buildSearchQueryJSON(THIRD_PARTY_QUERY_2)?.hash; - expect(search).toHaveBeenCalledWith(expect.objectContaining({queryJSON: expect.objectContaining({hash: hash1})})); - expect(search).toHaveBeenCalledWith(expect.objectContaining({queryJSON: expect.objectContaining({hash: hash2})})); + // Then the row survives: that write only touches `search`, never the `group_` entries + expect(result.current.cardRows).toHaveLength(1); + expect(result.current.cardRows.at(0)).toMatchObject({total: 1234, currency: 'USD'}); }); it('does not aggregate totals when two third-party rows have different currencies', () => { @@ -674,38 +712,10 @@ describe('useYourSpendData — third-party cardRows', () => { {cardID: THIRD_PARTY_CARD_ID_2, lastFourPAN: THIRD_PARTY_LAST_FOUR_2}, ]), ); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, { - search: { - type: 'expense', - offset: 0, - hash: 0, - sortBy: 'date', - sortOrder: 'desc', - hasMoreResults: false, - hasResults: true, - isLoading: false, - count: 2, - total: 500, - currency: 'USD', - }, - data: {}, - }); - setupCardSnapshot(THIRD_PARTY_CARD_ID_2, { - search: { - type: 'expense', - offset: 0, - hash: 0, - sortBy: 'date', - sortOrder: 'desc', - hasMoreResults: false, - hasResults: true, - isLoading: false, - count: 3, - total: 2200, - currency: 'EUR', - }, - data: {}, - }); + setupCardGroups([ + {cardID: THIRD_PARTY_CARD_ID_1, count: 2, total: 500, currency: 'USD'}, + {cardID: THIRD_PARTY_CARD_ID_2, count: 3, total: 2200, currency: 'EUR'}, + ]); const {result} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(2); const [r1, r2] = result.current.cardRows; @@ -720,7 +730,7 @@ describe('useYourSpendData — third-party cardRows', () => { (c) => !errors.cardsWithBrokenFeedConnection[c.cardID] && !errors.personalCardsWithBrokenConnection[c.cardID], ), ); - setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + setupCardGroups([{cardID: THIRD_PARTY_CARD_ID_1, count: 1}]); // Start: card is in broken-feed-connection map → row absent. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const brokenCard = makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}]).at(0)!; diff --git a/tests/unit/HrUtilsTest.ts b/tests/unit/HrUtilsTest.ts index 6de7c8003723..12eb9548e9e8 100644 --- a/tests/unit/HrUtilsTest.ts +++ b/tests/unit/HrUtilsTest.ts @@ -3,7 +3,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {getConnectedHRProvider, getHRApprovalMode, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import {getApprovalModeLabel, getHRCards, getHRCardState} from '@pages/workspace/hr/utils'; -import type {HRCardDescriptor} from '@pages/workspace/hr/utils'; +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; import CONST from '@src/CONST'; import MERGE_HR_PROVIDERS from '@src/CONST/MERGE_HR_PROVIDERS'; @@ -114,7 +114,7 @@ function stubTranslate(path: TranslationPaths): string { } const stubFormatPhoneNumber: LocaleContextProps['formatPhoneNumber'] = (phoneNumber) => phoneNumber; -function getRow(card: HRCardDescriptor | undefined, field: string) { +function getRow(card: MergeProviderCardDescriptor | undefined, field: string) { return card?.configRows?.find((row) => row.field === field); } @@ -588,35 +588,35 @@ describe('getHRCardState', () => { describe('getApprovalModeLabel', () => { it('returns notSet key when no approval mode is configured', () => { const policy = makePolicy(); - expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.hr.notSet'); + expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.merge.notSet'); }); it('returns basic label for Gusto basic approval mode', () => { const policy = makePolicy({ connections: {[GUSTO]: makeGustoConnection({config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.BASIC}})}, }); - expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.hr.approvalModes.basic.label'); + expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.merge.approvalModes.basic'); }); it('returns manager label for Gusto manager approval mode', () => { const policy = makePolicy({ connections: {[GUSTO]: makeGustoConnection({config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.MANAGER}})}, }); - expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.hr.approvalModes.manager.label'); + expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.merge.approvalModes.manager'); }); it('returns custom label for Gusto custom approval mode', () => { const policy = makePolicy({ connections: {[GUSTO]: makeGustoConnection({config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.CUSTOM}})}, }); - expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.hr.approvalModes.custom.label'); + expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.merge.approvalModes.custom'); }); it('returns basic label for Merge HR basic approval mode', () => { const policy = makePolicy({ connections: {[MERGE_HR]: makeMergeHRConnection({config: {approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC}})}, }); - expect(getApprovalModeLabel(policy, MERGE_HR, stubTranslate)).toBe('workspace.hr.approvalModes.basic.label'); + expect(getApprovalModeLabel(policy, MERGE_HR, stubTranslate)).toBe('workspace.merge.approvalModes.basic'); }); it('returns notSet for unknown approval mode', () => { @@ -624,11 +624,11 @@ describe('getApprovalModeLabel', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally unexpected approval mode value connections: {[GUSTO]: makeGustoConnection({config: {approvalMode: 'UNKNOWN_MODE' as GustoConnectionConfig['approvalMode']}})}, }); - expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.hr.notSet'); + expect(getApprovalModeLabel(policy, GUSTO, stubTranslate)).toBe('workspace.merge.notSet'); }); it('returns notSet when policy is null', () => { - expect(getApprovalModeLabel(undefined, GUSTO, stubTranslate)).toBe('workspace.hr.notSet'); + expect(getApprovalModeLabel(undefined, GUSTO, stubTranslate)).toBe('workspace.merge.notSet'); }); }); @@ -689,7 +689,6 @@ describe('getHRCards', () => { const zenefits = cards.find((c) => c.key === 'zenefits'); expect(zenefits?.isConnected).toBe(true); - expect(zenefits?.config).toBeDefined(); }); it('marks the connected Gusto card as connected with config', () => { @@ -701,8 +700,7 @@ describe('getHRCards', () => { const cards = getHRCards(makeGetHRCardsParams({policy})); expect(cards?.at(0)?.isConnected).toBe(true); - expect(cards?.at(0)?.config).toBeDefined(); - expect(getRow(cards?.at(0), 'approvalMode')?.title).toBe('workspace.hr.approvalModes.basic.label'); + expect(getRow(cards?.at(0), 'approvalMode')?.title).toBe('workspace.merge.approvalModes.basic'); }); it('marks only the matching Merge slug as connected', () => { @@ -718,14 +716,11 @@ describe('getHRCards', () => { const hibob = cards.find((c) => c.key === 'merge_hibob'); expect(bamboo?.isConnected).toBe(true); - expect(bamboo?.config).toBeDefined(); - expect(getRow(bamboo, 'approvalMode')?.title).toBe('workspace.hr.approvalModes.manager.label'); + expect(getRow(bamboo, 'approvalMode')?.title).toBe('workspace.merge.approvalModes.manager'); expect(workday?.isConnected).toBe(false); - expect(workday?.config).toBeUndefined(); expect(hibob?.isConnected).toBe(false); - expect(hibob?.config).toBeUndefined(); }); it('connected Merge card gets lastSyncErrorMessage when sync has failed', () => { diff --git a/tests/unit/IOUAmountSubmissionTest.ts b/tests/unit/IOUAmountSubmissionTest.ts index e250621c1290..9da449af3a40 100644 --- a/tests/unit/IOUAmountSubmissionTest.ts +++ b/tests/unit/IOUAmountSubmissionTest.ts @@ -12,7 +12,7 @@ import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; -import {formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const CURRENT_USER_ACCOUNT_ID = 5; @@ -171,6 +171,7 @@ describe('AmountSubmission', () => { report: baseReport, translate: translateLocal, dateFnsLocale: undefined, + convertToDisplayString, getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, transaction: undefined, @@ -223,6 +224,7 @@ describe('AmountSubmission', () => { conciergeReportID: undefined, conciergeChat: undefined, isTrackIntentUser: false, + rules: undefined, ...overrides, }; }; diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 9bf428c24129..9c3cb0232152 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -16,6 +16,7 @@ import {hasAnyTransactionWithoutRTERViolation} from '@src/libs/TransactionUtils' import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; @@ -630,6 +631,7 @@ describe('Check valid amount for IOU/Expense request', () => { test('Expense amount should be negative', () => { const expenseReport = ReportUtils.buildOptimisticExpenseReport({ + rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, chatReportID: '212', policyID: '123', @@ -916,12 +918,37 @@ describe('getExistingTransactionID', () => { expect(result1.chatReportID).toBeDefined(); expect(result2.chatReportID).toBeDefined(); }); + + it('should use the preferred optimistic ID when no existing report is found', () => { + // Given a new chat whose caller already reserved an optimistic report ID + // When chat resolution cannot find an existing report + const result = IOUUtils.resolveOptimisticChatReportID([100001, 100002], undefined, 'preferred-123'); + + // Then the reserved ID is reused so related optimistic data stays aligned + expect(result.chatReportID).toBe('preferred-123'); + expect(result.optimisticChatReportID).toBe('preferred-123'); + }); + + it('should prefer an existing report over the preferred optimistic ID', () => { + // Given both an existing chat and a caller-reserved optimistic ID + const existingReport = {reportID: 'existing-123'} as Report; + // When chat resolution chooses the report identity + const result = IOUUtils.resolveOptimisticChatReportID([1, 2], existingReport, 'preferred-123'); + + // Then the persisted chat wins because no optimistic replacement is needed + expect(result.chatReportID).toBe('existing-123'); + expect(result.optimisticChatReportID).toBeUndefined(); + }); }); describe('resolveReportForMoneyRequest', () => { const policyForResolve: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, 'Resolve Test Policy'), id: 'resolve-policy'}; const nonArchivedReportNameValuePair: ReportNameValuePairs = {}; + afterEach(() => { + jest.restoreAllMocks(); + }); + const makeOutstandingReport = (reportID: string): Report => ({ ...createRandomReport(Number(reportID), undefined), reportID, @@ -949,50 +976,53 @@ describe('getExistingTransactionID', () => { transaction, transactionReport, routeReport, - policy: policyForResolve, reportNameValuePair: nonArchivedReportNameValuePair, + rules: undefined, }), ).toBeUndefined(); }); - it('returns the picked report when it is outstanding (user-selected report wins)', () => { + it('returns the picked report when canAddTransaction allows it (user-selected report wins)', () => { const transaction = makeTransaction('500'); const transactionReport = makeOutstandingReport('500'); const routeReport = makeRouteReport('100'); + jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(true); expect( IOUUtils.resolveReportForMoneyRequest({ transaction, transactionReport, routeReport, - policy: policyForResolve, reportNameValuePair: nonArchivedReportNameValuePair, + rules: undefined, })?.reportID, ).toBe('500'); }); - it('returns undefined when the picked report is archived', () => { + it('returns undefined when canAddTransaction rejects the picked report (e.g. archived)', () => { const transaction = makeTransaction('500'); const transactionReport = makeOutstandingReport('500'); const routeReport = makeRouteReport('100'); const reportNameValuePair: ReportNameValuePairs = {private_isArchived: testDate}; + jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(false); - expect(IOUUtils.resolveReportForMoneyRequest({transaction, transactionReport, routeReport, policy: policyForResolve, reportNameValuePair})).toBeUndefined(); + expect(IOUUtils.resolveReportForMoneyRequest({transaction, transactionReport, routeReport, reportNameValuePair, rules: undefined})).toBeUndefined(); }); - it('returns undefined when the picked report is non-outstanding and differs from the route (forces a new optimistic IOU)', () => { + it('returns undefined when canAddTransaction rejects the picked report and it differs from the route (forces a new optimistic IOU)', () => { const transaction = makeTransaction('500'); const nonOutstandingPick: Report = { ...makeOutstandingReport('500'), policyID: 'someOtherPolicy', }; const routeReport = makeRouteReport('100'); + jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(false); expect( IOUUtils.resolveReportForMoneyRequest({ transaction, transactionReport: nonOutstandingPick, routeReport, - policy: policyForResolve, reportNameValuePair: nonArchivedReportNameValuePair, + rules: undefined, }), ).toBeUndefined(); }); @@ -1006,8 +1036,8 @@ describe('getExistingTransactionID', () => { transaction, transactionReport, routeReport, - policy: policyForResolve, reportNameValuePair: nonArchivedReportNameValuePair, + rules: undefined, })?.reportID, ).toBe('100'); }); @@ -1015,18 +1045,19 @@ describe('getExistingTransactionID', () => { it('falls back to the transaction report when no route report exists (the !routeReport branch)', () => { const transaction = makeTransaction('500'); const transactionReport = makeOutstandingReport('500'); + jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(true); expect( IOUUtils.resolveReportForMoneyRequest({ transaction, transactionReport, routeReport: undefined, - policy: policyForResolve, reportNameValuePair: nonArchivedReportNameValuePair, + rules: undefined, })?.reportID, ).toBe('500'); }); - it('returns undefined when the picked report is processing and policy harvesting is disabled', () => { + it('returns the picked submitted report when canAddTransaction allows it (harvesting disabled no longer blocks)', () => { const transaction = makeTransaction('500'); const processingPick: Report = { ...makeOutstandingReport('500'), @@ -1034,16 +1065,17 @@ describe('getExistingTransactionID', () => { statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }; const routeReport = makeRouteReport('100'); - const harvestingDisabledPolicy: Policy = {...policyForResolve, harvesting: {enabled: false}}; + jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(true); + expect( IOUUtils.resolveReportForMoneyRequest({ transaction, transactionReport: processingPick, routeReport, - policy: harvestingDisabledPolicy, reportNameValuePair: nonArchivedReportNameValuePair, - }), - ).toBeUndefined(); + rules: undefined, + })?.reportID, + ).toBe('500'); }); }); @@ -1231,6 +1263,36 @@ describe('isParticipantP2P', () => { }); }); +describe('getReusableP2PReportID', () => { + it('returns the transaction report ID for a brand-new P2P recipient', () => { + // Given a new P2P recipient without an existing chat + // When selecting an ID for its optimistic chat + // Then the transaction ID is reused so both optimistic records share an identity + expect(IOUUtils.getReusableP2PReportID({} as Participant, '123')).toBe('123'); + }); + + it('does not return the transaction report ID for an existing P2P chat', () => { + // Given a P2P recipient already linked to a persisted chat + // When selecting an ID for request creation + // Then no reusable ID is supplied because the existing chat remains authoritative + expect(IOUUtils.getReusableP2PReportID({reportID: '456'} as Participant, '123')).toBeUndefined(); + }); + + it('does not return the transaction report ID for a workspace chat', () => { + // Given a workspace recipient whose chat identity follows policy routing + // When selecting an optimistic P2P report ID + // Then reuse is rejected because workspace chats are not P2P destinations + expect(IOUUtils.getReusableP2PReportID({isPolicyExpenseChat: true} as Participant, '123')).toBeUndefined(); + }); + + it('does not return the unreported report ID', () => { + // Given a new recipient whose transaction still uses the unreported sentinel + // When selecting an optimistic chat identity + // Then the sentinel is rejected because it cannot identify a real chat + expect(IOUUtils.getReusableP2PReportID({} as Participant, CONST.REPORT.UNREPORTED_REPORT_ID)).toBeUndefined(); + }); +}); + describe('reportHasRealPolicy', () => { it('should return false for the placeholder self-DM policy', () => { expect(IOUUtils.reportHasRealPolicy({...createRandomReport(1), policyID: CONST.POLICY.ID_FAKE})).toBe(false); diff --git a/tests/unit/IsNativeOAuthCallbackURLTest.ts b/tests/unit/IsNativeOAuthCallbackURLTest.ts new file mode 100644 index 000000000000..46c92a9595b5 --- /dev/null +++ b/tests/unit/IsNativeOAuthCallbackURLTest.ts @@ -0,0 +1,49 @@ +import isNativeOAuthCallbackURL from '@libs/Navigation/linkingConfig/isNativeOAuthCallbackURL'; + +import CONST from '@src/CONST'; + +describe('isNativeOAuthCallbackURL', () => { + it('matches the native callback path on any host, with or without a query string', () => { + // Given the claimed native callback path on the production host with OAuth query params + const url = `https://new.expensify.com${CONST.CLOUDFLARE_ACCESS.NATIVE_OAUTH_CALLBACK_PATH}?code=abc&state=xyz`; + + // When the URL is checked + const result = isNativeOAuthCallbackURL(url); + + // Then it is recognized as the native callback + expect(result).toBe(true); + }); + + it('does not match the web callback path', () => { + // Given the web-only callback path that Safari must keep + const url = 'https://new.expensify.com/oauth/callback?code=abc'; + + // When the URL is checked + const result = isNativeOAuthCallbackURL(url); + + // Then it is left for normal routing + expect(result).toBe(false); + }); + + it('does not match a path that merely contains the callback path', () => { + // Given a deep link whose path embeds the callback path as a substring + const url = `https://new.expensify.com/r/123${CONST.CLOUDFLARE_ACCESS.NATIVE_OAUTH_CALLBACK_PATH}`; + + // When the URL is checked + const result = isNativeOAuthCallbackURL(url); + + // Then it is not treated as the callback + expect(result).toBe(false); + }); + + it('returns false for a value that is not a URL', () => { + // Given a relative path with no origin + const url = CONST.CLOUDFLARE_ACCESS.NATIVE_OAUTH_CALLBACK_PATH; + + // When the URL is checked + const result = isNativeOAuthCallbackURL(url); + + // Then parsing fails safely + expect(result).toBe(false); + }); +}); diff --git a/tests/unit/KeysToPreserveTest.ts b/tests/unit/KeysToPreserveTest.ts new file mode 100644 index 000000000000..f053b62e4d4d --- /dev/null +++ b/tests/unit/KeysToPreserveTest.ts @@ -0,0 +1,49 @@ +/** + * The staging-server preference describes the install, not the account, so it has to outlive the Onyx clear that + * runs on sign-out. It is one line in a list that is easy to drop by accident, and there is one such list per way + * of signing out. + */ +import {KEYS_TO_PRESERVE} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE_DELEGATE_ACCESS} from '@libs/actions/Delegate'; +import {KEYS_TO_PRESERVE_SUPPORTAL} from '@libs/actions/Session'; +import {KEYS_TO_PRESERVE_ON_SIGN_OUT} from '@libs/actions/SignInRedirect'; + +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'; + +Onyx.init({keys: ONYXKEYS}); + +describe('the lists that survive a sign-out', () => { + describe.each([ + ['KEYS_TO_PRESERVE', KEYS_TO_PRESERVE], + ['KEYS_TO_PRESERVE_DELEGATE_ACCESS', KEYS_TO_PRESERVE_DELEGATE_ACCESS], + ['KEYS_TO_PRESERVE_SUPPORTAL', KEYS_TO_PRESERVE_SUPPORTAL], + ['KEYS_TO_PRESERVE_ON_SIGN_OUT', KEYS_TO_PRESERVE_ON_SIGN_OUT], + ])('%s', (_name, list) => { + it('lists the staging preference', () => { + expect(list).toContain(ONYXKEYS.ACTIVE_SERVER); + }); + + // Membership is what a dropped line breaks, but only a real clear proves the list is passed somewhere + // that honors it + it('keeps the staging preference across a clear', async () => { + await Onyx.multiSet({ + [ONYXKEYS.ACTIVE_SERVER]: CONST.SERVER.STAGING, + // A control: an account-scoped key that must not survive + [ONYXKEYS.IS_LOADING_REPORT_DATA]: true, + }); + await waitForBatchedUpdates(); + + await Onyx.clear(list); + await waitForBatchedUpdates(); + + await expect(getOnyxValue(ONYXKEYS.ACTIVE_SERVER)).resolves.toBe(CONST.SERVER.STAGING); + await expect(getOnyxValue(ONYXKEYS.IS_LOADING_REPORT_DATA)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/LastVisitedPathOnBackgroundTest.ts b/tests/unit/LastVisitedPathOnBackgroundTest.ts new file mode 100644 index 000000000000..3f9ea727f905 --- /dev/null +++ b/tests/unit/LastVisitedPathOnBackgroundTest.ts @@ -0,0 +1,73 @@ +import {saveCurrentPathBeforeBackground} from '@libs/actions/App'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import {navigationRef} from '@libs/Navigation/Navigation'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +function mockRootState(state: NavigationState) { + jest.spyOn(navigationRef, 'isReady').mockReturnValue(true); + jest.spyOn(navigationRef, 'getRootState').mockReturnValue(state); +} + +describe('saveCurrentPathBeforeBackground', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.restoreAllMocks(); + await Onyx.clear(); + await Onyx.set(ONYXKEYS.LAST_VISITED_PATH, '/search?q=status:outstanding'); + await Onyx.set(ONYXKEYS.SESSION, {authToken: 'token', accountID: 1, email: 'test@example.com'}); + await waitForBatchedUpdates(); + }); + + it('keeps the page a forced re-auth kept when the sign in page backgrounds behind the SAML browser', async () => { + await Onyx.set(ONYXKEYS.SESSION, null); + await waitForBatchedUpdates(); + // The navigator only ever reads routes and names, so a partial state stands in for a rehydrated one. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + mockRootState(getAdaptedStateFromPath('/inbox' as Route) as unknown as NavigationState); + + saveCurrentPathBeforeBackground(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.LAST_VISITED_PATH)).toBe('/search?q=status:outstanding'); + }); + + it('keeps the page a forced re-auth kept when the app backgrounds on the transition screen', async () => { + mockRootState({ + key: 'root', + index: 0, + routeNames: [SCREENS.TRANSITION_BETWEEN_APPS], + routes: [{key: 'transition', name: SCREENS.TRANSITION_BETWEEN_APPS, params: {shortLivedAuthToken: 'abc123', isSAML: true}}], + stale: false, + type: 'stack', + }); + + saveCurrentPathBeforeBackground(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.LAST_VISITED_PATH)).toBe('/search?q=status:outstanding'); + }); + + it('still saves a normal page when the app backgrounds', async () => { + // The navigator only ever reads routes and names, so a partial state stands in for a rehydrated one. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + mockRootState(getAdaptedStateFromPath('/settings/profile' as Route) as unknown as NavigationState); + + saveCurrentPathBeforeBackground(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.LAST_VISITED_PATH)).toBe('/settings/profile'); + }); +}); diff --git a/tests/unit/LegendListItemZIndexTest.ts b/tests/unit/LegendListItemZIndexTest.ts new file mode 100644 index 000000000000..cdf37f85cb33 --- /dev/null +++ b/tests/unit/LegendListItemZIndexTest.ts @@ -0,0 +1,31 @@ +import setLegendListItemZIndex from '@components/LegendList/setLegendListItemZIndex'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +import createMock from '../utils/createMock'; + +describe('setLegendListItemZIndex', () => { + it('updates the native LegendList item container', () => { + const setNativeProps = jest.fn(); + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => ({setNativeProps}), + }), + }); + + expect(setLegendListItemZIndex(list, 3, -3)).toBe(true); + expect(setNativeProps).toHaveBeenCalledWith({style: {zIndex: -3}}); + }); + + it('does nothing when the item container is not mounted', () => { + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => undefined, + }), + }); + + expect(setLegendListItemZIndex(list, 3, -3)).toBe(false); + }); +}); diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx new file mode 100644 index 000000000000..6f4c536c0c5a --- /dev/null +++ b/tests/unit/LegendListTest.tsx @@ -0,0 +1,117 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; + +import {LegendList as LibraryLegendList} from '@legendapp/list/react-native'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const DATA = ['first', 'second', 'third']; + +const renderItem: NonNullable['renderItem']> = ({item}) => { + return ; +}; + +describe('LegendList Jest mock', () => { + it('renders every item without another virtualized list', () => { + render( + , + ); + + for (const item of DATA) { + expect(screen.getByTestId(item)).toBeOnTheScreen(); + } + }); + + it('forwards scroll events and calculates the end distance', () => { + const onEndReached = jest.fn(); + const ref = createRef(); + const onScroll = jest.fn(() => ref.current?.getState()); + render( + , + ); + + fireEvent.scroll(screen.getByTestId('legend-list'), { + nativeEvent: { + contentOffset: {x: 0, y: 100}, + contentSize: {height: 600, width: 300}, + layoutMeasurement: {height: 400, width: 300}, + }, + }); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onScroll).toHaveLastReturnedWith(expect.objectContaining({contentLength: 600, scroll: 100, scrollLength: 400})); + expect(onEndReached).toHaveBeenCalledWith({distanceFromEnd: 100}); + }); + + it('fires edge callbacks again only after leaving and re-entering their thresholds', () => { + const onEndReached = jest.fn(); + const onStartReached = jest.fn(); + render( + , + ); + + const scrollTo = (y: number) => { + fireEvent.scroll(screen.getByTestId('legend-list'), { + nativeEvent: { + contentOffset: {x: 0, y}, + contentSize: {height: 1000, width: 100}, + layoutMeasurement: {height: 100, width: 100}, + }, + }); + }; + + scrollTo(860); + scrollTo(870); + expect(onEndReached).toHaveBeenCalledTimes(1); + + scrollTo(800); + scrollTo(860); + expect(onEndReached).toHaveBeenCalledTimes(2); + + scrollTo(40); + scrollTo(30); + expect(onStartReached).toHaveBeenCalledTimes(1); + + scrollTo(100); + scrollTo(40); + expect(onStartReached).toHaveBeenCalledTimes(2); + }); + + it('provides the imperative scroll methods used by list consumers', async () => { + const ref = createRef(); + const onLoad = jest.fn(() => ref.current?.getState()); + render( + , + ); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onLoad).toHaveLastReturnedWith(expect.objectContaining({data: DATA, endBuffered: DATA.length - 1, startBuffered: 0})); + await expect(ref.current?.scrollToIndex({index: 1})).resolves.toBeUndefined(); + await expect(ref.current?.scrollToOffset({offset: 20})).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/LhnSearchPreviewParityTest.ts b/tests/unit/LhnSearchPreviewParityTest.ts new file mode 100644 index 000000000000..f98a35e3f5ce --- /dev/null +++ b/tests/unit/LhnSearchPreviewParityTest.ts @@ -0,0 +1,697 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {act} from '@testing-library/react-native'; + +import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; + +import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; +import {clearFilteredOptionListCache, createFilteredOptionList, getSearchOptions} from '@libs/OptionsListUtils'; +import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isInviteOrRemovedAction} from '@libs/ReportActionsUtils'; +import {getExpensifyCardFromReportAction} from '@libs/ReportAlternateTextUtils'; +import {canUserPerformWriteAction} from '@libs/ReportUtils'; +import SidebarUtils from '@libs/SidebarUtils'; + +import initOnyxDerivedValues from '@userActions/OnyxDerived'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardList, PersonalDetailsList, Policy, Report, ReportAction, ReportActions} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import {convertToDisplayString, convertToDisplayStringWithoutCurrency, formatPhoneNumber, localeCompare, translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const CURRENT_USER_LOGIN = 'test@example.com'; +const CURRENT_USER_ACCOUNT_ID = 5; +const CONCIERGE_REPORT_ID = '999'; + +const PERSONAL_DETAILS: PersonalDetailsList = { + 1: {accountID: 1, login: 'alice@test.com', displayName: 'Alice Aluminum', firstName: 'Alice'}, + 2: {accountID: 2, login: 'bob@test.com', displayName: 'Bob Boron', firstName: 'Bob'}, + 3: {accountID: 3, login: 'carol@test.com', displayName: 'Carol Carbon', firstName: 'Carol'}, + [CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: CURRENT_USER_LOGIN, displayName: 'Current User'}, +}; + +function makeReport(overrides: Partial = {}): Report { + return { + reportID: '100', + type: CONST.REPORT.TYPE.CHAT, + reportName: '#test-room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + policyID: '6', + lastVisibleActionCreated: '2024-01-01 00:00:00.000', + lastReadTime: '2023-12-31 00:00:00.000', + lastMessageText: 'Fixture last message', + lastMessageHtml: 'Fixture last message', + participants: { + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + ...overrides, + }; +} + +function makeAction(actionName: ReportAction['actionName'], overrides: Partial = {}): ReportAction { + return { + reportActionID: '1', + actionName, + created: '2024-01-01 00:00:00.000', + actorAccountID: 1, + person: [{type: 'TEXT', style: 'strong', text: 'Alice Aluminum'}], + message: [{type: 'COMMENT', html: 'Fixture message html', text: 'Fixture message text', isDeletedParentAction: false, deleted: ''}], + ...overrides, + } as ReportAction; +} + +type ParityCase = { + report?: Report; + lastAction?: ReportAction; + mainReportActions?: ReportAction[]; + extraReports?: Report[]; + extraReportActions?: Record; + lateReportActions?: Record; + policy?: OnyxEntry; + invoiceReceiverPolicy?: OnyxEntry; + cardList?: OnyxEntry; + isReportArchived?: boolean; + isTrackIntentUser?: boolean; +}; + +function toReportActions(actions: ReportAction[]): ReportActions { + return Object.fromEntries(actions.map((action) => [action.reportActionID, action])); +} + +async function computeBothSurfaces({ + report = makeReport(), + lastAction, + mainReportActions, + extraReports = [], + extraReportActions = {}, + lateReportActions = {}, + policy, + invoiceReceiverPolicy, + cardList, + isReportArchived = false, + isTrackIntentUser = false, +}: ParityCase) { + const reportsById: Record = {[report.reportID]: report}; + for (const extra of extraReports) { + reportsById[extra.reportID] = extra; + } + + const seededMainActions = mainReportActions ?? (lastAction ? [lastAction] : undefined); + + await act(async () => { + await Promise.all(Object.values(reportsById).map((seeded) => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${seeded.reportID}`, seeded))); + if (seededMainActions) { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, toReportActions(seededMainActions)); + } + await Promise.all(Object.entries(extraReportActions).map(([reportID, actions]) => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, toReportActions(actions)))); + }); + await waitForBatchedUpdatesWithAct(); + + if (Object.keys(lateReportActions).length > 0) { + await act(async () => { + await Promise.all(Object.entries(lateReportActions).map(([reportID, actions]) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, toReportActions(actions)))); + }); + await waitForBatchedUpdatesWithAct(); + } + + const sortedData = await getOnyxValue(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); + const reportAttributesValue = await getOnyxValue(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributesDerived = reportAttributesValue?.reports; + + const canWrite = canUserPerformWriteAction(report, isReportArchived); + const oneTransactionThreadReportID = sortedData?.transactionThreadIDs?.[report.reportID]; + const actionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]: seededMainActions ? toReportActions(seededMainActions) : undefined, + }; + for (const [reportID, actions] of Object.entries(extraReportActions)) { + actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] = toReportActions(actions); + } + for (const [reportID, actions] of Object.entries(lateReportActions)) { + actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] = {...actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`], ...toReportActions(actions)}; + } + const lhnLastAction = getLastVisibleActionIncludingTransactionThread(report.reportID, canWrite, actionsCollection, undefined, oneTransactionThreadReportID); + let lastActionReport: OnyxEntry; + if (isInviteOrRemovedAction(lhnLastAction)) { + const originalMessage = getOriginalMessage(lhnLastAction); + lastActionReport = originalMessage?.reportID ? reportsById[String(originalMessage.reportID)] : undefined; + } + const movedFromReportID = getMovedReportID(lhnLastAction, CONST.REPORT.MOVE_TYPE.FROM); + const movedToReportID = getMovedReportID(lhnLastAction, CONST.REPORT.MOVE_TYPE.TO); + const card: Card | undefined = getExpensifyCardFromReportAction({reportAction: lhnLastAction, policy, cardList, workspaceCardList: undefined}); + + const lhnOption = SidebarUtils.getOptionData({ + report, + reportAttributes: reportAttributesDerived?.[report.reportID], + oneTransactionThreadReport: oneTransactionThreadReportID ? reportsById[oneTransactionThreadReportID] : undefined, + reportNameValuePairs: isReportArchived ? {private_isArchived: '2024-01-02 00:00:00.000'} : {}, + personalDetails: PERSONAL_DETAILS, + policy, + parentReportAction: undefined, + conciergeReportID: CONCIERGE_REPORT_ID, + invoiceReceiverPolicy, + lastMessageTextFromReport: undefined, + card, + lastAction: lhnLastAction, + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + localeCompare, + isReportArchived, + lastActionReport, + movedFromReport: movedFromReportID ? reportsById[movedFromReportID] : undefined, + movedToReport: movedToReportID ? reportsById[movedToReportID] : undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + visibleReportActionsData: undefined, + reportAttributesDerived, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + isTrackIntentUser, + formatPhoneNumber, + rules: undefined, + }); + + const reportsCollection: OnyxCollection = {}; + for (const [id, seeded] of Object.entries(reportsById)) { + reportsCollection[`${ONYXKEYS.COLLECTION.REPORT}${id}`] = seeded; + } + const privateIsArchivedMap: PrivateIsArchivedMap = isReportArchived ? {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]: true} : {}; + const policiesCollection: OnyxCollection = policy ? {[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy} : {}; + if (invoiceReceiverPolicy) { + policiesCollection[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicy.id}`] = invoiceReceiverPolicy; + } + + const optionList = createFilteredOptionList( + PERSONAL_DETAILS, + reportsCollection, + reportAttributesDerived, + privateIsArchivedMap, + policiesCollection, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, + transactionThreadIDs: sortedData?.transactionThreadIDs, + lastActions: sortedData?.lastActions, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: CONCIERGE_REPORT_ID, + isSearching: true, + }, + undefined, + undefined, + undefined, + isTrackIntentUser, + sortedData?.sortedActions, + ); + + const {options: searchResults} = getSearchOptions({ + dateFnsLocale: undefined, + convertToDisplayString, + options: optionList, + draftComments: {}, + loginList: {}, + betas: [CONST.BETAS.ALL], + policyCollection: policiesCollection, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: CURRENT_USER_LOGIN, + currentUserLogin: CURRENT_USER_LOGIN, + personalDetails: PERSONAL_DETAILS, + reportAttributesDerived, + sortedActions: sortedData?.sortedActions, + transactionThreadIDs: sortedData?.transactionThreadIDs, + lastActions: sortedData?.lastActions, + cardList, + localeCompare, + formatPhoneNumber, + conciergeReportID: CONCIERGE_REPORT_ID, + isTrackIntentUser, + translate: translateLocal, + rules: undefined, + }); + + const searchOption = searchResults.recentReports.find((option) => option.reportID === report.reportID); + return {lhnText: lhnOption?.alternateText, searchText: searchOption?.alternateText, searchOptionFound: !!searchOption}; +} + +async function expectParity(parityCase: ParityCase) { + const {lhnText, searchText, searchOptionFound} = await computeBothSurfaces(parityCase); + expect(searchOptionFound).toBe(true); + expect(lhnText).toBeTruthy(); + expect(searchText).toBe(lhnText); +} + +const SIMPLE_CHAIN_ACTIONS: Array = [ + CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_FORCE_UPGRADE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE, + CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED, + CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN, + CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_TAGS, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_ALL_TAGS, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_REQUIRED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAX, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_TAX_NAME, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY_DEFAULT_TAX, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_NAME, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAG, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_CUSTOM_UNIT_RATES, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_SUB_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_SUB_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FEATURE_ENABLED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_IS_ATTENDEE_TRACKING_ENABLED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRE_COMPANY_CARDS_ENABLED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRES_CATEGORY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRES_TAG, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_GLOBAL_REIMBURSEMENTS_FX_PREFERENCE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MCC_GROUP_CATEGORY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_SUBMITS_TO, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FORWARDS_TO, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSER, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_ENABLED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ACH_ACCOUNT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ADDRESS, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE, + CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CARD_FEED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.RENAME_CARD_FEED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ASSIGN_COMPANY_CARD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UNASSIGN_COMPANY_CARD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_LIABILITY, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_STATEMENT_PERIOD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUDIT_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_APPROVER_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_BUDGET, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_BUDGET, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_ENABLED, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_RATE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_PROHIBITED_EXPENSES, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL, + CONST.REPORT.ACTIONS.TYPE.REROUTE, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP, + CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED, + CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL, + CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT, + CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT, +]; + +describe('LHN vs Search preview parity', () => { + beforeAll(async () => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + IntlStore.load(CONST.LOCALES.EN); + initOnyxDerivedValues(); + await waitForBatchedUpdatesWithAct(); + }); + + beforeEach(() => { + clearFilteredOptionListCache(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + describe('special-action chain branches (policy room)', () => { + it.each(SIMPLE_CHAIN_ACTIONS)('should match for %s', async (actionName) => { + await expectParity({lastAction: makeAction(actionName)}); + }); + }); + + describe('branches with bespoke payloads', () => { + it('should match for RENAMED with old/new name', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, {originalMessage: {oldName: 'Old room name', newName: 'New room name'}}), + }); + }); + + it('should match for ROOM invite with targetAccountIDs and room name from originalMessage', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, {originalMessage: {targetAccountIDs: [2, 3], roomName: 'general'}}), + }); + }); + + it('should match for POLICY remove with single target', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, {originalMessage: {targetAccountIDs: [2]}}), + }); + }); + + it('should match for invite with room name resolved from lastActionReport', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, {originalMessage: {targetAccountIDs: [2], reportID: 200}}), + extraReports: [makeReport({reportID: '200', reportName: '#target-room'})], + }); + }); + + it('should match for ACTIONABLE_CARD_FRAUD_ALERT with resolution', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT, { + originalMessage: {resolution: CONST.CARD_FRAUD_ALERT_RESOLUTION.RECOGNIZED, cardID: 11, maskedCardNumber: '4444', triggerAmount: 1000, triggerMerchant: 'ACME'}, + }), + }); + }); + + it('should match for CARD_ISSUED without card', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, {originalMessage: {assigneeAccountID: 2, cardID: 11}}), + }); + }); + + it('should match for CARD_ISSUED with card', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, {originalMessage: {assigneeAccountID: 2, cardID: 11}}), + cardList: { + 11: { + cardID: 11, + state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED, + bank: CONST.EXPENSIFY_CARD.BANK, + domainName: 'test.com', + lastUpdated: '2024-01-01', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastFourPAN: '1234', + }, + }, + }); + }); + + it('should match when ROOM invite without targetAccountIDs falls back to mention-user count in lastMessageHtml', async () => { + await expectParity({ + report: makeReport({lastMessageHtml: ''}), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, {originalMessage: {}}), + }); + }); + + it('should match for ADD_INTEGRATION with connection name', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION, {originalMessage: {connectionName: CONST.POLICY.CONNECTIONS.NAME.QBO}}), + }); + }); + + it('should match for DELETE_INTEGRATION with connection name', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION, {originalMessage: {connectionName: CONST.POLICY.CONNECTIONS.NAME.XERO}}), + }); + }); + + it('should match for UPDATE_EMPLOYEE with role change', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE, { + originalMessage: {email: 'bob@test.com', field: 'role', oldValue: CONST.POLICY.ROLE.USER, newValue: CONST.POLICY.ROLE.ADMIN}, + }), + }); + }); + + it('should match for REASSIGN_APPROVER with new approver', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.REASSIGN_APPROVER, {originalMessage: {newApproverID: 2}}), + }); + }); + + it('should match for TRAVEL_UPDATE booking ticketed', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE, { + originalMessage: { + type: CONST.RESERVATION_TYPE.FLIGHT, + operation: CONST.TRAVEL.UPDATE_OPERATION_TYPE.BOOKING_TICKETED, + start: {date: '2024-03-01 10:00:00', shortName: 'KRK'}, + end: {date: '2024-03-01 14:00:00', shortName: 'SFO'}, + route: {airlineCode: 'LO 3925', number: '3925'}, + confirmations: [{name: 'PNR', value: 'ABC123'}], + }, + }), + }); + }); + + it('should match for MOVED_TRANSACTION with derived report name', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, {originalMessage: {toReportID: '300', fromReportID: '100'}}), + extraReports: [makeReport({reportID: '300', reportName: 'Target Expense Report', chatType: undefined, type: CONST.REPORT.TYPE.EXPENSE})], + }); + }); + + it('should match when generic ADD_COMMENT in room gets actor prefix', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT), + }); + }); + + it('should match for generic ADD_COMMENT from current user in room', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, {actorAccountID: CURRENT_USER_ACCOUNT_ID, person: [{type: 'TEXT', style: 'strong', text: 'Current User'}]}), + }); + }); + + it('should match when generic branch resolves actor from person[0].text when personalDetails miss the actor', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, {actorAccountID: 42, person: [{type: 'TEXT', style: 'strong', text: 'Mystery Person'}]}), + }); + }); + + it('should match for REPORT_PREVIEW on policy expense chat', async () => { + await expectParity({ + report: makeReport({chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, reportName: 'Workspace chat', lastMessageText: 'owes $1.00', lastMessageHtml: 'owes $1.00'}), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW), + }); + }); + }); + + describe('report-type and fallback variants', () => { + it('should match when empty room falls back to welcome message', async () => { + await expectParity({report: makeReport({lastMessageText: '', lastMessageHtml: ''})}); + }); + + it('should match when empty policy expense chat falls back to welcome message', async () => { + await expectParity({ + report: makeReport({ + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + reportName: 'Workspace chat', + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + lastMessageText: '', + lastMessageHtml: '', + }), + }); + }); + + it('should match when empty DM falls back to welcome message', async () => { + await expectParity({report: makeReport({chatType: undefined, reportName: '', lastMessageText: '', lastMessageHtml: ''})}); + }); + + it('should match for empty self-DM with isTrackIntentUser true', async () => { + await expectParity({ + report: makeReport({ + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + reportName: '', + lastMessageText: '', + lastMessageHtml: '', + participants: {[CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, + }), + isTrackIntentUser: true, + }); + }); + + it('should match for empty self-DM with isTrackIntentUser false', async () => { + await expectParity({ + report: makeReport({ + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + reportName: '', + lastMessageText: '', + lastMessageHtml: '', + participants: {[CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, + }), + }); + }); + + it('should match when DM with last message shows actor prefix', async () => { + await expectParity({ + report: makeReport({chatType: undefined, reportName: '', lastActorAccountID: 1}), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT), + }); + }); + + it('should match when DM last message from current user has no actor prefix', async () => { + await expectParity({ + report: makeReport({chatType: undefined, reportName: '', lastActorAccountID: CURRENT_USER_ACCOUNT_ID}), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, {actorAccountID: CURRENT_USER_ACCOUNT_ID, person: [{type: 'TEXT', style: 'strong', text: 'Current User'}]}), + }); + }); + + it('should match for group chat with last comment', async () => { + await expectParity({ + report: makeReport({ + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportName: 'Alice, Bob, Carol', + participants: { + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT), + }); + }); + + it('should match when archived room skips the special-action chain', async () => { + await expectParity({ + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, {originalMessage: {oldName: 'Old', newName: 'New'}}), + isReportArchived: true, + }); + }); + + it('should match when multiline last message collapses line breaks', async () => { + await expectParity({ + report: makeReport({lastMessageText: 'line one\nline two\nline three', lastMessageHtml: 'line one
line two
line three'}), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, { + message: [{type: 'COMMENT', html: 'line one
line two
line three', text: 'line one\nline two\nline three', isDeletedParentAction: false, deleted: ''}], + }), + }); + }); + + it('should match for empty invoice room where the welcome payer comes from invoiceReceiverPolicy', async () => { + const {lhnText, searchText, searchOptionFound} = await computeBothSurfaces({ + report: makeReport({ + chatType: CONST.REPORT.CHAT_TYPE.INVOICE, + reportName: 'Invoice room', + lastMessageText: '', + lastMessageHtml: '', + invoiceReceiver: {type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, policyID: 'P1'}, + }), + invoiceReceiverPolicy: {...createRandomPolicy(1), id: 'P1', name: 'Biz Co'}, + }); + expect(searchOptionFound).toBe(true); + expect(lhnText).toContain('Biz Co'); + expect(searchText).toBe(lhnText); + }); + + it('should match for a one-transaction expense report whose newest action is the IOU create action', async () => { + const createdAction = makeAction(CONST.REPORT.ACTIONS.TYPE.CREATED, {reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + const iouAction = makeAction(CONST.REPORT.ACTIONS.TYPE.IOU, { + reportActionID: '2', + created: '2024-01-02 00:00:00.000', + childReportID: '400', + originalMessage: { + IOUReportID: '100', + IOUTransactionID: 't1', + amount: 1234, + currency: 'USD', + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }); + const threadCreatedAction = makeAction(CONST.REPORT.ACTIONS.TYPE.CREATED, {reportActionID: '3', created: '2024-01-02 00:00:01.000'}); + const threadCommentAction = makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, { + reportActionID: '4', + created: '2024-01-03 00:00:00.000', + message: [{type: 'COMMENT', html: 'Thread follow-up', text: 'Thread follow-up', isDeletedParentAction: false, deleted: ''}], + }); + + await expectParity({ + report: makeReport({ + type: CONST.REPORT.TYPE.EXPENSE, + chatType: undefined, + reportName: 'Expense Report', + ownerAccountID: 1, + managerID: CURRENT_USER_ACCOUNT_ID, + lastVisibleActionCreated: '2024-01-02 00:00:00.000', + }), + mainReportActions: [createdAction, iouAction], + extraReports: [ + makeReport({ + reportID: '400', + chatType: undefined, + reportName: 'Transaction thread', + parentReportID: '100', + parentReportActionID: '2', + }), + ], + extraReportActions: {'400': [threadCreatedAction]}, + lateReportActions: {'400': [threadCommentAction]}, + }); + }); + + it('should match when SMS domain is stripped from last message', async () => { + await expectParity({ + report: makeReport({lastMessageText: 'ping +15551234567@expensify.sms please', lastMessageHtml: 'ping +15551234567@expensify.sms please'}), + lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, { + message: [{type: 'COMMENT', html: 'ping +15551234567@expensify.sms please', text: 'ping +15551234567@expensify.sms please', isDeletedParentAction: false, deleted: ''}], + }), + }); + }); + }); +}); diff --git a/tests/unit/LinkTest.ts b/tests/unit/LinkTest.ts index 5666c349c20f..f010ef1e03b2 100644 --- a/tests/unit/LinkTest.ts +++ b/tests/unit/LinkTest.ts @@ -7,7 +7,7 @@ import * as Url from '@libs/Url'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import {openLink} from '@src/libs/actions/Link'; +import {getInternalNewExpensifyPath, openLink} from '@src/libs/actions/Link'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -350,3 +350,30 @@ describe('Link.openLink', () => { ); }); }); + +describe('Link.getInternalNewExpensifyPath', () => { + // NEW_EXPENSIFY_URL carries a trailing slash and the other two do not, so let URL join them. + it.each([ + ['production', CONST.NEW_EXPENSIFY_URL], + ['staging', CONST.STAGING_NEW_EXPENSIFY_URL], + ['QA', CONST.QA_NEW_EXPENSIFY_URL], + ])('treats a %s link as internal and returns its path', (_environment, origin) => { + expect(getInternalNewExpensifyPath(new URL('/r/1234', origin).href)).toBe('r/1234'); + }); + + it('matches the dev server by prefix, so any port counts as internal', () => { + expect(getInternalNewExpensifyPath(`${CONST.DEV_NEW_EXPENSIFY_URL}8082/r/1234`)).toBe('r/1234'); + }); + + it('treats an unrelated origin as external', () => { + expect(getInternalNewExpensifyPath('https://example.com/r/1234')).toBe(''); + }); + + it.each(CONST.PATHS_TO_TREAT_AS_EXTERNAL)('treats %s as external even on an internal origin', (externalPath) => { + expect(getInternalNewExpensifyPath(new URL(`/${externalPath}`, CONST.QA_NEW_EXPENSIFY_URL).href)).toBe(''); + }); + + it('returns an empty path for an empty href', () => { + expect(getInternalNewExpensifyPath('')).toBe(''); + }); +}); diff --git a/tests/unit/ListItemCompactAvatarTest.tsx b/tests/unit/ListItemCompactAvatarTest.tsx new file mode 100644 index 000000000000..e53b724fb09e --- /dev/null +++ b/tests/unit/ListItemCompactAvatarTest.tsx @@ -0,0 +1,34 @@ +import {render} from '@testing-library/react-native'; + +import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; + +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)); + +const mockAvatarFromIcon = jest.mocked(AvatarFromIcon); + +describe('ListItemComposed.CompactAvatar', () => { + beforeEach(() => { + mockAvatarFromIcon.mockClear(); + }); + + it('forwards the icon to the avatar', () => { + const icon: Icon = { + source: 'avatar.png', + type: CONST.ICON_TYPE_AVATAR, + name: 'Test User', + id: 7, + fallbackIcon: 'fallback.png', + }; + + render(); + + expect(mockAvatarFromIcon).toHaveBeenCalledTimes(1); + expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon})); + }); +}); diff --git a/tests/unit/MentionSuggestionItemTest.tsx b/tests/unit/MentionSuggestionItemTest.tsx new file mode 100644 index 000000000000..0a4defb5f28d --- /dev/null +++ b/tests/unit/MentionSuggestionItemTest.tsx @@ -0,0 +1,89 @@ +import {render, screen} from '@testing-library/react-native'; + +import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; +import Icon from '@components/Icon'; +import MentionSuggestionItem from '@components/MentionSuggestions/MentionSuggestionItem'; +import type Mention from '@components/MentionSuggestions/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@components/Avatar/AvatarFromIcon', () => jest.fn(() => null)); + +jest.mock('@components/Icon', () => jest.fn(() => null)); + +const mockAvatarFromIcon = jest.mocked(AvatarFromIcon); +const mockIcon = jest.mocked(Icon); + +const USER_ICON = { + source: 'avatar.png', + type: CONST.ICON_TYPE_AVATAR, + name: 'Test User', + id: 7, +}; + +describe('MentionSuggestionItem', () => { + beforeEach(() => { + mockAvatarFromIcon.mockClear(); + mockIcon.mockClear(); + }); + + it('renders the avatar, display name and handle of a user mention', () => { + const item: Mention = { + text: 'Test User', + alternateText: '@someone', + handle: 'someone', + icons: [USER_ICON], + }; + + render( + , + ); + + expect(mockAvatarFromIcon).toHaveBeenCalledTimes(1); + expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon: USER_ICON})); + expect(mockIcon).not.toHaveBeenCalled(); + expect(screen.getByText('Test User')).toBeOnTheScreen(); + expect(screen.getByText('@someone')).toBeOnTheScreen(); + }); + + it('renders a plain success-colored icon instead of an avatar for the @here mention', () => { + const item: Mention = { + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: 'Notify everyone online in this room', + icons: [USER_ICON], + }; + + render( + , + ); + + expect(mockAvatarFromIcon).not.toHaveBeenCalled(); + expect(mockIcon).toHaveBeenCalledTimes(1); + expect(screen.getByText(CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT)).toBeOnTheScreen(); + }); + + it('does not repeat the handle when it matches the display text', () => { + const item: Mention = { + text: '@someone', + alternateText: '@someone', + icons: [USER_ICON], + }; + + render( + , + ); + + expect(screen.getAllByText('@someone')).toHaveLength(1); + }); +}); diff --git a/tests/unit/MenuItemLabelTest.tsx b/tests/unit/MenuItemLabelTest.tsx new file mode 100644 index 000000000000..398895263014 --- /dev/null +++ b/tests/unit/MenuItemLabelTest.tsx @@ -0,0 +1,73 @@ +import {render, screen, within} from '@testing-library/react-native'; + +import MenuItem from '@components/MenuItem'; +import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const ROW_ACCESSIBILITY_LABEL = 'Assignee, John Doe'; +const LABEL_TEXT = 'Assignee'; +const VALUE_TEXT = 'John Doe'; + +describe('MenuItem label placement', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return waitForBatchedUpdates(); + }); + + it('renders MenuItem.Label inside the row press target, so the label and the value share one lockup', async () => { + render( + + + + + {LABEL_TEXT} + {VALUE_TEXT} + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + const row = screen.getByLabelText(ROW_ACCESSIBILITY_LABEL); + + expect(within(row).getByText(LABEL_TEXT)).toBeOnTheScreen(); + expect(within(row).getByText(VALUE_TEXT)).toBeOnTheScreen(); + }); + + it('keeps the MenuItemWithLabel preset label outside the row press target', async () => { + render( + + + + + {VALUE_TEXT} + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + const row = screen.getByLabelText(ROW_ACCESSIBILITY_LABEL); + + expect(screen.getByText(LABEL_TEXT)).toBeOnTheScreen(); + expect(within(row).queryByText(LABEL_TEXT)).toBeNull(); + expect(within(row).getByText(VALUE_TEXT)).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/MerchantRuleSuggestionUtilsTest.ts b/tests/unit/MerchantRuleSuggestionUtilsTest.ts new file mode 100644 index 000000000000..4c95590467f0 --- /dev/null +++ b/tests/unit/MerchantRuleSuggestionUtilsTest.ts @@ -0,0 +1,197 @@ +import {getChangedTagLevels, getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; + +import CONST from '@src/CONST'; +import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const TRANSACTION_ID = '1234567890'; + +/** + * A minimal expense the draft builder can read. Tests override only the fields they exercise, so the seeding table is + * validated against a realistic transaction rather than a hand-picked subset. + */ +const buildTransaction = (overrides: Partial = {}) => + ({ + transactionID: TRANSACTION_ID, + merchant: 'Starbucks', + amount: -500, + currency: 'USD', + created: '2026-09-01', + reportID: '999', + comment: {}, + ...overrides, + }) as Transaction; + +/** The recorded tag levels, built rather than written inline so the level numbers stay out of an object literal. */ +const buildEditedTagLevels = (levels: number[]): Record => Object.fromEntries(levels.map((level) => [level, true])); + +/** A workspace holding one tax rate, to exercise seeding a rate that is still there against one that is not. */ +const buildPolicyWithTax = (taxCode: string): Policy => { + const policy = createRandomPolicy(0); + policy.taxRates = { + name: 'Tax', + defaultExternalID: taxCode, + defaultValue: '0%', + foreignTaxDefault: taxCode, + taxes: Object.fromEntries([[taxCode, {name: 'Tax exempt', value: '0%'}]]), + }; + return policy; +}; + +const buildSuggestion = (overrides: Partial = {}) => + ({ + transactionID: TRANSACTION_ID, + reportID: '999', + editedFields: {[TRANSACTION_ID]: {category: true}}, + ...overrides, + }) as MerchantRuleSuggestion; + +describe('isMerchantRuleSuggestionLive', () => { + it('is not live without a stored offer', () => { + expect(isMerchantRuleSuggestionLive(undefined)).toBe(false); + }); + + it('is not live without a transaction to offer for', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({transactionID: ''}))).toBe(false); + }); + + it('is live for a freshly recorded edit', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion())).toBe(true); + }); + + it('is not live once retired', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({isRetired: true}))).toBe(false); + }); + + it('stays live after being seen, since seeing it is not taking it', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({seenInReportID: '999'}))).toBe(true); + }); + + it('is not live once this expense is dismissed', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({dismissedTransactionIDs: [TRANSACTION_ID]}))).toBe(false); + }); + + it('stays live when a different expense was dismissed', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({dismissedTransactionIDs: ['9999']}))).toBe(true); + }); +}); + +describe('getChangedTagLevels', () => { + it('reports no change when the tag is untouched', () => { + expect(getChangedTagLevels('Sales:South America', 'Sales:South America')).toEqual([]); + }); + + it('reports only the level that changed', () => { + expect(getChangedTagLevels('Sales:South America:Project 6', 'Marketing:South America:Project 6')).toEqual([0]); + expect(getChangedTagLevels('Sales:South America:Project 6', 'Sales:Europe:Project 6')).toEqual([1]); + }); + + it('reports every level that changed', () => { + expect(getChangedTagLevels('Sales:South America', 'Marketing:Europe')).toEqual([0, 1]); + }); + + it('reports a level added to a shorter tag', () => { + expect(getChangedTagLevels('Sales', 'Sales:South America')).toEqual([1]); + }); + + it('reports a level removed from a longer tag', () => { + expect(getChangedTagLevels('Sales:South America', 'Sales')).toEqual([1]); + }); + + it('reports the whole tag when it is cleared', () => { + expect(getChangedTagLevels('Sales:South America', '')).toEqual([0, 1]); + }); + + it('reports a single-level tag being set', () => { + expect(getChangedTagLevels('', 'Sales')).toEqual([0]); + }); +}); + +describe('getMerchantRuleDraftFromTransaction', () => { + it('returns nothing when there is no expense', () => { + expect(getMerchantRuleDraftFromTransaction(undefined, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined)).toBeUndefined(); + }); + + it('returns nothing when the expense has no merchant to match on', () => { + const transaction = buildTransaction({merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT}); + expect(getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined)).toBeUndefined(); + }); + + it('names the rule type itself, so the editor opens rather than the type chooser', () => { + const draft = getMerchantRuleDraftFromTransaction(buildTransaction(), [], undefined); + expect(draft?.ruleType).toBe(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT); + expect(draft?.merchantToMatch).toBe('Starbucks'); + }); + + it('seeds the category that was edited', () => { + const transaction = buildTransaction({category: 'Benefits'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined); + expect(draft?.category).toBe('Benefits'); + }); + + it('leaves out a field that was cleared, so the rule would change nothing', () => { + const transaction = buildTransaction({category: ''}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined); + expect(draft).not.toHaveProperty('category'); + }); + + it('carries the whole tag when no levels were recorded', () => { + const transaction = buildTransaction({tag: 'Sales:South America'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined); + expect(draft?.tag).toBe('Sales:South America'); + }); + + it('blanks the levels that were not edited', () => { + const transaction = buildTransaction({tag: 'Sales:South America:Project 6'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined, buildEditedTagLevels([1])); + expect(draft?.tag).toBe(':South America'); + }); + + it('keeps the first level alone when only it was edited', () => { + const transaction = buildTransaction({tag: 'Sales:South America'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined, buildEditedTagLevels([0])); + expect(draft?.tag).toBe('Sales'); + }); + + it('leaves out the tag when every edited level is empty', () => { + const transaction = buildTransaction({tag: ''}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined, buildEditedTagLevels([0])); + expect(draft).not.toHaveProperty('tag'); + }); + + it('seeds a tax rate the workspace still holds', () => { + const transaction = buildTransaction({taxCode: 'id_TAX_EXEMPT'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX], buildPolicyWithTax('id_TAX_EXEMPT')); + expect(draft?.tax).toBe('id_TAX_EXEMPT'); + }); + + it('leaves out a tax rate the workspace no longer holds', () => { + const transaction = buildTransaction({taxCode: 'id_GONE'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX], buildPolicyWithTax('id_TAX_EXEMPT')); + expect(draft).not.toHaveProperty('tax'); + }); + + it('seeds an unset reimbursable as reimbursable, matching what the expense shows', () => { + const draft = getMerchantRuleDraftFromTransaction(buildTransaction(), [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE], undefined); + expect(draft?.reimbursable).toBe(true); + }); + + it('seeds reimbursable turned off', () => { + const transaction = buildTransaction({reimbursable: false}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE], undefined); + expect(draft?.reimbursable).toBe(false); + }); + + it('carries every edited field together, so one rule holds them all', () => { + const transaction = buildTransaction({category: 'Benefits', tag: 'Sales', billable: true}); + const draft = getMerchantRuleDraftFromTransaction( + transaction, + [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE], + undefined, + ); + expect(draft?.category).toBe('Benefits'); + expect(draft?.tag).toBe('Sales'); + expect(draft?.billable).toBe(true); + }); +}); diff --git a/tests/unit/MergeUtilsTest.ts b/tests/unit/MergeUtilsTest.ts index 7b7def71508e..a65ab6eb0cf8 100644 --- a/tests/unit/MergeUtilsTest.ts +++ b/tests/unit/MergeUtilsTest.ts @@ -1,6 +1,9 @@ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + import DateUtils from '@libs/DateUtils'; import { getMergeFinalApprover, + getMergeFinalApproverDisplayName, hasMergeAuthenticationError, hasMergeSyncError, isMergeConnected, @@ -12,13 +15,14 @@ import { import type {MergeConnectionName} from '@libs/merge/MergeUtils'; import CONST from '@src/CONST'; -import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; +import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import type {ConnectionLastSync, MergeConnectionLastSync} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import type {ValueOf} from 'type-fest'; import createRandomPolicy from 'tests/utils/collections/policies'; +import {formatPhoneNumber, translateLocal} from 'tests/utils/TestHelper'; const MERGE_HR = CONST.POLICY.CONNECTIONS.NAME.MERGE_HR; const MERGE_ATS = CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS; @@ -59,11 +63,6 @@ function makeMergePolicy(connectionName: MergeConnectionName, lastSync: Partial< return makePolicy({connections: {[connectionName]: connection}}); } -function stubTranslate(path: TPath, ...parameters: TranslationParameters): string; -function stubTranslate(path: TranslationPaths): string { - return path; -} - const dbTimeHoursAgo = (hoursAgo: number) => DateUtils.subtractMillisecondsFromDateTime(DateUtils.getDBTime(), hoursAgo * 60 * 60 * 1000); describe('MergeUtils', () => { @@ -220,6 +219,56 @@ describe('MergeUtils', () => { }); }); + describe('getMergeFinalApproverDisplayName', () => { + const APPROVER_LOGIN = 'boss@company.com'; + const SMS_LOGIN = '+15551234567@expensify.sms'; + + const personalDetailsFor = (login: string, overrides: Partial = {}): PersonalDetailsByLogin => ({ + [login]: {accountID: 42, login, ...overrides}, + }); + + it('returns "not set" 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'); + }); + + it('returns the display name when the approver is a known workspace member', () => { + // Given a final approver who is a workspace member with a display name + // When the approver's display name is resolved + // Then their display name is shown instead of their login + const personalDetails = personalDetailsFor(APPROVER_LOGIN, {displayName: 'Boss Man'}); + expect(getMergeFinalApproverDisplayName(APPROVER_LOGIN, personalDetails, translateLocal, formatPhoneNumber)).toBe('Boss Man'); + }); + + it('falls back to the login when the approver is not among the workspace members', () => { + // Given a final approver whose personal details are not loaded + // When the approver's display name is resolved + // Then their login is shown + expect(getMergeFinalApproverDisplayName(APPROVER_LOGIN, {}, translateLocal, formatPhoneNumber)).toBe(APPROVER_LOGIN); + expect(getMergeFinalApproverDisplayName(APPROVER_LOGIN, personalDetailsFor('someone.else@company.com'), translateLocal, formatPhoneNumber)).toBe(APPROVER_LOGIN); + }); + + it('falls back to the login when the approver has no display name set', () => { + // Given a final approver who is a workspace member but has not set a display name + // When the approver's display name is resolved + // Then their login is shown rather than "hidden" + expect(getMergeFinalApproverDisplayName(APPROVER_LOGIN, personalDetailsFor(APPROVER_LOGIN), translateLocal, formatPhoneNumber)).toBe(APPROVER_LOGIN); + expect(getMergeFinalApproverDisplayName(APPROVER_LOGIN, personalDetailsFor(APPROVER_LOGIN, {displayName: ''}), translateLocal, formatPhoneNumber)).toBe(APPROVER_LOGIN); + }); + + it('formats the phone number of an SMS approver who has no display name of their own', () => { + // Given a final approver who signed up with a phone number, so their display name is their SMS login + // When the approver's display name is resolved + // Then the phone number is formatted for display + const personalDetails = personalDetailsFor(SMS_LOGIN, {displayName: SMS_LOGIN}); + expect(getMergeFinalApproverDisplayName(SMS_LOGIN, personalDetails, translateLocal, formatPhoneNumber)).toBe(formatPhoneNumber(SMS_LOGIN)); + }); + }); + describe('showMergeManualSyncLimitModalIfReached', () => { const showConfirmModal: jest.MockedFunction[3]> = jest.fn(); @@ -230,14 +279,14 @@ describe('MergeUtils', () => { it.each(MERGE_CONNECTIONS)('allows the sync and shows no modal when the %s limit has not been reached', (connectionName) => { const policy = makeMergePolicy(connectionName, {manualSyncTimestamps: [dbTimeHoursAgo(1)]}); - expect(showMergeManualSyncLimitModalIfReached(policy, connectionName, stubTranslate, showConfirmModal)).toBe(false); + expect(showMergeManualSyncLimitModalIfReached(policy, connectionName, translateLocal, showConfirmModal)).toBe(false); expect(showConfirmModal).not.toHaveBeenCalled(); }); it.each(MERGE_CONNECTIONS)('blocks the sync and shows the modal when the %s limit has been reached', (connectionName) => { const policy = makeMergePolicy(connectionName, {manualSyncTimestamps: [dbTimeHoursAgo(1), dbTimeHoursAgo(10)]}); - expect(showMergeManualSyncLimitModalIfReached(policy, connectionName, stubTranslate, showConfirmModal)).toBe(true); + expect(showMergeManualSyncLimitModalIfReached(policy, connectionName, translateLocal, showConfirmModal)).toBe(true); expect(showConfirmModal).toHaveBeenCalledTimes(1); expect(showConfirmModal).toHaveBeenCalledWith( expect.objectContaining({ @@ -258,7 +307,7 @@ describe('MergeUtils', () => { }, }); - expect(showMergeManualSyncLimitModalIfReached(policy, CONST.POLICY.CONNECTIONS.NAME.GUSTO, stubTranslate, showConfirmModal)).toBe(false); + expect(showMergeManualSyncLimitModalIfReached(policy, CONST.POLICY.CONNECTIONS.NAME.GUSTO, translateLocal, showConfirmModal)).toBe(false); expect(showConfirmModal).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/MiddlewareEntryPointTest.ts b/tests/unit/MiddlewareEntryPointTest.ts index b38b887a888c..f898156245b1 100644 --- a/tests/unit/MiddlewareEntryPointTest.ts +++ b/tests/unit/MiddlewareEntryPointTest.ts @@ -1,17 +1,27 @@ import type * as RequestModule from '@libs/Request'; import {addMiddleware} from '@libs/Request'; +import appSetup from '@src/setup'; + jest.mock('@libs/Request', () => ({ ...jest.requireActual('@libs/Request'), addMiddleware: jest.fn(), })); +jest.mock('@libs/registerReportActionsPagination', () => jest.fn()); +jest.mock('@libs/IntlPolyfill', () => jest.fn()); +jest.mock('@userActions/Device', () => ({setDeviceID: jest.fn()})); +jest.mock('@userActions/OnyxDerived', () => jest.fn()); +jest.mock('@src/setup/addUtilsToWindow', () => jest.fn()); +jest.mock('@src/setup/platformSetup', () => jest.fn()); +jest.mock('@src/setup/telemetry', () => jest.fn()); + describe('src/setup attaches the API middlewares', () => { - it('registers all 13 middlewares at module scope when the composition root loads', () => { + it('registers all 16 middlewares when the composition root runs', () => { expect(jest.mocked(addMiddleware)).not.toHaveBeenCalled(); - require('@src/setup'); + appSetup(); - expect(jest.mocked(addMiddleware)).toHaveBeenCalledTimes(13); + expect(jest.mocked(addMiddleware)).toHaveBeenCalledTimes(16); }); }); diff --git a/tests/unit/MiddlewareRegistrationTest.ts b/tests/unit/MiddlewareRegistrationTest.ts index 8839548a6e2b..2a58fee5a806 100644 --- a/tests/unit/MiddlewareRegistrationTest.ts +++ b/tests/unit/MiddlewareRegistrationTest.ts @@ -1,7 +1,9 @@ import { FailureTracking, FraudMonitoring, + GlobalReimbursementPayError, handleDeletedAccount, + HandleMovedScanFailedExpenses, HandleUnusedOptimisticID, LoadPostDataForOpenOrReconnect, LoadTest, @@ -9,10 +11,12 @@ import { Pagination, Reauthentication, RecordFullReconnectTime, + ReplaceOptimisticAgentAccountID, SaveResponseInOnyx, SentryServerTiming, SupportalPermission, } from '@libs/Middleware'; +import registerMiddlewares from '@libs/Middleware/register'; import type * as RequestModule from '@libs/Request'; import {addMiddleware} from '@libs/Request'; @@ -28,11 +32,14 @@ const EXPECTED_ORDER: RequestModule.Middleware[] = [ Reauthentication, handleDeletedAccount, SupportalPermission, + GlobalReimbursementPayError, HandleUnusedOptimisticID, + ReplaceOptimisticAgentAccountID, Pagination, SentryServerTiming, RecordFullReconnectTime, LoadPostDataForOpenOrReconnect, + HandleMovedScanFailedExpenses, SaveResponseInOnyx, FraudMonitoring, ]; @@ -41,9 +48,7 @@ describe('Middleware registration', () => { let registered: RequestModule.Middleware[] = []; beforeAll(() => { - // jest.isolateModules would give register.ts its own module registry, so the middlewares it resolves - // would be distinct function objects from the ones imported above and every identity check would fail. - require('@libs/Middleware/register'); + registerMiddlewares(); registered = jest.mocked(addMiddleware).mock.calls.map(([middleware]) => middleware); }); @@ -51,9 +56,9 @@ describe('Middleware registration', () => { expect(registered).toEqual(EXPECTED_ORDER); }); - it('registers all 13 middlewares with no duplicates', () => { - expect(registered).toHaveLength(13); - expect(new Set(registered).size).toBe(13); + it('registers all 16 middlewares with no duplicates', () => { + expect(registered).toHaveLength(16); + expect(new Set(registered).size).toBe(16); }); it('keeps SaveResponseInOnyx after every other Onyx-writing middleware and before FraudMonitoring', () => { @@ -62,6 +67,7 @@ describe('Middleware registration', () => { expect(indexOf(SaveResponseInOnyx)).toBeGreaterThanOrEqual(0); expect(indexOf(RecordFullReconnectTime)).toBeLessThan(indexOf(SaveResponseInOnyx)); expect(indexOf(LoadPostDataForOpenOrReconnect)).toBeLessThan(indexOf(SaveResponseInOnyx)); + expect(indexOf(HandleMovedScanFailedExpenses)).toBeLessThan(indexOf(SaveResponseInOnyx)); expect(indexOf(FraudMonitoring)).toBeGreaterThan(indexOf(SaveResponseInOnyx)); }); }); diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index eca65f89fb27..5de3d626f9a2 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -7,12 +7,9 @@ import * as ReportNameUtils from '@libs/ReportNameUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import {translate} from '@src/libs/Localize'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type {OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; -import Onyx from 'react-native-onyx'; - import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import createMock from '../utils/createMock'; @@ -35,6 +32,7 @@ jest.mock('@libs/ReportNameUtils', () => ({ const MOVED_TO_REPORT_ID = '1'; const MOVED_FROM_REPORT_ID = '2'; const CURRENT_USER_LOGIN = 'test@example.com'; +const CURRENT_USER_ACCOUNT_ID = 12345; describe('ModifiedExpenseMessage', () => { beforeAll(() => { IntlStore.load(CONST.LOCALES.EN); @@ -114,7 +112,7 @@ describe('ModifiedExpenseMessage', () => { describe('when moving to a report', () => { it('returns "moved expense to personal space" message when moving an expense to selfDM', () => { const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); - const result = getMovedFromOrToReportMessage(translateLocal, undefined, selfDMReport, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, selfDMReport, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedToPersonalSpace'); expect(result).toEqual(expectedResult); }); @@ -128,13 +126,13 @@ describe('ModifiedExpenseMessage', () => { owner: CURRENT_USER_LOGIN, outputCurrency: CONST.CURRENCY.USD, }; - const result = getMovedFromOrToReportMessage(translateLocal, undefined, selfDMReport, CURRENT_USER_LOGIN, policy); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, selfDMReport, CURRENT_USER_ACCOUNT_ID, policy); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedToPersonalSpace'); expect(result).toEqual(expectedResult); }); it('returns "moved expense from personal space to chat with reportName" message when moving an expense to policy expense chat with only reportName', () => { const policyExpenseReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', policyExpenseReport.reportName); expect(result).toEqual(expectedResult); }); @@ -143,7 +141,7 @@ describe('ModifiedExpenseMessage', () => { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), policyName: 'Policy', }; - const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', policyExpenseReport.reportName, policyExpenseReport.policyName); expect(result).toEqual(expectedResult); }); @@ -160,7 +158,7 @@ describe('ModifiedExpenseMessage', () => { owner: CURRENT_USER_LOGIN, outputCurrency: CONST.CURRENCY.USD, }; - const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_LOGIN, policy); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_ACCOUNT_ID, policy); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', policyExpenseReport.reportName, policy.name); expect(result).toEqual(expectedResult); }); @@ -169,7 +167,7 @@ describe('ModifiedExpenseMessage', () => { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), reportName: '', }; - const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.changedTheExpense'); expect(result).toEqual(expectedResult); }); @@ -187,29 +185,24 @@ describe('ModifiedExpenseMessage', () => { owner: CURRENT_USER_LOGIN, outputCurrency: CONST.CURRENCY.USD, }; - const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_LOGIN, policy); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, policyExpenseReport, CURRENT_USER_ACCOUNT_ID, policy); // When a valid policy provides a name, the movedFromPersonalSpace message is returned // even if the report has no reportName, because policyName is sufficient. const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', policyExpenseReport.reportName, policy.name); expect(result).toEqual(expectedResult); }); - it('returns "moved from personal space to reportName" message when moving an expense to a 1:1 DM', async () => { - const mockAccountID = 12345; + it('returns "moved from personal space to reportName" message when moving an expense to a 1:1 DM', () => { const dmReportName = 'John Doe'; const dmReport = createRandomReport(1, undefined); - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [mockAccountID]: {accountID: mockAccountID, login: CURRENT_USER_LOGIN}, - }); - jest.mocked(ReportNameUtils.buildReportNameFromParticipantNames).mockImplementation(({currentUserAccountID}) => { - if (currentUserAccountID === mockAccountID) { + if (currentUserAccountID === CURRENT_USER_ACCOUNT_ID) { return dmReportName; } return ''; }); - const result = getMovedFromOrToReportMessage(translateLocal, undefined, dmReport, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, dmReport, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', dmReportName); expect(result).toEqual(expectedResult); }); @@ -221,7 +214,7 @@ describe('ModifiedExpenseMessage', () => { }; it('returns "moved expense from reportName" message', () => { - const result = getMovedFromOrToReportMessage(translateLocal, movedFromReport, undefined, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, movedFromReport, undefined, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromReport', movedFromReport.reportName ?? ''); expect(result).toEqual(expectedResult); }); @@ -235,7 +228,7 @@ describe('ModifiedExpenseMessage', () => { owner: CURRENT_USER_LOGIN, outputCurrency: CONST.CURRENCY.USD, }; - const result = getMovedFromOrToReportMessage(translateLocal, movedFromReport, undefined, CURRENT_USER_LOGIN, policy); + const result = getMovedFromOrToReportMessage(translateLocal, movedFromReport, undefined, CURRENT_USER_ACCOUNT_ID, policy); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromReport', movedFromReport.reportName ?? ''); expect(result).toEqual(expectedResult); }); @@ -245,7 +238,7 @@ describe('ModifiedExpenseMessage', () => { ...createRandomReport(1, undefined), reportName: '', }; - const result = getMovedFromOrToReportMessage(translateLocal, reportWithoutName, undefined, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, reportWithoutName, undefined, CURRENT_USER_ACCOUNT_ID, undefined); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromReportNoName'); expect(result).toEqual(expectedResult); @@ -253,7 +246,7 @@ describe('ModifiedExpenseMessage', () => { }); it('returns undefined when neither movedToReport nor movedFromReport is provided', () => { - const result = getMovedFromOrToReportMessage(translateLocal, undefined, undefined, CURRENT_USER_LOGIN, undefined); + const result = getMovedFromOrToReportMessage(translateLocal, undefined, undefined, CURRENT_USER_ACCOUNT_ID, undefined); expect(result).toBeUndefined(); }); }); @@ -280,6 +273,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -308,6 +302,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -335,6 +330,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -364,6 +360,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -394,6 +391,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -426,6 +424,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -456,6 +455,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -491,6 +491,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -524,6 +525,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -550,6 +552,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -576,6 +579,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -602,6 +606,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -628,6 +633,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -654,6 +660,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -682,6 +689,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -712,6 +720,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -738,6 +747,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -766,6 +776,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -796,6 +807,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -822,6 +834,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -847,6 +860,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -876,6 +890,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual(expectedResult); @@ -904,6 +919,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual(expectedResult); @@ -934,6 +950,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual(expectedResult); @@ -960,6 +977,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, movedFromReport, }); @@ -987,6 +1005,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1014,6 +1033,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1041,6 +1061,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1068,6 +1089,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1095,6 +1117,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1123,6 +1146,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: mockPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1153,6 +1177,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1180,6 +1205,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1206,6 +1232,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1233,6 +1260,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1276,6 +1304,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1309,6 +1338,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1335,6 +1365,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1361,6 +1392,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1387,6 +1419,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1414,6 +1447,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1443,6 +1477,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyRulesPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1471,6 +1506,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1498,6 +1534,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); @@ -1528,6 +1565,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1550,6 +1588,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1577,6 +1616,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1602,6 +1642,7 @@ describe('ModifiedExpenseMessage', () => { reportAction: firstEditAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1626,6 +1667,7 @@ describe('ModifiedExpenseMessage', () => { reportAction: secondEditAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1656,6 +1698,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1682,6 +1725,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1708,6 +1752,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1734,6 +1779,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1756,6 +1802,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1783,6 +1830,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1805,6 +1853,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1832,6 +1881,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1856,6 +1906,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1880,6 +1931,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: mockPolicy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -1912,6 +1964,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); expect(result).toEqual(expectedResult); @@ -1939,6 +1992,7 @@ describe('ModifiedExpenseMessage', () => { movedFromReport, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); expect(result).toEqual(expectedResult); @@ -1971,6 +2025,7 @@ describe('ModifiedExpenseMessage', () => { movedFromReport, policy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromReport', 'Some Report'); @@ -2004,6 +2059,7 @@ describe('ModifiedExpenseMessage', () => { movedToReport, policy, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', movedToReport.reportName, policy.name); @@ -2028,6 +2084,7 @@ describe('ModifiedExpenseMessage', () => { movedToReport, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); const expectedResult = translate(CONST.LOCALES.EN as 'en', 'iou.movedFromPersonalSpace', movedToReport.reportName, movedToReport.policyName); @@ -2048,6 +2105,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2068,6 +2126,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2091,6 +2150,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2113,6 +2173,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2139,6 +2200,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2161,6 +2223,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2187,6 +2250,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2209,6 +2273,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2232,6 +2297,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2254,6 +2320,7 @@ describe('ModifiedExpenseMessage', () => { outputCurrency: CONST.CURRENCY.USD, }, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: 'test@example.com', }); @@ -2281,7 +2348,8 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, - currentUserLogin: 'test@example.com', + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('changed the attendees to Alice, Bob (previously Alice)'); @@ -2303,7 +2371,8 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, - currentUserLogin: 'test@example.com', + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('set the attendees to Alice'); @@ -2325,7 +2394,8 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: undefined, policyTags: undefined, - currentUserLogin: 'test@example.com', + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('removed the attendees (previously Alice)'); @@ -2377,6 +2447,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithVendors, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('set the vendor to "Acme"'); @@ -2400,6 +2471,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithVendors, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('changed the vendor to "Office Supplies" (previously "Acme")'); @@ -2422,6 +2494,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithVendors, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('removed the vendor (previously "Acme")'); @@ -2444,6 +2517,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithVendors, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('set the vendor to "v-deleted"'); @@ -2466,6 +2540,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithVendors, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('set the vendor to "Amazon"'); @@ -2510,6 +2585,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithXeroSuppliers, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('set the supplier to "Acme Xero"'); @@ -2530,6 +2606,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithXeroSuppliers, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('changed the supplier to "Office Supplies Xero" (previously "Acme Xero")'); @@ -2549,6 +2626,7 @@ describe('ModifiedExpenseMessage', () => { reportAction, policy: policyWithXeroSuppliers, policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, }); expect(result).toEqual('set the supplier to "xcDeleted"'); diff --git a/tests/unit/MoneyRequestReportButtonUtils.test.ts b/tests/unit/MoneyRequestReportButtonUtils.test.ts index ec68b0614cd4..c5d6b4423c8e 100644 --- a/tests/unit/MoneyRequestReportButtonUtils.test.ts +++ b/tests/unit/MoneyRequestReportButtonUtils.test.ts @@ -1,4 +1,3 @@ -import {convertToDisplayString} from '@libs/CurrencyUtils'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; @@ -30,9 +29,7 @@ jest.mock('@libs/ReportUtils', () => ({ }), })); -jest.mock('@libs/CurrencyUtils', () => ({ - convertToDisplayString: jest.fn().mockImplementation((amountInCents = 0): string => `$${amountInCents}.00`), -})); +const convertToDisplayString = jest.fn().mockImplementation((amountInCents: number | undefined = 0): string => `$${amountInCents}.00`); describe('ReportButtonUtils', () => { describe('getTotalAmountForIOUReportPreviewButton', () => { diff --git a/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx b/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx index 5ca692bb657c..3e77e1a8826c 100644 --- a/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx +++ b/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx @@ -45,9 +45,16 @@ function useActiveTransactionIDsEffect(visualOrderTransactionIDs: string[]) { if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { return; } - if (getActiveTransactionIDs().descriptors) { + const {ids: activeIDs, descriptors: activeDescriptors} = getActiveTransactionIDs(); + if (activeDescriptors) { return; } + if (activeIDs && activeIDs.length === visualOrderTransactionIDs.length) { + const activeIDSet = new Set(activeIDs); + if (visualOrderTransactionIDs.every((transactionID) => activeIDSet.has(transactionID))) { + return; + } + } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); @@ -209,6 +216,38 @@ describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', () expect(mockClearActiveTransactionIDs).not.toHaveBeenCalled(); }); + it('should keep an active seed that covers the same rows in a different order', () => { + // Given the focused route is SEARCH_REPORT and a report preview press seeded the same rows in carousel order + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + mockGetActiveTransactionIDs.mockReturnValue({ids: ['trans3', 'trans1', 'trans2'], descriptors: null}); + + const transactionIDs = ['trans1', 'trans2', 'trans3']; + + // When the hook is rendered and then unmounted + const {unmount} = renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + // Then it should neither overwrite the carousel order nor clear it + expect(mockSetActiveTransactionIDs).not.toHaveBeenCalled(); + + unmount(); + + expect(mockClearActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('should re-seed when the active seed covers different rows', () => { + // Given the focused route is SEARCH_REPORT and the active seed is missing one of the rows + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + mockGetActiveTransactionIDs.mockReturnValue({ids: ['trans2', 'trans1'], descriptors: null}); + + const transactionIDs = ['trans1', 'trans2', 'trans3']; + + // When the hook is rendered + renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + // Then setActiveTransactionIDs should be called with the visual order + expect(mockSetActiveTransactionIDs).toHaveBeenCalledWith(transactionIDs); + }); + it('should handle empty transaction IDs array', () => { // Given the focused route is SEARCH_REPORT mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index d20a8663cb08..b04f479ee702 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -4,6 +4,8 @@ import { getStringFieldHasUnsavedChanges, getWaypointsHasUnsavedChanges, handleNegativeAmountFlipping, + isConfirmationAmountMissing, + isConfirmationDateMissing, isValidMerchant, isValidMoneyRequestAmount, validateAmount, @@ -523,3 +525,49 @@ describe('getWaypointsHasUnsavedChanges (distance map)', () => { }); }); }); + +describe('isConfirmationAmountMissing', () => { + it('flags a manual expense whose amount the user has not entered yet', () => { + expect(isConfirmationAmountMissing({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, isAmountSet: false})).toBe(true); + }); + + it('does not flag a manual expense once the amount is set', () => { + expect(isConfirmationAmountMissing({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, isAmountSet: true})).toBe(false); + }); + + // `isAmountSet` is only ever set by the manual flow, so without the manual gate every scan expense would look + // like it is missing its amount and show a phantom required error under a perfectly good value. + it('does not flag a scan expense, whose amount is populated programmatically and never sets isAmountSet', () => { + expect(isConfirmationAmountMissing({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, isAmountSet: undefined})).toBe(false); + }); + + it('does not flag a distance expense', () => { + expect(isConfirmationAmountMissing({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, isAmountSet: undefined})).toBe(false); + }); + + it('does not flag a missing transaction', () => { + expect(isConfirmationAmountMissing(undefined)).toBe(false); + }); +}); + +describe('isConfirmationDateMissing', () => { + const transactionWithoutDate = createMock({transactionID: '1', created: '', comment: {}}); + const transactionWithDate = createMock({transactionID: '1', created: '2026-07-29', comment: {}}); + + it('flags an editable confirmation that renders the date field with the date cleared', () => { + expect(isConfirmationDateMissing(transactionWithoutDate, true, false)).toBe(true); + }); + + it('does not flag it once the date is filled', () => { + expect(isConfirmationDateMissing(transactionWithDate, true, false)).toBe(false); + }); + + // A date the user can't see can't be fixed inline, so it must never keep the shared required error alive. + it('does not flag a confirmation that hides the date field', () => { + expect(isConfirmationDateMissing(transactionWithoutDate, false, false)).toBe(false); + }); + + it('does not flag a read-only confirmation, where the date is populated server-side', () => { + expect(isConfirmationDateMissing(transactionWithoutDate, true, true)).toBe(false); + }); +}); diff --git a/tests/unit/MultiSelectListItemTest.tsx b/tests/unit/MultiSelectListItemTest.tsx new file mode 100644 index 000000000000..7d26a479c3a9 --- /dev/null +++ b/tests/unit/MultiSelectListItemTest.tsx @@ -0,0 +1,71 @@ +import {render} from '@testing-library/react-native'; + +import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; +import BaseSelectListItem from '@components/SelectionList/ListItem/BaseSelectListItem'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +// The base item is stubbed to render just the left element, so the avatar wiring is exercised without the full row chrome. +jest.mock('@components/SelectionList/ListItem/BaseSelectListItem', () => jest.fn(({leftElement}: {leftElement?: React.ReactNode}) => leftElement ?? null)); + +jest.mock('@components/Avatar/AvatarFromIcon', () => jest.fn(() => null)); + +const mockBaseSelectListItem = jest.mocked(BaseSelectListItem); +const mockAvatarFromIcon = jest.mocked(AvatarFromIcon); + +const ICON = { + source: 'avatar.png', + type: CONST.ICON_TYPE_AVATAR, + name: 'Test User', + id: 7, +}; + +function renderItem(item: ListItem) { + render( + , + ); + return mockBaseSelectListItem.mock.calls.at(0)?.at(0); +} + +describe('MultiSelectListItem', () => { + beforeEach(() => { + mockBaseSelectListItem.mockClear(); + mockAvatarFromIcon.mockClear(); + }); + + it('delegates to BaseSelectListItem as a checkbox row', () => { + const props = renderItem({keyForList: 'row', text: 'Row'}); + + expect(props).toEqual( + expect.objectContaining({ + canSelectMultiple: true, + accessibilityRole: CONST.ROLE.CHECKBOX, + }), + ); + }); + + it('renders the item avatar as the left element when the item has icons', () => { + renderItem({keyForList: 'row', text: 'Row', icons: [ICON]}); + + expect(mockAvatarFromIcon).toHaveBeenCalledTimes(1); + expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon: ICON})); + }); + + it('leaves leftElement undefined when there are no icons, so the item value is used', () => { + const customLeftElement = ; + const props = renderItem({keyForList: 'row', text: 'Row', leftElement: customLeftElement}); + + expect(props?.leftElement).toBeUndefined(); + expect(props?.item.leftElement).toBe(customLeftElement); + }); +}); diff --git a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts index de4991a376f6..154020e06abe 100644 --- a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts +++ b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts @@ -14,6 +14,8 @@ import SCREENS from '@src/SCREENS'; import type {CommonActions, NavigationState, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {StackRouter} from '@react-navigation/native'; + import createMock from '../../../utils/createMock'; // Stub the linking parser so the test does not depend on the production linking config. @@ -108,13 +110,17 @@ function makeExistingState( return makeStackState([tabNavRoute, makeRHPRoute()]); } -function makeAction(): ReplaceFullscreenUnderRHPActionType { +function makeAction(shouldInsertPreMountBuffer?: boolean): ReplaceFullscreenUnderRHPActionType { return { type: CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP, - payload: {route: ROUTES.WORKSPACE_INITIAL.getRoute('NEW')}, + payload: {route: ROUTES.WORKSPACE_INITIAL.getRoute('NEW'), shouldInsertPreMountBuffer}, }; } +function getBufferRoute(result: StackNavigationState | null) { + return result?.routes.find((r) => r.name === SCREENS.PRE_MOUNT_BUFFER); +} + function makeReportsParsedState(reportID: string): PartialState { return { routes: [ @@ -141,10 +147,10 @@ function makeReportsAction(reportID: string): ReplaceFullscreenUnderRHPActionTyp }; } -function makeRemoveAction(): RemoveFullscreenUnderRHPActionType { +function makeRemoveAction(expectedRouteName: string = NAVIGATORS.TAB_NAVIGATOR): RemoveFullscreenUnderRHPActionType { return { type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, - payload: {expectedRouteName: NAVIGATORS.TAB_NAVIGATOR}, + payload: {expectedRouteName}, }; } @@ -478,3 +484,109 @@ describe('handleReplaceFullscreenUnderRHP — WORKSPACE_NAVIGATOR seeding', () = expect(result).toBeNull(); }); }); + +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) + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + const shareRoute = makeRoute(NAVIGATORS.SHARE_MODAL_NAVIGATOR, undefined, undefined, 'share-key'); + const originalTabRoute = makeRoute(NAVIGATORS.TAB_NAVIGATOR, undefined, {index: 0, routes: [{key: 'workspace-nav-key', name: NAVIGATORS.WORKSPACE_NAVIGATOR}]}, 'tab-nav-key'); + const existingState = makeStackState([originalTabRoute, shareRoute]); + const preInsertedState = handleReplaceFullscreenUnderRHP(existingState, makeAction(true), CONFIG_OPTIONS, stackRouter); + if (!preInsertedState) { + throw new Error('Expected the pre-insert to return a navigation state'); + } + expect(getBufferRoute(preInsertedState)).toBeDefined(); + + // When the user backs out and the teardown action is dispatched + const restoredState = handleRemoveFullscreenUnderRHP(preInsertedState, makeRemoveAction(), CONFIG_OPTIONS, stackRouter); + + // Then the handler does not bail: the buffer is gone and the original tab route is back under the Share modal + expect(restoredState).not.toBeNull(); + expect(getBufferRoute(restoredState)).toBeUndefined(); + expect(restoredState?.routes.map((r) => r.name)).toEqual([NAVIGATORS.TAB_NAVIGATOR, NAVIGATORS.SHARE_MODAL_NAVIGATOR]); + expect(restoredState?.routes.at(0)?.key).toBe('tab-nav-key'); + }); +}); + +describe('handleReplaceFullscreenUnderRHP / handleRemoveFullscreenUnderRHP — shouldInsertPreMountBuffer', () => { + it('inserts the buffer route directly under the RHP on the tab-switch path when shouldInsertPreMountBuffer is true', () => { + // Given a tab-switch pre-insert that needs protection from native RHP dismissal + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + // When the root state handler replaces the fullscreen destination + const result = handleReplaceFullscreenUnderRHP(makeExistingState(undefined), makeAction(true), CONFIG_OPTIONS, stackRouter); + + // Then the buffer sits next to the RHP so it intercepts an interrupted transition + expect(getBufferRoute(result)?.key).toBe(`pre-mount-buffer-${makeRHPRoute().key}`); + expect(result?.routes.at(-2)?.name).toBe(SCREENS.PRE_MOUNT_BUFFER); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('does not insert a buffer route on the tab-switch path when shouldInsertPreMountBuffer is false/undefined', () => { + // Given a tab-switch pre-insert that does not need swipe-dismiss protection + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + // When the root state handler replaces the fullscreen destination + const result = handleReplaceFullscreenUnderRHP(makeExistingState(undefined), makeAction(false), CONFIG_OPTIONS, stackRouter); + + // Then no buffer is added because the caller opted out of recovery routing + expect(getBufferRoute(result)).toBeUndefined(); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('inserts the buffer route directly under the RHP on the push path when shouldInsertPreMountBuffer is true', () => { + // Given a pushed fullscreen destination that needs protection from RHP dismissal + const routeNames = [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]; + const realStackRouter = StackRouter({}); + const configOptions: RouterConfigOptions = {routeNames, routeParamList: {}, routeGetIdList: {}}; + mockStubbedParsedState = {routes: [{name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}]}; + const existing = makeStackState([ + makeRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, undefined, undefined, 'search-key'), + makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, undefined, undefined, 'rhp-key'), + ]); + + // When the root state handler inserts the pushed destination + const result = handleReplaceFullscreenUnderRHP(existing, makeAction(true), configOptions, realStackRouter); + + // Then the buffer is adjacent to the RHP so recovery can remove the speculative push + expect(getBufferRoute(result)?.key).toBe('pre-mount-buffer-rhp-key'); + expect(result?.routes.at(-2)?.name).toBe(SCREENS.PRE_MOUNT_BUFFER); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('does not insert a buffer route on the push path when shouldInsertPreMountBuffer is false/undefined', () => { + // Given a pushed fullscreen destination without swipe-dismiss buffering enabled + const routeNames = [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]; + const realStackRouter = StackRouter({}); + const configOptions: RouterConfigOptions = {routeNames, routeParamList: {}, routeGetIdList: {}}; + mockStubbedParsedState = {routes: [{name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}]}; + const existing = makeStackState([ + makeRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, undefined, undefined, 'search-key'), + makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, undefined, undefined, 'rhp-key'), + ]); + + // When the root state handler inserts the pushed destination + const result = handleReplaceFullscreenUnderRHP(existing, makeAction(false), configOptions, realStackRouter); + + // Then the route stack stays buffer-free because recovery was not requested + expect(getBufferRoute(result)).toBeUndefined(); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('cancel (tab restore) drops any buffer route left between the restored tab and the RHP', () => { + // Given a tab-switch buffer route that's still around at cancel time (normally stripped earlier; + // this is a defensive check in case that ever stops happening) + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + const insertResult = handleReplaceFullscreenUnderRHP(makeExistingState(undefined), makeAction(true), CONFIG_OPTIONS, stackRouter); + expect(getBufferRoute(insertResult)).not.toBeUndefined(); + if (!insertResult) { + throw new Error('Expected handleReplaceFullscreenUnderRHP to return a state.'); + } + + // When the root handler restores the original tab after cancellation + const removeResult = handleRemoveFullscreenUnderRHP(insertResult, makeRemoveAction(NAVIGATORS.WORKSPACE_NAVIGATOR), CONFIG_OPTIONS, stackRouter); + + // Then the leftover buffer is discarded so it cannot surface after the RHP closes + expect(getBufferRoute(removeResult)).toBeUndefined(); + expect(removeResult?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); +}); diff --git a/tests/unit/Navigation/getLastRouteTest.ts b/tests/unit/Navigation/getLastRouteTest.ts new file mode 100644 index 000000000000..70d4e60367e1 --- /dev/null +++ b/tests/unit/Navigation/getLastRouteTest.ts @@ -0,0 +1,164 @@ +import getLastRoute from '@components/Navigation/NavigationTabBar/getLastRoute'; + +import {clearPreservedNavigatorStates, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +type Route = NavigationState['routes'][number]; + +function createRoute(name: string, key: string, state?: NavigationState): Route { + return {key, name, ...(state ? {state} : {})} as Route; +} + +function createState(key: string, type: string, routes: Route[], index = routes.length - 1): NavigationState { + return { + stale: false, + type, + key, + index, + routeNames: routes.map((route) => route.name), + routes, + }; +} + +const createRootState = (routes: Route[]) => createState('root', 'stack', routes); +const createTabState = (key: string, routes: Route[], index?: number) => createState(key, 'tab', routes, index); +const createNavigatorState = (key: string, routes: Route[]) => createState(key, 'stack', routes); + +// getLastRoute only considers a nested navigator route that carries its own state, i.e. one that has already been rendered. +// The inner route uses the searched screen on purpose, to show that the preserved state - not the inner one - is what gets returned. +const createNavigatorRouteWithState = (navigator: string, key: string, screen: string) => + createRoute(navigator, key, createNavigatorState(`${key}-inner`, [createRoute(screen, `${key}-inner-route`)])); + +describe('getLastRoute', () => { + beforeEach(() => { + clearPreservedNavigatorStates(); + }); + + describe('navigator at the root level', () => { + it('returns the preserved screen route of the root level navigator', () => { + const rootState = createRootState([createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-1')]); + setPreservedNavigatorState('reports-1', createNavigatorState('reports-1-state', [createRoute(SCREENS.HOME, 'home'), createRoute(SCREENS.REPORT, 'report-1')])); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'report-1', name: SCREENS.REPORT}); + }); + + it('uses the last navigator when the root state has several of them', () => { + const rootState = createRootState([createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-1'), createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-2')]); + setPreservedNavigatorState('reports-1', createNavigatorState('reports-1-state', [createRoute(SCREENS.REPORT, 'report-1')])); + setPreservedNavigatorState('reports-2', createNavigatorState('reports-2-state', [createRoute(SCREENS.REPORT, 'report-2')])); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'report-2', name: SCREENS.REPORT}); + }); + + it('uses the last matching screen inside the preserved navigator state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-1')]); + setPreservedNavigatorState( + 'reports-1', + createNavigatorState('reports-1-state', [createRoute(SCREENS.REPORT, 'report-1'), createRoute(SCREENS.HOME, 'home'), createRoute(SCREENS.REPORT, 'report-2')]), + ); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'report-2', name: SCREENS.REPORT}); + }); + + it('takes precedence over a navigator nested in the tab navigator', () => { + const tabRoute = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1', createTabState('tab-1-state', [createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'nested-reports')])); + const rootState = createRootState([tabRoute, createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'root-reports')]); + setPreservedNavigatorState('nested-reports', createNavigatorState('nested-reports-state', [createRoute(SCREENS.REPORT, 'nested-report')])); + setPreservedNavigatorState('root-reports', createNavigatorState('root-reports-state', [createRoute(SCREENS.REPORT, 'root-report')])); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'root-report', name: SCREENS.REPORT}); + }); + }); + + describe('navigator nested inside the tab navigator', () => { + it('returns the preserved screen route of the nested navigator', () => { + const tabRoute = createRoute( + NAVIGATORS.TAB_NAVIGATOR, + 'tab-1', + createTabState('tab-1-state', [createRoute(SCREENS.HOME, 'home'), createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1', SCREENS.SEARCH.ROOT)]), + ); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + + expect(getLastRoute(createRootState([tabRoute]), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toEqual({key: 'search-root-1', name: SCREENS.SEARCH.ROOT}); + }); + + it('prefers the tab navigator whose nested navigator has its own state even when it is not the focused tab', () => { + // The nested navigator is not focused (index points at Home), so it can only be found by looking at every tab route. + const tabWithSearchState = createRoute( + NAVIGATORS.TAB_NAVIGATOR, + 'tab-1', + createTabState( + 'tab-1-state', + [createRoute(SCREENS.HOME, 'home-1'), createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1', SCREENS.SEARCH.ROOT)], + 0, + ), + ); + const tabWithoutSearch = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-2', createTabState('tab-2-state', [createRoute(SCREENS.HOME, 'home-2')])); + const rootState = createRootState([tabWithSearchState, tabWithoutSearch]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toEqual({key: 'search-root-1', name: SCREENS.SEARCH.ROOT}); + }); + + it('returns undefined when no nested navigator has its own state, even if a preserved state exists', () => { + const firstTab = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1', createTabState('tab-1-state', [createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1')])); + const lastTab = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-2', createTabState('tab-2-state', [createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-2')])); + const rootState = createRootState([firstTab, lastTab]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + setPreservedNavigatorState('search-2', createNavigatorState('search-2-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-2')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('uses the last matching navigator inside the tab state', () => { + const tabRoute = createRoute( + NAVIGATORS.TAB_NAVIGATOR, + 'tab-1', + createTabState('tab-1-state', [ + createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1', SCREENS.SEARCH.ROOT), + createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-2', SCREENS.SEARCH.ROOT), + ]), + ); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + setPreservedNavigatorState('search-2', createNavigatorState('search-2-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-2')])); + + expect(getLastRoute(createRootState([tabRoute]), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toEqual({key: 'search-root-2', name: SCREENS.SEARCH.ROOT}); + }); + + it('returns undefined when the tab navigator route has no state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1')]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + }); + + describe('when there is nothing to restore', () => { + it('returns undefined when the navigator is nowhere in the root state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1', createTabState('tab-1-state', [createRoute(SCREENS.HOME, 'home')]))]); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('returns undefined when the navigator has no preserved state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1')]); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('returns undefined when the preserved state has no matching screen', () => { + const rootState = createRootState([createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1')]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.MONEY_REQUEST_REPORT, 'money-request-report')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('returns undefined when the root state has no routes', () => { + expect(getLastRoute(createRootState([]), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/Navigation/getSearchTabRouteTest.ts b/tests/unit/Navigation/getSearchTabRouteTest.ts index c238ab878e49..4ac22b73cced 100644 --- a/tests/unit/Navigation/getSearchTabRouteTest.ts +++ b/tests/unit/Navigation/getSearchTabRouteTest.ts @@ -40,7 +40,7 @@ describe('getSearchTabRoute', () => { return; } - expect(getSearchTabRoute(rootState, undefined)).toBe( + expect(getSearchTabRoute(rootState, undefined, undefined)).toBe( ROUTES.SEARCH_ROOT.getRoute({ query: buildSearchQueryString(queryJSON), rawQuery: q, @@ -58,12 +58,27 @@ describe('getSearchTabRoute', () => { return; } - expect(getSearchTabRoute(rootState, {queryJSON})).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildSearchQueryString(queryJSON)})); + expect(getSearchTabRoute(rootState, {queryJSON}, undefined)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildSearchQueryString(queryJSON)})); + }); + + it('falls back to the last Expenses search query when there is no navigation route or Onyx query', () => { + const lastQuery = `${buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})} merchant:Uber`; + mockGetLastRoute.mockReturnValue(undefined); + + expect(getSearchTabRoute(rootState, undefined, lastQuery)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: lastQuery})); + }); + + it('falls back to the default expense query when the last Expenses search query is not valid', () => { + mockGetLastRoute.mockReturnValue(undefined); + + expect(getSearchTabRoute(rootState, undefined, buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}))).toBe( + ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})}), + ); }); it('falls back to the default expense query', () => { mockGetLastRoute.mockReturnValue(undefined); - expect(getSearchTabRoute(rootState, undefined)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})})); + expect(getSearchTabRoute(rootState, undefined, undefined)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})})); }); }); diff --git a/tests/unit/Navigation/preMountBufferTest.ts b/tests/unit/Navigation/preMountBufferTest.ts new file mode 100644 index 000000000000..9236cf927cc3 --- /dev/null +++ b/tests/unit/Navigation/preMountBufferTest.ts @@ -0,0 +1,512 @@ +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; + +import {DeviceEventEmitter} from 'react-native'; + +type MockRoute = {key: string; name: string; state?: PartialState; params?: {reportID?: string}}; +type MockAction = {type: string; payload?: {routes?: MockRoute[]; index?: number; shouldInsertPreMountBuffer?: boolean}}; + +let mockRootState: {key: string; routes: MockRoute[]; index: number; routeNames?: string[]; stale?: boolean} | undefined; +const mockDispatch = jest.fn(); +let mockStateListener: (() => void) | undefined; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + isReady: () => true, + getRootState: () => mockRootState, + getState: () => mockRootState, + get current() { + return { + getRootState: () => mockRootState, + dispatch: mockDispatch, + addListener: (event: string, cb: () => void) => { + if (event !== 'state') { + return () => undefined; + } + mockStateListener = cb; + return () => { + mockStateListener = undefined; + }; + }, + }; + }, + }, +})); + +let mockIsNarrowLayout = true; +jest.mock('@libs/getIsNarrowLayout', () => ({ + __esModule: true, + default: () => mockIsNarrowLayout, +})); + +let mockStateFromPathRoutes: MockRoute[] = []; +jest.mock('@libs/Navigation/helpers/getStateFromPath', () => ({ + __esModule: true, + default: () => ({routes: mockStateFromPathRoutes}), +})); + +let mockOriginalTabRoute: MockRoute | undefined; +const mockClearPreInsertedOriginalTabRoute = jest.fn(() => { + mockOriginalTabRoute = undefined; +}); +jest.mock('@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers', () => ({ + __esModule: true, + getPreInsertedOriginalTabRoute: () => mockOriginalTabRoute, + clearPreInsertedOriginalTabRoute: () => mockClearPreInsertedOriginalTabRoute(), +})); + +const RHP_KEY = 'rhp-1'; +const DEST_KEY = 'dest-1'; +const BUFFER_KEY = `pre-mount-buffer-${RHP_KEY}`; +const ORIGIN_KEY = 'origin-1'; + +function setRootState(routes: MockRoute[]) { + mockRootState = {key: 'root', routes, index: routes.length - 1, routeNames: routes.map((r) => r.name), stale: false}; +} + +describe('Navigation pre-mount buffer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsNarrowLayout = true; + mockOriginalTabRoute = undefined; + mockStateFromPathRoutes = [{key: 'target', name: NAVIGATORS.WORKSPACE_NAVIGATOR}]; + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + + afterEach(() => { + // helpers/preMountBuffer.ts tracks the buffer transaction / pre-insert flag as module-level state with + // no exported reset, so a dirty flag from one test silently no-ops the next test's pre-insert. + if (!Navigation.getIsFullscreenPreInsertedUnderRHP()) { + return; + } + Navigation.clearFullscreenPreInsertedFlag(); + }); + + function preInsertAndCaptureBuffer() { + // Simulate the reducer's effect: destination pushed under a fresh Buffer, RHP stays on top. + mockDispatch.mockImplementationOnce(() => { + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + } + + it('inserts a pre-mount buffer directly under the RHP when pre-inserting a fullscreen destination', () => { + // Given a narrow layout with an RHP above the current fullscreen route + // When a fullscreen destination is speculatively inserted beneath the RHP + preInsertAndCaptureBuffer(); + + // Then a buffer protects the origin from becoming visible during swipe dismissal + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(true); + expect(mockRootState?.routes.map((r) => r.name)).toEqual([SCREENS.REPORT, NAVIGATORS.WORKSPACE_NAVIGATOR, SCREENS.PRE_MOUNT_BUFFER, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]); + }); + + it('does not insert a pre-mount buffer when the focused RHP inner flow can handle the native swipe', () => { + // Given an RHP with inner navigation history that can consume the swipe gesture + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + { + key: RHP_KEY, + name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR, + state: { + index: 0, + routes: [ + { + key: 'rhp-stack', + name: 'RHPStack', + state: { + index: 1, + routes: [ + {key: 'inner-1', name: 'InnerOne'}, + {key: 'inner-2', name: 'InnerTwo'}, + ], + }, + }, + ], + }, + }, + ]); + mockDispatch.mockImplementationOnce((action) => { + expect(action).toEqual(expect.objectContaining({payload: expect.objectContaining({shouldInsertPreMountBuffer: false})})); + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + + // When a fullscreen destination is pre-inserted beneath that nested flow + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then no buffer is needed because the inner flow owns gesture recovery + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockRootState?.routes.some((route) => route.name === SCREENS.PRE_MOUNT_BUFFER)).toBe(false); + }); + + it('defaults to inserting a pre-mount buffer when the topmost route is not the RHP', () => { + // Given navigation state where the RHP topology cannot be inspected + setRootState([{key: ORIGIN_KEY, name: SCREENS.REPORT}]); + mockDispatch.mockImplementationOnce((action) => { + expect(action).toEqual(expect.objectContaining({payload: expect.objectContaining({shouldInsertPreMountBuffer: true})})); + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + ]); + }); + + // When a fullscreen destination is pre-inserted + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then the safer buffered behavior is requested because swipe handling is unknown + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('confirm: clearFullscreenPreInsertedFlag strips only the Buffer route, keeping the destination', () => { + // Given a live speculative destination protected by a buffer + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When submission confirms that the destination should remain mounted + Navigation.clearFullscreenPreInsertedFlag(); + + // Then only the temporary buffer is removed because the destination is now valid + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.type).toBe('RESET'); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY, DEST_KEY, RHP_KEY]); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('cancel: removePreInsertedFullscreenIfNeeded strips both the Buffer and the speculative destination', () => { + // Given a live speculative destination protected by a buffer + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When submission is canceled before the destination becomes valid + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then all speculative routes are removed so navigation returns to its origin + // First dispatch strips the Buffer only (removeBufferRouteOnly), second removes the destination itself. + expect(mockDispatch).toHaveBeenCalledTimes(2); + const bufferStripAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(bufferStripAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY, DEST_KEY, RHP_KEY]); + + const removeFullscreenAction = mockDispatch.mock.calls.at(1)?.at(0); + expect(removeFullscreenAction?.type).toBe(CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('cancel: removePreInsertedFullscreenIfNeeded also tears down when the Share modal, not the RHP, hosts the buffer', () => { + // Given a Submit-tab destination pre-inserted under the Share modal (#100767) + const SHARE_KEY = 'share-1'; + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: SHARE_KEY, name: NAVIGATORS.SHARE_MODAL_NAVIGATOR}, + ]); + mockDispatch.mockImplementationOnce(() => { + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: `pre-mount-buffer-${SHARE_KEY}`, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: SHARE_KEY, name: NAVIGATORS.SHARE_MODAL_NAVIGATOR}, + ]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + mockDispatch.mockClear(); + + // When the user backs out of the Submit confirm page while the Share modal is still on top + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then the buffer and the speculative destination are removed, so a later Share reveal cannot inherit them + expect(mockDispatch).toHaveBeenCalledTimes(2); + const bufferStripAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(bufferStripAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY, DEST_KEY, SHARE_KEY]); + const removeFullscreenAction = mockDispatch.mock.calls.at(1)?.at(0); + expect(removeFullscreenAction?.type).toBe(CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('dismissModalWithReport clears the live buffer when dismissing to the already topmost report', () => { + // Given a buffered destination above the report that dismissal targets + const reportID = 'report-1'; + preInsertAndCaptureBuffer(); + setRootState([ + { + key: ORIGIN_KEY, + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 0, + routes: [ + { + key: 'reports-split', + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + state: {index: 0, routes: [{key: 'report-screen', name: SCREENS.REPORT, params: {reportID}}]}, + }, + ], + }, + }, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + mockDispatch.mockClear(); + + // When the modal dismisses to a report already visible in the tab state + Navigation.dismissModalWithReport({reportID}); + + // Then the stale buffer is cleared because no speculative transition remains + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + const bufferStripAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(bufferStripAction?.payload?.routes?.map((route) => route.key)).toEqual([ORIGIN_KEY, DEST_KEY, RHP_KEY]); + }); + + it('native swipe-dismiss while the buffer is live atomically strips the destination and Buffer, restoring the origin', () => { + // Given a live buffered transaction whose RHP disappears outside confirm or cancel + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + const restoreAnimationSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + + // RHP got removed by something other than our own confirm/cancel path (native gesture, predictive-back). + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + // When the navigation listener observes the native swipe dismissal + mockStateListener?.(); + + // Then origin restoration is atomic so no speculative screen can flash + expect(restoreAnimationSpy).toHaveBeenCalledWith(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY]); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + + restoreAnimationSpy.mockRestore(); + }); + + it('RHP-closed listener is a no-op while the RHP route is still present', () => { + // Given a buffered transaction whose RHP remains mounted + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When an unrelated navigation state update reaches the dismissal listener + mockStateListener?.(); + + // Then recovery is skipped because the protected transition is still active + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('tab-switch mode: RHP-closed listener restores the original tab route instead of popping a pushed destination', () => { + // Given a buffered tab switch that replaced the original tab route beneath the RHP + mockOriginalTabRoute = {key: 'tab-origin', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.INBOX}]} as PartialState}; + mockStateFromPathRoutes = [{key: 'target', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}]; + + const TAB_KEY = 'tab-1'; + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + + mockDispatch.mockImplementationOnce(() => { + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + mockDispatch.mockClear(); + + // RHP dismissed externally while the tab-switch buffer transaction is still live. + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + // When the RHP is dismissed outside the normal completion paths + mockStateListener?.(); + + // Then the original tab is restored because there is no pushed route to pop + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + const restoredTabRoute = resetAction?.payload?.routes?.find((r) => r.key === TAB_KEY); + expect(restoredTabRoute).toEqual(mockOriginalTabRoute); + expect(resetAction?.payload?.routes?.some((r) => r.key === BUFFER_KEY)).toBe(false); + }); + + it('tab-switch mode falls back to stripping the Buffer when the original tab route was cleared', () => { + // Given a buffered tab switch whose saved origin is no longer available + mockOriginalTabRoute = {key: 'tab-origin', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.INBOX}]} as PartialState}; + mockStateFromPathRoutes = [{key: 'target', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}]; + + const TAB_KEY = 'tab-1'; + const switchedTabRoute = {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}; + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + mockDispatch.mockImplementationOnce(() => { + setRootState([switchedTabRoute, {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + mockDispatch.mockClear(); + mockOriginalTabRoute = undefined; + + setRootState([switchedTabRoute, {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}]); + // When external dismissal triggers recovery + mockStateListener?.(); + + // Then only the buffer is stripped because the current tab cannot be safely replaced + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes).toEqual([switchedTabRoute]); + }); + + it('guard: preInsertFullscreenUnderRHP is a no-op on wide layout (buffer only guards a swipe gesture that exists on narrow)', () => { + // Given a wide layout without the narrow-screen swipe dismissal risk + mockIsNarrowLayout = false; + + // When speculative fullscreen insertion is requested + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then navigation stays unchanged because the buffer has no purpose on wide screens + expect(mockDispatch).not.toHaveBeenCalled(); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('guard: preInsertFullscreenUnderRHP is a no-op on a repeated call while already pre-inserted', () => { + // Given an active pre-insert transaction already tracking its recovery state + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When another pre-insert is requested before the first one finishes + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then it is ignored so the original recovery snapshot is not overwritten + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('guard: removePreInsertedFullscreenIfNeeded is a no-op when nothing was pre-inserted', () => { + // Given no active speculative navigation transaction + const restoreAnimationSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + + // When cancellation cleanup is called defensively + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then navigation and animation stay untouched because there is nothing to restore + expect(mockDispatch).not.toHaveBeenCalled(); + expect(restoreAnimationSpy).not.toHaveBeenCalled(); + restoreAnimationSpy.mockRestore(); + }); + + it('guard: removePreInsertedFullscreenIfNeeded backs off when the RHP is gone and the buffer transaction is still live', () => { + // Given external dismissal has removed the RHP from a live buffered transaction + preInsertAndCaptureBuffer(); + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + mockDispatch.mockClear(); + + // When the normal cancellation path races with listener-based recovery + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then cancellation backs off so the listener remains the single recovery owner + expect(mockDispatch).not.toHaveBeenCalled(); + + mockStateListener?.(); + }); + + it('guard: recoverFromPreMountBuffer is a no-op when the topmost route is not the Buffer screen', () => { + // Given ordinary navigation state without a stranded topmost buffer + // When startup recovery checks for an interrupted transition + Navigation.recoverFromPreMountBuffer(); + + // Then navigation remains unchanged because no recovery evidence exists + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('recoverFromPreMountBuffer with a live transaction (app resumed while a buffer route was stranded on top) delegates to the same atomic reset as the native-swipe path', () => { + // Given app resume exposes a stranded buffer from a still-live transaction + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // RHP already gone; Buffer is the new topmost route (e.g. app was backgrounded mid-transition). + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + + // When buffer recovery runs after the interrupted transition + Navigation.recoverFromPreMountBuffer(); + + // Then the saved origin is restored atomically to avoid showing speculative state + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY]); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('recoverFromPreMountBuffer falls back to stripping Buffer + the speculative destination when the transaction itself was lost (e.g. a cold restart)', () => { + // Given a cold restart retained routes but lost the in-memory recovery transaction + // No preceding preInsert call: bufferTransaction is not live, simulating a lost/never-captured transaction. + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + + // When startup detects the stranded buffer + Navigation.recoverFromPreMountBuffer(); + + // Then it removes both temporary routes because no richer snapshot survives + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY]); + }); + + it('recoverFromPreMountBuffer fallback restores the original tab route when the stranded buffer came from a tab switch', () => { + // Given a stranded tab-switch buffer with its original tab snapshot still available + const TAB_KEY = 'tab-1'; + mockOriginalTabRoute = {key: 'tab-origin', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.INBOX}]} as PartialState}; + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + + // When startup recovery handles the interrupted tab switch + Navigation.recoverFromPreMountBuffer(); + + // Then it restores the saved tab because stripping a pushed destination is insufficient + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + const restoredTabRoute = resetAction?.payload?.routes?.find((r) => r.key === TAB_KEY); + expect(restoredTabRoute).toEqual(mockOriginalTabRoute); + expect(resetAction?.payload?.routes?.some((r) => r.key === BUFFER_KEY)).toBe(false); + expect(mockClearPreInsertedOriginalTabRoute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/NetworkStateTest.ts b/tests/unit/NetworkStateTest.ts index 1d81495f28f8..e12a1c211013 100644 --- a/tests/unit/NetworkStateTest.ts +++ b/tests/unit/NetworkStateTest.ts @@ -1,5 +1,6 @@ -import {getCommandURL} from '@libs/ApiUtils'; +import {getCommandURL, isQAServerActive} from '@libs/ApiUtils'; +import CONST from '@src/CONST'; import { getDBTimeWithSkew, getIsOffline, @@ -43,7 +44,7 @@ jest.mock('@libs/ApiUtils', () => ({ __esModule: true, getApiRoot: jest.fn(() => 'https://test-api.expensify.com/'), getCommandURL: jest.fn(() => mockPingUrl), - isUsingStagingApi: jest.fn(() => false), + isQAServerActive: jest.fn(() => false), })); // NetworkState awaits getEnvironment() so configureAndSubscribe runs after ApiUtils settles its @@ -542,6 +543,7 @@ describe('NetworkState', () => { beforeEach(async () => { jest.mocked(getCommandURL).mockReturnValue(mockPingUrl); + jest.mocked(isQAServerActive).mockReturnValue(false); configureMock.mockClear(); await Onyx.clear(); await waitForBatchedUpdates(); @@ -567,34 +569,45 @@ describe('NetworkState', () => { expect(configureMock.mock.calls.at(-1)?.[0].reachabilityUrl).toBe(`${mockPingUrl}accountID=2`); }); - test('SHOULD_USE_STAGING_SERVER change triggers a reconfigure when the reachability URL changes', async () => { + test('ACTIVE_SERVER change triggers a reconfigure when the reachability URL changes', async () => { const callsBefore = configureMock.mock.calls.length; jest.mocked(getCommandURL).mockReturnValue(mockStagingPingUrl); - await Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, true); + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, CONST.SERVER.STAGING); await waitForBatchedUpdates(); expect(configureMock.mock.calls.length).toBeGreaterThan(callsBefore); expect(configureMock.mock.calls.at(-1)?.[0].reachabilityUrl).toBe(`${mockStagingPingUrl}accountID=unknown`); }); - test('SHOULD_USE_STAGING_SERVER same-value rewrite does NOT reconfigure', async () => { + test('ACTIVE_SERVER same-value rewrite does NOT reconfigure', async () => { jest.mocked(getCommandURL).mockReturnValue(mockStagingPingUrl); - await Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, true); + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, CONST.SERVER.STAGING); await waitForBatchedUpdates(); const callsAfterFlip = configureMock.mock.calls.length; - await Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, true); + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, CONST.SERVER.STAGING); await waitForBatchedUpdates(); expect(configureMock.mock.calls.length).toBe(callsAfterFlip); }); - test('SHOULD_USE_STAGING_SERVER flip that does not change the reachability URL does NOT reconfigure', async () => { + test('ACTIVE_SERVER flip that does not change the reachability URL does NOT reconfigure', async () => { // e.g. production, where ApiUtils forces the effective flag off regardless of the toggle const callsBefore = configureMock.mock.calls.length; - await Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, true); + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, CONST.SERVER.STAGING); + await waitForBatchedUpdates(); + + expect(configureMock.mock.calls.length).toBe(callsBefore); + }); + + test('QA leaves NetInfo unconfigured — the Ping URL sits behind Cloudflare Access and NetInfo cannot carry the bearer', async () => { + jest.mocked(isQAServerActive).mockReturnValue(true); + jest.mocked(getCommandURL).mockReturnValue(mockStagingPingUrl); + const callsBefore = configureMock.mock.calls.length; + + await Onyx.set(ONYXKEYS.ACTIVE_SERVER, CONST.SERVER.QA); await waitForBatchedUpdates(); expect(configureMock.mock.calls.length).toBe(callsBefore); diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index c3344726bca9..9bdef0d129c2 100644 --- a/tests/unit/NextStepUtilsTest.ts +++ b/tests/unit/NextStepUtilsTest.ts @@ -50,6 +50,7 @@ describe('libs/NextStepUtils', () => { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, }; const report = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: 'fake-chat-report-id-1', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID, @@ -104,6 +105,7 @@ describe('libs/NextStepUtils', () => { '2025-03-31 13:23:11', [CONST.BETAS.ALL], getCurrencyDecimalsLocal, + undefined, ); const expectedResult: ReportNextStep = { @@ -112,6 +114,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: currentUserAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report: emptyReport, policy, currentUserAccountIDParam: currentUserAccountID, @@ -137,6 +140,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: currentUserAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -161,6 +165,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: currentUserAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -191,6 +196,7 @@ describe('libs/NextStepUtils', () => { }, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -223,6 +229,7 @@ describe('libs/NextStepUtils', () => { }, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -256,6 +263,7 @@ describe('libs/NextStepUtils', () => { }, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -281,6 +289,7 @@ describe('libs/NextStepUtils', () => { describe('monthly on the 2nd', () => { const buildMonthlyOnThe2ndNextStep = () => buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -347,6 +356,7 @@ describe('libs/NextStepUtils', () => { }, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -380,6 +390,7 @@ describe('libs/NextStepUtils', () => { }, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -414,6 +425,7 @@ describe('libs/NextStepUtils', () => { }, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -444,6 +456,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: currentUserAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy: { ...policy, @@ -477,6 +490,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: -1, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -505,6 +519,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: -1, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -542,6 +557,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: strangeAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -574,6 +590,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: currentUserAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -600,6 +617,7 @@ describe('libs/NextStepUtils', () => { icon: CONST.NEXT_STEP.ICONS.CHECKMARK, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -628,6 +646,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: ownerAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -656,6 +675,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: strangeAccountID, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -683,6 +703,7 @@ describe('libs/NextStepUtils', () => { icon: CONST.NEXT_STEP.ICONS.CHECKMARK, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -710,6 +731,7 @@ describe('libs/NextStepUtils', () => { icon: CONST.NEXT_STEP.ICONS.CHECKMARK, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -738,6 +760,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: -1, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -770,6 +793,7 @@ describe('libs/NextStepUtils', () => { actorAccountID: -1, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -794,6 +818,7 @@ describe('libs/NextStepUtils', () => { icon: CONST.NEXT_STEP.ICONS.CHECKMARK, }; const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -873,6 +898,7 @@ describe('libs/NextStepUtils', () => { const report: Report = { ...buildOptimisticExpenseReport({ + rules: undefined, chatReportID: 'chat-1', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID, @@ -889,6 +915,7 @@ describe('libs/NextStepUtils', () => { } as Report; const result = getReportNextStep({ + rules: undefined, moneyRequestReport: report, moneyRequestReportOwnerLogin: currentUserEmail, transactions: [], @@ -903,6 +930,7 @@ describe('libs/NextStepUtils', () => { it('returns an optimistic fix issue next step when all transactions have submission-blocking violations', () => { const report: Report = { ...buildOptimisticExpenseReport({ + rules: undefined, chatReportID: 'chat-2', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID, @@ -935,6 +963,7 @@ describe('libs/NextStepUtils', () => { }; const result = getReportNextStep({ + rules: undefined, moneyRequestReport: report, moneyRequestReportOwnerLogin: currentUserEmail, transactions: [transaction], @@ -974,6 +1003,7 @@ describe('libs/NextStepUtils', () => { const report: Report = { ...buildOptimisticExpenseReport({ + rules: undefined, chatReportID: 'chat-3', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID, @@ -993,6 +1023,7 @@ describe('libs/NextStepUtils', () => { await waitForBatchedUpdates(); const result = getReportNextStep({ + rules: undefined, moneyRequestReport: report, moneyRequestReportOwnerLogin: currentUserEmail, transactions: [], @@ -1027,6 +1058,7 @@ describe('libs/NextStepUtils', () => { const report: Report = { ...buildOptimisticExpenseReport({ + rules: undefined, chatReportID: 'chat-4', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID, @@ -1062,6 +1094,7 @@ describe('libs/NextStepUtils', () => { await waitForBatchedUpdates(); const result = getReportNextStep({ + rules: undefined, moneyRequestReport: report, moneyRequestReportOwnerLogin: currentUserEmail, transactions: [transaction], @@ -1265,6 +1298,7 @@ describe('libs/NextStepUtils', () => { const getOpenSubmitAndCloseReport = (): Report => ({ ...buildOptimisticExpenseReport({ + rules: undefined, chatReportID: 'chat-track-intent', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID, @@ -1286,6 +1320,7 @@ describe('libs/NextStepUtils', () => { const report = getOpenSubmitAndCloseReport(); const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, @@ -1306,6 +1341,7 @@ describe('libs/NextStepUtils', () => { const report = getOpenSubmitAndCloseReport(); const result = buildOptimisticNextStep({ + rules: undefined, report, policy, currentUserAccountIDParam: currentUserAccountID, diff --git a/tests/unit/NoRawTypographyRuleTest.ts b/tests/unit/NoRawTypographyRuleTest.ts index bd21f6f0393a..c33543528285 100644 --- a/tests/unit/NoRawTypographyRuleTest.ts +++ b/tests/unit/NoRawTypographyRuleTest.ts @@ -53,7 +53,6 @@ const tsRuleTester = new RuleTester({ describe('no-raw-typography', () => { ruleTester.run(ruleModule.name, ruleModule, { valid: [ - 'const style = {fontSize: variables.fontSizeNormal};', 'const style = {fontSize: fontScale.text, lineHeight: lineHeightScale.text};', 'const style = {...textVariants.h1, color: theme.heading};', 'const style = {lineHeight: undefined};', @@ -61,10 +60,55 @@ describe('no-raw-typography', () => { 'const style = {[fontSize]: 17};', 'const style = {fontWeight: 700, height: 20, size: 17};', 'const jsx = hi;', - 'const jsx = hi;', 'const jsx = ;', + 'const style = StyleUtils.getFontSizeStyle(fontScale.h2);', + // `variables` is only the escape hatch for typography names, so unrelated keys are untouched. + 'const style = {fontSize: variables.iconSizeNormal};', + 'const style = {padding: variables.fontSizeNormal};', + 'const width = getWidth(variables.fontSizeNormal);', + // Aliases are only followed when there is a single value to follow. + 'const size = fontScale.text; const style = {fontSize: size};', + 'let size = 17; size = fontScale.text; const style = {fontSize: size};', + 'const style = {fontSize: props.fontSize};', + 'const style = {fontSize: isSmall ? fontScale.micro : fontScale.text};', + // Only `variables.*` is traced through an alias, never a bare number. + 'const FONT_SIZE = 12; const style = {fontSize: FONT_SIZE};', + 'const size = isSmall ? fontScale.micro : 17; const style = {fontSize: size};', + // The styles layer composes tokens out of `variables`, so it reads them by name. + { + code: 'const style = {fontSize: variables.fontSizeNormal};', + options: [{allowVariablesReferences: true}], + }, + { + code: 'const style = StyleUtils.getFontSizeStyle(variables.fontSizeMedium);', + options: [{allowVariablesReferences: true}], + }, ], invalid: [ + { + code: 'const style = {fontSize: variables.fontSizeNormal};', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const style = {lineHeight: variables.lineHeightXLarge};', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const jsx = hi;', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const style = StyleUtils.getFontSizeStyle(variables.fontSizeNormal);', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const style = getLineHeightStyle(variables.lineHeightXLarge);', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const style = getFontSizeStyle(17);', + errors: [{messageId: 'rawTypography'}], + }, { code: 'const style = {fontSize: 17};', errors: [{messageId: 'rawTypography'}], @@ -101,12 +145,53 @@ describe('no-raw-typography', () => { code: 'const jsx = hi;', errors: [{messageId: 'rawTypography'}], }, + // A `const` alias is not an escape hatch. The value is traced back to where it was written. + { + code: 'const size = variables.fontSizeXXSmall; const style = StyleUtils.getFontSizeStyle(size);', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const size = variables.fontSizeNormal; const jsx = hi;', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const outer = variables.fontSizeNormal; const inner = outer; const style = {fontSize: inner};', + errors: [{messageId: 'rawTypographyVariable'}], + }, + // Both branches of a ternary are checked, aliased or not. + { + code: 'const style = {fontSize: isSmall ? variables.fontSizeXXSmall : variables.fontSizeExtraSmall};', + errors: [{messageId: 'rawTypographyVariable'}], + }, + { + code: 'const style = {fontSize: isSmall ? fontScale.micro : 17};', + errors: [{messageId: 'rawTypography'}], + }, + { + code: 'const size = isSmall ? variables.fontSizeXXSmall : fontScale.micro; const style = {fontSize: size};', + errors: [{messageId: 'rawTypographyVariable'}], + }, + // `allowVariablesReferences` only lifts the named ban. Raw numeric literals stay banned. + { + code: 'const style = {fontSize: 17};', + options: [{allowVariablesReferences: true}], + errors: [{messageId: 'rawTypography'}], + }, + { + code: 'const style = StyleUtils.getFontSizeStyle(17);', + options: [{allowVariablesReferences: true}], + errors: [{messageId: 'rawTypography'}], + }, ], }); tsRuleTester.run(`${ruleModule.name} (TS assertions)`, ruleModule, { - valid: ['const style = {fontSize: variables.fontSizeNormal as number};'], + valid: ['const style = {fontSize: fontScale.text as number};'], invalid: [ + { + code: 'const style = {fontSize: variables.fontSizeNormal as number};', + errors: [{messageId: 'rawTypographyVariable'}], + }, { code: 'const style = {fontSize: 17 as const};', errors: [{messageId: 'rawTypography'}], diff --git a/tests/unit/OnyxConnectBypassTest.ts b/tests/unit/OnyxConnectBypassTest.ts index 1930c13de48d..50afee0cbd20 100644 --- a/tests/unit/OnyxConnectBypassTest.ts +++ b/tests/unit/OnyxConnectBypassTest.ts @@ -1,31 +1,116 @@ -import type {ESLint} from 'eslint'; +import {BANNED_RULE_ID, collectDisableDirectivesFromSource, findNewBypasses} from '../../scripts/onyxConnectBypass'; -import type {ResultWithSuppressed} from '../../scripts/onyxConnectBypass'; +const ONYX_CONNECT_CALL = `Onyx${'.connect'}`; +const onyxConnectCall = (key: string): string => `${ONYX_CONNECT_CALL}({key: "${key}"});`; -import {BANNED_RULE_ID, collectSuppressedBans, findNewBypasses} from '../../scripts/onyxConnectBypass'; +describe('collectDisableDirectivesFromSource', () => { + it('keeps only disable directives that name the no-onyx-connect ban', () => { + const source = [ + '// eslint-disable-next-line no-console', + 'console.log(1);', + `// eslint-disable-next-line ${BANNED_RULE_ID}`, + onyxConnectCall('x'), + '/* eslint-disable no-console */', + ].join('\n'); -const PROJECT_ROOT = '/repo'; + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 3}]); + }); + + it('flags a blanket eslint-disable that covers a banned call', () => { + const source = ['/* eslint-disable */', onyxConnectCall('x'), '// eslint-disable-next-line'].join('\n'); + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('flags a blanket disable covering a spaced or split Onyx.connect call', () => { + const spaced = ['/* eslint-disable */', `Onyx${' . connect'} ({key: "x"});`].join('\n'); + const split = ['/* eslint-disable */', `Onyx${'.'}`, ' connect({key: "x"});'].join('\n'); + + expect(collectDisableDirectivesFromSource(spaced, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + expect(collectDisableDirectivesFromSource(split, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('flags a blanket disable covering commented or parenthesized Onyx.connect calls', () => { + const commented = ['/* eslint-disable */', `Onyx${' /* x */ . connect'}({key: "x"});`].join('\n'); + const parenthesized = ['/* eslint-disable */', `(Onyx)${'.connect'}({key: "x"});`].join('\n'); + + expect(collectDisableDirectivesFromSource(commented, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + expect(collectDisableDirectivesFromSource(parenthesized, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); -function makeResult(relativePath: string, suppressedMessages: ESLint.LintResult['suppressedMessages']): ResultWithSuppressed { - return {filePath: `${PROJECT_ROOT}/${relativePath}`, suppressedMessages}; -} + it('flags a blanket disable covering an optional-chained Onyx.connect call', () => { + const optionalMember = ['/* eslint-disable */', `Onyx?${'.connect'}({key: "x"});`].join('\n'); + const optionalCall = ['/* eslint-disable */', `Onyx${'.connect'}?.({key: "x"});`].join('\n'); -function suppressed(ruleId: string, line: number): ESLint.LintResult['suppressedMessages'][number] { - return {ruleId, line, column: 1, message: 'x', severity: 2, suppressions: [{kind: 'directive', justification: ''}]}; -} + expect(collectDisableDirectivesFromSource(optionalMember, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + expect(collectDisableDirectivesFromSource(optionalCall, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); -describe('collectSuppressedBans', () => { - it('keeps only suppressed no-onyx-connect violations and relativizes their paths', () => { - const results = [makeResult('src/libs/Foo.ts', [suppressed(BANNED_RULE_ID, 12), suppressed('no-console', 3)]), makeResult('src/libs/Bar.ts', [suppressed(BANNED_RULE_ID, 7)])]; + it('flags blanket line and next-line disables only when they cover a call', () => { + const source = [`${onyxConnectCall('line')} // eslint-disable-line`, '// eslint-disable-next-line', onyxConnectCall('next')].join('\n'); - expect(collectSuppressedBans(results, PROJECT_ROOT)).toEqual([ - {file: 'src/libs/Foo.ts', line: 12}, - {file: 'src/libs/Bar.ts', line: 7}, + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([ + {file: 'src/libs/Foo.ts', line: 1}, + {file: 'src/libs/Foo.ts', line: 2}, ]); }); - it('returns nothing when there are no suppressed messages', () => { - expect(collectSuppressedBans([makeResult('src/libs/Foo.ts', [])], PROJECT_ROOT)).toEqual([]); + it('flags a multiline blanket eslint-disable-next-line covering the line after the comment ends', () => { + const source = ['/* eslint-disable-next-line', '*/', onyxConnectCall('x')].join('\n'); + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('counts LS/PS line terminators the same way ESLint does for next-line coverage', () => { + const lineSeparator = '\u2028'; + const paragraphSeparator = '\u2029'; + const ls = `// eslint-disable-next-line${lineSeparator}${onyxConnectCall('x')}`; + const ps = `// eslint-disable-next-line${paragraphSeparator}${onyxConnectCall('x')}`; + + expect(collectDisableDirectivesFromSource(ls, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + expect(collectDisableDirectivesFromSource(ps, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('ignores blanket disables that do not cover a banned call', () => { + const source = ['/* eslint-disable */', 'console.log(1);', '// eslint-disable-next-line', 'console.log(2);'].join('\n'); + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([]); + }); + + it('ignores a blanket disable after the ban is re-enabled', () => { + const source = ['/* eslint-disable */', '/* eslint-enable */', onyxConnectCall('x')].join('\n'); + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([]); + }); + + it('does not treat eslint-enable-line or eslint-enable-next-line as re-enabling the ban', () => { + const enableLine = ['/* eslint-disable */', '// eslint-enable-line', onyxConnectCall('x')].join('\n'); + const enableNextLine = ['/* eslint-disable */', '// eslint-enable-next-line', onyxConnectCall('x')].join('\n'); + + expect(collectDisableDirectivesFromSource(enableLine, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + expect(collectDisableDirectivesFromSource(enableNextLine, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('matches a trailing eslint-disable-line that names the ban', () => { + const source = `${onyxConnectCall('x')} // eslint-disable-line ${BANNED_RULE_ID}\n`; + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('matches a multiline block disable that names the ban after the first line', () => { + const source = ['/* eslint-disable no-console,', ` ${BANNED_RULE_ID} */`, onyxConnectCall('x')].join('\n'); + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('does not treat directive text in a string as an eslint-enable', () => { + const source = ['/* eslint-disable */', 'const text = "/* eslint-enable */";', onyxConnectCall('x')].join('\n'); + + expect(collectDisableDirectivesFromSource(source, 'src/libs/Foo.ts')).toEqual([{file: 'src/libs/Foo.ts', line: 1}]); + }); + + it('returns nothing when there are no matching directives', () => { + expect(collectDisableDirectivesFromSource('const x = 1;\n', 'src/libs/Foo.ts')).toEqual([]); }); }); diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index 059f5ef85cfc..66d6d3af104a 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -192,11 +192,45 @@ describe('OnyxDerived', () => { // When the report attributes are recomputed with both report and transaction updates reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], {}, ); const reportAttributesComputedValue = reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], { sourceValues: { [ONYXKEYS.COLLECTION.REPORT]: { @@ -310,17 +344,68 @@ describe('OnyxDerived', () => { // Reset the module-level diff baseline (no sourceValues clears it) so the seed below is deterministic. reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, personalDetails, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + personalDetails, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], {}, ); // First personal-details compute computes both reports (no prior currentValue) and seeds the baseline. const initial = reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, personalDetails, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + personalDetails, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], personalDetailsSource, ); // Changing only account 2's display name should recompute just its report, carrying the other by reference. const afterChange = reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, changedPersonalDetails, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + changedPersonalDetails, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], {...personalDetailsSource, currentValue: initial}, ); diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index 40158371e9c5..7c0c98c5919e 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -1,4 +1,5 @@ import {isClientTheLeader} from '@libs/ActiveClientManager'; +import {WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; @@ -8,11 +9,13 @@ import * as OnyxUpdateManager from '@userActions/OnyxUpdateManager'; import type {OnyxUpdateManagerUtilsMock} from '@userActions/OnyxUpdateManager/utils/__mocks__'; import type {ApplyUpdatesMock} from '@userActions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; import type * as OnyxUpdatesImport from '@userActions/OnyxUpdates'; +import * as PersistedRequests from '@userActions/PersistedRequests'; import CONST from '@src/CONST'; import {flushQueue} from '@src/libs/actions/QueuedOnyxUpdates'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; +import type {AnyRequest} from '@src/types/onyx/Request'; import Onyx from 'react-native-onyx'; @@ -748,6 +751,86 @@ describe('OnyxUpdateManager', () => { }); }); + describe('in-flight reconnect coverage', () => { + const putReconnectOnTheWire = (data: {updateIDFrom?: number}) => { + let landTheReconnect: () => void = () => {}; + const reconnectLanded = new Promise((resolve) => { + landTheReconnect = resolve; + }); + jest.spyOn(PersistedRequests, 'getOngoingRequest').mockReturnValue({command: WRITE_COMMANDS.RECONNECT_APP, data} as AnyRequest); + jest.spyOn(SequentialQueue, 'getCurrentRequest').mockReturnValue(reconnectLanded); + return landTheReconnect; + }; + + it('should wait for an in-flight ReconnectApp that already covers the gap instead of asking for the same range again', async () => { + // Given a reconnect on the wire asking from where the client stopped, whose flight nothing on the client records today. + const landTheReconnect = putReconnectOnTheWire({updateIDFrom: 1}); + + // When an update arrives whose predecessor falls inside the range that reconnect is already carrying. + const cycle = OnyxUpdateManager.handleMissingOnyxUpdates(update3); + await waitForBatchedUpdates(); + + // Then nothing is asked for on top of it, because 97.8% of these fetches re-requested a range wholly inside the reconnect. + expect(App.getMissingOnyxUpdates).not.toHaveBeenCalled(); + + // When that reconnect lands without closing the hole. + landTheReconnect(); + await cycle; + + // Then the range it left open is still fetched, which is what stops the suppression from swallowing a real gap. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledWith(1, 2); + }); + + it('should treat a full ReconnectApp on the wire as covering every gap, since it re-downloads everything', async () => { + // Given a full reconnect on the wire, which carries no updateIDFrom and so cannot start above the client. + const landTheReconnect = putReconnectOnTheWire({}); + + // When a gap is detected. Reading "no updateIDFrom" as "covers nothing" would leave the app's widest catch-up double-fetching. + const cycle = OnyxUpdateManager.handleMissingOnyxUpdates(update3); + await waitForBatchedUpdates(); + + // Then nothing is asked for on top of it. + expect(App.getMissingOnyxUpdates).not.toHaveBeenCalled(); + + landTheReconnect(); + await cycle; + }); + + it('should fall back to the normal fetch when the ongoing reconnect is a stale record with nothing on the wire', async () => { + // Given a persisted ongoing reconnect restored on boot, naming a request that is no longer in flight. + jest.spyOn(PersistedRequests, 'getOngoingRequest').mockReturnValue({command: WRITE_COMMANDS.RECONNECT_APP, data: {updateIDFrom: 1}} as AnyRequest); + + // When a gap is detected against it. + await OnyxUpdateManager.handleMissingOnyxUpdates(update3); + + // Then it self-heals into a fetch rather than holding the queue paused until the watchdog. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledWith(1, 2); + }); + + it('should still fetch when the in-flight ReconnectApp starts ahead of the client, so a real hole is never jumped', async () => { + // Given a reconnect on the wire asking from update 2 while the client is still at update 1. + putReconnectOnTheWire({updateIDFrom: 2}); + + // When a gap is detected. Its response cannot close a hole below its own updateIDFrom. + await OnyxUpdateManager.handleMissingOnyxUpdates(update3); + + // Then the normal fetch still runs, instead of stranding the client one update short forever. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + + it('should still fetch when the request on the wire is not a reconnect, because no other command returns a range', async () => { + // Given an ordinary WRITE on the wire, which brings no update range back. + jest.spyOn(PersistedRequests, 'getOngoingRequest').mockReturnValue({command: WRITE_COMMANDS.ADD_COMMENT, data: {}} as AnyRequest); + + // When a gap is detected. + await OnyxUpdateManager.handleMissingOnyxUpdates(update3); + + // Then the gap is closed the usual way. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + it('should apply deferred updates after fetching pending updates', () => { App.mockValues.missingOnyxUpdatesToBeApplied = [update2, update3]; diff --git a/tests/unit/OnyxUpdatesPusherChainFailureTest.ts b/tests/unit/OnyxUpdatesPusherChainFailureTest.ts new file mode 100644 index 000000000000..72801c755bca --- /dev/null +++ b/tests/unit/OnyxUpdatesPusherChainFailureTest.ts @@ -0,0 +1,75 @@ +import PusherUtils from '@libs/PusherUtils'; + +import CONST from '@src/CONST'; +import {apply, doesClientNeedToBeUpdated} from '@src/libs/actions/OnyxUpdates'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; + +import type {OnyxKey} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const pusherUpdate = (previousUpdateID: number, lastUpdateID: number): OnyxUpdatesFromServer => ({ + type: CONST.ONYX_UPDATE_TYPES.PUSHER, + previousUpdateID, + lastUpdateID, + updates: [{eventType: 'onyxApiUpdate', data: []}], +}); + +// A rejected Pusher apply leaves the module-scoped pusherEventsPromise rejected for the rest of the module's life, +// so this lives in its own file rather than poisoning the chain for the other tests. +describe('OnyxUpdates, when a Pusher apply fails', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it('relies on pusherEventsPromise staying rejected to stop a follower whose gap check the failed update had suppressed', async () => { + // Given the client is caught up to update 10 and update 20 from Pusher is held mid-apply + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + let failHeldApply: (error: Error) => void = () => {}; + let handlerCallCount = 0; + const handlerSpy = jest.spyOn(PusherUtils, 'triggerMultiEventHandler').mockImplementation(() => { + handlerCallCount += 1; + if (handlerCallCount > 1) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + failHeldApply = reject; + }); + }); + const heldApply = apply(pusherUpdate(10, 20)); + await waitForBatchedUpdates(); + + // When update 30 arrives chained on it, so the pending marker tells it there is no gap + expect(doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); + const followerApply = apply(pusherUpdate(20, 30)); + await waitForBatchedUpdates(); + + // And update 20 then fails to apply + failHeldApply(new Error('storage write failed')); + await expect(heldApply).rejects.toThrow('storage write failed'); + await expect(followerApply).rejects.toThrow('storage write failed'); + await waitForBatchedUpdates(); + + // Then update 30 is never written either. Nothing checks that update IDs are contiguous before advancing the + // watermark, so were it written the watermark would move to 30 and updates 11 to 20 would be lost with no gap + // left to trigger recovery. Serializing on pusherEventsPromise is the only thing preventing that. + expect(handlerCallCount).toBe(1); + expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT)).toBe(10); + + // And both updates are back in the gap, so recovery can refetch them + expect(doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + expect(doesClientNeedToBeUpdated({previousUpdateID: 30, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + handlerSpy.mockRestore(); + }); +}); diff --git a/tests/unit/OnyxUpdatesTest.ts b/tests/unit/OnyxUpdatesTest.ts index 0fee69e61e0a..271d0ba4571b 100644 --- a/tests/unit/OnyxUpdatesTest.ts +++ b/tests/unit/OnyxUpdatesTest.ts @@ -1,4 +1,5 @@ import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import PusherUtils from '@libs/PusherUtils'; import CONST from '@src/CONST'; import * as OnyxUpdates from '@src/libs/actions/OnyxUpdates'; @@ -24,6 +25,12 @@ describe('OnyxUpdatesTest', () => { beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + let releaseHeldApply: (() => void) | undefined; + afterEach(() => { + releaseHeldApply?.(); + releaseHeldApply = undefined; + }); + it('applies Airship Onyx updates correctly', () => { const reportID = NumberUtils.rand64(); const reportActionID = NumberUtils.rand64(); @@ -249,7 +256,7 @@ describe('OnyxUpdatesTest', () => { // Then a following response chained on update 20 is not treated as a gap, even though the // persisted watermark is still at 10 — otherwise every queued WRITE would pause the queue - expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20})).toBe(false); + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); // And once the flush applies the staged updates, the persisted watermark catches up await flushQueue(); @@ -302,19 +309,157 @@ describe('OnyxUpdatesTest', () => { }, }); await waitForBatchedUpdates(); - expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20})).toBe(false); + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); // When the user signs out, which clears Onyx storage await Onyx.clear(); await waitForBatchedUpdates(); // Then the pending watermark from the previous session no longer masks gaps in the new session - expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15})).toBe(true); + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); // Drain the staged updates so they don't leak into other tests await flushQueue(); }); + const applyHeldPusherUpdate = (previousUpdateID: number, lastUpdateID: number) => { + let releaseApply: () => void = () => {}; + const handlerSpy = jest.spyOn(PusherUtils, 'triggerMultiEventHandler').mockReturnValueOnce( + new Promise((resolve) => { + releaseApply = resolve; + releaseHeldApply = resolve; + }), + ); + const applyPromise = OnyxUpdates.apply({ + type: CONST.ONYX_UPDATE_TYPES.PUSHER, + previousUpdateID, + lastUpdateID, + updates: [{eventType: 'onyxApiUpdate', data: []}], + }); + + return { + release: () => { + releaseApply(); + handlerSpy.mockRestore(); + return applyPromise; + }, + }; + }; + + it('does not report a gap for a Pusher update that is still applying', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply is held mid-flight + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // Then the next event, chained on update 20, is not treated as a gap even though the watermark is still at 10 + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); + + await heldApply.release(); + }); + + it('reports a gap for an HTTPS response chained on a Pusher update that is still applying', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply is held mid-flight + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // Then an HTTPS response chained on update 20 still reports the gap, because its apply runs on its own + // promise chain and would advance the watermark past the updates the held apply has not written yet + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20})).toBe(true); + + await heldApply.release(); + }); + + it('keeps a Pusher update that is still applying out of the catch-up fetch range', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply is held mid-flight + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // Then a genuinely later gap is still detected + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 30, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + // And it fetches from the persisted watermark, so a rejected apply cannot strand update 20 + expect(OnyxUpdates.getEffectiveLastUpdateID()).toBe(10); + + await heldApply.release(); + }); + + it('clears the pending apply watermark on sign-out', async () => { + // Given the client is caught up to update 10 and update 20 from Pusher is held mid-apply + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // When the user signs out, which clears Onyx storage + await Onyx.clear(); + await waitForBatchedUpdates(); + + // Then the pending marker from the previous session no longer masks gaps in the new session + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + // And the held apply settling afterwards does not reintroduce it + await heldApply.release(); + await waitForBatchedUpdates(); + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + }); + + it('clears the Pusher pending apply marker when an unrelated apply fails below it, so a real gap is never masked', async () => { + // Given the client is caught up to update 10 and update 20 from Pusher is held mid-apply + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // When an unrelated READ request's apply fails at update 15, below the held Pusher update + const updateSpy = jest.spyOn(Onyx, 'update').mockRejectedValueOnce(new Error('storage write failed')); + await expect( + OnyxUpdates.apply({ + type: CONST.ONYX_UPDATE_TYPES.HTTPS, + previousUpdateID: 10, + lastUpdateID: 15, + request: {command: 'OpenReport', data: {apiRequestType: CONST.API_REQUEST_TYPE.READ}}, + response: { + jsonCode: 200, + onyxData: [{onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.REPORT}${NumberUtils.rand64()}`, value: {}}], + }, + }), + ).rejects.toThrow('storage write failed'); + await waitForBatchedUpdates(); + + // Then update 20 stops counting as applied, so the gap left by update 15 is detected instead of masked + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + updateSpy.mockRestore(); + await heldApply.release(); + }); + + it('stops counting a Pusher update as in flight once its apply has settled', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply finishes + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + await heldApply.release(); + await waitForBatchedUpdates(); + + // Then a caller that overrides the watermark with its own lower value is no longer told it is caught up + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + }); + it('does not move the watermark backwards when a slower older update settles after a newer one', async () => { // Given the client is caught up to update 10 await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 1b8d9bc12f22..48eb2de7b5bd 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -66,7 +66,7 @@ import Onyx from 'react-native-onyx'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport, createRegularChat} from '../utils/collections/reports'; import {getFakeAdvancedReportAction} from '../utils/LHNTestUtils'; -import {convertToDisplayString, formatPhoneNumber, localeCompare, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, convertToDisplayStringWithoutCurrency, formatPhoneNumber, localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@rnmapbox/maps', () => { @@ -793,19 +793,35 @@ describe('OptionsListUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}10`, reportNameValuePairs); await waitForBatchedUpdates(); - OPTIONS = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + OPTIONS = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); OPTIONS_WITH_CONCIERGE = createFilteredOptionList( PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE, MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CONCIERGE, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); OPTIONS_WITH_CHRONOS = createFilteredOptionList( PERSONAL_DETAILS_WITH_CHRONOS, @@ -813,7 +829,14 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CHRONOS, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); OPTIONS_WITH_RECEIPTS = createFilteredOptionList( PERSONAL_DETAILS_WITH_RECEIPTS, @@ -821,7 +844,14 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_RECEIPTS, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); OPTIONS_WITH_WORKSPACE_ROOM = createFilteredOptionList( PERSONAL_DETAILS, @@ -829,7 +859,14 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_WORKSPACE_ROOM, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); }); @@ -854,7 +891,9 @@ describe('OptionsListUtils', () => { // Given a set of options // When we call getSearchOptions with all betas const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -905,12 +944,21 @@ describe('OptionsListUtils', () => { createMockReportAttributesDerived(memberWorkspaceChat, PERSONAL_DETAILS, CURRENT_USER_ACCOUNT_ID), EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); // When we call getSearchOptions const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: optionsWithMemberWorkspaceChat, reportAttributesDerived: createMockReportAttributesDerived(memberWorkspaceChat, PERSONAL_DETAILS, CURRENT_USER_ACCOUNT_ID), @@ -933,7 +981,9 @@ describe('OptionsListUtils', () => { // Given a set of options where the current user is Iron Man (accountID: 2) // When we call getSearchOptions with includeCurrentUser set to true const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, draftComments: {}, @@ -968,7 +1018,9 @@ describe('OptionsListUtils', () => { // Given a set of options where the current user is Iron Man (accountID: 2) // When we call getSearchOptions with includeCurrentUser set to false (default behavior) const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, draftComments: {}, @@ -1000,7 +1052,9 @@ describe('OptionsListUtils', () => { // Given a set of options with workspace rooms // When we call getSearchOptions with policyCollection const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_WORKSPACE_ROOM, draftComments: {}, @@ -1032,7 +1086,9 @@ describe('OptionsListUtils', () => { // Given a set of options // When we call getSearchOptions with empty policyCollection const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, draftComments: {}, @@ -1063,7 +1119,9 @@ describe('OptionsListUtils', () => { // Given a set of options // When we call getSearchOptions with undefined policyCollection const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, draftComments: {}, @@ -1095,7 +1153,9 @@ describe('OptionsListUtils', () => { const conciergeReportID = '11'; // When we call getSearchOptions with conciergeReportID const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_CONCIERGE, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CONCIERGE, @@ -1121,7 +1181,9 @@ describe('OptionsListUtils', () => { // Given a set of options with Concierge // When we call getSearchOptions with conciergeReportID set to undefined const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_CONCIERGE, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CONCIERGE, @@ -1149,7 +1211,9 @@ describe('OptionsListUtils', () => { const conciergeReportID = '11'; // When we call getSearchOptions with a search query matching Concierge const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_CONCIERGE, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CONCIERGE, @@ -1187,8 +1251,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); let results: Pick = validOptions; // When we call orderOptions() @@ -1229,8 +1294,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); let results: Pick = validOptions; // When we call orderOptions() @@ -1267,8 +1333,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the result should be empty @@ -1294,8 +1361,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user @@ -1333,12 +1401,21 @@ describe('OptionsListUtils', () => { reportID: '', }, }; - const optionList = createFilteredOptionList(personalDetails, {}, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const optionList = createFilteredOptionList( + personalDetails, + {}, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const {options: preFilteredOptions} = getValidOptions( optionList, @@ -1350,17 +1427,32 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, includeRecentReports: false, personalDetails, searchString, }, translateLocal, + undefined, ); // The contact must survive getValidOptions' pre-filter so the final filter can return it. expect(preFilteredOptions.personalDetails).toEqual(expect.arrayContaining([expect.objectContaining({login: 'contact1003@example.com'})])); - const filteredOptions = filterAndOrderOptions(preFilteredOptions, searchString, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, personalDetails); + const filteredOptions = filterAndOrderOptions( + preFilteredOptions, + searchString, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + personalDetails, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(filteredOptions.personalDetails).toEqual([expect.objectContaining({login: 'contact1003@example.com'})]); }); @@ -1379,8 +1471,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {[CONST.EMAIL.CONCIERGE]: true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {[CONST.EMAIL.CONCIERGE]: true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user and Concierge @@ -1404,8 +1502,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, conciergeReportID, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the result should still include Concierge in the results @@ -1429,8 +1528,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {[CONST.EMAIL.CHRONOS]: true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {[CONST.EMAIL.CHRONOS]: true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user and Chronos @@ -1453,8 +1558,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {[CONST.EMAIL.RECEIPTS]: true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {[CONST.EMAIL.RECEIPTS]: true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user and receipts @@ -1506,8 +1617,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeMultipleParticipantReports: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeMultipleParticipantReports: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); const adminRoomOption = results.recentReports.find((report) => report.reportID === '1455140530846319'); @@ -1559,8 +1676,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeMultipleParticipantReports: true, showRBR: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeMultipleParticipantReports: true, + showRBR: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); expect(results.recentReports.at(0)?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); @@ -1609,8 +1733,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeMultipleParticipantReports: true, showRBR: false, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeMultipleParticipantReports: true, + showRBR: false, + sortedActions: undefined, + }, translateLocal, + undefined, ); expect(results.recentReports.at(0)?.brickRoadIndicator).toBe(null); }); @@ -1663,8 +1794,16 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, shouldUnreadBeBold: true, includeMultipleParticipantReports: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + shouldUnreadBeBold: true, + includeMultipleParticipantReports: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); expect(results.recentReports.at(0)?.isBold).toBe(true); @@ -1695,8 +1834,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, personalDetails: customPersonalDetails, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + personalDetails: customPersonalDetails, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the function should complete without errors and return valid results @@ -1718,8 +1863,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, selectedOptions, includeSelectedOptions: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + selectedOptions, + includeSelectedOptions: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the matching personal detail should be kept in the list and marked as selected @@ -1741,8 +1893,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, maxElements: 1}, + {dateFnsLocale: undefined, convertToDisplayString, maxElements: 1}, translateLocal, + undefined, ); expect(hasMore).toBe(true); @@ -1757,8 +1910,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, maxElements: 100}, + {dateFnsLocale: undefined, convertToDisplayString, maxElements: 100}, translateLocal, + undefined, ); expect(hasMore).toBe(false); @@ -1778,12 +1932,21 @@ describe('OptionsListUtils', () => { // The non-circular check — hydration output vs. a direct createOption call with non-default inputs — // lives in 'lazy contact hydration vs. a direct createOption build' below. const buildOptionLists = () => { - const lazyList = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const lazyList = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const eagerList = hydrateAllPersonalDetails(lazyList); return {eagerList, lazyList}; }; @@ -1803,9 +1966,14 @@ describe('OptionsListUtils', () => { const {eagerList, lazyList} = buildOptionLists(); // When both lists go through getValidOptions with a top-N cap that exercises the heap - const config = {dateFnsLocale: undefined, maxElements: 3, personalDetails: PERSONAL_DETAILS}; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + 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); // Then the surviving contacts are hydrated (createOption ran for them) and match the eager results exactly expect(lazyResults.personalDetails.length).toBeGreaterThan(0); @@ -1819,9 +1987,14 @@ describe('OptionsListUtils', () => { const {eagerList, lazyList} = buildOptionLists(); // When both lists go through getValidOptions with a search string (contact filtering reads text/login/participantsList) - const config = {dateFnsLocale: undefined, searchString: 'spider', personalDetails: PERSONAL_DETAILS}; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + 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); // Then filtering and ordering are unchanged and the hydrated results match expect(lazyResults.personalDetails).toEqual(eagerResults.personalDetails); @@ -1886,13 +2059,39 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, searchString, includeP2P: true}, + { + dateFnsLocale: undefined, + convertToDisplayString, + searchString, + includeP2P: true, + }, translateLocal, + undefined, + ); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, ); - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); // Then the report must survive both filtering stages - expect(filteredOptions.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({reportID: report.reportID, text: reportText})])); + expect(filteredOptions.recentReports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reportID: report.reportID, + text: reportText, + }), + ]), + ); }); it('should keep every shell filter and sort field identical to the hydrated option', () => { @@ -1951,9 +2150,14 @@ describe('OptionsListUtils', () => { const {eagerList, lazyList} = buildOptionLists(); // When both lists go through getValidOptions with a custom exclusion (filter reads shell.login) - const config = {dateFnsLocale: undefined, 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); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + 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); // 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); @@ -1962,12 +2166,21 @@ describe('OptionsListUtils', () => { it('should handle empty personal details', () => { // Given a lazily built option list with no personal details - const lazyList = createFilteredOptionList({}, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const lazyList = createFilteredOptionList( + {}, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When it goes through getValidOptions const {options: results} = getValidOptions( @@ -1978,8 +2191,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then no contacts are produced and nothing throws @@ -2002,9 +2216,11 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, personalDetails: PERSONAL_DETAILS, }, translateLocal, + undefined, ); // Then every surviving contact has the properties SelectionList rendering and selection rely on, @@ -2027,9 +2243,15 @@ describe('OptionsListUtils', () => { // When both lists go through getValidOptions with search + maxElements together // (filter selects matches, then the heap keeps only the top-N survivors to hydrate) - const config = {dateFnsLocale: undefined, searchString: 'man', maxElements: 3, personalDetails: PERSONAL_DETAILS}; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + searchString: 'man', + 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); // Then filtering, ranking, and hydration match the eager path expect(lazyResults.personalDetails.length).toBeGreaterThan(0); @@ -2041,38 +2263,83 @@ describe('OptionsListUtils', () => { it('should hydrate correctly after a filtered option list cache hit', () => { // Given lazy contact options built while not searching (the only mode that uses the option-list cache) clearFilteredOptionListCache(); - const firstLazyList = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); - const cachedLazyList = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); - const eagerList = hydrateAllPersonalDetails(firstLazyList); - - // Then the cache hit still returns shells - expect(cachedLazyList.personalDetails.every((option) => !option.isHydrated)).toBe(true); - - // And the clone from the cache hit shares each shell's hydration closure, so the expensive build is - // reused rather than re-run per clone. cloneOptionList allocates a new shell object on every return, - // so a memo keyed on shell identity would start empty here and rebuild the whole visible page. - const freshShell = firstLazyList.personalDetails.at(0); - const cachedShell = cachedLazyList.personalDetails.at(0); - expect(freshShell).toBeDefined(); - expect(cachedShell).toBeDefined(); - expect(cachedShell).not.toBe(freshShell); - if (freshShell && cachedShell) { - expect(hydrateContactOption(cachedShell).icons).toBe(hydrateContactOption(freshShell).icons); + const firstLazyList = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); + const cachedLazyList = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); + const eagerList = hydrateAllPersonalDetails(firstLazyList); + + // Then the cache hit still returns shells + expect(cachedLazyList.personalDetails.every((option) => !option.isHydrated)).toBe(true); + + // And the clone from the cache hit shares each shell's hydration closure, so the expensive build is + // reused rather than re-run per clone. cloneOptionList allocates a new shell object on every return, + // so a memo keyed on shell identity would start empty here and rebuild the whole visible page. + const freshShell = firstLazyList.personalDetails.at(0); + const cachedShell = cachedLazyList.personalDetails.at(0); + expect(freshShell).toBeDefined(); + expect(cachedShell).toBeDefined(); + expect(cachedShell).not.toBe(freshShell); + if (freshShell && cachedShell) { + expect(hydrateContactOption(cachedShell).icons).toBe(hydrateContactOption(freshShell).icons); } // When both the fresh and cached lazy lists go through getValidOptions - const config = {dateFnsLocale: undefined, maxElements: 3, personalDetails: PERSONAL_DETAILS}; - const {options: firstResults} = getValidOptions(firstLazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); - const {options: cachedResults} = getValidOptions(cachedLazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + maxElements: 3, + personalDetails: PERSONAL_DETAILS, + }; + const {options: firstResults} = getValidOptions( + firstLazyList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + translateLocal, + undefined, + ); + const {options: cachedResults} = getValidOptions( + cachedLazyList, + 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, translateLocal, undefined); // Then hydration from the cached clone matches the fresh lazy build and the eager path expect(cachedResults.personalDetails).toEqual(firstResults.personalDetails); @@ -2085,6 +2352,7 @@ describe('OptionsListUtils', () => { const options = { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, maxRecentReports, includeP2P, @@ -2094,8 +2362,8 @@ 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); - const opened = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, options); + 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); // 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 @@ -2103,7 +2371,7 @@ 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}) + createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, {...options, isSearching: true}, undefined) .personalDetails.length, ).toBeGreaterThan(0); }); @@ -2120,12 +2388,21 @@ describe('OptionsListUtils', () => { brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, }, }; - const lazyList = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, attributesWithError, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const lazyList = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + attributesWithError, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const shell = lazyList.personalDetails.find((option) => option.reportID === dmReportID); expect(shell).toBeDefined(); if (!shell) { @@ -2213,9 +2490,36 @@ describe('OptionsListUtils', () => { }; // When both mixed lists are filtered for the device contact - const config = {dateFnsLocale: undefined, searchString: 'Device Contact Jane', personalDetails: PERSONAL_DETAILS}; - const {options: eagerResults} = getValidOptions(eagerWithContacts, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); - const {options: lazyResults} = getValidOptions(lazyWithContacts, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + searchString: 'Device Contact Jane', + personalDetails: PERSONAL_DETAILS, + }; + const {options: eagerResults} = getValidOptions( + eagerWithContacts, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + translateLocal, + undefined, + ); + const {options: lazyResults} = getValidOptions( + lazyWithContacts, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + translateLocal, + undefined, + ); // Then the device contact survives hydrate unchanged, lazy shells hydrate, and results match eager expect(lazyResults.personalDetails.some((option) => option.login === deviceContactLogin)).toBe(true); @@ -2251,11 +2555,17 @@ describe('OptionsListUtils', () => { reportName: 'Parity Contact', lastVisibleActionCreated: '2024-01-01 00:00:00.000', participants: { - [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [PARITY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [CURRENT_USER_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + [PARITY_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, }, }; - const PARITY_REPORTS: OnyxCollection = {[PARITY_REPORT_ID]: PARITY_REPORT}; + const PARITY_REPORTS: OnyxCollection = { + [PARITY_REPORT_ID]: PARITY_REPORT, + }; // Non-default hydration inputs. Each one drives a different field of the built option, asserted below. const PARITY_ATTRIBUTES: Record = { @@ -2273,12 +2583,21 @@ describe('OptionsListUtils', () => { const buildParityShell = () => { clearFilteredOptionListCache(); - const list = createFilteredOptionList(PARITY_PERSONAL_DETAILS, PARITY_REPORTS, PARITY_ATTRIBUTES, PARITY_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const list = createFilteredOptionList( + PARITY_PERSONAL_DETAILS, + PARITY_REPORTS, + PARITY_ATTRIBUTES, + PARITY_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const shell = list.personalDetails.at(0); expect(shell).toBeDefined(); // Guard against a vacuous pass: the DM has to be mapped onto the contact, or none of the @@ -2303,11 +2622,13 @@ describe('OptionsListUtils', () => { item: PARITY_PERSONAL_DETAIL, ...createOption({ dateFnsLocale: undefined, + convertToDisplayString, accountIDs: [PARITY_ACCOUNT_ID], personalDetails: PARITY_PERSONAL_DETAILS, report: PARITY_REPORT, policy: undefined, privateIsArchived: true, + rules: undefined, conciergeReportID: undefined, config: {showPersonalDetails: true}, reportAttributesDerived: PARITY_ATTRIBUTES, @@ -2345,12 +2666,21 @@ describe('OptionsListUtils', () => { it('should carry conciergeReportID into the built option', () => { // Given the mapped DM is the Concierge report clearFilteredOptionListCache(); - const list = createFilteredOptionList(PARITY_PERSONAL_DETAILS, PARITY_REPORTS, PARITY_ATTRIBUTES, PARITY_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: PARITY_REPORT_ID, - isSearching: true, - }); + const list = createFilteredOptionList( + PARITY_PERSONAL_DETAILS, + PARITY_REPORTS, + PARITY_ATTRIBUTES, + PARITY_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: PARITY_REPORT_ID, + isSearching: true, + }, + undefined, + ); const shell = list.personalDetails.at(0); expect(shell).toBeDefined(); if (!shell) { @@ -2362,11 +2692,13 @@ describe('OptionsListUtils', () => { const hydrated = hydrateContactOption(shell); const withConcierge = createOption({ dateFnsLocale: undefined, + convertToDisplayString, accountIDs: [PARITY_ACCOUNT_ID], personalDetails: PARITY_PERSONAL_DETAILS, report: PARITY_REPORT, policy: undefined, privateIsArchived: true, + rules: undefined, conciergeReportID: PARITY_REPORT_ID, config: {showPersonalDetails: true}, reportAttributesDerived: PARITY_ATTRIBUTES, @@ -2374,7 +2706,11 @@ describe('OptionsListUtils', () => { visibleReportActionsData: {}, }); - expect(hydrated).toEqual({item: PARITY_PERSONAL_DETAIL, ...withConcierge, isHydrated: true}); + expect(hydrated).toEqual({ + item: PARITY_PERSONAL_DETAIL, + ...withConcierge, + isHydrated: true, + }); }); }); @@ -2387,24 +2723,46 @@ describe('OptionsListUtils', () => { it('should not rebuild any contact option when the second option list comes from the entry cache', () => { // Given two identical createFilteredOptionList calls, the second served from the entry cache clearFilteredOptionListCache(); - const first = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); - const second = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const first = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); + const second = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); expect(first.personalDetails.length).toBeGreaterThan(0); expect(second.personalDetails.length).toBe(first.personalDetails.length); // When both are run through getValidOptions (the first pass builds, the second must not) - const config = {dateFnsLocale: undefined, personalDetails: PERSONAL_DETAILS}; + const config = { + dateFnsLocale: undefined, + convertToDisplayString, + 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); - const {options: secondResults} = getValidOptions(second, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal); + 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 secondBuilds = second.personalDetails.map(buildIdentity); // Then every contact on the second pass reused the first pass's build. @@ -2423,12 +2781,21 @@ describe('OptionsListUtils', () => { it('should freeze the memoized build in dev while leaving the copy handed to callers writable', () => { // Given a hydrated contact option clearFilteredOptionListCache(); - const list = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const list = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const shell = list.personalDetails.at(0); expect(shell).toBeDefined(); if (!shell) { @@ -2462,12 +2829,21 @@ describe('OptionsListUtils', () => { describe('PersonalDetailOptionOrShell typing', () => { it('should not let a display field be read without narrowing on isHydrated', () => { clearFilteredOptionListCache(); - const list = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const list = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const option = list.personalDetails.at(0); expect(option).toBeDefined(); if (!option) { @@ -2500,8 +2876,17 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, includeMultipleParticipantReports: true, includeP2P: true, includeOwnedWorkspaceChats: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + includeMultipleParticipantReports: true, + includeP2P: true, + includeOwnedWorkspaceChats: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all reports except the currently logged in user @@ -2539,6 +2924,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, includeRecentReports: true, includeMultipleParticipantReports: true, includeP2P: true, @@ -2548,6 +2934,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // Then the function should process without errors and return workspace chats @@ -2569,6 +2956,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, includeRecentReports: true, includeMultipleParticipantReports: true, includeP2P: true, @@ -2578,6 +2966,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // Then the function should fall back to allPersonalDetails and process without errors @@ -2598,6 +2987,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, includeRecentReports: true, includeMultipleParticipantReports: true, includeP2P: true, @@ -2607,6 +2997,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // Then the function should fall back to allPersonalDetails and process without errors @@ -2627,6 +3018,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, includeRecentReports: true, includeMultipleParticipantReports: true, includeP2P: true, @@ -2636,6 +3028,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // Then the function should fall back to allPersonalDetails and process without errors @@ -2656,8 +3049,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); const reportLogins = new Set(results.recentReports.map((reportOption) => reportOption.login)); const personalDetailsOverlapWithReports = results.personalDetails.every((personalDetailOption) => reportLogins.has(personalDetailOption.login)); @@ -2679,8 +3073,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {'peterparker@expensify.com': true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {'peterparker@expensify.com': true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the option should not appear anywhere in either list @@ -2702,8 +3102,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user @@ -2726,8 +3127,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {[CONST.EMAIL.CONCIERGE]: true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {[CONST.EMAIL.CONCIERGE]: true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user and Concierge @@ -2751,8 +3158,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {[CONST.EMAIL.CHRONOS]: true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {[CONST.EMAIL.CHRONOS]: true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user and Chronos @@ -2776,8 +3189,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: {[CONST.EMAIL.RECEIPTS]: true}, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: {[CONST.EMAIL.RECEIPTS]: true}, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the result should include all personalDetails except the currently logged in user and receipts @@ -2799,8 +3218,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, maxRecentReportElements: maxRecentReports, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReportElements: maxRecentReports, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the recent reports should be limited to the specified number @@ -2818,8 +3243,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); const {options: resultsWithLimit} = getValidOptions( {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, @@ -2829,8 +3255,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, maxRecentReportElements: 2, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReportElements: 2, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then the results without limit should have more or equal reports @@ -2848,8 +3280,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); const {options: resultsWithLimit} = getValidOptions( {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, @@ -2859,8 +3292,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, maxRecentReportElements: 2, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReportElements: 2, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then personalDetails should remain the same regardless of maxRecentReportElements @@ -2880,8 +3319,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, maxElements: maxTotalElements, maxRecentReportElements: maxRecentReports, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxElements: maxTotalElements, + maxRecentReportElements: maxRecentReports, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then recent reports should be limited by maxRecentReportElements @@ -2919,6 +3365,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, betas: [], includeMultipleParticipantReports: true, showChatPreviewLine: true, @@ -2934,6 +3381,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // Then all the recent reports should be returned except the archived rooms and the hidden thread @@ -2966,6 +3414,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, betas: [], includeMultipleParticipantReports: true, showChatPreviewLine: true, @@ -2981,6 +3430,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // Then all recent reports should be returned except the archived rooms and the hidden thread @@ -3013,7 +3463,9 @@ describe('OptionsListUtils', () => { // Given a set of options // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3028,7 +3480,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with an empty search value - const filteredOptions = filterAndOrderOptions(options, '', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + '', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then all options should be returned expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(14); @@ -3039,7 +3504,9 @@ describe('OptionsListUtils', () => { // Given a set of options // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3054,10 +3521,21 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value and sortByReportTypeInSearch param - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - sortByReportTypeInSearch: true, - }); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + sortByReportTypeInSearch: true, + }, + undefined, + ); // Then we expect all options to be part of the recentReports list and reports should be first: expect(filteredOptions.personalDetails.length).toBe(0); @@ -3075,7 +3553,9 @@ describe('OptionsListUtils', () => { // Given a set of options // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3090,7 +3570,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only one report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -3104,15 +3597,26 @@ describe('OptionsListUtils', () => { const archivedMap: PrivateIsArchivedMap = { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}10`]: !!reportNameValuePairs.private_isArchived, }; - const OPTIONS_WITH_ARCHIVED = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, archivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_ARCHIVED = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + archivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_ARCHIVED, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3127,7 +3631,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only one report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -3139,15 +3656,26 @@ describe('OptionsListUtils', () => { // cspell:disable-next-line const searchText = 'barryallen'; // Given a set of options created from PERSONAL_DETAILS_WITH_PERIODS - const OPTIONS_WITH_PERIODS = createFilteredOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_PERIODS = createFilteredOptionList( + PERSONAL_DETAILS_WITH_PERIODS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_PERIODS, draftComments: {}, @@ -3161,10 +3689,21 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value and sortByReportTypeInSearch param - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS_WITH_PERIODS, { - dateFnsLocale: undefined, - sortByReportTypeInSearch: true, - }); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS_WITH_PERIODS, + { + dateFnsLocale: undefined, + convertToDisplayString, + sortByReportTypeInSearch: true, + }, + undefined, + ); // Then only one report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -3177,7 +3716,9 @@ describe('OptionsListUtils', () => { // Given a set of options with workspace rooms // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_WORKSPACE_ROOM, draftComments: {}, @@ -3191,7 +3732,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only one report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -3203,7 +3757,9 @@ describe('OptionsListUtils', () => { const searchText = 'reedrichards@expensify.com'; // Given a set of options with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3218,7 +3774,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only one report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -3236,11 +3805,20 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CHAT_ROOM, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_CHAT_ROOMS, draftComments: {}, @@ -3254,7 +3832,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we pass the returned options to filterAndOrderOptions with a search value - const filterOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filterOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only two reports should be returned expect(filterOptions.recentReports.length).toBe(2); @@ -3267,7 +3858,9 @@ describe('OptionsListUtils', () => { const searchText = 'fantastic'; // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3281,7 +3874,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only three reports should be returned expect(filteredOptions.recentReports.length).toBe(3); @@ -3295,7 +3901,9 @@ describe('OptionsListUtils', () => { const searchText = 'test@email.com'; // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3310,7 +3918,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then the user to invite should be returned expect(filteredOptions.userToInvite?.login).toBe(searchText); @@ -3327,14 +3948,31 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + sortedActions: undefined, + }, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value and excluded logins list - const filterOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - }); + const filterOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + }, + undefined, + ); // Then no personal details should be returned expect(filterOptions.recentReports.length).toBe(0); @@ -3344,7 +3982,9 @@ describe('OptionsListUtils', () => { const searchText = 'test@email.com'; // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3359,10 +3999,21 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value and excludeLogins - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - }); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + }, + undefined, + ); // Then the user to invite should be returned expect(filteredOptions.userToInvite?.login).toBe(searchText); @@ -3372,7 +4023,9 @@ describe('OptionsListUtils', () => { const searchText = ''; // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3387,20 +4040,42 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value and maxRecentReportsToShow set to 2 - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - maxRecentReportsToShow: 2, - }); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReportsToShow: 2, + }, + undefined, + ); // Then only two reports should be returned expect(filteredOptions.recentReports.length).toBe(2); // Note: in the past maxRecentReportsToShow: 0 would return all recent reports, this has changed, and is expected to return none now // When we call filterAndOrderOptions with a search value and maxRecentReportsToShow set to 0 - const limitToZeroOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - maxRecentReportsToShow: 0, - }); + const limitToZeroOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReportsToShow: 0, + }, + undefined, + ); // Then no reports should be returned expect(limitToZeroOptions.recentReports.length).toBe(0); @@ -3410,7 +4085,9 @@ describe('OptionsListUtils', () => { const searchText = 'natasharomanoff@expensify.com'; // Given a set of options with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -3425,7 +4102,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchText, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then there should be one matching result expect(filteredOptions.personalDetails.length).toBe(1); @@ -3453,6 +4143,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, betas: [], includeMultipleParticipantReports: true, showChatPreviewLine: true, @@ -3468,9 +4159,23 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // When we pass the returned options to filterAndOrderOptions with a search value that does not match the group chat name - const filteredOptions = filterAndOrderOptions(options, 'mutants', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'mutants', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3501,6 +4206,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, betas: [], includeMultipleParticipantReports: true, showChatPreviewLine: true, @@ -3516,9 +4222,23 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // When we pass the returned options to filterAndOrderOptions with a search value that matches the group chat name - const filteredOptions = filterAndOrderOptions(options, 'Avengers Room', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'Avengers Room', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then one recent report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -3549,6 +4269,7 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, betas: [], includeMultipleParticipantReports: true, showChatPreviewLine: true, @@ -3564,9 +4285,23 @@ describe('OptionsListUtils', () => { sortedActions: undefined, }, translateLocal, + undefined, ); // When we pass the returned options to filterAndOrderOptions with a search value that does not match the group chat name - const filteredOptions = filterAndOrderOptions(options, 'Mutants Lair', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'Mutants Lair', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3582,11 +4317,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that matches a personal detail with no existing report - const filteredOptions = filterAndOrderOptions(options, 'hulk', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'hulk', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3621,16 +4370,27 @@ describe('OptionsListUtils', () => { }, }; - const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList(PERSONAL_DETAILS, REPORTS_WITH_GROUP_CHAT, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_GROUP_CHAT, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions with a search query that matches a participant display name const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_GROUP_CHAT, draftComments: {}, @@ -3676,16 +4436,27 @@ describe('OptionsListUtils', () => { }, }; - const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList(PERSONAL_DETAILS, REPORTS_WITH_GROUP_CHAT, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_GROUP_CHAT, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions with a search query that matches a participant login const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_GROUP_CHAT, draftComments: {}, @@ -3731,16 +4502,27 @@ describe('OptionsListUtils', () => { }, }; - const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList(PERSONAL_DETAILS, REPORTS_WITH_GROUP_CHAT, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_GROUP_CHAT, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions with a search query that matches a participant name const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_GROUP_CHAT, draftComments: {}, @@ -3786,16 +4568,27 @@ describe('OptionsListUtils', () => { }, }; - const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList(PERSONAL_DETAILS, REPORTS_WITH_GROUP_CHAT, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_GROUP_CHAT = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_GROUP_CHAT, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions with a search query that does not match any participant const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_GROUP_CHAT, draftComments: {}, @@ -3842,12 +4635,21 @@ describe('OptionsListUtils', () => { undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, ); // When we call getSearchOptions with all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_GROUP_CHAT_NO_PARTICIPANTS, draftComments: {}, @@ -3862,7 +4664,20 @@ describe('OptionsListUtils', () => { }); // When we pass the returned options to filterAndOrderOptions with any search value - const filteredOptions = filterAndOrderOptions(options, 'Unknown', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'Unknown', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then the report should still be found by its reportName even if participantsList is empty expect(filteredOptions.recentReports.length).toBe(1); @@ -3879,11 +4694,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports - const filteredOptions = filterAndOrderOptions(options, 'marc@expensify', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'marc@expensify', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports or personal details should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3902,11 +4731,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports - const filteredOptions = filterAndOrderOptions(options, 'marc@expensify.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'marc@expensify.com', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports or personal details should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3925,11 +4768,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite - const filteredOptions = filterAndOrderOptions(options, 'peter.parker@expensify.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'peter.parker@expensify.com', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3947,11 +4804,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value without accent mark - const filteredOptions = filterAndOrderOptions(options, 'Timothee', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'Timothee', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then one personalDetails with accent mark should be returned expect(filteredOptions.personalDetails.length).toBe(1); @@ -3967,11 +4838,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite - const filteredOptions = filterAndOrderOptions(options, '5005550006', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + '5005550006', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports or personal details should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -3992,11 +4877,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite - const filteredOptions = filterAndOrderOptions(options, '+15005550006', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + '+15005550006', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports or personal details should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -4017,11 +4916,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite - const filteredOptions = filterAndOrderOptions(options, '+1 (800)324-3233', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + '+1 (800)324-3233', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports or personal details should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -4042,11 +4955,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports - const filteredOptions = filterAndOrderOptions(options, '998243aaaa', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + '998243aaaa', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no recent reports or personal details should be returned expect(filteredOptions.recentReports.length).toBe(0); @@ -4065,15 +4992,32 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeUserToInvite: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeUserToInvite: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName - const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - shouldAcceptName: false, - }); + const filteredOptions = filterAndOrderOptions( + options, + 'Jeff Amazon', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + shouldAcceptName: false, + }, + undefined, + ); // Then userToInvite should be null since plain names are not accepted by default expect(filteredOptions?.userToInvite).toBe(null); @@ -4089,15 +5033,32 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeUserToInvite: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeUserToInvite: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName - const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - shouldAcceptName: true, - }); + const filteredOptions = filterAndOrderOptions( + options, + 'Jeff', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + shouldAcceptName: true, + }, + undefined, + ); // Then userToInvite should be returned for the plain name expect(filteredOptions?.userToInvite?.text).toBe('Jeff'); @@ -4113,11 +5074,25 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details - const filteredOptions = filterAndOrderOptions(options, 'magneto', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'magneto', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then no personal details should be returned expect(filteredOptions.personalDetails.length).toBe(0); @@ -4133,14 +5108,26 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that matches an email - const filteredOptions = filterAndOrderOptions(options, 'peterparker@expensify.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - sortByReportTypeInSearch: true, - }); + const filteredOptions = filterAndOrderOptions( + options, + 'peterparker@expensify.com', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + sortByReportTypeInSearch: true, + }, + undefined, + ); // Then one recent report should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -4160,14 +5147,26 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with a search value that matches both reports and personal details and maxRecentReportsToShow param - const filteredOptions = filterAndOrderOptions(options, '.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { - dateFnsLocale: undefined, - maxRecentReportsToShow: 5, - }); + const filteredOptions = filterAndOrderOptions( + options, + '.com', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReportsToShow: 5, + }, + undefined, + ); // Then there should be 4 matching personal details expect(filteredOptions.personalDetails.length).toBe(5); @@ -4183,7 +5182,9 @@ describe('OptionsListUtils', () => { it('should return matching option when searching (getSearchOptions)', () => { // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -4197,7 +5198,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value that matches a personal detail - const filteredOptions = filterAndOrderOptions(options, 'spider', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'spider', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then one personal detail should be returned expect(filteredOptions.recentReports.length).toBe(1); @@ -4208,7 +5222,9 @@ describe('OptionsListUtils', () => { it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -4222,7 +5238,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value that matches multiple items - const filteredOptions = filterAndOrderOptions(options, 'fantastic', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'fantastic', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then only three reports should be returned expect(filteredOptions.recentReports.length).toBe(3); @@ -4235,15 +5264,26 @@ describe('OptionsListUtils', () => { .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) .then(() => { // Given a set of options with periods - const OPTIONS_WITH_PERIODS = createFilteredOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_PERIODS = createFilteredOptionList( + PERSONAL_DETAILS_WITH_PERIODS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // When we call getSearchOptions const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_PERIODS, draftComments: {}, @@ -4264,7 +5304,12 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS_WITH_PERIODS, - {dateFnsLocale: undefined, sortByReportTypeInSearch: true}, + { + dateFnsLocale: undefined, + convertToDisplayString, + sortByReportTypeInSearch: true, + }, + undefined, ); // Then only one report should be returned @@ -4282,7 +5327,9 @@ describe('OptionsListUtils', () => { // Given a set of options const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, @@ -4297,7 +5344,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a an empty search value - const filteredOptions = filterAndOrderOptions(options, '', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + '', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); const matchingEntries = filteredOptions.personalDetails.filter((detail) => detail.login === login); // Then there should be 2 unique login entries @@ -4308,16 +5368,27 @@ describe('OptionsListUtils', () => { it('should order self dm always on top if the search matches with the self dm login', () => { const searchTerm = 'tonystark@expensify.com'; - const OPTIONS_WITH_SELF_DM = createFilteredOptionList(PERSONAL_DETAILS, REPORTS_WITH_SELF_DM, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const OPTIONS_WITH_SELF_DM = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_SELF_DM, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // Given a set of options with self dm and all betas const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS_WITH_SELF_DM, draftComments: {}, @@ -4331,7 +5402,20 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); // When we call filterAndOrderOptions with a search value - const filteredOptions = filterAndOrderOptions(options, searchTerm, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + searchTerm, + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then the self dm should be on top. expect(filteredOptions.recentReports.at(0)?.isSelfDM).toBe(true); @@ -4339,8 +5423,10 @@ describe('OptionsListUtils', () => { it('should return the same matches for normalized multi-word queries with extra spaces', () => { const {options} = getSearchOptions({ + rules: undefined, translate: translateLocal, dateFnsLocale: undefined, + convertToDisplayString, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, draftComments: {}, @@ -4354,8 +5440,34 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, }); - const multiSpaceQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); - const spaceSeparatedQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const multiSpaceQueryResults = filterAndOrderOptions( + options, + 'Invisible Woman', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); + const spaceSeparatedQueryResults = filterAndOrderOptions( + options, + 'Invisible Woman', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(multiSpaceQueryResults.recentReports.map((option) => option.reportID)).toEqual(spaceSeparatedQueryResults.recentReports.map((option) => option.reportID)); expect(multiSpaceQueryResults.personalDetails.map((option) => option.accountID)).toEqual(spaceSeparatedQueryResults.personalDetails.map((option) => option.accountID)); @@ -4407,12 +5519,21 @@ describe('OptionsListUtils', () => { // Given a set of reports and personal details await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS); // When we call createFilteredOptionList and extract the reports - const reports = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }).reports; + const reports = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ).reports; // Then the returned reports should match the expected values expect(reports.at(10)?.subtitle).toBe(`Submits to Mister Fantastic`); @@ -4422,12 +5543,21 @@ describe('OptionsListUtils', () => { await waitForBatchedUpdates(); // When we call createFilteredOptionList again - const newReports = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }).reports; + const newReports = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ).reports; // Then the returned reports should change to Spanish // cspell:disable-next-line expect(newReports.at(10)?.subtitle).toBe('Se envía a Mister Fantastic'); @@ -4510,12 +5640,21 @@ describe('OptionsListUtils', () => { const archivedMap: PrivateIsArchivedMap = { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}10`]: !!reportNameValuePairs.private_isArchived, }; - const reports = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, archivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }).reports; + const reports = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + archivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ).reports; const archivedReport = reports.find((report) => report.reportID === '10'); // Then the returned report should contain default archived reason @@ -4528,8 +5667,12 @@ describe('OptionsListUtils', () => { const DM_REPORT_ID = '9200'; const participants = { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 2: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + 3: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, }; const buildRoomReport = (overrides: Partial = {}): Report => ({ @@ -4560,11 +5703,30 @@ describe('OptionsListUtils', () => { ...overrides, }); - const buildAction = (actionName: Parameters[0], actorAccountID = 3, originalMessage?: Record): ReportAction => + const buildAction = ( + actionName: Parameters[0], + actorAccountID = 3, + originalMessage?: Record, + messageText?: string, + ): ReportAction => ({ ...getFakeAdvancedReportAction(actionName), actorAccountID, ...(originalMessage === undefined ? {} : {originalMessage}), + ...(messageText === undefined + ? {} + : { + message: [ + { + type: 'COMMENT', + html: messageText, + text: messageText, + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + }, + ], + }), }) as ReportAction; const setReport = async (report: Report) => { @@ -4574,216 +5736,371 @@ describe('OptionsListUtils', () => { type AlternateTextConfig = Parameters[2]; - const buildConfig = (lastAction?: ReportAction, reportID: string = ROOM_REPORT_ID, overrides: Partial = {}): AlternateTextConfig => ({ + const buildConfig = (overrides: Partial = {}): AlternateTextConfig => ({ isReportArchived: false, personalDetails: PERSONAL_DETAILS, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - ...(lastAction ? {sortedActions: {[reportID]: [lastAction]}} : {}), + rules: undefined, ...overrides, }); + const seedActions = async (reportID: string, ...actions: ReportAction[]) => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, Object.fromEntries(actions.map((action) => [action.reportActionID, action]))); + await waitForBatchedUpdates(); + }; + it('should keep the raw comment text when the last action is ADD_COMMENT', async () => { // Given a DM whose last action is a plain comment containing markup typed by the user const report = buildDMReport({lastMessageText: 'test'}); await setReport(report); - const option: OptionData = {reportID: DM_REPORT_ID, keyForList: '', lastMessageText: 'test'}; + const option: OptionData = { + reportID: DM_REPORT_ID, + keyForList: '', + lastMessageText: 'test', + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, DM_REPORT_ID)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); // Then the markup is preserved as typed (https://github.com/Expensify/App/issues/82036) expect(result).toBe('test'); }); - it('should strip HTML from the last message when the last action is not ADD_COMMENT', async () => { - // Given a DM whose last action is not a comment, so the last message is server-built HTML - const report = buildDMReport({lastMessageText: 'test', lastActionType: CONST.REPORT.ACTIONS.TYPE.RENAMED}); + it('should fall back to the raw report lastMessageText when no last action is available', async () => { + const report = buildDMReport({ + lastMessageText: 'test', + lastActionType: CONST.REPORT.ACTIONS.TYPE.RENAMED, + }); await setReport(report); - const option: OptionData = {reportID: DM_REPORT_ID, keyForList: '', lastMessageText: 'test'}; + const option: OptionData = { + reportID: DM_REPORT_ID, + keyForList: '', + lastMessageText: 'test', + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, DM_REPORT_ID)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); - expect(result).toBe('test'); + expect(result).toBe('test'); }); it('should prefix the room preview with the last actor display name', async () => { - await setReport(buildRoomReport()); - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + const reportID = '9351'; + await setReport(buildRoomReport({reportID})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3, undefined, 'hello')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: hello'); }); it('should use "You" as the prefix when the current user sent the last message', async () => { - await setReport(buildRoomReport({lastActorAccountID: CURRENT_USER_ACCOUNT_ID})); - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, CURRENT_USER_ACCOUNT_ID); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + const reportID = '9352'; + await setReport( + buildRoomReport({ + reportID, + lastActorAccountID: CURRENT_USER_ACCOUNT_ID, + }), + ); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, CURRENT_USER_ACCOUNT_ID, undefined, 'hello')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('You: hello'); }); - it('should omit the actor prefix when the report is archived', async () => { - await setReport(buildRoomReport()); - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + it('should show the archive reason without an actor prefix when the report is archived', async () => { + const reportID = '9353'; + await setReport(buildRoomReport({reportID})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3, undefined, 'hello')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: '', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment, ROOM_REPORT_ID, {isReportArchived: true})); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig({isReportArchived: true})); - expect(result).toBe('hello'); + expect(result).toBe('This chat room has been archived.'); }); - it('should omit the actor prefix when currentUserAccountID is undefined', async () => { - await setReport(buildRoomReport()); - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + it('should keep the actor prefix when currentUserAccountID is undefined', async () => { + const reportID = '9354'; + await setReport(buildRoomReport({reportID})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3, undefined, 'hello')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment, ROOM_REPORT_ID, {currentUserAccountID: undefined})); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig({currentUserAccountID: undefined})); - expect(result).toBe('hello'); + expect(result).toBe('Spider-Man: hello'); }); it('should omit the actor prefix when the last action is a report preview', async () => { - await setReport(buildRoomReport({lastActionType: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, lastMessageText: 'owes $10'})); - const preview = buildAction(CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'owes $10', isChatRoom: true}; + const reportID = '9355'; + await setReport( + buildRoomReport({ + reportID, + lastActionType: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + lastMessageText: 'owes $10', + }), + ); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, 3, undefined, 'owes $10')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'owes $10', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(preview)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('owes $10'); }); it('should fall back to the report action person text when the actor is missing from personal details', async () => { - await setReport(buildRoomReport({lastActorAccountID: 999})); + const reportID = '9356'; + await setReport(buildRoomReport({reportID, lastActorAccountID: 999})); // The fake action carries person: [{text: 'Email One'}] and account 999 is not in PERSONAL_DETAILS - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 999); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 999, undefined, 'hello')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Email One: hello'); }); it('should replace the preview with the rename message for a RENAMED last action', async () => { - await setReport(buildRoomReport({lastActionType: CONST.REPORT.ACTIONS.TYPE.RENAMED, lastMessageText: 'renamed this room'})); - const renamed = buildAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, 3, {oldName: 'Old Room', newName: 'New Room'}); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'renamed this room', isChatRoom: true}; + const reportID = '9357'; + await setReport( + buildRoomReport({ + reportID, + lastActionType: CONST.REPORT.ACTIONS.TYPE.RENAMED, + lastMessageText: 'renamed this room', + }), + ); + await seedActions( + reportID, + buildAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, 3, { + oldName: 'Old Room', + newName: 'New Room', + }), + ); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'renamed this room', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(renamed)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man renamed this room to "New Room" (previously "Old Room")'); }); it('should replace the preview with the leave message for a room LEAVE_ROOM last action', async () => { - await setReport(buildRoomReport({lastMessageText: 'left the chat'})); - const leave = buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'left the chat', isChatRoom: true}; + const reportID = '9358'; + await setReport(buildRoomReport({reportID, lastMessageText: 'left the chat'})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM, 3)); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'left the chat', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(leave)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: left the chat'); }); it('should prefix the action message with the actor for a policy LEAVE_ROOM last action', async () => { - await setReport(buildRoomReport({lastMessageText: 'left the workspace'})); + const reportID = '9359'; + await setReport(buildRoomReport({reportID, lastMessageText: 'left the workspace'})); // The fake action's message text is 'hey' - const leave = buildAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'left the workspace', isChatRoom: true}; + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM, 3)); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'left the workspace', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(leave)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: hey'); }); it('should build the invite message with member count and room name', async () => { - await setReport(buildRoomReport({lastMessageText: 'invited'})); - const invite = buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, 3, {targetAccountIDs: [4, 5], roomName: '#galaxy'}); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'invited', isChatRoom: true}; + const reportID = '9360'; + await setReport(buildRoomReport({reportID, lastMessageText: 'invited'})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, 3, {targetAccountIDs: [4, 5], roomName: '#galaxy'})); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'invited', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(invite)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: invited 2 members to #galaxy'); }); it('should build the remove message with a singular member and room name', async () => { - await setReport(buildRoomReport({lastMessageText: 'removed'})); - const remove = buildAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, 3, {targetAccountIDs: [4], roomName: '#galaxy'}); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'removed', isChatRoom: true}; + const reportID = '9361'; + await setReport(buildRoomReport({reportID, lastMessageText: 'removed'})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, 3, {targetAccountIDs: [4], roomName: '#galaxy'})); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'removed', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(remove)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: removed 1 member from #galaxy'); }); it('should count invited members from lastMessageHtml mentions when targetAccountIDs is empty', async () => { + const reportID = '9362'; await setReport( buildRoomReport({ + reportID, lastMessageText: 'invited', lastMessageHtml: ' ', }), ); - const invite = buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, 3, {targetAccountIDs: []}); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'invited', isChatRoom: true}; + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, 3, {targetAccountIDs: []})); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'invited', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(invite)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: invited 2 members'); }); - it.each([CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, CONST.REPORT.ACTIONS.TYPE.RETRACTED])( - 'should suppress the actor prefix for %s because its text already embeds the actor', - async (actionName) => { - await setReport(buildRoomReport({lastActionType: actionName, lastMessageText: 'issued a new card'})); - const action = buildAction(actionName, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'issued a new card', isChatRoom: true}; + it.each([ + [CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, 'issued @Hidden an Expensify Card! The card will arrive in 2-3 business days.'], + [CONST.REPORT.ACTIONS.TYPE.RETRACTED, 'retracted'], + ])('should suppress the actor prefix for %s because its text already embeds the actor', async (actionName, expectedText) => { + const reportID = actionName === CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED ? '9363' : '9364'; + await setReport( + buildRoomReport({ + reportID, + lastActionType: actionName, + lastMessageText: 'issued a new card', + }), + ); + await seedActions(reportID, buildAction(actionName, 3)); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'issued a new card', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(action)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); - expect(result).toBe('issued a new card'); - }, - ); + expect(result).toBe(expectedText); + }); - it('should skip whisper actions when picking the last visible action from sortedActions', async () => { - await setReport(buildRoomReport()); + it('should skip whisper actions when picking the last visible action', async () => { + const reportID = '9365'; + await setReport(buildRoomReport({reportID})); // getWhisperedTo prefers message.whisperedTo over originalMessage, so mark the whisper there const whisper = { ...buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 4), - message: [{type: 'COMMENT', html: 'psst', text: 'psst', isEdited: false, whisperedTo: [999], isDeletedParentAction: false}], + reportActionID: '2', + created: '2024-01-02 10:00:00.000', + message: [ + { + type: 'COMMENT', + html: 'psst', + text: 'psst', + isEdited: false, + whisperedTo: [999], + isDeletedParentAction: false, + }, + ], } as ReportAction; - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + const comment = { + ...buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3, undefined, 'hello'), + reportActionID: '1', + created: '2024-01-01 10:00:00.000', + } as ReportAction; + await seedActions(reportID, whisper, comment); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, ROOM_REPORT_ID, {sortedActions: {[ROOM_REPORT_ID]: [whisper, comment]}})); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(result).toBe('Spider-Man: hello'); }); - it('should resolve the same last action from Onyx when sortedActions is not provided', async () => { - // Dedicated reportID: module-level report-action caches survive Onyx.clear(), so writing - // REPORT_ACTIONS for the shared room would poison later tests that reuse its reportID. - const onyxRoomReportID = '9150'; - await setReport(buildRoomReport({reportID: onyxRoomReportID})); - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${onyxRoomReportID}`, {[comment.reportActionID]: comment}); - await waitForBatchedUpdates(); - const option: OptionData = {reportID: onyxRoomReportID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; + it('should resolve the last action from the live report-action collection', async () => { + const reportID = '9150'; + await setReport(buildRoomReport({reportID})); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3, undefined, 'hello')); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; - const withSortedActions = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment, onyxRoomReportID)); - const fromOnyx = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, onyxRoomReportID)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); - expect(fromOnyx).toBe('Spider-Man: hello'); - expect(fromOnyx).toBe(withSortedActions); + expect(result).toBe('Spider-Man: hello'); }); it('should fall back to type subtitles when showChatPreviewLine is false', async () => { await setReport(buildRoomReport()); - const roomOption: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true, subtitle: 'Custom subtitle'}; - const threadOption: OptionData = {reportID: '', keyForList: '', isThread: true}; + const roomOption: OptionData = { + reportID: ROOM_REPORT_ID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + subtitle: 'Custom subtitle', + }; + const threadOption: OptionData = { + reportID: '', + keyForList: '', + isThread: true, + }; expect(getAlternateText(roomOption, {showChatPreviewLine: false}, buildConfig())).toBe('Custom subtitle'); expect(getAlternateText(threadOption, {showChatPreviewLine: false}, buildConfig())).toBe(translateLocal('threads.thread')); @@ -4791,16 +6108,26 @@ describe('OptionsListUtils', () => { it('should thread currentUserAccountID through getValidOptions to build the actor prefix', async () => { // Given a room whose last visible action is a comment from another user - const report = buildRoomReport(); + const reportID = '9366'; + const report = buildRoomReport({reportID}); await setReport(report); - const comment = buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3); + await seedActions(reportID, buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3, undefined, 'hello')); - const optionList = createFilteredOptionList(PERSONAL_DETAILS, {[ROOM_REPORT_ID]: report}, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const optionList = createFilteredOptionList( + PERSONAL_DETAILS, + {[reportID]: report}, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); const {options} = getValidOptions( {reports: optionList.reports, personalDetails: []}, @@ -4812,29 +6139,42 @@ describe('OptionsListUtils', () => { undefined, { dateFnsLocale: undefined, + convertToDisplayString, showChatPreviewLine: true, includeMultipleParticipantReports: true, personalDetails: PERSONAL_DETAILS, - sortedActions: {[ROOM_REPORT_ID]: [comment]}, }, translateLocal, + undefined, ); // Then the search option preview matches the LHN format: `Name: message` - const roomOption = options.recentReports.find((option) => option.reportID === ROOM_REPORT_ID); + const roomOption = options.recentReports.find((option) => option.reportID === reportID); expect(roomOption?.alternateText).toBe('Spider-Man: hello'); }); it('should match the LHN alternate text from SidebarUtils.getOptionData for the same room and last action', async () => { - const report = buildRoomReport(); + const reportID = '9367'; + const report = buildRoomReport({reportID}); await setReport(report); // Align the action's own message with report.lastMessageText — the LHN reads the former, search options the latter const comment = { ...buildAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, 3), - message: [{type: 'COMMENT', html: 'hello', text: 'hello', isEdited: false, whisperedTo: [], isDeletedParentAction: false}], + message: [ + { + type: 'COMMENT', + html: 'hello', + text: 'hello', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + }, + ], } as ReportAction; + await seedActions(reportID, comment); const lhnOption = SidebarUtils.getOptionData({ + rules: undefined, report, reportAttributes: undefined, oneTransactionThreadReport: undefined, @@ -4849,6 +6189,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, isReportArchived: false, lastActionReport: undefined, @@ -4857,8 +6198,13 @@ describe('OptionsListUtils', () => { formatPhoneNumber, }); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'hello', isChatRoom: true}; - const searchAlternateText = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(comment)); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'hello', + isChatRoom: true, + }; + const searchAlternateText = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(lhnOption?.alternateText).toBe('Spider-Man: hello'); expect(searchAlternateText).toBe(lhnOption?.alternateText); @@ -4869,14 +6215,29 @@ describe('OptionsListUtils', () => { async (actionName) => { // Given a room whose last action is a policy change log action that has no custom // alternate text branch in SidebarUtils.getOptionData, so the LHN shows `Name: message` - const report = buildRoomReport({lastMessageText: 'updated a custom unit'}); + const reportID = actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT ? '9368' : '9369'; + const report = buildRoomReport({ + reportID, + lastMessageText: 'updated a custom unit', + }); await setReport(report); const action = { ...buildAction(actionName, 3), - message: [{type: 'COMMENT', html: 'updated a custom unit', text: 'updated a custom unit', isEdited: false, whisperedTo: [], isDeletedParentAction: false}], + message: [ + { + type: 'COMMENT', + html: 'updated a custom unit', + text: 'updated a custom unit', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + }, + ], } as ReportAction; + await seedActions(reportID, action); const lhnOption = SidebarUtils.getOptionData({ + rules: undefined, report, reportAttributes: undefined, oneTransactionThreadReport: undefined, @@ -4891,6 +6252,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, isReportArchived: false, lastActionReport: undefined, @@ -4899,8 +6261,13 @@ describe('OptionsListUtils', () => { formatPhoneNumber, }); - const option: OptionData = {reportID: ROOM_REPORT_ID, keyForList: '', lastMessageText: 'updated a custom unit', isChatRoom: true}; - const searchAlternateText = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(action)); + const option: OptionData = { + reportID, + keyForList: '', + lastMessageText: 'updated a custom unit', + isChatRoom: true, + }; + const searchAlternateText = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); expect(lhnOption?.alternateText).toBe('Spider-Man: updated a custom unit'); expect(searchAlternateText).toBe(lhnOption?.alternateText); @@ -4952,22 +6319,37 @@ describe('OptionsListUtils', () => { reportActionID: '9402', reportID: TRANSACTION_THREAD_REPORT_ID, created: '2024-01-02 10:00:00.000', - message: [{type: 'COMMENT', html: 'thread comment', text: 'thread comment', isEdited: false, whisperedTo: [], isDeletedParentAction: false}], + message: [ + { + type: 'COMMENT', + html: 'thread comment', + text: 'thread comment', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + }, + ], }; // Reports must exist before the report actions merge so the one-transaction thread caches resolve the thread ID await setReport(expenseReport); await setReport(transactionThreadReport); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`]: {[iouAction.reportActionID]: iouAction}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`]: { + [iouAction.reportActionID]: iouAction, + }, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${TRANSACTION_THREAD_REPORT_ID}`]: {[threadComment.reportActionID]: threadComment}, }); await waitForBatchedUpdates(); - const option: OptionData = {reportID: EXPENSE_REPORT_ID, keyForList: '', lastMessageText: 'thread comment', isMoneyRequestReport: true}; + const option: OptionData = { + reportID: EXPENSE_REPORT_ID, + keyForList: '', + lastMessageText: 'thread comment', + isMoneyRequestReport: true, + }; - // When the alternate text is built without sortedActions, forcing the fallback last-action lookup - const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig(undefined, EXPENSE_REPORT_ID)); + const result = getAlternateText(option, {showChatPreviewLine: true}, buildConfig()); // Then the actor prefix comes from the transaction thread comment, not from the parent report's IOU action expect(result).toBe('Spider-Man: thread comment'); @@ -4984,12 +6366,21 @@ describe('OptionsListUtils', () => { }; // When we call createFilteredOptionList with this privateIsArchivedMap - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // Then the hydrated personal detail option for account 1 (Mister Fantastic) should have private_isArchived set const misterFantasticOption = result.personalDetails.find((pd) => pd.item?.accountID === 1); @@ -5003,12 +6394,21 @@ describe('OptionsListUtils', () => { const emptyMap: PrivateIsArchivedMap = {}; // When we call createFilteredOptionList with an empty privateIsArchivedMap - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, emptyMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + emptyMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // Then no personal details options should have private_isArchived set once hydrated const optionsWithArchived = result.personalDetails.map(hydrateContactOption).filter((pd) => pd.private_isArchived); @@ -5024,12 +6424,21 @@ describe('OptionsListUtils', () => { }; // When we call createFilteredOptionList with this privateIsArchivedMap - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); // Then the hydrated personal detail options should have the correct private_isArchived values const misterFantasticOption = result.personalDetails.find((pd) => pd.item?.accountID === 1); @@ -5050,11 +6459,20 @@ describe('OptionsListUtils', () => { }; // When we call createFilteredOptionList with this privateIsArchivedMap - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); // Then the report options should have the correct private_isArchived values const report3Option = result.reports.find((r) => r.item?.reportID === '3'); @@ -5073,11 +6491,20 @@ describe('OptionsListUtils', () => { const emptyMap: PrivateIsArchivedMap = {}; // When we call createFilteredOptionList with an empty privateIsArchivedMap - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, emptyMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + emptyMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); // Then reports NOT in Onyx (like report 3, 5) should not have private_isArchived set // Note: Report 10 gets private_isArchived from Onyx (set in beforeAll) @@ -5098,11 +6525,20 @@ describe('OptionsListUtils', () => { }; // When we call createFilteredOptionList with this privateIsArchivedMap - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); // Then the report options should have the correct private_isArchived values const report1Option = result.reports.find((r) => r.item?.reportID === '1'); @@ -5129,12 +6565,21 @@ describe('OptionsListUtils', () => { }; // When we call createFilteredOptionList with a maxRecentReports limit that includes all reports - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 20, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 20, + }, + undefined, + ); // Then the report 7 (most recent) should still have private_isArchived set const report7Option = result.reports.find((r) => r.item?.reportID === '7'); @@ -5321,7 +6766,11 @@ describe('OptionsListUtils', () => { }, ])('should match report text containing $description', ({reportText, searchText}) => { // Given a report whose display text contains characters normalized by report search - const report: OptionData = {text: reportText, reportID: 'normalized', keyForList: 'normalized'}; + const report: OptionData = { + text: reportText, + reportID: 'normalized', + keyForList: 'normalized', + }; // When the report is filtered with the normalized search value const filteredReports = filterReports([report], [searchText]); @@ -5900,11 +7349,13 @@ describe('OptionsListUtils', () => { const result = createOption({ dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, report, privateIsArchived: undefined, + rules: undefined, config: {showChatPreviewLine: true}, }); @@ -5940,11 +7391,13 @@ describe('OptionsListUtils', () => { const result = createOption({ dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, report, privateIsArchived: undefined, + rules: undefined, }); expect(result.reportID).toBe(reportID); @@ -5970,11 +7423,13 @@ describe('OptionsListUtils', () => { // Should not throw when reports is undefined const result = createOption({ dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, report, privateIsArchived: undefined, + rules: undefined, }); expect(result.reportID).toBe(report.reportID); @@ -6127,6 +7582,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails, @@ -6135,6 +7591,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should return an option with isSelfDM and alternateText set @@ -6157,6 +7614,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails, @@ -6165,6 +7623,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should return an option with invoice room text and alternateText @@ -6189,6 +7648,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails, personalDetails, @@ -6197,6 +7657,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should return an option with unknownUserDetails data @@ -6220,6 +7681,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails, @@ -6228,6 +7690,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should return an option with workspace name @@ -6259,6 +7722,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption with custom personalDetails const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: customPersonalDetails, @@ -6267,6 +7731,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should use the custom personalDetails parameter @@ -6286,6 +7751,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: emptyPersonalDetails, @@ -6294,6 +7760,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should not throw and return a valid option @@ -6308,6 +7775,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption with undefined report const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report: undefined, unknownUserDetails: undefined, personalDetails, @@ -6316,6 +7784,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then it should return a valid option (createOption handles undefined) @@ -6350,8 +7819,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); expect(results).toBeDefined(); @@ -6369,8 +7839,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); expect(results).toBeDefined(); @@ -6380,7 +7851,18 @@ describe('OptionsListUtils', () => { it('should work with empty policies collection', () => { const options = {reports: [], personalDetails: []}; - const {options: results} = getValidOptions(options, {}, undefined, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, {dateFnsLocale: undefined}, translateLocal); + const {options: results} = getValidOptions( + options, + {}, + undefined, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + {dateFnsLocale: undefined, convertToDisplayString}, + translateLocal, + undefined, + ); expect(results).toBeDefined(); expect(results.recentReports).toBeDefined(); @@ -6413,8 +7895,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, betas: [], includeRecentReports: true, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + betas: [], + includeRecentReports: true, + sortedActions: undefined, + }, translateLocal, + undefined, ); expect(results.recentReports).toBeDefined(); @@ -6453,10 +7942,22 @@ describe('OptionsListUtils', () => { isPolicyExpenseChat: true, }; - const option = getReportOption(participant, undefined, policy, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + policy, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.text).toBe('Test Workspace'); expect(option.alternateText).toBe(translateLocal('workspace.common.workspace')); @@ -6477,20 +7978,43 @@ describe('OptionsListUtils', () => { // A self DM's own participant entry is excluded from the display list (the backend sends an empty // notificationPreference, which isHiddenForCurrentUser treats the same as HIDDEN), so createOption has // no personal details to build a name from and has to fall back to the report name. - participants: {[ownerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}}, + participants: { + [ownerAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, }; const selfPersonalDetails = { - [ownerAccountID]: {accountID: ownerAccountID, displayName: 'Test', login: 'test@test.com'}, + [ownerAccountID]: { + accountID: ownerAccountID, + displayName: 'Test', + login: 'test@test.com', + }, }; - await Onyx.merge(ONYXKEYS.SESSION, {accountID: ownerAccountID, email: 'test@test.com'}); + await Onyx.merge(ONYXKEYS.SESSION, { + accountID: ownerAccountID, + email: 'test@test.com', + }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await waitForBatchedUpdates(); - const option = getReportOption({reportID}, undefined, undefined, selfPersonalDetails, undefined, undefined, undefined, ownerAccountID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + {reportID}, + undefined, + undefined, + selfPersonalDetails, + undefined, + undefined, + undefined, + ownerAccountID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.text).toBe(`Test (${translateLocal('common.you').toLowerCase()})`); expect(option.alternateText).toBe(translateLocal('reportActionsView.yourSpace')); @@ -6544,10 +8068,22 @@ describe('OptionsListUtils', () => { }; // Pass the real personalDetails so the submits-to subtitle resolves to a name - const option = getReportOption(participant, undefined, policy, personalDetails, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + policy, + personalDetails, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.text).toBe('Test Workspace with Submit'); // For a BASIC-approval policy the report submits to the default approver (the owner), @@ -6570,7 +8106,22 @@ describe('OptionsListUtils', () => { reportID, }; - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, report, CONST.DEFAULT_NUMBER_ID, {translate: translateLocal, dateFnsLocale: undefined}); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + report, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isDisabled).toBe(true); }); @@ -6611,10 +8162,22 @@ describe('OptionsListUtils', () => { isSelfDM: true, }; - const option = getReportOption(participant, undefined, POLICY, personalDetails, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + personalDetails, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // The option.isSelfDM is set by createOption based on the report type // Just verify the alternateText is correct for self DM @@ -6649,10 +8212,22 @@ describe('OptionsListUtils', () => { isInvoiceRoom: true, }; - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isInvoiceRoom).toBe(true); expect(option.alternateText).toBe(translateLocal('workspace.common.invoices')); @@ -6694,10 +8269,22 @@ describe('OptionsListUtils', () => { }); await waitForBatchedUpdates(); - const option = getReportOption(participant, !!reportNameValuePair?.private_isArchived, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + !!reportNameValuePair?.private_isArchived, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.text).toBe(POLICY.name); expect(option.alternateText).toBeTruthy(); @@ -6740,10 +8327,22 @@ describe('OptionsListUtils', () => { }); await waitForBatchedUpdates(); - const option = getReportOption(participant, !!reportNameValuePair?.private_isArchived, POLICY, {}, undefined, {}, draftReport, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + !!reportNameValuePair?.private_isArchived, + POLICY, + {}, + undefined, + {}, + draftReport, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isDisabled).toBe(true); }); @@ -6762,10 +8361,22 @@ describe('OptionsListUtils', () => { const participant: Participant = {reportID, selected: false}; // Pass reportDraft = undefined → not a draft, should NOT be disabled - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isDisabled).toBeFalsy(); }); @@ -6781,10 +8392,22 @@ describe('OptionsListUtils', () => { const participant: Participant = {reportID, selected: false}; // Pass reportDraft explicitly → should be disabled regardless of Onyx state - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, draftReport, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + draftReport, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isDisabled).toBe(true); }); @@ -6804,10 +8427,22 @@ describe('OptionsListUtils', () => { const participant: Participant = {reportID, selected: false}; // Callers are responsible for passing reportDraft explicitly — undefined means not disabled - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isDisabled).toBeFalsy(); }); @@ -6831,6 +8466,7 @@ describe('OptionsListUtils', () => { const option = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -6839,6 +8475,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); expect(option).toBeDefined(); @@ -6864,6 +8501,7 @@ describe('OptionsListUtils', () => { const option = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -6872,6 +8510,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); expect(option).toBeDefined(); @@ -6894,6 +8533,7 @@ describe('OptionsListUtils', () => { const option = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -6902,6 +8542,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); expect(option).toBeDefined(); @@ -6928,6 +8569,7 @@ describe('OptionsListUtils', () => { const option = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -6936,6 +8578,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); expect(option).toBeDefined(); @@ -6962,6 +8605,7 @@ describe('OptionsListUtils', () => { const option = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -6970,6 +8614,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); expect(option).toBeDefined(); @@ -6993,10 +8638,22 @@ describe('OptionsListUtils', () => { selected: true, }; - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option.isSelected).toBe(true); expect(option.selected).toBe(true); @@ -7017,10 +8674,22 @@ describe('OptionsListUtils', () => { reportID, }; - const option = getReportOption(participant, undefined, undefined, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + undefined, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.text).toBeDefined(); @@ -7042,18 +8711,30 @@ describe('OptionsListUtils', () => { }; // Test that the function works with reportAttributesDerived parameter (optional) - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); - - expect(option).toBeDefined(); - }); - - it('should use personalDetails to populate participant display names', async () => { - const reportID = '109'; - const participantAccountID = 12345; - const report: Report = { + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); + + expect(option).toBeDefined(); + }); + + it('should use personalDetails to populate participant display names', async () => { + const reportID = '109'; + const participantAccountID = 12345; + const report: Report = { reportID, reportName: 'Test Chat', type: CONST.REPORT.TYPE.CHAT, @@ -7081,10 +8762,22 @@ describe('OptionsListUtils', () => { reportID, }; - const option = getReportOption(participant, undefined, POLICY, testPersonalDetails, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + testPersonalDetails, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); // The createOption function uses personalDetails to build display names @@ -7146,10 +8839,22 @@ describe('OptionsListUtils', () => { isPolicyExpenseChat: true, }; - const option = getReportOption(participant, undefined, policy, testPersonalDetails, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + policy, + testPersonalDetails, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.text).toBe('Test Workspace with Approver'); @@ -7174,10 +8879,22 @@ describe('OptionsListUtils', () => { }; // Pass empty personalDetails - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.text).toBeDefined(); @@ -7199,10 +8916,22 @@ describe('OptionsListUtils', () => { }; // Pass undefined personalDetails - const option = getReportOption(participant, undefined, POLICY, undefined, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + undefined, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.text).toBeDefined(); @@ -7252,10 +8981,22 @@ describe('OptionsListUtils', () => { isInvoiceRoom: true, }; - const option = getReportOption(participant, undefined, POLICY, testPersonalDetails, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + testPersonalDetails, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.isInvoiceRoom).toBe(true); @@ -7277,10 +9018,22 @@ describe('OptionsListUtils', () => { const participant = {reportID}; - const option = getReportOption(participant, undefined, POLICY, {}, conciergeReportID, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + conciergeReportID, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.reportID).toBe(reportID); @@ -7299,10 +9052,22 @@ describe('OptionsListUtils', () => { const participant = {reportID}; - const option = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.reportID).toBe(reportID); @@ -7339,10 +9104,22 @@ describe('OptionsListUtils', () => { // Passing conciergeReportID matching the reportID identifies this as the Concierge chat, // which affects getMovedTransactionMessage to use CONST.CONCIERGE_DISPLAY_NAME ('Concierge') - const option = getReportOption(participant, undefined, POLICY, testPersonalDetails, conciergeReportID, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const option = getReportOption( + participant, + undefined, + POLICY, + testPersonalDetails, + conciergeReportID, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); expect(option).toBeDefined(); expect(option.reportID).toBe(reportID); @@ -7362,14 +9139,38 @@ describe('OptionsListUtils', () => { const participant = {reportID}; - const optionWithConcierge = getReportOption(participant, undefined, POLICY, {}, differentConciergeReportID, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); - const optionWithoutConcierge = getReportOption(participant, undefined, POLICY, {}, undefined, undefined, undefined, CONST.DEFAULT_NUMBER_ID, { - translate: translateLocal, - dateFnsLocale: undefined, - }); + const optionWithConcierge = getReportOption( + participant, + undefined, + POLICY, + {}, + differentConciergeReportID, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); + const optionWithoutConcierge = getReportOption( + participant, + undefined, + POLICY, + {}, + undefined, + undefined, + undefined, + CONST.DEFAULT_NUMBER_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Both should produce the same result since the IDs don't match expect(optionWithConcierge.reportID).toBe(optionWithoutConcierge.reportID); @@ -7432,8 +9233,13 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); expect(option).toBeDefined(); @@ -7460,8 +9266,26 @@ describe('OptionsListUtils', () => { // A translate that tags the workspace subtitle so we can prove the option used the injected translate const translateWithMarker: LocalizedTranslate = (path, ...parameters) => (path === 'workspace.common.workspace' ? 'WorkspaceMarker' : translateLocal(path, ...parameters)); - const participant = {reportID, policyID: testPolicyID, isPolicyExpenseChat: true, selected: false}; - const option = getPolicyExpenseReportOption(participant, undefined, {}, report, undefined, {translate: translateWithMarker, dateFnsLocale: undefined}, CURRENT_USER_ACCOUNT_ID); + const participant = { + reportID, + policyID: testPolicyID, + isPolicyExpenseChat: true, + selected: false, + }; + const option = getPolicyExpenseReportOption( + participant, + undefined, + {}, + report, + undefined, + { + translate: translateWithMarker, + dateFnsLocale: undefined, + convertToDisplayString, + }, + CURRENT_USER_ACCOUNT_ID, + undefined, + ); // The subtitle resolves to the marker, proving the option builder used the injected translate (not translateLocal) expect(option.alternateText).toBe('WorkspaceMarker'); @@ -7529,8 +9353,13 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); expect(option).toBeDefined(); @@ -7576,7 +9405,20 @@ describe('OptionsListUtils', () => { }; // Should not throw when personalDetails is empty - const option = getPolicyExpenseReportOption(participant, undefined, {}, report, undefined, {translate: translateLocal, dateFnsLocale: undefined}, CURRENT_USER_ACCOUNT_ID); + const option = getPolicyExpenseReportOption( + participant, + undefined, + {}, + report, + undefined, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + CURRENT_USER_ACCOUNT_ID, + undefined, + ); expect(option).toBeDefined(); expect(option.text).toBe('Workspace Without Details'); @@ -7621,7 +9463,20 @@ describe('OptionsListUtils', () => { }; // Should not throw when personalDetails is undefined - const option = getPolicyExpenseReportOption(participant, undefined, undefined, report, undefined, {translate: translateLocal, dateFnsLocale: undefined}, CURRENT_USER_ACCOUNT_ID); + const option = getPolicyExpenseReportOption( + participant, + undefined, + undefined, + report, + undefined, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + CURRENT_USER_ACCOUNT_ID, + undefined, + ); expect(option).toBeDefined(); expect(option.text).toBe('Workspace Undefined Details'); @@ -7680,8 +9535,13 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); // eslint-disable-next-line rulesdir/no-negated-variables @@ -7691,8 +9551,13 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, - CURRENT_USER_ACCOUNT_ID, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + CURRENT_USER_ACCOUNT_ID, + undefined, ); expect(optionSelected.isSelected).toBe(true); @@ -7714,7 +9579,9 @@ describe('OptionsListUtils', () => { policyID: testPolicyID, ownerAccountID, participants: { - [ownerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [ownerAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, }, }; @@ -7729,7 +9596,12 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await waitForBatchedUpdates(); - const participant = {reportID, policyID: testPolicyID, isPolicyExpenseChat: true, selected: true}; + const participant = { + reportID, + policyID: testPolicyID, + isPolicyExpenseChat: true, + selected: true, + }; const optionWithCurrentUser = getPolicyExpenseReportOption( participant, @@ -7737,8 +9609,13 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); const optionWithDifferentUser = getPolicyExpenseReportOption( participant, @@ -7746,8 +9623,13 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, 9999, + undefined, ); expect(optionWithCurrentUser).toBeDefined(); @@ -7811,8 +9693,13 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); expect(option).toBeDefined(); @@ -7858,7 +9745,20 @@ describe('OptionsListUtils', () => { isPolicyExpenseChat: true, }; - const option = getPolicyExpenseReportOption(participant, undefined, {}, report, undefined, {translate: translateLocal, dateFnsLocale: undefined}, CURRENT_USER_ACCOUNT_ID); + const option = getPolicyExpenseReportOption( + participant, + undefined, + {}, + report, + undefined, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, + CURRENT_USER_ACCOUNT_ID, + undefined, + ); expect(option).toBeDefined(); expect(option?.private_isArchived).toBeUndefined(); @@ -7956,6 +9856,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, true, @@ -7991,6 +9893,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, true, @@ -8041,6 +9945,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, true, @@ -8084,6 +9990,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, false, @@ -8124,6 +10032,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, true, @@ -8164,6 +10074,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, true, @@ -8179,7 +10091,21 @@ describe('OptionsListUtils', () => { }); it('should return empty section when no selectedOptions are provided', () => { - const result = formatSectionsFromSearchTerm('', [], [], [], {}, CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, undefined, formatPersonalDetails, true); + const result = formatSectionsFromSearchTerm( + '', + [], + [], + [], + {}, + CURRENT_USER_ACCOUNT_ID, + undefined, + translateLocal, + convertToDisplayString, + undefined, + undefined, + formatPersonalDetails, + true, + ); expect(result.section.data).toHaveLength(0); }); @@ -8197,7 +10123,9 @@ describe('OptionsListUtils', () => { policyID: formatTestPolicyID, ownerAccountID: formatOwnerAccountID, participants: { - [formatOwnerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [formatOwnerAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, }, }; const getReportByID = (reportID: string | undefined) => (reportID === resolverReportID ? resolverReport : undefined); @@ -8227,6 +10155,8 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, undefined, translateLocal, + convertToDisplayString, + undefined, undefined, formatPersonalDetails, true, @@ -8246,10 +10176,12 @@ describe('OptionsListUtils', () => { it('should not return userToInvite for plain text name when shouldAcceptName is false', () => { const result = getUserToInviteOption({ dateFnsLocale: undefined, + convertToDisplayString, searchValue: 'Jeff Amazon', personalDetails: PERSONAL_DETAILS, loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, + rules: undefined, }); expect(result).toBeNull(); }); @@ -8257,11 +10189,13 @@ describe('OptionsListUtils', () => { it('should return userToInvite for plain text name when shouldAcceptName is true', () => { const result = getUserToInviteOption({ dateFnsLocale: undefined, + convertToDisplayString, searchValue: 'Jeff Amazon', personalDetails: PERSONAL_DETAILS, shouldAcceptName: true, loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, + rules: undefined, }); expect(result).not.toBeNull(); expect(result?.login).toBe('Jeff Amazon'); @@ -8279,8 +10213,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the function should complete without errors and return valid results @@ -8299,12 +10234,26 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // When we call filterAndOrderOptions with the reports parameter - const filteredOptions = filterAndOrderOptions(options, 'spider', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions( + options, + 'spider', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + { + dateFnsLocale: undefined, + convertToDisplayString, + }, + undefined, + ); // Then the function should complete without errors and return valid results expect(filteredOptions).toBeDefined(); @@ -8315,7 +10264,9 @@ describe('OptionsListUtils', () => { it('getSearchOptions should use reports parameter from config', () => { // When we call getSearchOptions with reports in the config const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, draftComments: {}, @@ -8337,7 +10288,9 @@ describe('OptionsListUtils', () => { it('getSearchOptions should forward sortedActions to getValidOptions', () => { const sortedActions = {}; const {options} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: OPTIONS, draftComments: {}, @@ -8359,10 +10312,12 @@ describe('OptionsListUtils', () => { // Given a valid email search value and reports collection const result = getUserToInviteOption({ dateFnsLocale: undefined, + convertToDisplayString, searchValue: 'newuser@example.com', loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, personalDetails: PERSONAL_DETAILS, + rules: undefined, }); // Then the function should return a user to invite @@ -8380,8 +10335,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the function should still work correctly @@ -8400,8 +10356,9 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, + undefined, ); // Then the function should still work correctly @@ -8444,11 +10401,13 @@ describe('OptionsListUtils', () => { // When we call createOption with the linked chat report const result = createOption({ dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, report: expenseReport, privateIsArchived: undefined, + rules: undefined, }); // Then the option should be created successfully @@ -8483,6 +10442,7 @@ describe('OptionsListUtils', () => { // When we call getReportDisplayOption with chat report const option = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -8491,6 +10451,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); // Then the option should be created successfully using the reports collection @@ -8537,8 +10498,13 @@ describe('OptionsListUtils', () => { PERSONAL_DETAILS, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); // Then the option should be created successfully @@ -8566,9 +10532,11 @@ describe('OptionsListUtils', () => { // When the threaded conciergeReportID matches the report const conciergeOption = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: undefined, sortedActions: undefined, conciergeReportID: report.reportID, @@ -8578,9 +10546,11 @@ describe('OptionsListUtils', () => { // And an identical report with a non-matching conciergeReportID is not treated as Concierge const regularOption = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: undefined, sortedActions: undefined, conciergeReportID: 'a-different-report-id', @@ -8607,9 +10577,11 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: undefined, sortedActions, conciergeReportID: undefined, @@ -8639,9 +10611,11 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: true, + rules: undefined, policy: undefined, sortedActions, conciergeReportID: undefined, @@ -8670,9 +10644,11 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: undefined, sortedActions, conciergeReportID: undefined, @@ -8701,9 +10677,11 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: undefined, sortedActions, conciergeReportID: undefined, @@ -8733,9 +10711,11 @@ describe('OptionsListUtils', () => { const config = {showPersonalDetails: true}; const result = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: undefined, sortedActions, conciergeReportID: undefined, @@ -8770,18 +10750,22 @@ describe('OptionsListUtils', () => { const roomOption = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: POLICY, sortedActions, conciergeReportID: undefined, }); const personalDetailsOption = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: POLICY, sortedActions, conciergeReportID: undefined, @@ -8816,13 +10800,22 @@ describe('OptionsListUtils', () => { it('returns the most recent reports when maxRecentReports is less than the input size', () => { const reports = [createChatReport('101', '2022-01-01 00:00:00'), createChatReport('102', '2024-01-01 00:00:00'), createChatReport('103', '2023-01-01 00:00:00')]; - const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 2, - includeP2P: false, - }); + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 2, + includeP2P: false, + }, + undefined, + ); expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102', '103']); }); @@ -8830,13 +10823,22 @@ describe('OptionsListUtils', () => { it('prioritizes self-DM over newer non-self-DM reports', () => { const reports = [createChatReport('101', '2024-01-01 00:00:00'), createChatReport('102', '2020-01-01 00:00:00', CONST.REPORT.CHAT_TYPE.SELF_DM)]; - const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 1, - includeP2P: false, - }); + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 1, + includeP2P: false, + }, + undefined, + ); expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102']); }); @@ -8847,13 +10849,22 @@ describe('OptionsListUtils', () => { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}102`]: true, }; - const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 1, - includeP2P: false, - }); + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 1, + includeP2P: false, + }, + undefined, + ); expect(result.reports.map((option) => option.item?.reportID)).toEqual(['101']); }); @@ -8861,14 +10872,23 @@ describe('OptionsListUtils', () => { it('returns all reports when isSearching is true', () => { const reports = [createChatReport('101', '2022-01-01 00:00:00'), createChatReport('102', '2024-01-01 00:00:00'), createChatReport('103', '2023-01-01 00:00:00')]; - const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 1, - isSearching: true, - includeP2P: false, - }); + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 1, + isSearching: true, + includeP2P: false, + }, + undefined, + ); expect(result.reports.map((option) => option.item?.reportID)).toEqual(['101', '102', '103']); }); @@ -8876,23 +10896,41 @@ describe('OptionsListUtils', () => { it('returns an empty array when maxRecentReports is zero', () => { const reports = [createChatReport('101', '2024-01-01 00:00:00')]; - const result = createFilteredOptionList({}, reportsToCollection(reports), undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 0, - includeP2P: false, - }); + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 0, + includeP2P: false, + }, + undefined, + ); expect(result.reports).toEqual([]); }); it('should return report options limited by maxRecentReports', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 5, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 5, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.reports.length).toBeLessThanOrEqual(5); @@ -8944,24 +10982,42 @@ describe('OptionsListUtils', () => { }, ] satisfies Report[]; - const result = createFilteredOptionList({}, Object.fromEntries(reportsWithDates.map((report) => [report.reportID, report])), undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - maxRecentReports: 3, - includeP2P: false, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + {}, + Object.fromEntries(reportsWithDates.map((report) => [report.reportID, report])), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + maxRecentReports: 3, + includeP2P: false, + conciergeReportID: undefined, + }, + undefined, + ); expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102', '103', '101']); }); it('should include personal details when includeP2P is true', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - includeP2P: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + includeP2P: true, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.personalDetails).toBeDefined(); @@ -8969,34 +11025,61 @@ describe('OptionsListUtils', () => { }); it('should exclude personal details when includeP2P is false', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - includeP2P: false, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + includeP2P: false, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.personalDetails.length).toBe(0); }); it('should handle empty reports collection', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, {}, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + {}, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.reports.length).toBe(0); }); it('should handle undefined reports collection', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, undefined, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + undefined, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.reports.length).toBe(0); @@ -9026,23 +11109,41 @@ describe('OptionsListUtils', () => { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}999`]: true, }; - const result = createFilteredOptionList(PERSONAL_DETAILS, reportsCollection, undefined, privateIsArchivedMap, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - maxRecentReports: 10, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + reportsCollection, + undefined, + privateIsArchivedMap, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 10, + }, + undefined, + ); expect(result).toBeDefined(); }); it('should handle isSearching filtering', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.reports.length).toBe(Object.keys(REPORTS).length); @@ -9055,7 +11156,15 @@ describe('OptionsListUtils', () => { undefined, {}, {}, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, conciergeReportID: undefined, isSearching: true, maxRecentReports: 2}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + maxRecentReports: 2, + }, + undefined, ); expect(result).toBeDefined(); @@ -9063,11 +11172,20 @@ describe('OptionsListUtils', () => { }); it('should return both reports and personal details', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); expect(result).toBeDefined(); expect(result).toHaveProperty('reports'); @@ -9077,46 +11195,82 @@ describe('OptionsListUtils', () => { // The SearchRouter relies on this: its empty-query state renders recent reports only, // so contacts must not be built until the user starts searching. it('should not build personal details when deferContactsUntilSearch is true and not searching', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - deferContactsUntilSearch: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + deferContactsUntilSearch: true, + }, + undefined, + ); expect(result.reports.length).toBeGreaterThan(0); expect(result.personalDetails.length).toBe(0); }); it('should build personal details when deferContactsUntilSearch is true and searching', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - deferContactsUntilSearch: true, - isSearching: true, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + deferContactsUntilSearch: true, + isSearching: true, + }, + undefined, + ); expect(result.personalDetails.length).toBeGreaterThan(0); }); it('should keep top-level fields of returned options mutable while the cached entry stays pristine', () => { - const first = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const first = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); const firstOption = first.personalDetails.at(0); expect(firstOption).toBeDefined(); if (firstOption) { firstOption.isSelected = true; } - const second = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const second = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); const secondOption = second.personalDetails.at(0); // Same cache entry (nested objects are shared between clones), but each caller gets fresh top-level objects. @@ -9130,16 +11284,34 @@ describe('OptionsListUtils', () => { // conciergeReportID affects the Concierge option's subtitle/alternate text, so a change must // invalidate the cache instead of serving options built with the previous value. it('should recompute cached options when only conciergeReportID changes', () => { - const first = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); - const second = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: '1', - }); + const first = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); + const second = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: '1', + }, + undefined, + ); // A cache hit copies each shell's `hydrate` by reference between clones (see the pristine-cache test // above), so a fresh closure is what proves the entry was rebuilt rather than served. @@ -9151,11 +11323,20 @@ describe('OptionsListUtils', () => { // The cached entry is frozen in dev, so a consumer that mutates a nested object shared with the // cache throws instead of silently corrupting the results returned to every other screen. it('should throw when a nested object shared with the cache is mutated', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); // Report options still carry fully-built icons; personal detail shells do not. const icons = result.reports.at(0)?.icons; @@ -9166,11 +11347,20 @@ describe('OptionsListUtils', () => { // Onyx snapshot objects referenced by the options are shared with the whole app and existing code // still writes to them in place, so the dev freeze must leave them untouched (see deepFreeze). it('should not freeze the Onyx snapshot objects referenced by cached options', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); const personalDetailItem = result.personalDetails.at(0)?.item; const reportItem = result.reports.at(0)?.item; @@ -9204,8 +11394,16 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: false, recentAttendees, maxRecentReportElements: 5, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: false, + recentAttendees, + maxRecentReportElements: 5, + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then only the first 5 recent attendees are shown @@ -9222,8 +11420,17 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: false, recentAttendees, maxRecentReportElements: 5, searchString: 'john', sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: false, + recentAttendees, + maxRecentReportElements: 5, + searchString: 'john', + sortedActions: undefined, + }, translateLocal, + undefined, ); // Then all matching recent attendees are shown, not just the first 5 @@ -9233,11 +11440,20 @@ describe('OptionsListUtils', () => { describe('policy parameter passing', () => { it('createFilteredOptionList should accept policiesCollection parameter', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - }); + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + }, + undefined, + ); expect(result).toBeDefined(); expect(result.reports).toBeDefined(); expect(result.personalDetails).toBeDefined(); @@ -9263,9 +11479,11 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + convertToDisplayString, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, + rules: undefined, policy: POLICY, sortedActions, conciergeReportID: undefined, @@ -9292,6 +11510,7 @@ describe('OptionsListUtils', () => { const result = getReportDisplayOption({ dateFnsLocale: undefined, + convertToDisplayString, report, unknownUserDetails: undefined, personalDetails: PERSONAL_DETAILS, @@ -9300,6 +11519,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + rules: undefined, }); expect(result).toBeDefined(); expect(result.policyID).toBe(policyID); @@ -9335,15 +11555,34 @@ describe('OptionsListUtils', () => { PERSONAL_DETAILS, report, POLICY, - {translate: translateLocal, dateFnsLocale: undefined}, + { + translate: translateLocal, + dateFnsLocale: undefined, + convertToDisplayString, + }, CURRENT_USER_ACCOUNT_ID, + undefined, ); expect(result).toBeDefined(); expect(result.policyID).toBe(policyID); }); it('formatSectionsFromSearchTerm should accept policy parameter', () => { - const result = formatSectionsFromSearchTerm('', [], [], [], {}, CURRENT_USER_ACCOUNT_ID, allPolicies, translateLocal, undefined, PERSONAL_DETAILS, true); + const result = formatSectionsFromSearchTerm( + '', + [], + [], + [], + {}, + CURRENT_USER_ACCOUNT_ID, + allPolicies, + translateLocal, + convertToDisplayString, + undefined, + undefined, + PERSONAL_DETAILS, + true, + ); expect(result).toBeDefined(); expect(result.section).toBeDefined(); }); @@ -9417,8 +11656,16 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, includeMultipleParticipantReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + includeMultipleParticipantReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, translateLocal, + undefined, ); expect(results.recentReports.length).toBe(1); @@ -9476,8 +11723,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, translateLocal, + undefined, ); const resultOption = results.recentReports.at(0); @@ -9532,8 +11786,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions: undefined}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions: undefined, + }, translateLocal, + undefined, ); const resultOption = results.recentReports.at(0); @@ -9614,8 +11875,16 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, includeMultipleParticipantReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + includeMultipleParticipantReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, translateLocal, + undefined, ); expect(results.recentReports.length).toBe(1); @@ -9689,8 +11958,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, sortedActions}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + sortedActions, + }, translateLocal, + undefined, ); const resultOption = results.recentReports.at(0); @@ -9754,8 +12029,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, translateLocal, + undefined, ); const resultOption = results.recentReports.at(0); @@ -9766,9 +12048,27 @@ describe('OptionsListUtils', () => { describe('orderPersonalDetailsOptions()', () => { it('sorts options alphabetically using text values', () => { const options: SearchOptionData[] = [ - {accountID: 1, reportID: '1', keyForList: '1', text: 'Charlie', login: 'c@example.com'}, - {accountID: 2, reportID: '2', keyForList: '2', text: 'aaron', login: 'a@example.com'}, - {accountID: 3, reportID: '3', keyForList: '3', text: 'Bob', login: 'b@example.com'}, + { + accountID: 1, + reportID: '1', + keyForList: '1', + text: 'Charlie', + login: 'c@example.com', + }, + { + accountID: 2, + reportID: '2', + keyForList: '2', + text: 'aaron', + login: 'a@example.com', + }, + { + accountID: 3, + reportID: '3', + keyForList: '3', + text: 'Bob', + login: 'b@example.com', + }, ]; const sorted = orderPersonalDetailsOptions(options); @@ -9786,7 +12086,13 @@ describe('OptionsListUtils', () => { alternateText: 'mango', login: 'm@example.com', }, - {accountID: 2, reportID: '2', keyForList: '2', text: 'apple', login: 'a@example.com'}, + { + accountID: 2, + reportID: '2', + keyForList: '2', + text: 'apple', + login: 'a@example.com', + }, { accountID: 3, reportID: '3', @@ -9871,8 +12177,16 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, includeMultipleParticipantReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + includeMultipleParticipantReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, translateLocal, + undefined, ); expect(results.recentReports.length).toBe(1); @@ -9932,8 +12246,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined, includeRecentReports: true, action: CONST.IOU.ACTION.CREATE, sortedActions: {[reportID]: [commentAction]}}, + { + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions: {[reportID]: [commentAction]}, + }, translateLocal, + undefined, ); expect(results.recentReports.at(0)?.lastIOUCreationDate).toBeUndefined(); @@ -9982,7 +12303,9 @@ describe('OptionsListUtils', () => { }; const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: {reports: [inputOption], personalDetails: []}, draftComments: {}, @@ -10050,7 +12373,9 @@ describe('OptionsListUtils', () => { }; const {options: results} = getSearchOptions({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, translate: translateLocal, options: {reports: [inputOption], personalDetails: []}, draftComments: {}, diff --git a/tests/unit/PaymentUtilsTest.ts b/tests/unit/PaymentUtilsTest.ts index 99fc16b22a86..31be37aec557 100644 --- a/tests/unit/PaymentUtilsTest.ts +++ b/tests/unit/PaymentUtilsTest.ts @@ -148,6 +148,7 @@ describe('PaymentUtils', () => { hasViolations: false, isASAPSubmitBetaEnabled: false, iouReport: {reportID: '1'} as Report, + rules: undefined, betas: [], userBillingGracePeriodEnds: undefined, amountOwed: 0, diff --git a/tests/unit/PolicyConnectionsTest.ts b/tests/unit/PolicyConnectionsTest.ts new file mode 100644 index 000000000000..0fdac613a54d --- /dev/null +++ b/tests/unit/PolicyConnectionsTest.ts @@ -0,0 +1,48 @@ +import {openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; +import {read} from '@libs/API'; +import {READ_COMMANDS} from '@libs/API/types'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +jest.mock('@libs/API'); + +const mockRead = jest.mocked(read); +const policyID = 'policyID'; +const hasConnectionsDataBeenFetchedKey = `${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${policyID}` as const; + +describe('PolicyConnections', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('openPolicyRecruitingPage', () => { + it('reads the recruiting page and records whether the connections data was fetched', () => { + // When the openPolicyRecruitingPage is called + openPolicyRecruitingPage(policyID); + + // Then the recruiting page data is read, and the "data fetched" flag is set on success and cleared on failure + expect(mockRead).toHaveBeenCalledWith( + READ_COMMANDS.OPEN_POLICY_RECRUITING_PAGE, + {policyID}, + { + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: hasConnectionsDataBeenFetchedKey, + value: true, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: hasConnectionsDataBeenFetchedKey, + value: false, + }, + ], + }, + ); + }); + }); +}); diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index a17b048302f6..eec18f4e78ea 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -106,6 +106,27 @@ describe('PolicyDistanceRatesUtils', () => { expect(isGovernmentRateUnmodified(buildRate({rate: Number('0.29') * 100}, governmentRate))).toBe(true); }); + it('should return true when a kilometer-based government snapshot matches a mile-based stored rate', () => { + const governmentRate = {sourceRateID: 'CA_2026-01-01', rate: 73, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 117.48}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(true); + }); + + it('should return true when a mile-based government snapshot matches a kilometer-based stored rate', () => { + const governmentRate = {sourceRateID: 'US_2026-01-01', rate: 76, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 47.22}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS)).toBe(true); + }); + + it('should return false when a converted government rate has been edited', () => { + const governmentRate = {sourceRateID: 'CA_2026-01-01', rate: 73, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 117.49}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(false); + }); + + it('should fall back to the same-unit comparison when the source country is unknown', () => { + const governmentRate = {sourceRateID: 'NZ_2026-01-01', rate: 73, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 73}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(true); + expect(isGovernmentRateUnmodified(buildRate({rate: 117.48}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(false); + }); + it('should return false when the snapshot is malformed and has no rate amount', () => { // A snapshot missing its rate amount alongside an unset rate must not be reported as unmodified. expect(isGovernmentRateUnmodified(buildRate({rate: undefined}, {sourceRateID: 'US_2026-01-01', startDate: '2026-01-01', endDate: '2026-12-31'}))).toBe(false); diff --git a/tests/unit/PolicySelectorTest.ts b/tests/unit/PolicySelectorTest.ts index 98505ee69277..47aaf4fe75f1 100644 --- a/tests/unit/PolicySelectorTest.ts +++ b/tests/unit/PolicySelectorTest.ts @@ -26,25 +26,25 @@ describe('lastWorkspaceNumberSelector', () => { beforeAll(() => IntlStore.load(CONST.LOCALES.DEFAULT)); it('should return undefined when there are no policies', () => { - expect(lastWorkspaceNumberSelector({}, email)).toBeUndefined(); + expect(lastWorkspaceNumberSelector({}, email, displayName)).toBeUndefined(); }); it('should return undefined when email is invalid', () => { - expect(lastWorkspaceNumberSelector({}, 'invalid-email')).toBeUndefined(); + expect(lastWorkspaceNumberSelector({}, 'invalid-email', displayName)).toBeUndefined(); }); it('should return 0 when there is a matching workspace without a number', () => { const policies = { [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildPolicy({name: workspaceName}), }; - expect(lastWorkspaceNumberSelector(policies, email)).toBe(0); + expect(lastWorkspaceNumberSelector(policies, email, displayName)).toBe(0); }); it('should return the number when there is a matching workspace with a number', () => { const policies = { [`${ONYXKEYS.COLLECTION.POLICY}1`]: createMock({name: `${workspaceName} 2`}), }; - expect(lastWorkspaceNumberSelector(policies, email)).toBe(2); + expect(lastWorkspaceNumberSelector(policies, email, displayName)).toBe(2); }); it('should return the maximum number when there are multiple matching workspaces', () => { @@ -54,7 +54,7 @@ describe('lastWorkspaceNumberSelector', () => { [`${ONYXKEYS.COLLECTION.POLICY}3`]: createMock({name: `${workspaceName} 5`}), [`${ONYXKEYS.COLLECTION.POLICY}4`]: buildPolicy({name: 'Other Workspace'}), }; - expect(lastWorkspaceNumberSelector(policies, email)).toBe(5); + expect(lastWorkspaceNumberSelector(policies, email, displayName)).toBe(5); }); it('should handle SMS domain correctly', () => { @@ -64,7 +64,7 @@ describe('lastWorkspaceNumberSelector', () => { [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildPolicy({name: smsDisplayName}), [`${ONYXKEYS.COLLECTION.POLICY}2`]: createMock({name: `${smsDisplayName} 3`}), }; - expect(lastWorkspaceNumberSelector(policies, smsEmail)).toBe(3); + expect(lastWorkspaceNumberSelector(policies, smsEmail, displayName)).toBe(3); }); it('should ignore case when matching workspace names', () => { @@ -72,7 +72,7 @@ describe('lastWorkspaceNumberSelector', () => { [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildPolicy({name: workspaceName.toLowerCase()}), [`${ONYXKEYS.COLLECTION.POLICY}2`]: createMock({name: `${workspaceName.toUpperCase()} 4`}), }; - expect(lastWorkspaceNumberSelector(policies, email)).toBe(4); + expect(lastWorkspaceNumberSelector(policies, email, displayName)).toBe(4); }); }); @@ -268,6 +268,36 @@ describe('createWorkspaceListPoliciesSelector', () => { expect(result).toHaveLength(1); }); + it('excludes a policy whose id has not been merged in yet', () => { + // A `policy_` record is merged field-by-field, so a freshly joined workspace can pass shouldShowPolicy + // before its `id` arrives. Such a row has no key, no avatar seed and nothing to navigate to. + const policies = { + [`${P}p1`]: makePolicy({id: undefined}), + }; + expect(createWorkspaceListPoliciesSelector(userLogin)(policies)).toEqual([]); + }); + + it('still includes an id-less policy when it is a pending join request', () => { + const policies = { + [`${P}p1`]: makePolicy({ + id: undefined, + isJoinRequestPending: true, + policyDetailsForNonMembers: { + p1: { + name: 'Pending Workspace', + type: CONST.POLICY.TYPE.TEAM, + ownerAccountID: 1, + ownerEmail: 'owner@example.com', + }, + }, + }), + }; + const [item] = createWorkspaceListPoliciesSelector(userLogin)(policies); + expect(item?.isJoinRequestPending).toBe(true); + expect(item?.id).toBeUndefined(); + expect(item?.nonMemberDetails?.policyID).toBe('p1'); + }); + it('projects only the expected fields onto each result item', () => { const policy = makePolicy({ id: 'p1', diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 163d8d7eee9e..c670928ba8ef 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -13,6 +13,7 @@ import { canMemberRead, canMemberWrite, canSendInvoiceFromWorkspace, + evaluateApprovalWorkflowRule, findVendorByID, getActivePolicies, getActivePoliciesWithExpenseChat, @@ -28,9 +29,13 @@ import { getEligibleBankAccountShareRecipientEmails, getExcludedUsers, getExpensifyTeamExclusions, + getForwardsToAccount, + getForwardsToFromRules, getManagerAccountID, + getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, + getVendorEmptyState, getPolicyApproverLogins, getPolicyBrickRoadIndicatorStatus, getPolicyByCustomUnitID, @@ -41,7 +46,10 @@ import { getSubmitToAccountID, getSubmitToEmail, getTagApproverRule, + findPolicyTagAtLevel, getTagGLCode, + isTagInPolicy, + matchesParentTagPath, getGLCodeFromPolicyTag, getTagList, getTagListByOrderWeight, @@ -66,24 +74,29 @@ import { isPerDiemEnabled, isPolicyMemberWithoutPendingDelete, isSubmitterApproveBlockedOnSubmitWorkspace, + isRilletVendorMatchingActive, isTaxCodeCustomized, isXeroActiveMatchingSource, isXeroVendorMatchingActive, + shouldHideDynamicExternalWorkflowPeople, shouldShowPolicy, sortPoliciesByName, sortWorkspacesBySelected, tryNavigateToSubmitWorkspaceUpgrade, } from '@libs/PolicyUtils'; -import {isWorkspaceEligibleForReportChange} from '@libs/ReportUtils'; +import {getApprovalChain, isWorkspaceEligibleForReportChange} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTags, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type {Connections, QBONonReimbursableExportAccountType, SageIntacctExportConfig, TaxRates} from '@src/types/onyx/Policy'; +import type Rule from '@src/types/onyx/Rule'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -835,7 +848,7 @@ describe('PolicyUtils', () => { ownerAccountID: employeeAccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(ownerAccountID); + 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 = { @@ -850,7 +863,7 @@ describe('PolicyUtils', () => { ownerAccountID: employeeAccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(ownerAccountID); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); }); it('should return the employee submitsTo if the policy use the advance workflow', () => { const policy: Policy = { @@ -866,7 +879,7 @@ describe('PolicyUtils', () => { ownerAccountID: employeeAccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(adminAccountID); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); }); }); describe('Has category/tag approver', () => { @@ -900,7 +913,7 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(categoryApprover1AccountID); + 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 = { @@ -926,7 +939,7 @@ describe('PolicyUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email)).toBe(adminAccountID); + expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(adminAccountID); }); 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 = { @@ -960,7 +973,7 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(categoryApprover2AccountID); + 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 = { @@ -1008,7 +1021,7 @@ describe('PolicyUtils', () => { }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email)).toBe(tagApprover1AccountID); + 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 () => { @@ -1045,7 +1058,7 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(tagApprover1AccountID); + 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 = { @@ -1081,7 +1094,7 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail)).toBe(tagApprover2AccountID); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover2AccountID); }); }); }); @@ -1112,7 +1125,7 @@ describe('PolicyUtils', () => { managerID: categoryApprover1AccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBeUndefined(); + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined)).toBeUndefined(); }); it('should return the known approver accountID when the policy route is reliable', () => { const policy: Policy = { @@ -1129,7 +1142,7 @@ describe('PolicyUtils', () => { managerID: categoryApprover1AccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBe(adminAccountID); + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); }); it('should return undefined instead of a generated accountID when the approver is missing from personal details', () => { const policy: Policy = { @@ -1157,7 +1170,7 @@ describe('PolicyUtils', () => { managerID: categoryApprover1AccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBeUndefined(); + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined)).toBeUndefined(); }); }); describe('shouldShowPolicy', () => { @@ -1221,7 +1234,7 @@ describe('PolicyUtils', () => { type: CONST.POLICY.TYPE.PERSONAL, approver: categoryApprover1Email, }; - const result = getManagerAccountID(policy, ''); + const result = getManagerAccountID(policy, '', undefined); expect(result).toBe(categoryApprover1AccountID); }); @@ -1235,7 +1248,7 @@ describe('PolicyUtils', () => { owner: '', }; - const result = getManagerAccountID(policy, ''); + const result = getManagerAccountID(policy, '', undefined); expect(result).toBe(-1); }); @@ -1253,7 +1266,7 @@ describe('PolicyUtils', () => { }, }; - const result = getManagerAccountID(policy, employeeEmail); + const result = getManagerAccountID(policy, employeeEmail, undefined); expect(result).toBe(adminAccountID); }); @@ -1278,7 +1291,7 @@ describe('PolicyUtils', () => { ownerAccountID: employeeAccountID, }; - expect(getSubmitToEmail(policy, report, employeeEmail)).toBe(adminEmail); + expect(getSubmitToEmail(policy, report, employeeEmail, undefined)).toBe(adminEmail); }); it('should return the default approver', () => { @@ -1289,12 +1302,224 @@ describe('PolicyUtils', () => { approver: categoryApprover1Email, }; - const result = getManagerAccountID(policy, ''); + const result = getManagerAccountID(policy, '', undefined); 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]}; + + const buildRule = (rule: ApprovalWorkflowRule): Rule => ({...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID}); + + const submitRule: ApprovalWorkflowRule = { + triggers: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT}, + filters: submitFilter, + actions: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.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: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + 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: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver: approverEmail}}, + }; + const overLimitRule: ApprovalWorkflowRule = { + triggers: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + 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: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver: categoryApprover1Email}}, + }; + const terminalRule: ApprovalWorkflowRule = { + triggers: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.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: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.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(); + }); + + describe('evaluateApprovalWorkflowRule', () => { + it('matches the submit rule for a submitter it covers', () => { + expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); + }); + + it('does not match the submit rule for another submitter', () => { + expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: adminEmail, reportTotal: 0})).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('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); + }); + + const buildAmountRule = (operator: ValueOf, right: number): ApprovalWorkflowRule => ({ + triggers: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT}, + filters: {operator, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right}, + actions: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.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); + }); + + 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 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('does not match a filter on a field this client does not understand', () => { + const rule: ApprovalWorkflowRule = { + triggers: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: 'unsupportedField', right: employeeEmail}, + actions: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.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: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.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: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.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); + }); + }); + + describe('getForwardsToFromRules', () => { + it('returns the first approver from the submit rule', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toEqual({forwardsTo: adminEmail}); + }); + + 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('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('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); + }); + }); + describe('getSubmitReportManagerAccountID', () => { beforeEach(() => { wrapOnyxWithWaitForBatchedUpdates(Onyx); @@ -1329,7 +1554,7 @@ describe('PolicyUtils', () => { ownerAccountID: employeeAccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBe(ownerAccountID); + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined)).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 = { @@ -1361,7 +1586,7 @@ describe('PolicyUtils', () => { ownerAccountID: employeeAccountID, type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBe(adminAccountID); + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); }); }); @@ -1605,6 +1830,166 @@ describe('PolicyUtils', () => { }); }); + describe('matchesParentTagPath', () => { + it('matches any parent tag path when the tag has no parent filter', () => { + expect(matchesParentTagPath({name: 'Roadshow', enabled: true}, '')).toBe(true); + expect(matchesParentTagPath({name: 'Roadshow', enabled: true}, 'California:100:South')).toBe(true); + }); + + it('matches only the parent tag path allowed by the parent filter', () => { + const tag = {name: '20', enabled: true, rules: {parentTagsFilter: '^California\\:100\\:North$'}}; + expect(matchesParentTagPath(tag, 'California:100:North')).toBe(true); + expect(matchesParentTagPath(tag, 'California:100:South')).toBe(false); + }); + + it('reads the parent filter off the tag root when it is not nested under rules', () => { + expect(matchesParentTagPath({name: 'Roadshow', enabled: true, parentTagsFilter: '^Marketing$'}, 'Marketing')).toBe(true); + expect(matchesParentTagPath({name: 'Roadshow', enabled: true, parentTagsFilter: '^Marketing$'}, 'Engineering')).toBe(false); + }); + + it('prefers the parent filter nested under rules over the one on the tag root', () => { + const tag = {name: 'Roadshow', enabled: true, parentTagsFilter: '^Marketing$', rules: {parentTagsFilter: '^Engineering$'}}; + expect(matchesParentTagPath(tag, 'Engineering')).toBe(true); + expect(matchesParentTagPath(tag, 'Marketing')).toBe(false); + }); + }); + + describe('findPolicyTagAtLevel', () => { + // Dependent tag lists key their tags by the full tag path, so the tag name alone is never a record key + const dependentLevelTags: PolicyTags = { + 'California:100:North:20': {name: '20', enabled: true, rules: {parentTagsFilter: '^California\\:100\\:North$'}}, + 'California:100:South:30': {name: '30', enabled: true, rules: {parentTagsFilter: '^California\\:100\\:South$'}}, + }; + + // Independent tag lists key their tags by name and carry no parent filter + const independentLevelTags: PolicyTags = { + Engineering: {name: 'Engineering', enabled: true}, + Marketing: {name: 'Marketing', enabled: true}, + }; + + it('returns undefined when no tag of the level carries the name', () => { + expect(findPolicyTagAtLevel(dependentLevelTags, 'Nonexistent', 'California:100:North')).toBeUndefined(); + }); + + it('returns the tag matching the record key when the level has no parent filters', () => { + expect(findPolicyTagAtLevel(independentLevelTags, 'Engineering', '')?.name).toBe('Engineering'); + }); + + it('resolves a dependent tag by name when the record key is the full tag path', () => { + expect(findPolicyTagAtLevel(dependentLevelTags, '20', 'California:100:North')?.name).toBe('20'); + expect(findPolicyTagAtLevel(dependentLevelTags, '30', 'California:100:South')?.name).toBe('30'); + }); + + it('returns undefined when the name exists at the level but under a different parent', () => { + expect(findPolicyTagAtLevel(dependentLevelTags, '20', 'California:100:South')).toBeUndefined(); + expect(findPolicyTagAtLevel(dependentLevelTags, '30', 'California:100:North')).toBeUndefined(); + }); + + it('tells same-named children of different parents apart', () => { + const sameNamedChildren: PolicyTags = { + Roadshow: {name: 'Roadshow', enabled: true, 'GL Code': '1111', rules: {parentTagsFilter: '^Marketing$'}}, + 'Roadshow-1': {name: 'Roadshow', enabled: true, 'GL Code': '2222', rules: {parentTagsFilter: '^Engineering$'}}, + }; + + expect(findPolicyTagAtLevel(sameNamedChildren, 'Roadshow', 'Marketing')?.['GL Code']).toBe('1111'); + expect(findPolicyTagAtLevel(sameNamedChildren, 'Roadshow', 'Engineering')?.['GL Code']).toBe('2222'); + expect(findPolicyTagAtLevel(sameNamedChildren, 'Roadshow', 'Sales')).toBeUndefined(); + }); + + it('skips a record key that collides with the name but fails the parent filter', () => { + // The record key equals the name here, so the direct key lookup must still be parent filtered + const collidingKey: PolicyTags = { + Roadshow: {name: 'Roadshow', enabled: true, 'GL Code': '1111', rules: {parentTagsFilter: '^Marketing$'}}, + 'Roadshow-1': {name: 'Roadshow', enabled: true, 'GL Code': '2222', rules: {parentTagsFilter: '^Engineering$'}}, + }; + + expect(findPolicyTagAtLevel(collidingKey, 'Roadshow', 'Engineering')?.['GL Code']).toBe('2222'); + }); + + it('matches a parent tag path holding escaped colons', () => { + // Parent tag paths keep the escaped colons of getTagArrayFromName, so a parent filter of a tag + // nested under a colon holding parent has to escape the backslash to match + const escapedParentTags: PolicyTags = { + 'Sales\\:EMEA:Roadshow': {name: 'Roadshow', enabled: true, rules: {parentTagsFilter: '^Sales\\\\:EMEA$'}}, + }; + + expect(findPolicyTagAtLevel(escapedParentTags, 'Roadshow', 'Sales\\:EMEA')?.name).toBe('Roadshow'); + expect(findPolicyTagAtLevel(escapedParentTags, 'Roadshow', 'Sales')).toBeUndefined(); + }); + }); + + describe('isTagInPolicy', () => { + const dependentPolicyTagLists: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true}, + Marketing: {name: 'Marketing', enabled: true}, + }, + }, + Project: { + name: 'Project', + orderWeight: 1, + required: false, + tags: { + Roadshow: {name: 'Roadshow', enabled: true, rules: {parentTagsFilter: '^Marketing$'}}, + 'Roadshow-1': {name: 'Roadshow', enabled: true, rules: {parentTagsFilter: '^Engineering$'}}, + }, + }, + }; + + it('returns false when policy tags are undefined', () => { + expect(isTagInPolicy('Engineering', undefined)).toBe(false); + }); + + it('returns false when the tag list level is missing', () => { + expect(isTagInPolicy('Engineering:Roadshow:Extra', dependentPolicyTagLists)).toBe(false); + }); + + it('returns false when the tag is not in the policy', () => { + expect(isTagInPolicy('Nonexistent', dependentPolicyTagLists)).toBe(false); + }); + + it('returns true for every level of an existing multi-level tag', () => { + expect(isTagInPolicy('Engineering:Roadshow', dependentPolicyTagLists)).toBe(true); + expect(isTagInPolicy('Marketing:Roadshow', dependentPolicyTagLists)).toBe(true); + }); + + it('returns false when the dependent child matching the parent tag path is pending deletion', () => { + // Only the Engineering child is deleted, so the same-named Marketing child must not keep the tag alive + const tagListsWithDeletedChild: PolicyTagLists = { + ...dependentPolicyTagLists, + Project: { + ...dependentPolicyTagLists.Project, + tags: { + Roadshow: {name: 'Roadshow', enabled: true, rules: {parentTagsFilter: '^Marketing$'}}, + 'Roadshow-1': {name: 'Roadshow', enabled: true, rules: {parentTagsFilter: '^Engineering$'}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }, + }, + }; + + expect(isTagInPolicy('Engineering:Roadshow', tagListsWithDeletedChild)).toBe(false); + expect(isTagInPolicy('Marketing:Roadshow', tagListsWithDeletedChild)).toBe(true); + }); + + it('returns false when a non-dependent tag is pending deletion', () => { + const tagListsWithDeletedTag: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }, + }, + }; + + expect(isTagInPolicy('Engineering', tagListsWithDeletedTag)).toBe(false); + }); + }); + describe('getGLCodeFromPolicyTag', () => { it('returns empty string when tag is undefined', () => { expect(getGLCodeFromPolicyTag(undefined)).toBe(''); @@ -3754,6 +4139,21 @@ describe('PolicyUtils', () => { }, }); + const RILLET_VENDORS_UNSYNCED = Symbol('RILLET_VENDORS_UNSYNCED'); + const buildRilletPolicy = ( + vendors: Array<{id: string; name: string; email?: string}> | typeof RILLET_VENDORS_UNSYNCED = [{id: 'rv-1', name: 'Acme Rillet', email: 'acme@rillet.com'}], + {isConfigured = true}: {isConfigured?: boolean} = {}, + ): Policy => + createMock({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: {isConfigured}, + data: vendors === RILLET_VENDORS_UNSYNCED ? {} : {vendors}, + }, + }, + }); + describe('hasVendorFeature', () => { it('returns true when beta is enabled and QBO non-reimbursable export is Credit Card', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), true)).toBe(true); @@ -3786,6 +4186,18 @@ describe('PolicyUtils', () => { expect(hasVendorFeature(buildXeroPolicy(undefined, {isConfigured: false}), true)).toBe(false); }); + it('returns true when beta is enabled and Rillet is connected with isConfigured=true', () => { + expect(hasVendorFeature(buildRilletPolicy(), true)).toBe(true); + }); + + it('returns true when beta is enabled and Rillet is configured but vendors have not synced yet', () => { + expect(hasVendorFeature(buildRilletPolicy(RILLET_VENDORS_UNSYNCED), true)).toBe(true); + }); + + it('returns false when Rillet is connected but isConfigured=false', () => { + expect(hasVendorFeature(buildRilletPolicy(undefined, {isConfigured: false}), true)).toBe(false); + }); + it('returns true when beta is disabled and QBO non-reimbursable export is Credit Card because QBO (R1) is generally available', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), false)).toBe(true); }); @@ -3806,6 +4218,10 @@ describe('PolicyUtils', () => { expect(hasVendorFeature(buildXeroPolicy(), false)).toBe(false); }); + it('returns false when beta is disabled and Rillet is connected because Rillet is still pre-GA', () => { + expect(hasVendorFeature(buildRilletPolicy(), false)).toBe(false); + }); + it('returns false when QBO non-reimbursable export is Vendor Bill', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL), true)).toBe(false); }); @@ -3907,6 +4323,41 @@ describe('PolicyUtils', () => { expect(getMatchingVendors(buildXeroPolicy(XERO_CONTACTS_UNSYNCED))).toEqual([]); }); + it('returns the Rillet vendor list normalized to {id, name, currency, email}', () => { + const rilletVendors = [ + {id: 'rv-1', name: 'Acme Rillet', email: 'acme@rillet.com'}, + {id: 'rv-2', name: 'Other Rillet'}, + ]; + const policy = buildRilletPolicy(rilletVendors); + expect(getMatchingVendors(policy)).toEqual([ + {id: 'rv-1', name: 'Acme Rillet', currency: '', email: 'acme@rillet.com'}, + {id: 'rv-2', name: 'Other Rillet', currency: '', email: ''}, + ]); + }); + + it('returns Xero vendors when both Xero and Rillet are connected (Xero precedence over Rillet)', () => { + const xeroContacts = {xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@xero.com'}}; + const rilletVendors = [{id: 'rv-1', name: 'Acme Rillet', email: 'acme@rillet.com'}]; + const policy = createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + data: {contacts: xeroContacts}, + }, + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: {isConfigured: true}, + data: {vendors: rilletVendors}, + }, + }), + }); + expect(getMatchingVendors(policy)).toEqual([{id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@xero.com'}]); + }); + + it('returns an empty array when Rillet is connected but vendors have not synced yet (data.vendors undefined)', () => { + expect(getMatchingVendors(buildRilletPolicy(RILLET_VENDORS_UNSYNCED))).toEqual([]); + }); + it('returns an empty array when no supported connection exists', () => { const policy = createMock({...createRandomPolicy(0), connections: {}}); expect(getMatchingVendors(policy)).toEqual([]); @@ -3946,6 +4397,19 @@ describe('PolicyUtils', () => { expect(getMatchingVendorByID(buildXeroPolicy(), 'xcMissing')).toBeUndefined(); }); + it('returns the matching Rillet vendor (normalized) when the ID exists in the list', () => { + const policy = buildRilletPolicy([ + {id: 'rv-1', name: 'Acme Rillet', email: 'acme@rillet.com'}, + {id: 'rv-2', name: 'Other Rillet'}, + ]); + expect(getMatchingVendorByID(policy, 'rv-1')).toEqual({id: 'rv-1', name: 'Acme Rillet', currency: '', email: 'acme@rillet.com'}); + expect(getMatchingVendorByID(policy, 'rv-2')).toEqual({id: 'rv-2', name: 'Other Rillet', currency: '', email: ''}); + }); + + it('returns undefined for a Rillet vendor ID that is not in the list (inactive-vendor case)', () => { + expect(getMatchingVendorByID(buildRilletPolicy(), 'rv-missing')).toBeUndefined(); + }); + it('returns undefined when the ID is not in the list (the inactive-vendor case)', () => { const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); expect(getMatchingVendorByID(policy, 'v-missing')).toBeUndefined(); @@ -3973,6 +4437,41 @@ describe('PolicyUtils', () => { expect(findVendorByID(policy, 'xc1')).toEqual({id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}); }); + it('resolves a Rillet vendor (normalized) from connections.rillet.data.vendors', () => { + const policy = buildRilletPolicy([{id: 'rv-1', name: 'Acme Rillet', email: 'acme@rillet.com'}]); + expect(findVendorByID(policy, 'rv-1')).toEqual({id: 'rv-1', name: 'Acme Rillet', currency: '', email: 'acme@rillet.com'}); + }); + + it('prefers the active Xero integration over stale Rillet data when both hold the same vendor ID', () => { + const xeroPolicy = buildXeroPolicy({shared: {id: 'shared', name: 'Xero Name', email: 'xero@xero.com'}}); + const policy = createMock({ + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: {isConfigured: true}, + data: {vendors: [{id: 'shared', name: 'Rillet Name', email: 'rillet@rillet.com'}]}, + }, + }, + }); + expect(findVendorByID(policy, 'shared')).toEqual({id: 'shared', name: 'Xero Name', currency: '', email: 'xero@xero.com'}); + }); + + it('prefers the active Rillet integration over stale QBO data when both hold the same vendor ID', () => { + const rilletPolicy = buildRilletPolicy([{id: 'shared', name: 'Rillet Name', email: 'rillet@rillet.com'}]); + const policy = createMock({ + ...rilletPolicy, + connections: { + ...rilletPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + data: {vendors: [{id: 'shared', name: 'QBO Stale Name', currency: 'USD'}]}, + }, + }, + }); + expect(findVendorByID(policy, 'shared')).toEqual({id: 'shared', name: 'Rillet Name', currency: '', email: 'rillet@rillet.com'}); + }); + it('prefers the active Intacct integration over stale QBO data when both hold the same vendor ID', () => { // Workspace state: QBO connected but in Vendor Bill mode (not vendor-matching), // Intacct connected in Credit Card Charge mode (the active matching integration). @@ -4252,6 +4751,136 @@ describe('PolicyUtils', () => { expect(isXeroVendorMatchingActive(undefined)).toBe(false); }); }); + + describe('isRilletVendorMatchingActive', () => { + it('returns true when Rillet is connected with isConfigured=true', () => { + expect(isRilletVendorMatchingActive(buildRilletPolicy())).toBe(true); + }); + + it('returns false when Rillet is connected but isConfigured=false', () => { + expect(isRilletVendorMatchingActive(buildRilletPolicy(undefined, {isConfigured: false}))).toBe(false); + }); + + it('returns false when Rillet is not connected', () => { + const policy = {...createRandomPolicy(0), connections: {}}; + expect(isRilletVendorMatchingActive(policy)).toBe(false); + }); + + it('returns false when policy is undefined', () => { + expect(isRilletVendorMatchingActive(undefined)).toBe(false); + }); + }); + + describe('getActiveVendorMatchingIntegration', () => { + it('returns QBO when QBO is in vendor matching export mode', () => { + expect(getActiveVendorMatchingIntegration(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD))).toBe(CONST.POLICY.CONNECTIONS.NAME.QBO); + }); + + it('returns Sage Intacct when Intacct is in vendor matching export mode', () => { + expect(getActiveVendorMatchingIntegration(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE))).toBe( + CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, + ); + }); + + it('returns Xero when Xero is connected and configured', () => { + expect(getActiveVendorMatchingIntegration(buildXeroPolicy())).toBe(CONST.POLICY.CONNECTIONS.NAME.XERO); + }); + + it('returns Rillet when Rillet is connected and configured', () => { + expect(getActiveVendorMatchingIntegration(buildRilletPolicy())).toBe(CONST.POLICY.CONNECTIONS.NAME.RILLET); + }); + + it('follows cascade precedence QBO > Sage Intacct > Xero > Rillet', () => { + const policy = createMock({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + }, + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}}, + }, + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + }, + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: {isConfigured: true}, + }, + }, + }); + expect(getActiveVendorMatchingIntegration(policy)).toBe(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT); + }); + + it('returns Xero over Rillet when both are configured', () => { + const policy = createMock({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + }, + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: {isConfigured: true}, + }, + }, + }); + expect(getActiveVendorMatchingIntegration(policy)).toBe(CONST.POLICY.CONNECTIONS.NAME.XERO); + }); + + it('returns undefined when no integration is configured for vendor matching', () => { + const policy = createMock({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + }, + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: {isConfigured: false}, + }, + }, + }); + expect(getActiveVendorMatchingIntegration(policy)).toBeUndefined(); + }); + + it('returns undefined when policy is undefined', () => { + expect(getActiveVendorMatchingIntegration(undefined)).toBeUndefined(); + }); + }); + + describe('getVendorEmptyState', () => { + const translate = TestHelper.translateLocal; + + it('returns QBO empty state copy when QBO is the active integration', () => { + const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); + expect(getVendorEmptyState(policy, translate)).toEqual({ + title: translate('workspace.qbo.noAccountsFound'), + subtitle: translate('workspace.qbo.noAccountsFoundDescription', 'QuickBooks Online'), + }); + }); + + it('returns Sage Intacct empty state copy when Intacct is the active integration', () => { + const policy = buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE); + expect(getVendorEmptyState(policy, translate)).toEqual({ + title: translate('workspace.sageIntacct.noAccountsFound'), + subtitle: translate('workspace.sageIntacct.noAccountsFoundDescription'), + }); + }); + + 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'), + }); + }); + + it('returns Rillet empty state copy when Rillet is the active integration', () => { + const policy = buildRilletPolicy(); + expect(getVendorEmptyState(policy, translate)).toEqual({ + title: translate('workspace.rillet.noVendorsFound'), + subtitle: translate('workspace.rillet.noVendorsFoundDescription'), + }); + }); + }); }); describe('hasPolicyRulesError', () => { @@ -4441,12 +5070,8 @@ describe('arePolicyRulesEnabled', () => { expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: undefined})).toBe(false); }); - it('returns false for a team policy with areRulesEnabled explicitly true when rules revamp beta is disabled', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: true})).toBe(false); - }); - - it('returns true for a team policy with areRulesEnabled explicitly true when rules revamp beta is enabled', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: true}, undefined, true)).toBe(true); + 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', () => { @@ -4631,3 +5256,25 @@ describe('getPolicyApproverLogins', () => { expect([...getPolicyApproverLogins(policy)]).toEqual(['director@test.com']); }); }); + +describe('shouldHideDynamicExternalWorkflowPeople', () => { + it('returns false when the policy is undefined', () => { + expect(shouldHideDynamicExternalWorkflowPeople(undefined)).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); + }); + + // 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); + }); +}); diff --git a/tests/unit/ProductMarketingWindowTest.tsx b/tests/unit/ProductMarketingWindowTest.tsx index 940a027523fc..d9576b3adc7f 100644 --- a/tests/unit/ProductMarketingWindowTest.tsx +++ b/tests/unit/ProductMarketingWindowTest.tsx @@ -47,6 +47,8 @@ const POLICY_ID = 'product-marketing-policy'; const SECOND_POLICY_ID = 'second-product-marketing-policy'; const USER_EMAIL = 'user@example.com'; const USER_ACCOUNT_ID = 7; +const SECOND_USER_EMAIL = 'second-user@example.com'; +const SECOND_USER_ACCOUNT_ID = 8; const OLDER_UPDATE_KEY = 'productUpdateJuly2026'; jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); @@ -142,9 +144,12 @@ const renderManager = (topmostRouteName?: string, theme: ThemePreferenceWithoutS , ); -async function setupOnyxBaseline({isAdmin, activePolicyID = POLICY_ID, initializeBetas = true}: {isAdmin: boolean; activePolicyID?: string; initializeBetas?: boolean}) { +async function setupOnyxBaseline({isAdmin, activePolicyID = POLICY_ID, initializeOnboarding = true}: {isAdmin: boolean; activePolicyID?: string; initializeOnboarding?: boolean}) { await Onyx.clear(); await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + if (initializeOnboarding) { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + } await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { [USER_ACCOUNT_ID]: buildPersonalDetails(USER_EMAIL, USER_ACCOUNT_ID, 'User'), }); @@ -152,9 +157,7 @@ async function setupOnyxBaseline({isAdmin, activePolicyID = POLICY_ID, initializ email: USER_EMAIL, accountID: USER_ACCOUNT_ID, }); - if (initializeBetas) { - await Onyx.set(ONYXKEYS.BETAS, []); - } + await Onyx.set(ONYXKEYS.BETAS, []); if (isAdmin) { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildAdminPolicy()); await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, activePolicyID); @@ -188,38 +191,186 @@ describe('ProductMarketingWindowManager', () => { }); }); - it('shows the member variant for a user without an admin role on any workspace', async () => { + it('renders nothing while the onboarding NVP is hydrating', async () => { await act(async () => { - await setupOnyxBaseline({isAdmin: false}); - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CUSTOM_AGENT]); + await setupOnyxBaseline({isAdmin: true, initializeOnboarding: false}); + await waitForBatchedUpdatesWithAct(); + }); + + const onboardingHydration = Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + renderManager(); + + expect(screen.queryByText(adminHeading)).toBeNull(); + + await act(async () => { + await onboardingHydration; + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.getByText(adminHeading)).toBeTruthy(); + }); + + it.each([ + ['admin', true, adminHeading], + ['member', false, memberHeading], + ] as const)('suppresses the %s variant when onboarding is incomplete', async (_audience, isAdmin, heading) => { + await act(async () => { + await setupOnyxBaseline({isAdmin}); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); await waitForBatchedUpdatesWithAct(); }); renderManager(); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText(memberHeading)).toBeTruthy(); - expect(screen.getByText(memberBody)).toBeTruthy(); - expect(screen.UNSAFE_getByType(Image).props.source).toBe(August2026PromoEmployeesImage); + expect(screen.queryByText(heading)).toBeNull(); + expect(mockDismissMarketingWindow).not.toHaveBeenCalled(); }); - it('does not show the member variant until the Custom Agent beta is available', async () => { + it('keeps the window hidden after onboarding completes, then allows it after an authenticated-root remount', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: true}); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await waitForBatchedUpdatesWithAct(); + }); + + const {unmount} = renderManager(); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText(adminHeading)).toBeNull(); + await act(async () => { - await setupOnyxBaseline({isAdmin: false, initializeBetas: false}); + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.queryByText(adminHeading)).toBeNull(); + expect(mockDismissMarketingWindow).not.toHaveBeenCalled(); + + unmount(); + renderManager(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(adminHeading)).toBeTruthy(); + }); + + it('does not leak the onboarding-session latch into a different account without a remount', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: true}); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); await waitForBatchedUpdatesWithAct(); }); renderManager(); await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText(adminHeading)).toBeNull(); + + await act(async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [SECOND_USER_ACCOUNT_ID]: buildPersonalDetails(SECOND_USER_EMAIL, SECOND_USER_ACCOUNT_ID, 'Second User'), + }); + await Onyx.merge(ONYXKEYS.SESSION, { + email: SECOND_USER_EMAIL, + accountID: SECOND_USER_ACCOUNT_ID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, null); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, null); + await waitForBatchedUpdatesWithAct(); + }); expect(screen.queryByText(memberHeading)).toBeNull(); await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CUSTOM_AGENT]); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.getByText(memberHeading)).toBeTruthy(); + + await act(async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); + await Onyx.merge(ONYXKEYS.SESSION, { + email: USER_EMAIL, + accountID: USER_ACCOUNT_ID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildAdminPolicy()); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + await Onyx.set(ONYXKEYS.BETAS, []); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.queryByText(adminHeading)).toBeNull(); + + await act(async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.queryByText(adminHeading)).toBeNull(); + expect(mockDismissMarketingWindow).not.toHaveBeenCalled(); + }); + + it('does not latch incomplete onboarding observed while acting as a copilot', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: true}); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, { + delegatedAccess: {delegate: 'copilot@example.com'}, + }); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText(adminHeading)).toBeNull(); + + await act(async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); + await Onyx.set(ONYXKEYS.ACCOUNT, {}); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); await waitForBatchedUpdatesWithAct(); }); + expect(screen.queryByText(adminHeading)).toBeNull(); + + await act(async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await waitForBatchedUpdatesWithAct(); + }); + + expect(screen.getByText(adminHeading)).toBeTruthy(); + expect(mockDismissMarketingWindow).not.toHaveBeenCalled(); + }); + + it.each([ + ['empty', {}], + ['migrated', {signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB}], + ] as const)('keeps %s completed-onboarding NVPs eligible', async (_accountType, onboarding) => { + await act(async () => { + await setupOnyxBaseline({isAdmin: true}); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, onboarding); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(adminHeading)).toBeTruthy(); + }); + + it('shows the member variant for a user without an admin role on any workspace', async () => { + await act(async () => { + await setupOnyxBaseline({isAdmin: false}); + await waitForBatchedUpdatesWithAct(); + }); + + renderManager(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(memberHeading)).toBeTruthy(); + expect(screen.getByText(memberBody)).toBeTruthy(); + expect(screen.UNSAFE_getByType(Image).props.source).toBe(August2026PromoEmployeesImage); }); it('shows the admin variant when the user administers at least one active workspace', async () => { @@ -740,7 +891,6 @@ describe('ProductMarketingWindowManager', () => { it('routes the member CTA to the new Agents page', async () => { await act(async () => { await setupOnyxBaseline({isAdmin: false}); - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CUSTOM_AGENT]); await waitForBatchedUpdatesWithAct(); }); diff --git a/tests/unit/PromotedActionsBarTest.ts b/tests/unit/PromotedActionsBarTest.ts index 03be56089a5b..83d622c2985d 100644 --- a/tests/unit/PromotedActionsBarTest.ts +++ b/tests/unit/PromotedActionsBarTest.ts @@ -34,6 +34,7 @@ describe('PromotedActions.message', () => { it('should pass introSelected to navigateToAndOpenReport when login is provided', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, login: 'test@example.com', currentUserAccountID: 1, @@ -48,6 +49,7 @@ describe('PromotedActions.message', () => { action.onSelected(); expect(mockNavigateToAndOpenReport).toHaveBeenCalledWith({ + isSupportalSession: false, userLogins: ['test@example.com'], personalDetails: {}, currentUserAccountID: 1, @@ -64,6 +66,7 @@ describe('PromotedActions.message', () => { it('should pass introSelected to navigateToAndOpenReportWithAccountIDs when accountID is provided', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, accountID: 42, currentUserAccountID: 1, @@ -82,6 +85,7 @@ describe('PromotedActions.message', () => { it('should pass undefined introSelected when not provided', () => { const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, accountID: 42, currentUserAccountID: 1, @@ -100,6 +104,7 @@ describe('PromotedActions.message', () => { it('should navigate to report directly when reportID is provided', () => { const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, reportID: 'report123', currentUserAccountID: 1, @@ -121,6 +126,7 @@ describe('PromotedActions.message', () => { it('should prefer login over accountID when both are provided', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, accountID: 42, personalDetails: {}, @@ -136,6 +142,7 @@ describe('PromotedActions.message', () => { action.onSelected(); expect(mockNavigateToAndOpenReport).toHaveBeenCalledWith({ + isSupportalSession: false, userLogins: ['test@example.com'], personalDetails: {}, currentUserAccountID: 1, @@ -154,6 +161,7 @@ describe('PromotedActions.message', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const betas = [CONST.BETAS.ALL]; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, accountID: 42, personalDetails: {}, @@ -173,6 +181,7 @@ describe('PromotedActions.message', () => { it('should call navigateToAndOpenReportWithAccountIDs with isSelfTourViewed=true when self tour has been viewed and accountID is provided', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, accountID: 42, currentUserAccountID: 1, @@ -193,6 +202,7 @@ describe('PromotedActions.message', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const betas = [CONST.BETAS.ALL]; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, login: 'test@example.com', currentUserAccountID: 1, @@ -207,6 +217,7 @@ describe('PromotedActions.message', () => { action.onSelected(); expect(mockNavigateToAndOpenReport).toHaveBeenCalledWith({ + isSupportalSession: false, userLogins: ['test@example.com'], personalDetails: {}, currentUserAccountID: 1, @@ -223,6 +234,7 @@ describe('PromotedActions.message', () => { it('should pass hasReportActions to navigateToAndOpenReport when the existing chat already has report actions', () => { const introSelected = {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}; const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, login: 'test@example.com', currentUserAccountID: 1, @@ -241,6 +253,7 @@ describe('PromotedActions.message', () => { it('should prefer reportID for self profile message action', () => { const action = PromotedActions.message({ + isSupportalSession: false, conciergeChat: undefined, reportID: 'selfReport123', accountID: 1, diff --git a/tests/unit/PusherPingPongTest.ts b/tests/unit/PusherPingPongTest.ts deleted file mode 100644 index 1213201e24bc..000000000000 --- a/tests/unit/PusherPingPongTest.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {subscribeToUserEvents} from '@libs/actions/User'; -import * as API from '@libs/API'; -import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; -import Log from '@libs/Log'; -import type * as NetworkStateModule from '@libs/NetworkState'; -import Pusher from '@libs/Pusher'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -import Onyx from 'react-native-onyx'; - -jest.mock('@libs/API'); -const mockAPI = jest.mocked(API); - -jest.mock('@libs/PusherUtils'); -jest.mock('@libs/ActiveClientManager', () => ({ - isClientTheLeader: jest.fn(() => true), - isReady: jest.fn(() => Promise.resolve()), - init: jest.fn(), -})); -jest.mock('@libs/NetworkState', () => ({ - ...jest.requireActual('@libs/NetworkState'), - getIsOffline: () => false, -})); - -const PING_INTERVAL_MS = 30_000; -const MISSING_PONG_THRESHOLD_MS = 60_000; - -describe('Pusher PINGPONG', () => { - let reconnectSpy: jest.SpyInstance; - let logSpy: jest.SpyInstance; - - beforeAll(() => { - // jest/setupAfterEnv.ts calls jest.useRealTimers() after the globally-enabled fake timers are installed, - // so they must be re-installed here for setInterval/setTimeout in User.ts to be controllable - jest.useFakeTimers(); - Onyx.init({keys: ONYXKEYS}); - reconnectSpy = jest.spyOn(Pusher, 'reconnect').mockImplementation(() => {}); - logSpy = jest.spyOn(Log, 'info').mockImplementation(() => {}); - - // The automock returns undefined, and pingPusher chains a .catch on the returned promise - mockAPI.makeRequestWithSideEffects.mockResolvedValue(undefined); - - subscribeToUserEvents(123, 'test@example.com', () => undefined); - }); - - afterAll(() => { - reconnectSpy.mockRestore(); - logSpy.mockRestore(); - jest.useRealTimers(); - }); - - it('logs a missing PONG without reconnecting, leaving recovery to the Pusher SDK', async () => { - await jest.advanceTimersByTimeAsync(5 * MISSING_PONG_THRESHOLD_MS); - - expect(reconnectSpy).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('leaving recovery to the Pusher SDK')); - }); - - it('sends the PING off the durable write queue', async () => { - mockAPI.makeRequestWithSideEffects.mockClear(); - await jest.advanceTimersByTimeAsync(PING_INTERVAL_MS); - - expect(mockAPI.makeRequestWithSideEffects).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.PUSHER_PING, expect.anything()); - expect(mockAPI.writeWithNoDuplicatesConflictAction).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/PusherSubscribeTest.ts b/tests/unit/PusherSubscribeTest.ts index 684367d4d4a6..356aa20dd9b3 100644 --- a/tests/unit/PusherSubscribeTest.ts +++ b/tests/unit/PusherSubscribeTest.ts @@ -29,7 +29,6 @@ async function initPusher() { Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }); // Flush microtasks so initPromise resolves. diff --git a/tests/unit/PusherSubscribeWebTest.ts b/tests/unit/PusherSubscribeWebTest.ts index 07e4de515550..365498adc764 100644 --- a/tests/unit/PusherSubscribeWebTest.ts +++ b/tests/unit/PusherSubscribeWebTest.ts @@ -169,7 +169,6 @@ describe('Pusher.subscribe on web', () => { await Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }); }); @@ -211,7 +210,7 @@ describe('Pusher.subscribe on web', () => { it('should authorize again when a caller subscribes after a subscription error', async () => { // Given a channel that failed authorization, because pusher-js keeps such a channel and the // old guard read it as usable and never asked for authorization again - const failed = Pusher.subscribe(CHANNEL, 'pong', () => {}); + const failed = Pusher.subscribe(CHANNEL, 'reportComment', () => {}); await jest.runAllTimersAsync(); const channel = mockChannels.get(CHANNEL); @@ -221,7 +220,7 @@ describe('Pusher.subscribe on web', () => { await expect(failed).rejects.toBe('Forbidden'); // When a caller subscribes to the same channel after that failure - const retried = Pusher.subscribe(CHANNEL, 'pong', () => {}); + const retried = Pusher.subscribe(CHANNEL, 'reportComment', () => {}); await jest.runAllTimersAsync(); channel?.completeHandshake(); await retried; @@ -235,7 +234,7 @@ describe('Pusher.subscribe on web', () => { // channel on its own at the next reconnect and the caller must not stay silent const onEvent = jest.fn(); - const failed = Pusher.subscribe(CHANNEL, 'pong', onEvent); + const failed = Pusher.subscribe(CHANNEL, 'reportComment', onEvent); await jest.runAllTimersAsync(); const channel = mockChannels.get(CHANNEL); @@ -245,7 +244,7 @@ describe('Pusher.subscribe on web', () => { // When pusher-js authorizes the channel again and the channel then carries one event channel?.startSubscription(); channel?.completeHandshake(); - channel?.receiveEvent('pong', {}); + channel?.receiveEvent('reportComment', {}); // Then the caller reads the event, because its success handler stays bound after a failure expect(onEvent).toHaveBeenCalledTimes(1); @@ -254,7 +253,7 @@ describe('Pusher.subscribe on web', () => { it('should reject every caller waiting on a failed handshake, not only the first', async () => { // Given two callers that wait on the same first handshake, because one shared error handler // rejected only the caller that opened the channel - const first = Pusher.subscribe(CHANNEL, 'pong', () => {}); + const first = Pusher.subscribe(CHANNEL, 'reportComment', () => {}); const second = Pusher.subscribe(CHANNEL, 'multipleEvents', () => {}); await jest.runAllTimersAsync(); @@ -386,13 +385,13 @@ describe('Pusher.subscribe on web', () => { }); it('should trigger one reconnect per drop, however many events subscribe to the private user channel', async () => { - // Given a private user channel with both PONG and MULTIPLE_EVENTS subscribed, because each + // Given a private user channel with both REPORT_COMMENT and MULTIPLE_EVENTS subscribed, because each // call carried its own onResubscribe and one drop sent two ReconnectApp requests const accountID = '1'; const userChannel = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${accountID}${CONFIG.PUSHER.SUFFIX}`; PusherUtils.onPrivateUserChannelResubscribe(accountID); - PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.PONG, accountID, () => {}); + PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.REPORT_COMMENT, accountID, () => {}); PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.MULTIPLE_EVENTS, accountID, () => {}); await jest.runAllTimersAsync(); @@ -507,7 +506,6 @@ describe('Pusher.subscribe on web', () => { await Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }); PusherUtils.onPrivateUserChannelResubscribe(accountID); diff --git a/tests/unit/QuickActionMenuItemTest.tsx b/tests/unit/QuickActionMenuItemTest.tsx index fe50107d1d8b..60cd251b0338 100644 --- a/tests/unit/QuickActionMenuItemTest.tsx +++ b/tests/unit/QuickActionMenuItemTest.tsx @@ -4,6 +4,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; import FABFocusableMenuItem from '@pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem'; @@ -27,6 +28,11 @@ const mockFormatPhoneNumber = jest.fn((value: string) => value); jest.mock('@hooks/useLocalize', () => () => ({translate: mockTranslate, formatPhoneNumber: mockFormatPhoneNumber})); jest.mock('@hooks/useCurrentUserPersonalDetails'); +jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ + startMoneyRequest: jest.fn(), +})); +jest.mock('@libs/interceptAnonymousUser', () => jest.fn((callback: () => void) => callback())); + jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: () => new Proxy({}, {get: (_, name) => String(name)}), })); @@ -49,6 +55,7 @@ jest.mock('@libs/ReportUtils', () => { const mockGetDisplayNameForParticipant = jest.mocked(getDisplayNameForParticipant); const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); +const mockStartMoneyRequest = jest.mocked(startMoneyRequest); const mockFABFocusableMenuItem = jest.mocked(FABFocusableMenuItem); const QUICK_ACTION_REPORT_ID = '991001'; @@ -67,21 +74,22 @@ describe('QuickActionMenuItem', () => { mockTranslate.mockClear(); mockFormatPhoneNumber.mockClear(); mockGetDisplayNameForParticipant.mockClear(); + mockStartMoneyRequest.mockClear(); }); - const renderWithActivePolicy = async (policyType: ValueOf) => { + const renderWithActivePolicy = async (policyType: ValueOf, shouldIncludePolicyChat = true, policyChatReportID = POLICY_CHAT_REPORT_ID) => { const activePolicy = createRandomPolicy(Number(ACTIVE_POLICY_ID), policyType); const policyExpenseChat = { ...createPolicyExpenseChat(Number(POLICY_CHAT_REPORT_ID)), - reportID: POLICY_CHAT_REPORT_ID, + reportID: policyChatReportID, policyID: ACTIVE_POLICY_ID, ownerAccountID: 1, }; await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, ACTIVE_POLICY_ID); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${ACTIVE_POLICY_ID}`, activePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${POLICY_CHAT_REPORT_ID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${POLICY_CHAT_REPORT_ID}`, shouldIncludePolicyChat ? policyExpenseChat : null); await Onyx.merge(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, null); await waitForBatchedUpdates(); @@ -117,7 +125,31 @@ describe('QuickActionMenuItem', () => { const props = await renderWithActivePolicy(CONST.POLICY.TYPE.TEAM); expect(mockFABFocusableMenuItem).toHaveBeenCalled(); - expect(props).toEqual(expect.objectContaining({isVisible: true})); + expect(props).toEqual(expect.objectContaining({isVisible: true, title: 'quickAction.scanReceipt', rightIconReportID: POLICY_CHAT_REPORT_ID})); + }); + + it('starts a scan request in the selected workspace chat', async () => { + const props = await renderWithActivePolicy(CONST.POLICY.TYPE.TEAM); + + props?.onPress?.(); + + expect(mockStartMoneyRequest.mock.calls.at(-1)?.[1]).toBe(POLICY_CHAT_REPORT_ID); + }); + + it('falls back to the component report when the selected workspace chat ID is empty', async () => { + const props = await renderWithActivePolicy(CONST.POLICY.TYPE.TEAM, true, ''); + + props?.onPress?.(); + + expect(mockStartMoneyRequest.mock.calls.at(-1)?.[1]).toBe(QUICK_ACTION_REPORT_ID); + }); + + it('falls back to the component report when no workspace chat is selected', async () => { + const props = await renderWithActivePolicy(CONST.POLICY.TYPE.TEAM, false); + + props?.onPress?.(); + + expect(mockStartMoneyRequest.mock.calls.at(-1)?.[1]).toBe(QUICK_ACTION_REPORT_ID); }); it('hides the workspace fallback quick action for a personal policy', async () => { @@ -126,4 +158,27 @@ describe('QuickActionMenuItem', () => { expect(mockFABFocusableMenuItem).toHaveBeenCalled(); expect(props).toEqual(expect.objectContaining({isVisible: false})); }); + + it('hides the workspace fallback quick action for a group policy without a matching chat', async () => { + const props = await renderWithActivePolicy(CONST.POLICY.TYPE.TEAM, false); + + expect(mockFABFocusableMenuItem).toHaveBeenCalled(); + expect(props).toEqual(expect.objectContaining({isVisible: false, rightIconReportID: undefined})); + }); + + it('uses the mapped title for a non-send quick action', async () => { + const report = {...createRegularChat(Number(QUICK_ACTION_REPORT_ID), [1, AVATAR_ACCOUNT_ID]), reportID: QUICK_ACTION_REPORT_ID}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${QUICK_ACTION_REPORT_ID}`, report); + await Onyx.merge(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, {action: CONST.QUICK_ACTIONS.REQUEST_MANUAL, chatReportID: QUICK_ACTION_REPORT_ID}); + await waitForBatchedUpdates(); + + render( + + + , + ); + await waitForBatchedUpdates(); + + expect(mockFABFocusableMenuItem.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({title: 'quickAction.requestMoney'})); + }); }); diff --git a/tests/unit/QuickActionUtilsTest.ts b/tests/unit/QuickActionUtilsTest.ts index 1d8ebf39efe5..5e67d8ae98a2 100644 --- a/tests/unit/QuickActionUtilsTest.ts +++ b/tests/unit/QuickActionUtilsTest.ts @@ -65,24 +65,24 @@ describe('QuickActionUtils', () => { }; it('should restrict REQUEST action on DMs', () => { - const withoutRestrictionsResult = isQuickActionAllowed(requestManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = isQuickActionAllowed(requestManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = isQuickActionAllowed(requestManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = isQuickActionAllowed(requestManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); }); it('should restrict SPLIT action on DMs', () => { - const withoutRestrictionsResult = isQuickActionAllowed(splitManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = isQuickActionAllowed(splitManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = isQuickActionAllowed(splitManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = isQuickActionAllowed(splitManualAction, DMReport, undefined, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); }); it('should restrict SEND_MONEY action on DMs', () => { - const withoutRestrictionsResult = isQuickActionAllowed(sendMoneyAction, DMReport, undefined, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = isQuickActionAllowed(sendMoneyAction, DMReport, undefined, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = isQuickActionAllowed(sendMoneyAction, DMReport, undefined, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = isQuickActionAllowed(sendMoneyAction, DMReport, undefined, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); @@ -91,8 +91,8 @@ describe('QuickActionUtils', () => { it('should restrict SPLIT action on Group chats', () => { const groupChatReport: Report = LHNTestUtils.getFakeReport([1, 2, 3, 4]); - const withoutRestrictionsResult = isQuickActionAllowed(splitManualAction, groupChatReport, undefined, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = isQuickActionAllowed(splitManualAction, groupChatReport, undefined, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = isQuickActionAllowed(splitManualAction, groupChatReport, undefined, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = isQuickActionAllowed(splitManualAction, groupChatReport, undefined, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); @@ -104,8 +104,8 @@ describe('QuickActionUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, }; - const withoutRestrictionsResult = isQuickActionAllowed(splitManualAction, policyRoomReport, undefined, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = isQuickActionAllowed(splitManualAction, policyRoomReport, undefined, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = isQuickActionAllowed(splitManualAction, policyRoomReport, undefined, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = isQuickActionAllowed(splitManualAction, policyRoomReport, undefined, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); @@ -161,7 +161,7 @@ describe('QuickActionUtils', () => { mockedPolicyUtils.isControlPolicy.mockReturnValue(true); mockedPolicyUtils.isPerDiemEnabled.mockReturnValue(true); - expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], false)).toBe(true); + expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], undefined, false)).toBe(true); }); it("should not allow per diem action when policy doesn't have per diem rates", () => { mockedPolicyUtils.getPerDiemCustomUnit.mockReturnValue(undefined); @@ -169,7 +169,7 @@ describe('QuickActionUtils', () => { id: '1', arePerDiemRatesEnabled: true, }); - expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], false)).toBe(false); + expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], undefined, false)).toBe(false); }); it("should not allow per diem action when policy doesn't have per diem enabled", () => { mockedPolicyUtils.isControlPolicy.mockReturnValue(true); @@ -178,7 +178,7 @@ describe('QuickActionUtils', () => { id: '1', arePerDiemRatesEnabled: false, }); - expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], false)).toBe(false); + expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], undefined, false)).toBe(false); }); it('should not allow per diem action when policy is not a control workspace', () => { const perDiemCustomUnit = { @@ -202,7 +202,7 @@ describe('QuickActionUtils', () => { }, }); - expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], false)).toBe(false); + expect(isQuickActionAllowed(perDiemAction, report, policy, false, [CONST.BETAS.ALL], undefined, false)).toBe(false); }); }); @@ -226,6 +226,7 @@ describe('QuickActionUtils', () => { }), false, [CONST.BETAS.ALL], + undefined, false, ), ).toBe(true); @@ -248,6 +249,7 @@ describe('QuickActionUtils', () => { }), false, [CONST.BETAS.ALL], + undefined, false, ), ).toBe(false); diff --git a/tests/unit/ReauthenticationMiddlewareTest.ts b/tests/unit/ReauthenticationMiddlewareTest.ts index 701c339e8ad5..829c6859b1be 100644 --- a/tests/unit/ReauthenticationMiddlewareTest.ts +++ b/tests/unit/ReauthenticationMiddlewareTest.ts @@ -47,7 +47,7 @@ beforeEach(() => { describe('Reauthentication middleware', () => { test('clears original request failure updates when failed reauthentication redirects to sign-in', () => { - jest.mocked(reauthenticate).mockResolvedValueOnce(false); + jest.mocked(reauthenticate).mockResolvedValueOnce({wasSuccessful: false}); const request: OnyxRequest = { command: 'TestCommand', @@ -81,8 +81,43 @@ describe('Reauthentication middleware', () => { }); }); + test('preserves original request failure and finally data when reauthentication restores the original session', () => { + jest.mocked(reauthenticate).mockResolvedValueOnce({wasSuccessful: false, didRestoreOriginalSession: true}); + + const request: OnyxRequest = { + command: 'TestCommand', + data: {apiRequestType: CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS}, + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NETWORK, + value: {shouldFailAllRequests: true}, + }, + ], + finallyData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NETWORK, + value: {shouldForceOffline: true}, + }, + ], + }; + + return Reauthentication( + Promise.resolve({ + jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, + }), + request, + false, + ).then((response) => { + expect(response?.jsonCode).toBe(CONST.JSON_CODE.NOT_AUTHENTICATED); + expect(request.failureData).toBeDefined(); + expect(request.finallyData).toBeDefined(); + }); + }); + test('does not apply original request failure data after failed reauthentication redirects to sign-in', () => { - jest.mocked(reauthenticate).mockResolvedValueOnce(false); + jest.mocked(reauthenticate).mockResolvedValueOnce({wasSuccessful: false}); let shouldFailAllRequests: boolean | undefined; let shouldForceOffline: boolean | undefined; @@ -129,7 +164,7 @@ describe('Reauthentication middleware', () => { }); test('rejects a queued write when reauthentication gives up so the queue keeps it', () => { - jest.mocked(reauthenticate).mockResolvedValueOnce(false); + jest.mocked(reauthenticate).mockResolvedValueOnce({wasSuccessful: false}); const request: OnyxRequest = { command: 'TestCommand', @@ -149,7 +184,7 @@ describe('Reauthentication middleware', () => { test('keeps the failure updates of a queued write when reauthentication gives up and signs out', () => { // Same mock as the abort case on purpose: the middleware cannot tell the two apart. - jest.mocked(reauthenticate).mockResolvedValueOnce(false); + jest.mocked(reauthenticate).mockResolvedValueOnce({wasSuccessful: false}); const request: OnyxRequest = { command: 'TestCommand', diff --git a/tests/unit/ReceiptAlternativeMethodsTest.tsx b/tests/unit/ReceiptAlternativeMethodsTest.tsx index 6d2b3184d2fc..d34f7042353a 100644 --- a/tests/unit/ReceiptAlternativeMethodsTest.tsx +++ b/tests/unit/ReceiptAlternativeMethodsTest.tsx @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import type ReactNative from 'react-native'; +import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; jest.mock('@hooks/useHasLoggedIntoMobileApp'); @@ -69,6 +70,14 @@ jest.mock('@hooks/useLocalize', () => const mockUseHasLoggedIntoMobileApp = jest.mocked(useHasLoggedIntoMobileApp); const mockUseHasPhoneNumberLogin = jest.mocked(useHasPhoneNumberLogin); +function renderComponent() { + return render( + + + , + ); +} + describe('ReceiptAlternativeMethods', () => { afterEach(() => { cleanup(); @@ -79,7 +88,7 @@ describe('ReceiptAlternativeMethods', () => { mockUseHasLoggedIntoMobileApp.mockReturnValue({hasLoggedIntoMobileApp: false, isLastMobileAppLoginLoaded: true}); mockUseHasPhoneNumberLogin.mockReturnValue({hasPhoneNumberLogin: false, isPhoneNumberLoaded: true}); - render(); + renderComponent(); expect(screen.getByText('Other ways to add receipts:')).toBeTruthy(); expect(screen.getByText('Download the app to scan from your phone')).toBeTruthy(); @@ -92,7 +101,7 @@ describe('ReceiptAlternativeMethods', () => { mockUseHasLoggedIntoMobileApp.mockReturnValue({hasLoggedIntoMobileApp: true, isLastMobileAppLoginLoaded: true}); mockUseHasPhoneNumberLogin.mockReturnValue({hasPhoneNumberLogin: false, isPhoneNumberLoaded: true}); - render(); + renderComponent(); expect(screen.queryByText('Download the app to scan from your phone')).toBeNull(); expect(screen.getByText(`Add your number to text receipts to ${CONST.SMS.RECEIPTS_PHONE_NUMBER}`)).toBeTruthy(); @@ -103,7 +112,7 @@ describe('ReceiptAlternativeMethods', () => { mockUseHasLoggedIntoMobileApp.mockReturnValue({hasLoggedIntoMobileApp: false, isLastMobileAppLoginLoaded: true}); mockUseHasPhoneNumberLogin.mockReturnValue({hasPhoneNumberLogin: true, isPhoneNumberLoaded: true}); - render(); + renderComponent(); expect(screen.getByText('Download the app to scan from your phone')).toBeTruthy(); expect(screen.getByText(`Text receipts to ${CONST.SMS.RECEIPTS_PHONE_NUMBER} (US numbers only)`)).toBeTruthy(); @@ -114,7 +123,7 @@ describe('ReceiptAlternativeMethods', () => { mockUseHasLoggedIntoMobileApp.mockReturnValue({hasLoggedIntoMobileApp: true, isLastMobileAppLoginLoaded: true}); mockUseHasPhoneNumberLogin.mockReturnValue({hasPhoneNumberLogin: true, isPhoneNumberLoaded: true}); - render(); + renderComponent(); expect(screen.queryByText('Download the app to scan from your phone')).toBeNull(); expect(screen.queryByText('Add your number')).toBeNull(); @@ -125,7 +134,7 @@ describe('ReceiptAlternativeMethods', () => { mockUseHasLoggedIntoMobileApp.mockReturnValue({hasLoggedIntoMobileApp: false, isLastMobileAppLoginLoaded: false}); mockUseHasPhoneNumberLogin.mockReturnValue({hasPhoneNumberLogin: false, isPhoneNumberLoaded: false}); - const {toJSON} = render(); + const {toJSON} = renderComponent(); expect(toJSON()).toBeNull(); }); diff --git a/tests/unit/RecruitingUtilsTest.ts b/tests/unit/RecruitingUtilsTest.ts index b78dcd33922d..4bdc56483721 100644 --- a/tests/unit/RecruitingUtilsTest.ts +++ b/tests/unit/RecruitingUtilsTest.ts @@ -5,19 +5,37 @@ import { getMergeATSOfficesLabel, getMergeATSStagesLabel, getMergeATSTagsLabel, + isAnyRecruitingConnected, isMergeATSCompleteSetupNeeded, shouldShowRecruitingConnectionError, } from '@libs/merge/RecruitingUtils'; +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; +import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; + import CONST from '@src/CONST'; import MERGE_ATS_PROVIDERS from '@src/CONST/MERGE_ATS_PROVIDERS'; import type {MergeATSProviderSlug} from '@src/CONST/MERGE_ATS_PROVIDERS'; +import ROUTES from '@src/ROUTES'; import type {ConnectionLastSync, Connections, MergeATSConnectionConfig, MergeConnectionLastSync} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import {formatPhoneNumber, translateLocal} from 'tests/utils/TestHelper'; + +jest.mock('@libs/PersonalDetailsUtils', () => ({ + temporaryGetDisplayNameOrDefault: jest.fn( + ({passedPersonalDetails, defaultValue}: {passedPersonalDetails?: {displayName?: string}; defaultValue: string}) => passedPersonalDetails?.displayName ?? defaultValue, + ), +})); const MERGE_ATS = CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS; const POLICY_ID = 'ABC123'; +const GREENHOUSE: MergeATSProviderSlug = 'greenhouse'; +const STUB_ICON: IconAsset = {uri: 'stub'}; +const APPROVER_LOGIN = 'approver@test.com'; +const ERROR_TIMESTAMP = 123; function makePolicy(overrides: Partial = {}): Policy { return { @@ -75,6 +93,36 @@ function makeMergeATSPolicy(connectionOverrides: Parameters[0]; + +function makeGetRecruitingCardsParams(overrides: Partial = {}): GetRecruitingCardsParams { + return { + policy: makePolicy(), + policyEmployeePersonalDetails: {}, + policyID: POLICY_ID, + icons: {Download: STUB_ICON}, + translate: translateLocal, + formatPhoneNumber, + ...overrides, + }; +} + +function getGreenhouseCard(overrides: Partial = {}): MergeProviderCardDescriptor | undefined { + return getRecruitingCards(makeGetRecruitingCardsParams(overrides)).find((card) => card.key === `merge_ats_${GREENHOUSE}`); +} + +function getRow(card: MergeProviderCardDescriptor | undefined, field: string) { + return card?.configRows?.find((row) => row.field === field); +} + +function getDefaultApproverTitle( + connectionOverrides: Parameters[0], + personalDetails: GetRecruitingCardsParams['policyEmployeePersonalDetails'] = {}, +): string | undefined { + const card = getGreenhouseCard({policy: makeMergeATSPolicy(connectionOverrides), policyEmployeePersonalDetails: personalDetails}); + return getRow(card, 'approvalMode')?.title; +} + describe('RecruitingUtils', () => { describe('getConnectedATSProvider', () => { it('returns null when no ATS provider is connected', () => { @@ -105,6 +153,26 @@ describe('RecruitingUtils', () => { }); }); + describe('isAnyRecruitingConnected', () => { + it('returns false when the policy has no ATS connection', () => { + // Given a policy with no connections at all, and one with an empty connections object + // When each is checked for a recruiting connection + // Then neither counts as connected + expect(isAnyRecruitingConnected(undefined)).toBe(false); + expect(isAnyRecruitingConnected(makePolicy())).toBe(false); + expect(isAnyRecruitingConnected(makePolicy({connections: {}}))).toBe(false); + }); + + it('returns true when a Merge ATS provider is connected', () => { + // Given a policy connected to Greenhouse + const policy = makeMergeATSPolicy({config: {integration: GREENHOUSE}}); + + // When the policy is checked for a recruiting connection + // Then it counts as connected + expect(isAnyRecruitingConnected(policy)).toBe(true); + }); + }); + describe('getMergeATSTagsLabel', () => { it('returns undefined when there is no connection, no filters, or no tag selected', () => { expect(getMergeATSTagsLabel(undefined)).toBeUndefined(); @@ -299,3 +367,288 @@ describe('RecruitingUtils', () => { }); }); }); + +describe('getRecruitingCards', () => { + it('returns one card per supported Merge ATS provider', () => { + // Given a policy with no ATS connection + // When the recruiting cards are built + const cards = getRecruitingCards(makeGetRecruitingCardsParams()); + + // Then every supported provider gets a card carrying its brand info and setup link + expect(cards).toHaveLength(Object.keys(MERGE_ATS_PROVIDERS).length); + for (const [slug, provider] of Object.entries(MERGE_ATS_PROVIDERS)) { + const card = cards.find((c) => c.key === `merge_ats_${slug}`); + expect(card?.category).toBe(CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING); + expect(card?.connectionName).toBe(MERGE_ATS); + expect(card?.displayName).toBe(provider.displayName); + expect(card?.icon).toBe(provider.iconUrl); + expect(card?.setupLink).toContain(`integration=${slug}`); + } + }); + + it('leaves a disconnected card without state or config rows', () => { + // Given a policy with no ATS connection + // When the Greenhouse card is built + const card = getGreenhouseCard(); + + // Then it is idle and offers nothing to configure + expect(card?.isConnected).toBe(false); + expect(card?.isSyncInProgress).toBe(false); + expect(card?.isInitialSyncInProgress).toBe(false); + expect(card?.hasError).toBe(false); + expect(card?.needsReconnect).toBe(false); + expect(card?.configRows).toEqual([]); + }); + + it('marks only the connected provider as connected', () => { + // Given a policy connected to Greenhouse + const policy = makeMergeATSPolicy({config: {integration: GREENHOUSE}}); + + // When the cards are built + const cards = getRecruitingCards(makeGetRecruitingCardsParams({policy})); + + // Then the Greenhouse card is connected and every other provider stays disconnected + expect(cards.find((card) => card.key === `merge_ats_${GREENHOUSE}`)?.isConnected).toBe(true); + expect(cards.filter((card) => card.key !== `merge_ats_${GREENHOUSE}`).every((card) => !card.isConnected)).toBe(true); + }); + + it('flags the first sync separately from a manual sync', () => { + // Given a connection whose very first sync is running + const initialSync = getGreenhouseCard({ + policy: makeMergeATSPolicy({lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING, syncType: CONST.MERGE.SYNC_TYPE.INITIAL}}), + }); + + // And a connection running a manual re-sync + const manualSync = getGreenhouseCard({ + policy: makeMergeATSPolicy({lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING, syncType: CONST.MERGE.SYNC_TYPE.MANUAL}}), + }); + + // Then both report a sync in progress, but only the first one is the initial sync + expect(initialSync?.isSyncInProgress).toBe(true); + expect(initialSync?.isInitialSyncInProgress).toBe(true); + expect(manualSync?.isSyncInProgress).toBe(true); + expect(manualSync?.isInitialSyncInProgress).toBe(false); + }); + + it('surfaces the error message of a failed sync', () => { + // Given a connection whose last sync failed + const policy = makeMergeATSPolicy({lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, errorMessage: 'Something broke'}}); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then the failure and its message are carried on the card + expect(card?.hasError).toBe(true); + expect(card?.lastSyncErrorMessage).toBe('Something broke'); + }); + + it('drops the error message of a sync that did not fail', () => { + // Given a connection that synced successfully but still carries an error message from an earlier attempt + const policy = makeMergeATSPolicy({ + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE, errorMessage: 'Stale error', successfulDate: '2024-01-01'}, + }); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then no error is shown and the successful sync date is passed through + expect(card?.hasError).toBe(false); + expect(card?.lastSyncErrorMessage).toBeUndefined(); + expect(card?.successfulDate).toBe('2024-01-01'); + }); + + it('hides the config rows while the connection needs to be reconnected', () => { + // Given a connection whose last sync failed to authenticate + const policy = makeMergeATSPolicy({ + config: {approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC}, + lastSync: {isAuthenticationError: true}, + }); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then the admin is steered to reconnect instead of configuring the connection + expect(card?.needsReconnect).toBe(true); + expect(card?.configRows).toEqual([]); + }); + + it('points to the import settings page while the setup is incomplete', () => { + // Given a synced connection that returned filter options the admin has not chosen from yet + const policy = makeMergeATSPolicy({ + data: {stages: [{id: 's1', name: 'Offer'}]}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + }); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then it links to the import settings page to finish the setup + expect(card?.completeSetupRoute).toBe(ROUTES.WORKSPACE_RECRUITING_MERGE_IMPORT_SETTINGS.getRoute(POLICY_ID)); + }); + + it('does not ask to complete the setup once filters are chosen', () => { + // Given a synced connection whose filters are already saved + const policy = makeMergeATSPolicy({ + config: {filters: {stages: ['Offer']}}, + data: {stages: [{id: 's1', name: 'Offer'}]}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + }); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then there is no setup route left to follow + expect(card?.completeSetupRoute).toBeUndefined(); + }); + + it('does not ask to complete the setup while the connection needs to be reconnected', () => { + // Given a connection that both needs reconnecting and has an unfinished setup + const policy = makeMergeATSPolicy({ + data: {stages: [{id: 's1', name: 'Offer'}]}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE, isAuthenticationError: true}, + }); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then reconnecting takes priority over the setup prompt + expect(card?.needsReconnect).toBe(true); + expect(card?.completeSetupRoute).toBeUndefined(); + }); + + it('builds the import settings and default approver rows for a connected card', () => { + // Given a connected policy + const policy = makeMergeATSPolicy({config: {integration: GREENHOUSE}}); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then the two config rows are returned in display order, each pointing at its own RHP + expect(card?.configRows?.map((row) => row.field)).toEqual(['filters', 'approvalMode']); + expect(getRow(card, 'filters')?.route).toBe(ROUTES.WORKSPACE_RECRUITING_MERGE_IMPORT_SETTINGS.getRoute(POLICY_ID)); + expect(getRow(card, 'approvalMode')?.route).toBe(ROUTES.WORKSPACE_RECRUITING_MERGE_APPROVAL_MODE.getRoute(POLICY_ID)); + }); + + it('renders the import settings row as a plain labelled menu item', () => { + // Given a connected policy + const policy = makeMergeATSPolicy({config: {integration: GREENHOUSE}}); + + // When the import settings row is built + const row = getRow(getGreenhouseCard({policy}), 'filters'); + + // Then it opts out of the top description and shows the download icon next to its label + expect(row?.shouldRenderAsMenuItem).toBe(true); + expect(row?.icon).toBe(STUB_ICON); + expect(row?.title).toBe('workspace.recruiting.importSettings'); + expect(row?.description).toBeUndefined(); + }); + + it('carries the pending action and errors of each config field', () => { + // Given a connection with a filters update in flight and a failed approval mode update + const policy = makeMergeATSPolicy({ + config: { + pendingFields: {filters: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {approvalMode: {[ERROR_TIMESTAMP]: 'Generic error'}}, + }, + }); + + // When the Greenhouse card is built + const card = getGreenhouseCard({policy}); + + // Then each row reflects the state of its own field + expect(getRow(card, 'filters')?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(getRow(card, 'filters')?.errors).toBeUndefined(); + expect(getRow(card, 'approvalMode')?.pendingAction).toBeUndefined(); + expect(getRow(card, 'approvalMode')?.errors).toEqual({[ERROR_TIMESTAMP]: 'Generic error'}); + }); + + describe('default approver row', () => { + it('reads "not set" when no approval mode is configured', () => { + // Given a connected policy with no approval mode + // When the default approver row is built + // Then nothing is set yet + expect(getDefaultApproverTitle({config: {approvalMode: null}})).toBe('workspace.merge.notSet'); + }); + + it('shows only the mode name in custom mode', () => { + // Given a connection in custom approval mode, where approvals are configured in Expensify instead + // When the default approver row is built + // Then only the mode is shown, with no approver + expect(getDefaultApproverTitle({config: {approvalMode: CONST.MERGE.APPROVAL_MODE.CUSTOM, finalApprover: APPROVER_LOGIN}})).toBe('workspace.merge.approvalModes.custom'); + }); + + it('shows the mode and the approver in basic mode', () => { + // Given a connection in basic mode with a final approver + // When the default approver row is built + // Then the mode and the approver are shown together + expect(getDefaultApproverTitle({config: {approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC, finalApprover: APPROVER_LOGIN}})).toBe( + `workspace.merge.approvalModes.basic • ${APPROVER_LOGIN}`, + ); + }); + + 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 + // Then their display name is shown instead of their login + expect( + getDefaultApproverTitle( + {config: {approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC, finalApprover: APPROVER_LOGIN}}, + {[APPROVER_LOGIN]: {accountID: 1, displayName: 'Alex Approver'}}, + ), + ).toBe('workspace.merge.approvalModes.basic • Alex Approver'); + }); + + it('shows the ATS field the approver is read from in advanced mode', () => { + // Given a connection in advanced mode reading the approver from the recruiter field + // When the default approver row is built + // Then the mode, the translated ATS field, and the fallback approver are all shown + expect( + getDefaultApproverTitle({ + config: {approvalMode: CONST.MERGE.APPROVAL_MODE.ADVANCED, approverField: CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, finalApprover: APPROVER_LOGIN}, + }), + ).toBe(`workspace.merge.approvalModes.advanced • workspace.recruiting.approverFields.recruiter -> ${APPROVER_LOGIN}`); + }); + + it('translates the recruiting coordinator field in advanced mode', () => { + // Given a connection in advanced mode reading the approver from the recruiting coordinator field + // When the default approver row is built + // Then that field gets its own translated label + expect( + getDefaultApproverTitle({ + config: {approvalMode: CONST.MERGE.APPROVAL_MODE.ADVANCED, approverField: CONST.MERGE.ATS_APPROVER_FIELD.RECRUITING_COORDINATOR, finalApprover: APPROVER_LOGIN}, + }), + ).toBe(`workspace.merge.approvalModes.advanced • workspace.recruiting.approverFields.recruitingCoordinator -> ${APPROVER_LOGIN}`); + }); + + it('shows an unrecognized ATS field as-is', () => { + // Given an approver field the app does not have a translation for, since the backend can add new ones + // When the default approver row is built + // Then the raw field name is shown rather than a missing translation + expect( + getDefaultApproverTitle({ + config: {approvalMode: CONST.MERGE.APPROVAL_MODE.ADVANCED, approverField: 'hiringManager', finalApprover: APPROVER_LOGIN}, + }), + ).toBe(`workspace.merge.approvalModes.advanced • 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 + // Then the missing field is called out + expect( + getDefaultApproverTitle({ + config: {approvalMode: CONST.MERGE.APPROVAL_MODE.ADVANCED, approverField: null, finalApprover: APPROVER_LOGIN}, + }), + ).toBe(`workspace.merge.approvalModes.advanced • workspace.merge.notSet -> ${APPROVER_LOGIN}`); + }); + }); +}); diff --git a/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts b/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts new file mode 100644 index 000000000000..9bfd1a981955 --- /dev/null +++ b/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts @@ -0,0 +1,318 @@ +import {resolveAgentAccountID} from '@libs/AgentAccountIDMapping'; +import replaceOptimisticAgentAccountID from '@libs/Middleware/ReplaceOptimisticAgentAccountID'; + +import * as PersistedRequests from '@userActions/PersistedRequests'; +import {clear, getAll, getOngoingRequest, processNextRequest, save} from '@userActions/PersistedRequests'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyRequest} from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import cloneDeep from 'lodash/cloneDeep'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +type MappingKey = typeof ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING; + +// Optimistic agent accountIDs are generated with generateReportID(), so they are long random digit sequences. +// Long distinct literals keep the middleware's substring replacement honest: no ID is a substring of another. +const optimisticAccountID = 4864093870251437; +const realAccountID = 7093215648820996; +const otherAgentAccountID = 1592647380425183; + +let requestIndex = 0; + +function buildUpdateAgentPromptRequest(agentAccountID: number): AnyRequest { + requestIndex += 1; + return { + command: 'UpdateAgentPrompt', + data: {agentAccountID, prompt: 'Book my flights', apiRequestType: 'write'}, + requestIndex, + }; +} + +// createAgent() sends the optimistic accountID as a string. +function buildCreateAgentRequest(agentAccountID: number): AnyRequest { + requestIndex += 1; + return { + command: 'CreateAgent', + data: {firstName: 'Concierge', prompt: 'Book my flights', optimisticAccountID: String(agentAccountID), isPersonalAgent: true, apiRequestType: 'write'}, + requestIndex, + }; +} + +function buildMappingResponse(mapping: Record): Response { + return { + jsonCode: 200, + onyxData: [{onyxMethod: 'merge', key: ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, value: mapping}], + }; +} + +beforeAll(() => Onyx.init({keys: ONYXKEYS})); + +beforeEach(async () => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + // PersistedRequests.save() only lands in getAll() once the module's Onyx connect callback has initialized it. + await waitForBatchedUpdates(); + clear(); + await waitForBatchedUpdates(); +}); + +afterEach(async () => { + await clear(); + await Onyx.clear(); +}); + +describe('ReplaceOptimisticAgentAccountID middleware', () => { + it('rewrites the numeric agentAccountID of persisted agent update requests to the real accountID', async () => { + const request = buildUpdateAgentPromptRequest(optimisticAccountID); + save(request); + await waitForBatchedUpdates(); + + const response = buildMappingResponse({[optimisticAccountID]: realAccountID}); + const resolvedResponse = await replaceOptimisticAgentAccountID(Promise.resolve(response), buildUpdateAgentPromptRequest(optimisticAccountID), false); + + expect(resolvedResponse).toBe(response); + const persistedData = getAll().at(0)?.data; + expect(persistedData?.agentAccountID).toBe(realAccountID); + expect(persistedData?.prompt).toBe('Book my flights'); + expect(getAll().at(0)?.command).toBe('UpdateAgentPrompt'); + }); + + it('rewrites the optimistic accountID inside string values and object keys of persisted request data', async () => { + requestIndex += 1; + const request: AnyRequest = { + command: 'UpdateAgentPrompt', + data: { + firstName: 'Concierge', + optimisticAccountID: String(optimisticAccountID), + agentPromptKey: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, + nvpUpdates: { + [`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`]: {prompt: 'Book my flights'}, + }, + }, + requestIndex, + }; + save(request); + await waitForBatchedUpdates(); + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[optimisticAccountID]: realAccountID})), buildCreateAgentRequest(optimisticAccountID), false); + + 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?.nvpUpdates).toStrictEqual({ + [`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`]: {prompt: 'Book my flights'}, + }); + expect(persistedData?.firstName).toBe('Concierge'); + }); + + it('rewrites the Onyx keys inside persisted successData and failureData while leaving unrelated updates untouched', async () => { + requestIndex += 1; + const request: AnyRequest = { + 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}}, + ], + failureData: [ + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, value: {prompt: 'Old prompt', pendingAction: null}}, + {onyxMethod: 'merge', key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: {[optimisticAccountID]: {displayName: 'Concierge'}}}, + ], + requestIndex, + }; + save(request); + await waitForBatchedUpdates(); + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[optimisticAccountID]: realAccountID})), request, false); + + 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}}, + ]); + expect(persistedRequest?.failureData).toStrictEqual([ + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`, value: {prompt: 'Old prompt', pendingAction: null}}, + {onyxMethod: 'merge', key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: {[realAccountID]: {displayName: 'Concierge'}}}, + ]); + }); + + it('leaves the CreateAgent that produced the mapping untouched and only rewrites the requests queued behind it', async () => { + save(buildCreateAgentRequest(optimisticAccountID)); + save(buildUpdateAgentPromptRequest(optimisticAccountID)); + await waitForBatchedUpdates(); + + // The sequential queue moves the request being processed out of the persisted list before its response reaches the middleware. + const ongoingRequest = processNextRequest(); + expect(ongoingRequest?.command).toBe('CreateAgent'); + + if (!ongoingRequest) { + return; + } + const ongoingRequestBefore = cloneDeep(ongoingRequest); + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[optimisticAccountID]: realAccountID})), ongoingRequest, true); + + // The CreateAgent has already succeeded: rewriting it with the real accountID would create a second agent if the app + // closes or the queue retries before it is removed, so it must keep its optimistic accountID. + expect(getOngoingRequest()?.data?.optimisticAccountID).toBe(String(optimisticAccountID)); + expect(getOngoingRequest()).toStrictEqual(ongoingRequestBefore); + + expect(getAll()).toHaveLength(1); + expect(getAll().at(0)?.data?.agentAccountID).toBe(realAccountID); + }); + + it('leaves persisted requests untouched when the response carries no accountID mapping', async () => { + save(buildUpdateAgentPromptRequest(optimisticAccountID)); + await waitForBatchedUpdates(); + const requestsBefore = cloneDeep(getAll()); + + const responseWithOtherKey: Response = { + jsonCode: 200, + onyxData: [{onyxMethod: 'merge', key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: {[realAccountID]: {firstName: 'Concierge'}}}], + }; + await replaceOptimisticAgentAccountID(Promise.resolve(responseWithOtherKey), buildUpdateAgentPromptRequest(optimisticAccountID), false); + await replaceOptimisticAgentAccountID(Promise.resolve({jsonCode: 200}), buildUpdateAgentPromptRequest(optimisticAccountID), false); + + expect(getAll()).toStrictEqual(requestsBefore); + }); + + it('skips mapping entries that have already been consumed and cleared', async () => { + save(buildUpdateAgentPromptRequest(optimisticAccountID)); + await waitForBatchedUpdates(); + const requestsBefore = cloneDeep(getAll()); + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[optimisticAccountID]: null})), buildUpdateAgentPromptRequest(optimisticAccountID), false); + + expect(getAll()).toStrictEqual(requestsBefore); + }); + + it('exposes the mapping to resolveAgentAccountID as soon as the response passes through the middleware', async () => { + // Dedicated IDs: the in-memory mapping registered here lives for the whole test module. + const lateEditOptimisticAccountID = 3841956273048125; + const lateEditRealAccountID = 8102934756182347; + + await replaceOptimisticAgentAccountID( + Promise.resolve(buildMappingResponse({[lateEditOptimisticAccountID]: lateEditRealAccountID})), + buildCreateAgentRequest(lateEditOptimisticAccountID), + true, + ); + + // The response's Onyx data has not been flushed (replaceOptimisticAgentWithActualAgent has not run), yet an + // agent action fired in this window must already resolve to the real account. + expect(resolveAgentAccountID(lateEditOptimisticAccountID)).toBe(lateEditRealAccountID); + }); + + it('does not register invalid mapping entries with resolveAgentAccountID', async () => { + const shortOptimisticAccountID = 123456789; + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[shortOptimisticAccountID]: realAccountID})), buildCreateAgentRequest(shortOptimisticAccountID), true); + + expect(resolveAgentAccountID(shortOptimisticAccountID)).toBe(shortOptimisticAccountID); + }); + + it('only re-persists the requests that reference the optimistic accountID', async () => { + save(buildUpdateAgentPromptRequest(optimisticAccountID)); + const otherAgentRequest = buildUpdateAgentPromptRequest(otherAgentAccountID); + save(otherAgentRequest); + await waitForBatchedUpdates(); + const otherAgentRequestBefore = cloneDeep(otherAgentRequest); + const updateSpy = jest.spyOn(PersistedRequests, 'update'); + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[optimisticAccountID]: realAccountID})), buildUpdateAgentPromptRequest(optimisticAccountID), false); + + expect(getAll().at(0)?.data?.agentAccountID).toBe(realAccountID); + expect(getAll().at(1)).toStrictEqual(otherAgentRequestBefore); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenCalledWith(0, expect.objectContaining({command: 'UpdateAgentPrompt'})); + updateSpy.mockRestore(); + }); + + describe('mapping entry validation', () => { + // Every string and number below shares digits with the malformed keys under test, so any rewrite would show up. + const digitHeavyAccountID = 1029384756102938; + + function buildDigitHeavyRequest(): AnyRequest { + requestIndex += 1; + return { + command: 'UpdateAgentPrompt', + data: { + agentAccountID: digitHeavyAccountID, + optimisticAccountID: String(digitHeavyAccountID), + reportID: '1234', + page: 1, + offset: 0, + prompt: '', + agentPromptKey: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${digitHeavyAccountID}`, + apiRequestType: 'write', + }, + successData: [{onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${digitHeavyAccountID}`, value: {pendingAction: null, prompt: ''}}], + requestIndex, + }; + } + + async function expectMappingToLeaveQueueUntouched(mappingKey: string, mappedAccountID: number) { + save(buildDigitHeavyRequest()); + await waitForBatchedUpdates(); + const requestsBefore = cloneDeep(getAll()); + + await replaceOptimisticAgentAccountID(Promise.resolve(buildMappingResponse({[mappingKey]: mappedAccountID})), buildDigitHeavyRequest(), false); + + expect(getAll()).toStrictEqual(requestsBefore); + } + + it('leaves persisted requests untouched when the mapping key is a short digit string', async () => { + await expectMappingToLeaveQueueUntouched('1', realAccountID); + }); + + it('leaves persisted requests untouched when the mapping key is an empty string', async () => { + await expectMappingToLeaveQueueUntouched('', realAccountID); + }); + + it('leaves persisted requests untouched when the mapping key is shorter than the minimum optimistic accountID length', async () => { + await expectMappingToLeaveQueueUntouched('102938475', realAccountID); + }); + + it.each(['abc', '99999999999999999999', '0000000001', '-1029384756102938', '1029384756102938.5', '1029384756e3', ' 1029384756102938'])( + 'leaves persisted requests untouched when the mapping key is not a canonical safe positive integer (%p)', + async (mappingKey) => { + await expectMappingToLeaveQueueUntouched(mappingKey, realAccountID); + }, + ); + + it.each([0, -5, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'leaves persisted requests untouched when the real accountID is not a safe positive integer (%p)', + async (invalidRealAccountID) => { + await expectMappingToLeaveQueueUntouched(String(digitHeavyAccountID), invalidRealAccountID); + }, + ); + + it('leaves persisted requests untouched when the mapping maps an accountID to itself', async () => { + const updateSpy = jest.spyOn(PersistedRequests, 'update'); + + await expectMappingToLeaveQueueUntouched(String(digitHeavyAccountID), digitHeavyAccountID); + + expect(updateSpy).not.toHaveBeenCalled(); + updateSpy.mockRestore(); + }); + + it('rewrites persisted requests when the mapping key has exactly the minimum optimistic accountID length', async () => { + const shortOptimisticAccountID = 1029384756; + save(buildUpdateAgentPromptRequest(shortOptimisticAccountID)); + await waitForBatchedUpdates(); + + await replaceOptimisticAgentAccountID( + Promise.resolve(buildMappingResponse({[shortOptimisticAccountID]: realAccountID})), + buildUpdateAgentPromptRequest(shortOptimisticAccountID), + false, + ); + + expect(getAll().at(0)?.data?.agentAccountID).toBe(realAccountID); + expect(getAll().at(0)?.data?.prompt).toBe('Book my flights'); + }); + }); +}); diff --git a/tests/unit/ReplaceShouldUseStagingServerWithActiveServerTest.ts b/tests/unit/ReplaceShouldUseStagingServerWithActiveServerTest.ts new file mode 100644 index 000000000000..0b551d6d0d2e --- /dev/null +++ b/tests/unit/ReplaceShouldUseStagingServerWithActiveServerTest.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- exercising the migration means reading SHOULD_USE_STAGING_SERVER */ +import ReplaceShouldUseStagingServerWithActiveServer from '@libs/migrations/ReplaceShouldUseStagingServerWithActiveServer'; + +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'; + +describe('ReplaceShouldUseStagingServerWithActiveServer', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it.each([ + [true, CONST.SERVER.STAGING], + [false, CONST.SERVER.PRODUCTION], + ])('maps shouldUseStagingServer=%s to %s and removes the legacy key', async (legacyValue, expected) => { + await Onyx.set(ONYXKEYS.SHOULD_USE_STAGING_SERVER, legacyValue); + await ReplaceShouldUseStagingServerWithActiveServer(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.ACTIVE_SERVER)).toBe(expected); + expect(await getOnyxValue(ONYXKEYS.SHOULD_USE_STAGING_SERVER)).toBeUndefined(); + }); + + it('writes nothing when the legacy key is absent, so the env default still applies', async () => { + await ReplaceShouldUseStagingServerWithActiveServer(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.ACTIVE_SERVER)).toBeUndefined(); + }); + + it('leaves an existing activeServer alone — a newer build already chose', async () => { + await Onyx.multiSet({[ONYXKEYS.SHOULD_USE_STAGING_SERVER]: true, [ONYXKEYS.ACTIVE_SERVER]: CONST.SERVER.QA}); + await ReplaceShouldUseStagingServerWithActiveServer(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.ACTIVE_SERVER)).toBe(CONST.SERVER.QA); + expect(await getOnyxValue(ONYXKEYS.SHOULD_USE_STAGING_SERVER)).toBeUndefined(); + }); +}); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 604674e585e5..a9e7a2d9b77b 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1,6 +1,7 @@ import {isChronosStartOrStopMessage, isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import {getEnvironmentURL} from '@libs/Environment/Environment'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import getReportURLForCurrentContext from '@libs/Navigation/helpers/getReportURLForCurrentContext'; import {setHasRadio} from '@libs/NetworkState'; import {isExpenseReport} from '@libs/ReportUtils'; @@ -13,7 +14,7 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; import type {CompanyAddressOriginalMessage, UpdateACHAccountOriginalMessage} from '../../src/libs/ReportActionsUtils'; -import type {Card, DecisionName, PersonalDetailsList, Report, ReportAction, ReportActions} from '../../src/types/onyx'; +import type {Card, DecisionName, PersonalDetails, PersonalDetailsList, Report, ReportAction, ReportActions} from '../../src/types/onyx'; import type {OriginalMessageExportIntegration} from '../../src/types/onyx/OriginalMessage'; import type {ReportCollectionDataSet} from '../../src/types/onyx/Report'; import type {ReportActionsCollectionDataSet} from '../../src/types/onyx/ReportAction'; @@ -41,6 +42,7 @@ import { getIntegrationSyncFailedMessage, getInvoiceCompanyNameUpdateMessage, getInvoiceCompanyWebsiteUpdateMessage, + getJoinRequestMessage, getMccGroupCategoryMessage, getModerationFlagState, getOneTransactionThreadReportID, @@ -2228,6 +2230,59 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.getRenamedAction(translateLocal, reportAction, isExpenseReport(report), 'John')).toBe('John renamed to "New name" (previously "Old name")'); }); }); + + describe('getJoinRequestMessage', () => { + const joinRequestAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST, + reportActionID: 'join-request-1', + created: '2024-10-01 10:00:00.000', + originalMessage: { + choice: CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT, + policyID: '1', + accountID: 2, + email: 'requester@expensify.com', + }, + }; + + it('should use the display name and login when the requester has a first name', () => { + const userDetail: PersonalDetails = { + accountID: 2, + firstName: 'John', + displayName: 'John Doe', + login: 'john.doe@expensify.com', + }; + + expect(getJoinRequestMessage(translateLocal, 'Expensify', joinRequestAction, userDetail)).toBe('John Doe (john.doe@expensify.com) requested to join Expensify'); + }); + + it('should use only the login when the requester has no first name', () => { + const userDetail: PersonalDetails = { + accountID: 2, + displayName: 'John Doe', + login: 'john.doe@expensify.com', + }; + + expect(getJoinRequestMessage(translateLocal, 'Expensify', joinRequestAction, userDetail)).toBe('john.doe@expensify.com requested to join Expensify'); + }); + + it('should fall back to the email from the original message when the personal details are missing', () => { + expect(getJoinRequestMessage(translateLocal, 'Expensify', joinRequestAction, undefined)).toBe('requester@expensify.com requested to join Expensify'); + }); + + it('should fall back to an empty user when there are neither personal details nor an email', () => { + const actionWithoutEmail: ReportAction = { + ...joinRequestAction, + originalMessage: { + choice: CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT, + policyID: '1', + accountID: 2, + }, + }; + + expect(getJoinRequestMessage(translateLocal, 'Expensify', actionWithoutEmail, undefined)).toBe(' requested to join Expensify'); + }); + }); + describe('getCardIssuedMessage', () => { const mockVirtualCardIssuedAction: ReportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL, @@ -2262,6 +2317,7 @@ describe('ReportActionsUtils', () => { expensifyCard: undefined, translate: translateLocal, currentUserAccountID: 1, + buildDynamicRoute: createDynamicRoute, }); expect(messageResult).toBe('issued a virtual Expensify Card! The card can be used right away.'); @@ -2275,6 +2331,7 @@ describe('ReportActionsUtils', () => { expensifyCard: activeExpensifyCard, translate: translateLocal, currentUserAccountID: 1, + buildDynamicRoute: createDynamicRoute, }); expect(messageResult).toBe( @@ -2314,6 +2371,7 @@ describe('ReportActionsUtils', () => { companyCard: mockCompanyCard, translate: translateLocal, currentUserAccountID: 456, + buildDynamicRoute: createDynamicRoute, }); expect(messageResult).toContain(``); @@ -2326,6 +2384,7 @@ describe('ReportActionsUtils', () => { companyCard: mockCompanyCard, translate: translateLocal, currentUserAccountID: 1, + buildDynamicRoute: createDynamicRoute, }); expect(messageResult).not.toContain(' { }); describe('special-action chain branches (policy room)', () => { - it.each(SIMPLE_CHAIN_ACTIONS)('%s', (actionName) => { + it.each(SIMPLE_CHAIN_ACTIONS)('should match snapshot for %s', (actionName) => { expect(getAlternateText({lastAction: makeAction(actionName)})).toMatchSnapshot(); }); }); describe('branches with bespoke payloads', () => { - it('RENAMED with old/new name', () => { + it('should match snapshot for RENAMED with old/new name', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, {originalMessage: {oldName: 'Old room name', newName: 'New room name'}}), @@ -267,7 +269,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('ROOM invite with targetAccountIDs and room name from originalMessage', () => { + it('should match snapshot for ROOM invite with targetAccountIDs and room name from originalMessage', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, {originalMessage: {targetAccountIDs: [2, 3], roomName: 'general'}}), @@ -275,7 +277,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('POLICY remove with single target', () => { + it('should match snapshot for POLICY remove with single target', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, {originalMessage: {targetAccountIDs: [2]}}), @@ -283,7 +285,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('ROOM invite without targetAccountIDs falls back to mention-user count in lastMessageHtml', () => { + it('should match snapshot when ROOM invite without targetAccountIDs falls back to mention-user count in lastMessageHtml', () => { expect( getAlternateText({ report: makeReport({lastMessageHtml: ''}), @@ -292,7 +294,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('invite with room name resolved from lastActionReport', () => { + it('should match snapshot for invite with room name resolved from lastActionReport', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, {originalMessage: {targetAccountIDs: [2]}}), @@ -301,7 +303,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('ACTIONABLE_CARD_FRAUD_ALERT with resolution', () => { + it('should match snapshot for ACTIONABLE_CARD_FRAUD_ALERT with resolution', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT, { @@ -311,7 +313,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('CARD_ISSUED with card', () => { + it('should match snapshot for CARD_ISSUED with card', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, {originalMessage: {assigneeAccountID: 2, cardID: 11}}), @@ -328,7 +330,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('CARD_ISSUED without card', () => { + it('should match snapshot for CARD_ISSUED without card', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, {originalMessage: {assigneeAccountID: 2, cardID: 11}}), @@ -336,7 +338,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('OldDot action SELECTED_FOR_RANDOM_AUDIT (markdown leak preserved)', () => { + it('should match snapshot for OldDot action SELECTED_FOR_RANDOM_AUDIT (markdown leak preserved)', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT), @@ -344,7 +346,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('ADD_INTEGRATION with connection name', () => { + it('should match snapshot for ADD_INTEGRATION with connection name', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION, {originalMessage: {connectionName: CONST.POLICY.CONNECTIONS.NAME.QBO}}), @@ -352,7 +354,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('DELETE_INTEGRATION with connection name', () => { + it('should match snapshot for DELETE_INTEGRATION with connection name', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION, {originalMessage: {connectionName: CONST.POLICY.CONNECTIONS.NAME.XERO}}), @@ -360,7 +362,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('UPDATE_EMPLOYEE with role change', () => { + it('should match snapshot for UPDATE_EMPLOYEE with role change', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE, { @@ -370,7 +372,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('REASSIGN_APPROVER with new approver', () => { + it('should match snapshot for REASSIGN_APPROVER with new approver', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.REASSIGN_APPROVER, {originalMessage: {newApproverID: 2}}), @@ -378,7 +380,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('TRAVEL_UPDATE booking ticketed', () => { + it('should match snapshot for TRAVEL_UPDATE booking ticketed', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE, { @@ -395,7 +397,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('MOVED_TRANSACTION with derived report name', () => { + it('should match snapshot for MOVED_TRANSACTION with derived report name', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, {originalMessage: {toReportID: '300', fromReportID: '100'}}), @@ -406,7 +408,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('generic ADD_COMMENT in room gets actor prefix', () => { + it('should match snapshot when generic ADD_COMMENT in room gets actor prefix', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT), @@ -414,7 +416,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('generic ADD_COMMENT from current user in room', () => { + it('should match snapshot for generic ADD_COMMENT from current user in room', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, {actorAccountID: CURRENT_USER_ACCOUNT_ID, person: [{type: 'TEXT', style: 'strong', text: 'Current User'}]}), @@ -422,7 +424,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('generic branch resolves actor from person[0].text when personalDetails miss the actor', () => { + it('should match snapshot when generic branch resolves actor from person[0].text when personalDetails miss the actor', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, {actorAccountID: 42, person: [{type: 'TEXT', style: 'strong', text: 'Mystery Person'}]}), @@ -430,7 +432,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('REPORT_PREVIEW skips actor prefix and falls to else branch', () => { + it('should match snapshot when REPORT_PREVIEW skips actor prefix and falls to else branch', () => { expect( getAlternateText({ report: makeReport({chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, reportName: 'Workspace chat'}), @@ -442,11 +444,11 @@ describe('getOptionData alternateText parity snapshots', () => { }); describe('report-type and fallback variants', () => { - it('empty room falls back to welcome message', () => { + it('should match snapshot when empty room falls back to welcome message', () => { expect(getAlternateText({lastMessageTextFromReport: ''})).toMatchSnapshot(); }); - it('empty policy expense chat falls back to welcome message', () => { + it('should match snapshot when empty policy expense chat falls back to welcome message', () => { expect( getAlternateText({ report: makeReport({chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, reportName: 'Workspace chat', ownerAccountID: CURRENT_USER_ACCOUNT_ID}), @@ -455,7 +457,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('empty DM falls back to welcome message (non-chat else branch)', () => { + it('should match snapshot when empty DM falls back to welcome message (non-chat else branch)', () => { expect( getAlternateText({ report: makeReport({chatType: undefined, reportName: ''}), @@ -464,7 +466,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('empty self-DM with isTrackIntentUser true', () => { + it('should match snapshot for empty self-DM with isTrackIntentUser true', () => { expect( getAlternateText({ report: makeReport({ @@ -478,7 +480,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('empty self-DM with isTrackIntentUser false', () => { + it('should match snapshot for empty self-DM with isTrackIntentUser false', () => { expect( getAlternateText({ report: makeReport({ @@ -491,7 +493,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('DM with last message shows actor prefix via shouldShowLastActorDisplayName', () => { + it('should match snapshot when DM with last message shows actor prefix via shouldShowLastActorDisplayName', () => { expect( getAlternateText({ report: makeReport({chatType: undefined, reportName: '', lastActorAccountID: 1}), @@ -500,7 +502,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('DM last message from current user has no actor prefix', () => { + it('should match snapshot when DM last message from current user has no actor prefix', () => { expect( getAlternateText({ report: makeReport({chatType: undefined, reportName: '', lastActorAccountID: CURRENT_USER_ACCOUNT_ID}), @@ -509,7 +511,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('group chat with last comment', () => { + it('should match snapshot for group chat with last comment', () => { expect( getAlternateText({ report: makeReport({ @@ -527,7 +529,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('archived room skips the special-action chain', () => { + it('should match snapshot when archived room skips the special-action chain', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.RENAMED, {originalMessage: {oldName: 'Old', newName: 'New'}}), @@ -536,7 +538,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('multiline last message collapses line breaks', () => { + it('should match snapshot when multiline last message collapses line breaks', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT), @@ -545,7 +547,7 @@ describe('getOptionData alternateText parity snapshots', () => { ).toMatchSnapshot(); }); - it('SMS domain is stripped from last message', () => { + it('should match snapshot when SMS domain is stripped from last message', () => { expect( getAlternateText({ lastAction: makeAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT), diff --git a/tests/unit/ReportAlternateTextUtilsTest.ts b/tests/unit/ReportAlternateTextUtilsTest.ts index 306d8b62f543..a20c084bf5d5 100644 --- a/tests/unit/ReportAlternateTextUtilsTest.ts +++ b/tests/unit/ReportAlternateTextUtilsTest.ts @@ -37,14 +37,7 @@ import { getWelcomeMessage, shouldShowLastActorDisplayName, } from '@libs/ReportAlternateTextUtils'; -import { - formatReportLastMessageText, - getMovedActionMessage, - getMovedTransactionMessage, - getReportPreviewReportActionMessage, - getReportTransactions, - parseMovedTransactionReportIDs, -} from '@libs/ReportUtils'; +import {formatReportLastMessageText, getMovedActionMessage, getMovedTransactionMessage, getReportPreviewReportActionMessage, parseMovedTransactionReportIDs} from '@libs/ReportUtils'; import {isScanning} from '@libs/TransactionUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -62,7 +55,7 @@ import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; import * as LHNTestUtils from '../utils/LHNTestUtils'; -import {convertToDisplayString, formatPhoneNumber, getCurrencyDecimalsLocal, localeCompare, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, convertToDisplayStringWithoutCurrency, formatPhoneNumber, getCurrencyDecimalsLocal, localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -144,6 +137,8 @@ function getAlternateText({report = makeReport(), lastAction, lastMessageTextFro formatPhoneNumber, dateFnsLocale: undefined, convertToDisplayString, + convertToDisplayStringWithoutCurrency, + rules: undefined, }); } @@ -579,7 +574,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -646,7 +643,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -698,7 +697,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -750,7 +751,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -802,7 +805,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -853,7 +858,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -892,7 +899,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -957,7 +966,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1008,7 +1019,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1045,7 +1058,9 @@ describe('ReportAlternateTextUtils', () => { [movedTransactionAction.reportActionID]: movedTransactionAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1076,7 +1091,9 @@ describe('ReportAlternateTextUtils', () => { [submittedAction.reportActionID]: submittedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1107,7 +1124,9 @@ describe('ReportAlternateTextUtils', () => { [approvedAction.reportActionID]: approvedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1140,7 +1159,9 @@ describe('ReportAlternateTextUtils', () => { [forwardedAction.reportActionID]: forwardedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1170,7 +1191,9 @@ describe('ReportAlternateTextUtils', () => { [forwardedAction.reportActionID]: forwardedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1198,7 +1221,9 @@ describe('ReportAlternateTextUtils', () => { [corporateForceUpgradeAction.reportActionID]: corporateForceUpgradeAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1225,7 +1250,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1251,7 +1278,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1277,7 +1306,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, translate: translateLocal, @@ -1302,7 +1333,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, translate: translateLocal, @@ -1327,7 +1360,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, translate: translateLocal, @@ -1355,7 +1390,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1381,7 +1418,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1407,7 +1446,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1436,7 +1477,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1462,7 +1505,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1494,7 +1539,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1520,7 +1567,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1546,7 +1595,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1571,7 +1622,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1596,7 +1649,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1625,7 +1680,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1654,7 +1711,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1682,7 +1741,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1711,7 +1772,9 @@ describe('ReportAlternateTextUtils', () => { [action.reportActionID]: action, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1736,7 +1799,9 @@ describe('ReportAlternateTextUtils', () => { [takeControlAction.reportActionID]: takeControlAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1762,7 +1827,9 @@ describe('ReportAlternateTextUtils', () => { [rerouteAction.reportActionID]: rerouteAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1788,7 +1855,9 @@ describe('ReportAlternateTextUtils', () => { [movedAction.reportActionID]: movedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1818,7 +1887,9 @@ describe('ReportAlternateTextUtils', () => { // When getting the last message text for the report const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1850,7 +1921,9 @@ describe('ReportAlternateTextUtils', () => { const expectedVisibleText = ''; const result = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1901,7 +1974,9 @@ describe('ReportAlternateTextUtils', () => { // When we get the last message text while the mentioned user is absent from personal details const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, personalDetails: undefined, translate: translateLocal, @@ -1925,7 +2000,9 @@ describe('ReportAlternateTextUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1960,7 +2037,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const result = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -1972,7 +2051,7 @@ describe('ReportAlternateTextUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, }); - const transactions = getReportTransactions(report.reportID); + const transactions = [scannedTransaction]; const scanningTransactions = transactions.filter((transaction) => isScanning(transaction)); expect(result).toBe( translateLocal('iou.receiptScanning', { @@ -2008,7 +2087,9 @@ describe('ReportAlternateTextUtils', () => { await waitForBatchedUpdates(); const result = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -2057,7 +2138,9 @@ describe('ReportAlternateTextUtils', () => { [submittedAction.reportActionID]: submittedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -2096,7 +2179,9 @@ describe('ReportAlternateTextUtils', () => { [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -2131,7 +2216,9 @@ describe('ReportAlternateTextUtils', () => { [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -2174,7 +2261,9 @@ describe('ReportAlternateTextUtils', () => { }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -2216,7 +2305,9 @@ describe('ReportAlternateTextUtils', () => { }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, @@ -2255,7 +2346,9 @@ describe('ReportAlternateTextUtils', () => { path === 'workspace.common.unavailable' ? 'UnavailableMarker' : translateLocal(path, ...parameters); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, personalDetails: undefined, translate: translateWithUnavailableMarker, @@ -2290,7 +2383,9 @@ describe('ReportAlternateTextUtils', () => { }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, translate: translateLocal, @@ -2324,7 +2419,9 @@ describe('ReportAlternateTextUtils', () => { }); const lastMessage = getLastMessageTextForReport({ + rules: undefined, dateFnsLocale: undefined, + convertToDisplayString, conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: undefined, diff --git a/tests/unit/ReportFetchHandlerTest.tsx b/tests/unit/ReportFetchHandlerTest.tsx new file mode 100644 index 000000000000..434f788ae5c6 --- /dev/null +++ b/tests/unit/ReportFetchHandlerTest.tsx @@ -0,0 +1,148 @@ +import {act, render} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ReportFetchHandler from '@pages/inbox/ReportFetchHandler'; + +import type * as UserActionsReport from '@userActions/Report'; + +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 waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const REPORT_ID = '1'; + +let mockRouteParams: Record = {reportID: REPORT_ID}; +const mockSetParams = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useRoute: () => ({key: 'report', name: 'Report', params: mockRouteParams}), + useNavigation: () => ({setParams: mockSetParams, addListener: jest.fn(() => jest.fn())}), + useIsFocused: () => true, +})); + +const mockOpenReport = jest.fn(); +jest.mock('@userActions/Report', () => ({ + ...jest.requireActual('@userActions/Report'), + openReport: (...args: Parameters) => { + mockOpenReport(...args); + }, +})); + +function renderHandler() { + return render( + + + , + ); +} + +/** Regression tests for the guards that suppress openReport for a client-generated report ID that doesn't exist on the server yet. */ +describe('ReportFetchHandler', () => { + beforeEach(async () => { + mockOpenReport.mockClear(); + mockSetParams.mockClear(); + mockRouteParams = {reportID: REPORT_ID}; + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + }); + await waitForBatchedUpdates(); + }); + + it('does NOT call openReport when isPendingCreation is set and the report does not exist locally yet', async () => { + // Given an optimistic destination that has not been created locally yet + mockRouteParams = {reportID: REPORT_ID, isPendingCreation: 'true'}; + + // When the pre-mounted destination starts handling report fetches + renderHandler(); + await waitForBatchedUpdates(); + + // Then fetching is deferred because the server cannot resolve the optimistic report ID + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + + 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 + mockRouteParams = {reportID: REPORT_ID, isPendingCreation: 'true'}; + jest.useFakeTimers(); + + // When the focused screen waits without a report row + renderHandler(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.STALE_PENDING_CREATION_ROUTE_TIMEOUT - 1); + }); + expect(mockSetParams).not.toHaveBeenCalled(); + + // Then the flag is cleared once the grace period elapses, so fetching and the not-found guard can resolve the screen + act(() => { + jest.advanceTimersByTime(1); + }); + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + jest.useRealTimers(); + }); + + it('calls openReport again once the pre-mounted report exists locally and isPendingCreation clears', async () => { + // Given a pre-mounted report that has become locally available + mockRouteParams = {reportID: REPORT_ID}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await waitForBatchedUpdates(); + + // When report fetching resumes after creation completes + renderHandler(); + await waitForBatchedUpdates(); + + // Then the real report is fetched because its optimistic guard is no longer needed + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); + + it('clears isPendingCreation once the report exists locally', async () => { + // Given an optimistic route whose report has just become locally available + mockRouteParams = {reportID: REPORT_ID, isPendingCreation: 'true'}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await waitForBatchedUpdates(); + + // When the handler observes the newly created report + renderHandler(); + await waitForBatchedUpdates(); + + // Then the temporary route guard is removed because future fetches are safe + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + }); + + it('does NOT call openReport while the pre-mount marker is set, even though the report row exists', async () => { + // Given a draft report pre-mounted only for speculative pre-mounting + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${REPORT_ID}`, true); + await waitForBatchedUpdates(); + + // When the handler sees the speculative report row + renderHandler(); + await waitForBatchedUpdates(); + + // Then fetching stays blocked because the report is not committed on the server + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + + it('calls openReport again once the pre-mount marker is cleared', async () => { + // Given a pre-mounted report that has completed its speculative lifecycle + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${REPORT_ID}`, null); + await waitForBatchedUpdates(); + + // When the handler observes that the pre-mount is complete + renderHandler(); + await waitForBatchedUpdates(); + + // Then normal fetching resumes because the report is now safe to request + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); +}); diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 0ca8e449c1be..032e7444ef2a 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -1,6 +1,5 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; import {translate} from '@libs/Localize'; import { buildReportNameFromParticipantNames, @@ -30,7 +29,7 @@ import {createAdminRoom, createExpenseReport, createPolicyExpenseChat, createReg import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; import {fakePersonalDetails} from '../utils/LHNTestUtils'; -import {formatPhoneNumber, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, convertToDisplayStringWithoutCurrency, formatPhoneNumber, getCurrencySymbolLocal, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const currentUserLogin = 'lagertha2@vikings.net'; @@ -48,6 +47,9 @@ describe('ReportNameUtils', () => { ) => computeReportNameOriginal({ dateFnsLocale: undefined, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol: getCurrencySymbolLocal, conciergeReportID: undefined, report, reports, @@ -61,6 +63,7 @@ describe('ReportNameUtils', () => { reportTransactions: buildTransactionsByReportID(transactions), translate: translateLocal, isTrackIntentUser: false, + rules: undefined, }); const participantsPersonalDetails: PersonalDetailsList = [ { @@ -306,6 +309,9 @@ describe('ReportNameUtils', () => { const translateWithYouMarker: LocalizedTranslate = (path, ...parameters) => (path === 'common.you' ? 'You Marker' : translateLocal(path, ...parameters)); const name = computeReportNameOriginal({ dateFnsLocale: undefined, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol: getCurrencySymbolLocal, conciergeReportID: undefined, report, reports: emptyCollections.reports, @@ -318,6 +324,7 @@ describe('ReportNameUtils', () => { translate: translateWithYouMarker, isTrackIntentUser: false, reportTransactions: {}, + rules: undefined, }); // temporaryGetDisplayNameOrDefault lowercases the "you" postfix sourced from translate('common.you'). expect(name).toBe('Lagertha Lothbrok (you marker)'); @@ -694,6 +701,9 @@ describe('ReportNameUtils', () => { const name = computeReportNameOriginal({ dateFnsLocale: undefined, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol: getCurrencySymbolLocal, conciergeReportID: undefined, report: thread, reports: emptyCollections.reports, @@ -707,6 +717,7 @@ describe('ReportNameUtils', () => { allPolicyTags: policyTagsCollection, reportTransactions: {}, isTrackIntentUser: false, + rules: undefined, }); expect(name).toContain('Cost Center'); @@ -1930,6 +1941,7 @@ describe('ReportNameUtils', () => { const reportName = getMoneyRequestReportName({ report: iouReport, translate: translateWithHiddenMarker, + convertToDisplayString, personalDetailsList: undefined, linkedTransactions: [], }); @@ -1955,7 +1967,13 @@ describe('ReportNameUtils', () => { currency: 'USD', }; - const reportName = getMoneyRequestReportName({report: invoiceReport, personalDetailsList: participantsPersonalDetails, linkedTransactions: [], translate: translateLocal}); + const reportName = getMoneyRequestReportName({ + report: invoiceReport, + personalDetailsList: participantsPersonalDetails, + linkedTransactions: [], + translate: translateLocal, + convertToDisplayString, + }); expect(reportName?.replaceAll(/\s+/g, ' ')).toContain('Ragnar Lothbrok'); }); @@ -1985,6 +2003,7 @@ describe('ReportNameUtils', () => { personalDetailsList: undefined, linkedTransactions: [], translate: translateLocal, + convertToDisplayString, }); // Then it should return "New Report" @@ -2033,6 +2052,7 @@ describe('ReportNameUtils', () => { personalDetailsList: undefined, linkedTransactions: [], translate: translateLocal, + convertToDisplayString, }); // Then it should NOT return empty string — it should fall through to dynamic name computation @@ -2095,6 +2115,7 @@ describe('ReportNameUtils', () => { personalDetailsList: undefined, linkedTransactions: [nonReimbursableTransaction], translate: translateLocal, + convertToDisplayString, }); // Then it should use the "spent" wording with the owner's display name @@ -2151,6 +2172,7 @@ describe('ReportNameUtils', () => { personalDetailsList: undefined, linkedTransactions: [], translate: translateWithUnavailableMarker, + convertToDisplayString, }); expect(reportName).toContain('UnavailableWorkspaceMarker'); @@ -2294,6 +2316,9 @@ describe('ReportNameUtils', () => { // When the threaded conciergeReportID matches the report const nameWithMatchingID = computeReportNameOriginal({ dateFnsLocale: undefined, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol: getCurrencySymbolLocal, conciergeReportID: 'concierge-name-1', report, transactions: undefined, @@ -2302,12 +2327,16 @@ describe('ReportNameUtils', () => { translate: translateLocal, reportTransactions: {}, isTrackIntentUser: false, + rules: undefined, }); expect(nameWithMatchingID).toBe(CONST.CONCIERGE_DISPLAY_NAME); // And an identical report with a non-matching conciergeReportID keeps its regular name const nameWithDifferentID = computeReportNameOriginal({ dateFnsLocale: undefined, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol: getCurrencySymbolLocal, conciergeReportID: 'a-different-report-id', report, transactions: undefined, @@ -2316,6 +2345,7 @@ describe('ReportNameUtils', () => { translate: translateLocal, reportTransactions: {}, isTrackIntentUser: false, + rules: undefined, }); expect(nameWithDifferentID).not.toBe(CONST.CONCIERGE_DISPLAY_NAME); }); diff --git a/tests/unit/ReportNotFoundGuardTest.tsx b/tests/unit/ReportNotFoundGuardTest.tsx index 10d15f24d400..460af8bb55ee 100644 --- a/tests/unit/ReportNotFoundGuardTest.tsx +++ b/tests/unit/ReportNotFoundGuardTest.tsx @@ -18,9 +18,16 @@ const REPORT_ID = '1'; const CHILD_TEST_ID = 'report-content'; const NOT_FOUND_TEST_ID = 'FullPageNotFoundView'; +let mockRouteParams: {reportID: string; isPendingCreation?: string} = {reportID: REPORT_ID}; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), - useRoute: () => ({key: 'report', name: 'Report', params: {reportID: REPORT_ID}}), + useRoute: () => ({key: 'report', name: 'Report', params: mockRouteParams}), +})); + +let mockIsOffline = false; +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: mockIsOffline}), })); // FullPageNotFoundView lazy-loads the ToddBehindCloud illustration; stub it so the blocking view renders synchronously. @@ -52,6 +59,8 @@ const isNotFoundVisible = () => screen.queryByTestId(NOT_FOUND_TEST_ID) !== null */ describe('ReportNotFoundGuard', () => { beforeEach(async () => { + mockRouteParams = {reportID: REPORT_ID}; + mockIsOffline = false; await Onyx.clear(); await Onyx.multiSet({ [ONYXKEYS.IS_LOADING_APP]: false, @@ -94,6 +103,44 @@ describe('ReportNotFoundGuard', () => { expect(isContentVisible()).toBe(false); }); + it('does NOT show the not-found page offline for a pre-mounted report that is still pending creation (#100785)', async () => { + // Given the route carries isPendingCreation and the device is offline, so the actions-loading term is ignored + mockRouteParams = {reportID: REPORT_ID, isPendingCreation: 'true'}; + mockIsOffline = true; + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isLoadingInitialReportActions: true}); + await waitForBatchedUpdates(); + + // When the app finishes loading before the optimistic report row exists + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: true, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: true, + }); + renderGuard(); + await waitForBatchedUpdates(); + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + }); + await waitForBatchedUpdates(); + + // Then the guard keeps rendering the loading content instead of flashing "not here" + expect(isContentVisible()).toBe(true); + expect(isNotFoundVisible()).toBe(false); + }); + + it('still shows the not-found page for an invalid reportID even when isPendingCreation is set', async () => { + // Given a malformed report path that also carries the pending-creation flag + mockRouteParams = {reportID: '0', isPendingCreation: 'true'}; + + // When the guard renders + renderGuard(); + await waitForBatchedUpdates(); + + // Then the invalid path wins, since no submit can ever create a report under it + expect(isNotFoundVisible()).toBe(true); + expect(isContentVisible()).toBe(false); + }); + it('renders the report content when the report exists', async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isLoadingInitialReportActions: false}); diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index c4f800700d56..dff3bacbed53 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -83,6 +83,7 @@ describe('getPrimaryAction', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -115,6 +116,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -147,6 +149,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -181,6 +184,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -214,6 +218,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -247,6 +252,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -285,6 +291,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -320,6 +327,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -355,6 +363,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -391,6 +400,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -443,6 +453,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -493,6 +504,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -554,6 +566,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -590,6 +603,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -626,6 +640,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -660,6 +675,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -700,6 +716,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -738,6 +755,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -909,6 +927,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -953,6 +972,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -986,6 +1006,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1018,6 +1039,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1052,6 +1074,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1101,6 +1124,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1152,6 +1176,7 @@ describe('getPrimaryAction', () => { try { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1190,6 +1215,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1224,6 +1250,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1265,6 +1292,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1307,6 +1335,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1346,6 +1375,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1385,6 +1415,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1451,6 +1482,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1510,6 +1542,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1601,6 +1634,7 @@ describe('getPrimaryAction', () => { bankAccountList: {}, policy, isChatReportArchived: false, + rules: undefined, }; expect(getReportPrimaryAction({...params, isOffline: false})).toBe(CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD); @@ -1666,6 +1700,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1727,6 +1762,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1769,6 +1805,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1808,6 +1845,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1847,6 +1885,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1886,6 +1925,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1926,6 +1966,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -1963,6 +2004,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -2011,6 +2053,7 @@ describe('getPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -2051,6 +2094,7 @@ describe('getPrimaryAction', () => { // Then the getReportPrimaryAction should return the empty string expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -2313,6 +2357,7 @@ describe('getTransactionThreadPrimaryAction', () => { expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -2354,6 +2399,7 @@ describe('getTransactionThreadPrimaryAction', () => { }); expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, @@ -2396,6 +2442,7 @@ describe('getTransactionThreadPrimaryAction', () => { }); expect( getReportPrimaryAction({ + rules: undefined, currentUserLogin: CURRENT_USER_EMAIL, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 39da538d1ccb..aed88eea2a20 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -161,6 +161,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }), ).toEqual(result); }); @@ -184,6 +185,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS)).toBe(true); @@ -204,6 +206,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS)).toBe(false); @@ -224,6 +227,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS)).toBe(false); @@ -274,6 +278,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.PAY)).toBe(true); @@ -330,6 +335,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); @@ -389,6 +395,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); @@ -441,6 +448,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); @@ -494,6 +502,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); @@ -556,6 +565,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); @@ -582,6 +592,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.PRINT)).toBe(false); @@ -611,6 +622,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.PRINT)).toBe(true); @@ -649,6 +661,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE)).toBe(true); }); @@ -682,6 +695,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -716,6 +730,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -754,6 +769,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -796,6 +812,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -840,6 +857,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -882,6 +900,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -934,6 +953,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -973,6 +993,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1009,6 +1030,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -1045,6 +1067,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -1078,6 +1101,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -1118,6 +1142,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -1165,6 +1190,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -1197,6 +1223,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1245,6 +1272,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -1280,6 +1308,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1320,6 +1349,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1366,6 +1396,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1407,6 +1438,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1447,6 +1479,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); }); @@ -1486,6 +1519,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); @@ -1529,6 +1563,7 @@ describe('getSecondaryAction', () => { violations, bankAccountList: {}, policy, + rules: undefined, }); // Then APPROVE should be included because DEW approval is not in progress @@ -1575,6 +1610,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, + rules: undefined, }); // Then APPROVE should not be included because DEW is already processing an approval @@ -1619,6 +1655,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); @@ -1662,6 +1699,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); @@ -1704,6 +1742,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); }); @@ -1745,6 +1784,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); @@ -1783,6 +1823,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); }); @@ -1821,6 +1862,7 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); @@ -1856,6 +1898,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); @@ -1882,6 +1925,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(true); }); @@ -1911,6 +1955,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(true); }); @@ -1939,6 +1984,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(true); }); @@ -1967,6 +2013,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); @@ -1996,6 +2043,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); @@ -2025,6 +2073,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); @@ -2055,6 +2104,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); @@ -2086,6 +2136,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); // Then UNAPPROVE should not be included because DEW policies restrict unapprove to admins only @@ -2120,6 +2171,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); // Then UNAPPROVE should be included because admins can unapprove on DEW policies @@ -2152,6 +2204,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); @@ -2221,10 +2274,18 @@ describe('getSecondaryAction', () => { bankAccountList: {}, }; - const resultWithoutArchive = getSecondaryReportActions({...baseArgs, policy: createMock(policyData)}); + const resultWithoutArchive = getSecondaryReportActions({ + ...baseArgs, + policy: createMock(policyData), + rules: undefined, + }); expect(resultWithoutArchive.includes(action)).toBe(true); - const resultWithArchive = getSecondaryReportActions({...baseArgs, policy: createMock({...policyData, archivedDate: '2026-08-01 00:00:00'})}); + const resultWithArchive = getSecondaryReportActions({ + ...baseArgs, + policy: createMock({...policyData, archivedDate: '2026-08-01 00:00:00'}), + rules: undefined, + }); expect(resultWithArchive.includes(action)).toBe(false); }); @@ -2268,6 +2329,7 @@ describe('getSecondaryAction', () => { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, }, isChatReportArchived: true, + rules: undefined, }); expect(result.includes(action)).toBe(true); }); @@ -2320,6 +2382,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); @@ -2372,6 +2435,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); @@ -2424,6 +2488,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); @@ -2476,6 +2541,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); @@ -2514,6 +2580,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy: undefined, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); @@ -2552,6 +2619,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy: undefined, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); }); @@ -2590,6 +2658,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy: undefined, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); }); @@ -2628,6 +2697,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy: undefined, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); }); @@ -2658,6 +2728,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(true); }); @@ -2688,6 +2759,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(true); }); @@ -2718,6 +2790,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -2749,6 +2822,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(true); }); @@ -2786,6 +2860,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [reportAction], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -2821,6 +2896,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -2858,6 +2934,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [reportAction], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(true); }); @@ -2889,6 +2966,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(true); }); @@ -2920,6 +2998,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -2952,6 +3031,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -2987,6 +3067,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -3018,6 +3099,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); @@ -3052,6 +3134,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [actionR14932], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.HOLD)).toBe(true); }); @@ -3085,6 +3168,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [actionR14932], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.HOLD)).toBe(false); }); @@ -3121,6 +3205,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions: [actionR14932], + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.HOLD)).toBe(false); }); @@ -3165,6 +3250,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, policies, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE)).toBe(false); }); @@ -3210,6 +3296,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, policies, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE)).toBe(true); }); @@ -3270,6 +3357,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy: oldPolicy, policies, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE)).toBe(true); }); @@ -3330,6 +3418,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, policies, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE)).toBe(true); }); @@ -3390,6 +3479,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, policies, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE)).toBe(true); }); @@ -3416,6 +3506,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -3477,6 +3568,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(false); @@ -3508,6 +3600,7 @@ describe('getSecondaryAction', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); @@ -3554,6 +3647,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -3600,6 +3694,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -3661,6 +3756,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(false); }); @@ -3706,6 +3802,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -3757,6 +3854,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -3799,6 +3897,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(false); }); @@ -3837,6 +3936,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(false); }); @@ -3891,6 +3991,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(false); }); @@ -3934,6 +4035,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -3965,6 +4067,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result).toContain(CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD); }); @@ -4000,6 +4103,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result).not.toContain(CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD); }); @@ -4046,6 +4150,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(true); }); @@ -4077,6 +4182,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, originalTransaction: createMock({}), policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); @@ -4138,6 +4244,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); @@ -4179,6 +4286,7 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); @@ -4225,6 +4333,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); @@ -4263,6 +4372,7 @@ describe('getSecondaryAction', () => { policy, reportActions, moveExpenseReportNameValuePairs, + rules: undefined, }); expect(result).toContain(CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE); expect(ReportUtils.canEditFieldOfMoneyRequest).toHaveBeenCalledWith( @@ -4301,6 +4411,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, reportActions, + rules: undefined, }); expect(result).not.toContain(CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE); }); @@ -4727,6 +4838,7 @@ describe('getReportAccountingExportActions', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result).toContain(CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD); }); @@ -4762,6 +4874,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }), ).toEqual(result); }); @@ -4792,6 +4905,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.HOLD)).toBe(true); }); @@ -4819,6 +4933,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy, transactionThreadReport, isChatReportArchived: false, + rules: undefined, }); expect(result).toContain(CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD); @@ -4834,6 +4949,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy, transactionThreadReport, isChatReportArchived: false, + rules: undefined, }); expect(result2).not.toContain(CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD); }); @@ -4860,6 +4976,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); @@ -4919,6 +5036,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction, policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(false); @@ -4972,6 +5090,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy, policies, reportActions, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE)).toBe(false); }); @@ -5017,6 +5136,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(true); }); @@ -5078,6 +5198,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction, policy, isChatReportArchived: false, + rules: undefined, }); // Then the SPLIT option is available @@ -5124,6 +5245,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(false); }); @@ -5170,6 +5292,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(false); }); @@ -5219,6 +5342,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(false); }); @@ -5248,6 +5372,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(true); }); @@ -5279,6 +5404,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy: createMock({}), isChatReportArchived, hasWorkspaceToSubmitTo, + rules: undefined, }); } @@ -5347,6 +5473,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy, grandParentReport: selfDMReport, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(true); }); @@ -5376,6 +5503,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(false); }); @@ -5408,6 +5536,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy, reportNameValuePairs, isChatReportArchived: false, + rules: undefined, }); expect(result).toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MOVE_EXPENSE); expect(ReportUtils.canEditFieldOfMoneyRequest).toHaveBeenCalledWith( @@ -5443,6 +5572,7 @@ describe('getSecondaryTransactionThreadActions', () => { originalTransaction: createMock({}), policy, isChatReportArchived: false, + rules: undefined, }); expect(result).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MOVE_EXPENSE); }); @@ -5502,6 +5632,7 @@ describe('getSecondaryTransactionThreadActions', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.MERGE)).toBe(true); @@ -5557,6 +5688,7 @@ describe('getSecondaryTransactionThreadActions', () => { violations: {}, bankAccountList: {}, policy, + rules: undefined, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.MERGE)).toBe(true); @@ -5577,7 +5709,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); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); }); @@ -5591,7 +5723,7 @@ describe('getSecondaryTransactionThreadActions', () => { ]; const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); }); @@ -5605,7 +5737,7 @@ describe('getSecondaryTransactionThreadActions', () => { {id: 'policy3', role: CONST.POLICY.ROLE.ADMIN}, ]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); }); @@ -5617,10 +5749,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(false); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false, undefined); }); it('should return true for single transaction when report is eligible for merge', () => { @@ -5630,10 +5762,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(true); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true, undefined); }); it('should return true for two eligible transactions', () => { @@ -5656,7 +5788,7 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(true); }); @@ -5681,7 +5813,7 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); }); @@ -5691,7 +5823,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); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, 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) @@ -5705,10 +5837,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(true); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true, undefined); }); it('should return false for non-admin user with ineligible reports', () => { @@ -5718,10 +5850,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(false); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false, undefined); }); it('should return false when one of multiple reports is not eligible', () => { @@ -5739,12 +5871,12 @@ describe('getSecondaryTransactionThreadActions', () => { .mockReturnValueOnce(true) // First report eligible .mockReturnValueOnce(false); // Second report not eligible - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(false); expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledTimes(2); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(1, reports.at(0), true); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(2, reports.at(1), false); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(1, reports.at(0), true, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(2, reports.at(1), false, undefined); }); describe('preventing merge for transactions belonging to different users', () => { @@ -5767,7 +5899,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports: Report[] = []; const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); expect(result).toBe(true); }); @@ -5790,7 +5922,7 @@ describe('getSecondaryTransactionThreadActions', () => { ]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(true); }); @@ -5813,7 +5945,7 @@ describe('getSecondaryTransactionThreadActions', () => { ]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(false); }); @@ -5833,7 +5965,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, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(true); }); @@ -5853,7 +5985,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, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(true); }); @@ -5873,7 +6005,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, MANAGER_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, MANAGER_ACCOUNT_ID); expect(result).toBe(false); }); @@ -5893,7 +6025,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, MANAGER_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, MANAGER_ACCOUNT_ID); expect(result).toBe(false); }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6d66930224a9..0831ed30b66d 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -83,12 +83,10 @@ import { canSeeDefaultRoom, canUserPerformWriteAction, changeMoneyRequestHoldStatus, - createDraftTransactionAndNavigateToParticipantSelector, doesReportBelongToWorkspace, excludeParticipantsForDisplay, findLastAccessedReport, getActionErrorsByTransaction, - getAddExpenseDropdownOptions, getAllPolicyExpenseChatReportActions, getAllReportActionsErrorsAndReportActionThatRequiresAttention, getApprovalChain, @@ -126,9 +124,9 @@ import { getParentReport, getParsedComment, getParticipantsList, - getPendingDeleteMemberAccountIDs, getPayeeName, getPendingChatMembers, + getPendingDeleteMemberAccountIDs, getPolicyChangeLogCopyMessage, getPolicyExpenseChat, getPolicyIDsWithEmptyReportsForAccount, @@ -138,6 +136,7 @@ import { getReimbursementDeQueuedOrCanceledActionMessage, getReimbursementQueuedActionMessage, getReportActionWithSmartscanError, + getReportFieldMaps, getReportFieldsByPolicyID, getReportForHeader, getReportIDFromLink, @@ -152,23 +151,23 @@ import { getTitleFieldWithFallback, getTransactionDetails, getTransactionReportName, - getUploadingAttachmentHtmlFromComment, getTransactionSortValue, getTransactionsWithReceipts, getUnheldReimbursableTotal, getUnreportedTransactionMessage, + getUploadingAttachmentHtmlFromComment, getUserDetailTooltipText, getViolatingReportIDForRBRInLHN, - getWorkspaceIcon, getWhisperDisplayNames, + getWorkspaceIcon, getWorkspaceNameUpdatedMessage, hasActionWithErrorsForTransaction, - hasReportBeenForwardedSinceLastSubmit, hasEmptyReportsForPolicy, - hasExportError, hasExpensifyGuidesEmails, + hasExportError, hasNonReimbursableTransactions, hasReceiptError, + hasReportBeenForwardedSinceLastSubmit, hasSmartscanError, hasVisibleReportFieldViolations, isActionCreator, @@ -184,6 +183,7 @@ import { isDeprecatedGroupDM, isGroupPolicyExpenseReport, isHarvestCreatedExpenseReport, + isInvoiceReport, isJoinRequestInAdminRoom, isMoneyRequestReportEligibleForMerge, isOneOnOneChat, @@ -210,6 +210,7 @@ import { shouldBlockSubmitDueToStrictPolicyRules, shouldDisableRename, shouldDisableThread, + shouldDisplayReportFields, shouldEnableNegative, shouldExcludeAncestorReportAction, shouldHideSingleReportField, @@ -224,6 +225,10 @@ import { } from '@libs/ReportUtils'; import {buildTransactionsByReportID} from '@libs/TodosUtils'; import {buildOptimisticTransaction} from '@libs/TransactionUtils'; +import {generateAccountID} from '@libs/UserUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; + +import {createDraftTransactionAndNavigateToParticipantSelector, getAddExpenseDropdownOptions} from '@userActions/IOU/StartExpenseFlows'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -295,7 +300,15 @@ import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import {fakePersonalDetails} from '../utils/LHNTestUtils'; -import {convertToDisplayString, formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal, localeCompare, translateLocal} from '../utils/TestHelper'; +import { + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + formatPhoneNumber, + getCurrencyDecimalsLocal, + getCurrencySymbolLocal, + localeCompare, + translateLocal, +} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; type ClosedReportActionMessage = ReportAction['message']; @@ -376,9 +389,13 @@ const computeReportName = ( currentUserAccountID: currentUserID, currentUserLogin: currentUserEmail, translate: translateLocal, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + getCurrencySymbol: getCurrencySymbolLocal, conciergeReportID, reportTransactions: buildTransactionsByReportID(transactions), isTrackIntentUser: false, + rules: undefined, }); const participantsPersonalDetails: PersonalDetailsList = { '1': { @@ -860,24 +877,24 @@ describe('ReportUtils', () => { describe('prepareOnboardingOnyxData', () => { const REPORT_ID = '5'; + const conciergeChatReport: Report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + participants: { + [CONST.ACCOUNT_ID.CONCIERGE]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; beforeEach(async () => { Onyx.merge(ONYXKEYS.SESSION, {email: 'test+test@example.com'}); - - const chatReport: Report = { - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.CHAT, - participants: { - [CONST.ACCOUNT_ID.CONCIERGE]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${conciergeChatReport.reportID}`, conciergeChatReport); }); it('provides test drive url to task title', () => { const title = jest.fn(); prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -907,6 +924,7 @@ describe('ReportUtils', () => { const description = jest.fn(); prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -939,6 +957,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -958,6 +977,7 @@ describe('ReportUtils', () => { it('should send the Submit message and tasks to the Concierge DM for EMPLOYER', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, onboardingMessage: {message: 'This is a test', tasks: []}, @@ -970,6 +990,7 @@ describe('ReportUtils', () => { it('should send nothing to the Concierge DM for EMPLOYER when onboarding is handled elsewhere', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, onboardingMessage: {message: 'This is a test', tasks: []}, @@ -988,6 +1009,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1007,6 +1029,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1026,6 +1049,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1045,6 +1069,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1064,6 +1089,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1083,6 +1109,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1104,6 +1131,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1122,6 +1150,7 @@ describe('ReportUtils', () => { it('should send tasks to server for MANAGE_TEAM without adding them to optimisticData', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1140,6 +1169,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1167,6 +1197,7 @@ describe('ReportUtils', () => { const mergeSpy = jest.spyOn(Onyx, 'merge'); prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, onboardingMessage: { @@ -1208,6 +1239,7 @@ describe('ReportUtils', () => { const description = jest.fn(); prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -1240,6 +1272,7 @@ describe('ReportUtils', () => { it('should produce empty guidedSetupData for LOOKING_AROUND intent with empty message', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -1255,6 +1288,7 @@ describe('ReportUtils', () => { it('should not include sign-off message for LOOKING_AROUND intent', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -1276,6 +1310,7 @@ describe('ReportUtils', () => { it('should include guidedSetupData for non-LOOKING_AROUND intents', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, onboardingMessage: { @@ -1295,6 +1330,7 @@ describe('ReportUtils', () => { it('should auto-complete VIEW_TOUR task when isSelfTourViewed is true', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -1322,6 +1358,7 @@ describe('ReportUtils', () => { it('should not auto-complete VIEW_TOUR task when isSelfTourViewed is false', () => { const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -1352,6 +1389,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const result = prepareOnboardingOnyxData({ + conciergeChat: conciergeChatReport, introSelected: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, onboardingMessage: { @@ -3278,6 +3316,35 @@ describe('ReportUtils', () => { expect(reportName).toBe('The Regions Bank cards connection is broken. To restore card imports, log into your bank.'); }); + test('should handle concierge company card connection broken for 30 days action', () => { + const companyCardConnectionBroken30DaysAction: ReportAction = { + ...baseParentReportAction, + actionName: CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN_30_DAYS, + originalMessage: { + feedName: 'Regions Bank cards', + policyID: '1', + }, + }; + + const threadReport: Report = { + ...baseExpenseReport, + parentReportID: baseChatReport.reportID, + parentReportActionID: companyCardConnectionBroken30DaysAction.reportActionID, + }; + + const reportActions = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${threadReport.parentReportID}`]: { + [companyCardConnectionBroken30DaysAction.reportActionID]: companyCardConnectionBroken30DaysAction, + }, + }; + const reportName = computeReportName(threadReport, undefined, undefined, undefined, undefined, participantsPersonalDetails, reportActions); + + // Note: computeReportName returns the text version, not HTML + expect(reportName).toBe( + "The Regions Bank cards connection has been broken for 30 days. Log into your bank to fix it or remove the connection if it's no longer in use. You won't lose any submitted expenses if you remove it.", + ); + }); + test('should handle automatically paid with Expensify action', () => { const expensifyPayAction: ReportAction = { ...baseParentReportAction, @@ -4203,13 +4270,13 @@ describe('ReportUtils', () => { it('should return empty string for chat thread', () => { const report = createWorkspaceThread(1); - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(''); }); it('should return "Your space" for self DM', () => { const report = createSelfDM(1, currentUserAccountID); - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe('Your space'); }); @@ -4221,44 +4288,44 @@ describe('ReportUtils', () => { }; // When the threaded conciergeReportID matches the report - const conciergeSubtitle = getChatRoomSubtitle(report, undefined, report.reportID, translateLocal); + const conciergeSubtitle = getChatRoomSubtitle(report, undefined, report.reportID, translateLocal, undefined); expect(conciergeSubtitle).toBe(translateLocal('reportActionsView.conciergeSupport')); // And an identical report with a non-matching conciergeReportID is not treated as Concierge - const regularSubtitle = getChatRoomSubtitle(report, undefined, 'a-different-report-id', translateLocal); + const regularSubtitle = getChatRoomSubtitle(report, undefined, 'a-different-report-id', translateLocal, undefined); expect(regularSubtitle).not.toBe(translateLocal('reportActionsView.conciergeSupport')); }); it('should return "Invoices" for invoice room', () => { const report = createInvoiceRoom(1); - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe('Invoices'); }); it('should return empty string for non-default, non-user-created, non-policy-expense chat', () => { const report = createRegularChat(1, [currentUserAccountID, 2]); - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(''); }); it('should return domain name for domain room', () => { const report = createDomainRoom(1); report.reportName = '#example.com'; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe('example.com'); }); it('should return policy name for admin room', () => { const report = createAdminRoom(1); report.policyID = policy.id; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(policy.name); }); it('should return policy name for announce room', () => { const report = createAnnounceRoom(1); report.policyID = policy.id; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(policy.name); }); @@ -4268,42 +4335,42 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, policyID: policy.id, }; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(policy.name); }); it('should return policy name for policy expense chat when not in create expense flow', () => { const report = createPolicyExpenseChat(1); report.policyID = policy.id; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(policy.name); }); it('should return empty string for expense report (not default/user-created/policy-expense)', () => { const report = createExpenseReport(1); report.policyID = policy.id; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(''); }); it('should return empty string for expense report in create expense flow (not default/user-created/policy-expense)', () => { const report = createExpenseReport(1); report.policyID = policy.id; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, true); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined, true); expect(result).toBe(''); }); it('should return oldPolicyName when report is archived', () => { const report = createAdminRoom(1); report.oldPolicyName = 'Old Policy Name'; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, false, true); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined, false, true); expect(result).toBe('Old Policy Name'); }); it('should return empty string when report is archived but has no oldPolicyName', () => { const report = createAdminRoom(1); report.oldPolicyName = undefined; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, false, true); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined, false, true); expect(result).toBe(''); }); @@ -4311,26 +4378,26 @@ describe('ReportUtils', () => { const report = createAdminRoom(1); report.policyID = policy.id; report.oldPolicyName = 'Archived Policy'; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, true, true); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined, true, true); expect(result).toBe('Archived Policy'); }); it('should handle with only report data', () => { const report = createAdminRoom(1); report.policyID = policy.id; - const result = getChatRoomSubtitle(report, policy, undefined, translateLocal); + const result = getChatRoomSubtitle(report, policy, undefined, translateLocal, undefined); expect(result).toBe(policy.name); }); it('should return "Concierge support" when the report is the Concierge chat passed via conciergeReportID', () => { const report = createRegularChat(1, [currentUserAccountID, CONST.ACCOUNT_ID.CONCIERGE]); - const result = getChatRoomSubtitle(report, policy, report.reportID, translateLocal); + const result = getChatRoomSubtitle(report, policy, report.reportID, translateLocal, undefined); expect(result).toBe('Your personal AI agent'); }); it('should not return "Concierge support" when conciergeReportID does not match the report', () => { const report = createRegularChat(1, [currentUserAccountID, CONST.ACCOUNT_ID.CONCIERGE]); - const result = getChatRoomSubtitle(report, policy, 'some-other-report-id', translateLocal); + const result = getChatRoomSubtitle(report, policy, 'some-other-report-id', translateLocal, undefined); expect(result).not.toBe('Your personal AI agent'); }); }); @@ -4356,7 +4423,7 @@ describe('ReportUtils', () => { describe('return empty iou options if', () => { it('participants array contains excluded expensify iou emails', () => { const allEmpty = CONST.EXPENSIFY_ACCOUNT_IDS.every((accountID) => { - const moneyRequestOptions = temporary_getMoneyRequestOptions(undefined, undefined, [currentUserAccountID, accountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(undefined, undefined, [currentUserAccountID, accountID], [CONST.BETAS.ALL], undefined); return moneyRequestOptions.length === 0; }); expect(allEmpty).toBe(true); @@ -4367,7 +4434,7 @@ describe('ReportUtils', () => { ...LHNTestUtils.getFakeReport(), chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); @@ -4377,7 +4444,7 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, isOwnPolicyExpenseChat: false, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); @@ -4387,7 +4454,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.IOU, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); @@ -4398,7 +4465,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); @@ -4408,7 +4475,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.EXPENSE, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], true); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined, true); expect(moneyRequestOptions.length).toBe(0); }); @@ -4418,7 +4485,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); @@ -4428,7 +4495,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.EXPENSE, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); @@ -4442,7 +4509,7 @@ describe('ReportUtils', () => { parentReportID: '100', type: CONST.REPORT.TYPE.EXPENSE, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); }); @@ -4452,7 +4519,7 @@ describe('ReportUtils', () => { ...LHNTestUtils.getFakeReport(), type: CONST.REPORT.TYPE.EXPENSE, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, 20], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, 20], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); it('the current user is an invited user of the iou report', () => { @@ -4462,7 +4529,7 @@ describe('ReportUtils', () => { ownerAccountID: 20, managerID: 21, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, 20, 21], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, 20, 21], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(0); }); }); @@ -4475,8 +4542,8 @@ describe('ReportUtils', () => { }; // Both calls should work with explicitly passed betas - const withAllBetas = canCreateRequest(report, undefined, CONST.IOU.TYPE.SUBMIT, false, [CONST.BETAS.ALL], false); - const withEmptyBetas = canCreateRequest(report, undefined, CONST.IOU.TYPE.SUBMIT, false, [], false); + const withAllBetas = canCreateRequest(report, undefined, CONST.IOU.TYPE.SUBMIT, false, [CONST.BETAS.ALL], undefined, false); + const withEmptyBetas = canCreateRequest(report, undefined, CONST.IOU.TYPE.SUBMIT, false, [], undefined, false); // With BETAS.ALL, SUBMIT should be allowed in a 1:1 DM expect(withAllBetas).toBe(true); @@ -4492,8 +4559,8 @@ describe('ReportUtils', () => { }; // Self DM should return TRACK option regardless of betas - const withBetas = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], false, false); - const withoutBetas = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [], false, false); + const withBetas = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined, false, false); + const withoutBetas = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [], undefined, false, false); // Both should include TRACK for self DM expect(withBetas).toContain(CONST.IOU.TYPE.TRACK); @@ -4513,6 +4580,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); return moneyRequestOptions.length === 1 && moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT); }); @@ -4524,7 +4592,7 @@ describe('ReportUtils', () => { ...LHNTestUtils.getFakeReport(), chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); }); @@ -4534,7 +4602,7 @@ describe('ReportUtils', () => { ...LHNTestUtils.getFakeReport(), chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); }); @@ -4545,7 +4613,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, participantsAccountIDs: [currentUserAccountID, ...participantsAccountIDs], }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs.map(Number)], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs.map(Number)], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); }); @@ -4565,6 +4633,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4583,6 +4652,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4603,7 +4673,7 @@ describe('ReportUtils', () => { ownerAccountID: currentUserAccountID, }; mockedPolicyUtils.isPaidGroupPolicy.mockReturnValue(true); - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(2); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK)).toBe(true); @@ -4638,6 +4708,7 @@ describe('ReportUtils', () => { paidPolicy, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(2); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4659,6 +4730,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4677,6 +4749,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(1); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4724,6 +4797,7 @@ describe('ReportUtils', () => { paidPolicy, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(2); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4744,6 +4818,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(3); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); @@ -4784,6 +4859,7 @@ describe('ReportUtils', () => { paidPolicy, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); expect(moneyRequestOptions.length).toBe(2); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); @@ -4798,7 +4874,7 @@ describe('ReportUtils', () => { isOwnPolicyExpenseChat: true, managerID: currentUserAccountID, }; - const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.ALL]); + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, undefined, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.ALL], undefined); expect(moneyRequestOptions.length).toBe(2); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); expect(moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK)).toBe(true); @@ -4823,6 +4899,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); // Should not include SUBMIT (Create Expense) or TRACK (Track distance) — members can only split @@ -4846,6 +4923,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); // Should include SUBMIT (Create Expense) @@ -4872,6 +4950,7 @@ describe('ReportUtils', () => { undefined, [currentUserAccountID, participantsAccountIDs.at(0) ?? CONST.DEFAULT_NUMBER_ID], [CONST.BETAS.ALL], + undefined, ); // Should not include SUBMIT @@ -4892,8 +4971,8 @@ describe('ReportUtils', () => { }; const selfDMParticipants = [currentUserAccountID]; - const withoutRestrictionsResult = temporary_getMoneyRequestOptions(selfDMReport, undefined, selfDMParticipants, [CONST.BETAS.ALL], false, false); - const withRestrictionsResult = temporary_getMoneyRequestOptions(selfDMReport, undefined, selfDMParticipants, [CONST.BETAS.ALL], false, true); + const withoutRestrictionsResult = temporary_getMoneyRequestOptions(selfDMReport, undefined, selfDMParticipants, [CONST.BETAS.ALL], undefined, false, false); + const withRestrictionsResult = temporary_getMoneyRequestOptions(selfDMReport, undefined, selfDMParticipants, [CONST.BETAS.ALL], undefined, false, true); expect(withoutRestrictionsResult.includes(CONST.IOU.TYPE.TRACK)).toBe(true); expect(withRestrictionsResult.includes(CONST.IOU.TYPE.TRACK)).toBe(true); @@ -4912,8 +4991,8 @@ describe('ReportUtils', () => { }; const dmParticipants = [currentUserAccountID, otherUserAccountID]; - const withoutRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], false, false); - const withRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], false, true); + const withoutRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], undefined, false, false); + const withRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], undefined, false, true); expect(withoutRestrictionsResult.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); expect(withRestrictionsResult.includes(CONST.IOU.TYPE.SUBMIT)).toBe(false); @@ -4931,8 +5010,8 @@ describe('ReportUtils', () => { }; const dmParticipants = [currentUserAccountID, otherUserAccountID]; - const withoutRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], false, false); - const withRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], false, true); + const withoutRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], undefined, false, false); + const withRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], undefined, false, true); if (withoutRestrictionsResult.includes(CONST.IOU.TYPE.PAY)) { expect(withRestrictionsResult.includes(CONST.IOU.TYPE.PAY)).toBe(false); @@ -4951,8 +5030,8 @@ describe('ReportUtils', () => { }; const dmParticipants = [currentUserAccountID, otherUserAccountID]; - const withoutRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], false, false); - const withRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], false, true); + const withoutRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], undefined, false, false); + const withRestrictionsResult = temporary_getMoneyRequestOptions(dmReport, undefined, dmParticipants, [CONST.BETAS.ALL], undefined, false, true); expect(withoutRestrictionsResult.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); expect(withRestrictionsResult.includes(CONST.IOU.TYPE.SPLIT)).toBe(false); @@ -4968,8 +5047,8 @@ describe('ReportUtils', () => { chatType: undefined, }; - const withoutRestrictionsResult = temporary_getMoneyRequestOptions(groupChatReport, undefined, groupParticipants, [CONST.BETAS.ALL], false, false); - const withRestrictionsResult = temporary_getMoneyRequestOptions(groupChatReport, undefined, groupParticipants, [CONST.BETAS.ALL], false, true); + const withoutRestrictionsResult = temporary_getMoneyRequestOptions(groupChatReport, undefined, groupParticipants, [CONST.BETAS.ALL], undefined, false, false); + const withRestrictionsResult = temporary_getMoneyRequestOptions(groupChatReport, undefined, groupParticipants, [CONST.BETAS.ALL], undefined, false, true); expect(withoutRestrictionsResult.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); expect(withRestrictionsResult.includes(CONST.IOU.TYPE.SPLIT)).toBe(false); @@ -4985,8 +5064,8 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, }; - const withoutRestrictionsResult = temporary_getMoneyRequestOptions(policyRoomReport, undefined, policyRoomParticipants, [CONST.BETAS.ALL], false, false); - const withRestrictionsResult = temporary_getMoneyRequestOptions(policyRoomReport, undefined, policyRoomParticipants, [CONST.BETAS.ALL], false, true); + const withoutRestrictionsResult = temporary_getMoneyRequestOptions(policyRoomReport, undefined, policyRoomParticipants, [CONST.BETAS.ALL], undefined, false, false); + const withRestrictionsResult = temporary_getMoneyRequestOptions(policyRoomReport, undefined, policyRoomParticipants, [CONST.BETAS.ALL], undefined, false, true); expect(withoutRestrictionsResult.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); expect(withRestrictionsResult.includes(CONST.IOU.TYPE.SPLIT)).toBe(false); @@ -5009,8 +5088,8 @@ describe('ReportUtils', () => { }, }; - const withoutRestrictionsResult = canCreateRequest(selfDMReport, undefined, CONST.IOU.TYPE.TRACK, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = canCreateRequest(selfDMReport, undefined, CONST.IOU.TYPE.TRACK, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = canCreateRequest(selfDMReport, undefined, CONST.IOU.TYPE.TRACK, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = canCreateRequest(selfDMReport, undefined, CONST.IOU.TYPE.TRACK, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(true); @@ -5029,8 +5108,8 @@ describe('ReportUtils', () => { }, }; - const withoutRestrictionsResult = canCreateRequest(dmReport, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = canCreateRequest(dmReport, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = canCreateRequest(dmReport, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = canCreateRequest(dmReport, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); @@ -5041,8 +5120,8 @@ describe('ReportUtils', () => { it('should restrict SPLIT requests for group chats', () => { const groupChat = LHNTestUtils.getFakeReport([currentUserAccountID, ...participantsAccountIDs.slice(0, 3)]); - const withoutRestrictionsResult = canCreateRequest(groupChat, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = canCreateRequest(groupChat, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = canCreateRequest(groupChat, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = canCreateRequest(groupChat, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); @@ -5056,8 +5135,8 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, }; - const withoutRestrictionsResult = canCreateRequest(policyRoom, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], false); - const withRestrictionsResult = canCreateRequest(policyRoom, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], true); + const withoutRestrictionsResult = canCreateRequest(policyRoom, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], undefined, false); + const withRestrictionsResult = canCreateRequest(policyRoom, undefined, CONST.IOU.TYPE.SPLIT, false, [CONST.BETAS.ALL], undefined, true); expect(withoutRestrictionsResult).toBe(true); expect(withRestrictionsResult).toBe(false); @@ -5472,6 +5551,7 @@ describe('ReportUtils', () => { const chatReport: Report = {reportID: '1'}; const reportPreviewReportActionID = '8'; const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: chatReport.reportID, getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '123', @@ -5534,12 +5614,15 @@ describe('ReportUtils', () => { }, ]); - expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, undefined, currentUserAccountID)).toEqual({ + expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, undefined, currentUserAccountID, undefined)).toEqual({ canHoldRequest: true, canUnholdRequest: false, }); - putOnHold(expenseTransaction.transactionID, 'hold', transactionThreadReport.reportID, false, currentUserEmail, currentUserAccountID, undefined, false, undefined, []); + putOnHold(expenseTransaction.transactionID, 'hold', transactionThreadReport.reportID, false, currentUserEmail, currentUserAccountID, undefined, false, undefined, { + rules: undefined, + ancestors: [], + }); await waitForBatchedUpdates(); const expenseReportUpdated = await new Promise>((resolve) => { @@ -5580,6 +5663,7 @@ describe('ReportUtils', () => { expenseTransactionUpdated, undefined, currentUserAccountID, + undefined, ), ).toEqual({ canHoldRequest: false, @@ -5619,7 +5703,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.ADMIN, }); - expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, adminPolicy, currentUserAccountID)).toEqual({ + expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, adminPolicy, currentUserAccountID, undefined)).toEqual({ canHoldRequest: false, canUnholdRequest: false, }); @@ -5651,7 +5735,7 @@ describe('ReportUtils', () => { iouReportID: expenseReport.reportID, }); - expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, undefined, currentUserAccountID)).toEqual({ + expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, undefined, currentUserAccountID, undefined)).toEqual({ canHoldRequest: false, canUnholdRequest: false, }); @@ -5684,7 +5768,7 @@ describe('ReportUtils', () => { }); expenseCreatedAction.actorAccountID = 99996; - expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, undefined, currentUserAccountID)).toEqual({ + expect(canHoldUnholdReportAction(expenseReport, expenseCreatedAction, undefined, expenseTransaction, undefined, currentUserAccountID, undefined)).toEqual({ canHoldRequest: true, canUnholdRequest: false, }); @@ -5781,7 +5865,7 @@ describe('ReportUtils', () => { const unholdRequestSpy = jest.spyOn(HoldUtils, 'unholdRequest').mockImplementation(() => undefined); // When changeMoneyRequestHoldStatus is called - changeMoneyRequestHoldStatus(reportAction, iouTransaction, false, currentUserEmail, currentUserAccountID, undefined, false, undefined); + changeMoneyRequestHoldStatus(reportAction, iouTransaction, false, currentUserEmail, currentUserAccountID, undefined, false, undefined, undefined); // Then unholdRequest should be called with the correct parameters and navigation should not be called expect(unholdRequestSpy).toHaveBeenCalledWith( @@ -5794,6 +5878,7 @@ describe('ReportUtils', () => { undefined, false, undefined, + undefined, ); expect(Navigation.navigate).not.toHaveBeenCalled(); }); @@ -5837,7 +5922,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); // When changeMoneyRequestHoldStatus is called - changeMoneyRequestHoldStatus(reportAction, iouTransaction, false, currentUserEmail, currentUserAccountID, undefined, false, undefined); + changeMoneyRequestHoldStatus(reportAction, iouTransaction, false, currentUserEmail, currentUserAccountID, undefined, false, undefined, undefined); // Then navigation should be called with the correct parameters expect(Navigation.navigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(transactionID, childReportID), 'mock-route')); @@ -5913,7 +5998,7 @@ describe('ReportUtils', () => { }, }); }); - expect(canDeleteMoneyRequestReport(invoiceReport, [], [], currentUserAccountID)).toBe(true); + expect(canDeleteMoneyRequestReport(invoiceReport, [], [], currentUserAccountID, undefined)).toBe(true); }); it('should allow deletion if the expense report is submitted but not yet approved by anyone', async () => { @@ -5952,7 +6037,7 @@ describe('ReportUtils', () => { }); }); - expect(canDeleteMoneyRequestReport(expenseReport, [], [], currentUserAccountID)).toBe(true); + expect(canDeleteMoneyRequestReport(expenseReport, [], [], currentUserAccountID, undefined)).toBe(true); }); }); @@ -6015,7 +6100,7 @@ describe('ReportUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${invoiceReport.reportID}`, invoiceReport); - const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, true, invoiceReport); + const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, undefined, true, invoiceReport); expect(canEditRequest).toEqual(false); }); @@ -6102,11 +6187,11 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); // When the passed reportActions show no forward since the last submit, the submitter can still edit - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, expenseReport, reportPolicy, {[submittedAction.reportActionID]: submittedAction})).toBe(true); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, expenseReport, reportPolicy, {[submittedAction.reportActionID]: submittedAction})).toBe(true); // When the passed reportActions show the report was forwarded after the last submit, the submitter can no longer edit expect( - canEditMoneyRequest(moneyRequestAction, transaction, false, expenseReport, reportPolicy, { + canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, expenseReport, reportPolicy, { [submittedAction.reportActionID]: submittedAction, [forwardedAction.reportActionID]: forwardedAction, }), @@ -6148,7 +6233,7 @@ describe('ReportUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, true, expenseReport); + const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, undefined, true, expenseReport); expect(canEditRequest).toEqual(true); }); @@ -6179,7 +6264,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - const canEditRequest = canEditMoneyRequest(moneyRequestAction, undefined); + const canEditRequest = canEditMoneyRequest(moneyRequestAction, undefined, undefined); expect(canEditRequest).toEqual(false); }); @@ -6209,7 +6294,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction); + const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, undefined); expect(canEditRequest).toEqual(false); }); @@ -6241,7 +6326,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction); + const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, undefined); expect(canEditRequest).toEqual(false); }); @@ -6277,7 +6362,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction); + const canEditRequest = canEditMoneyRequest(moneyRequestAction, transaction, undefined); expect(canEditRequest).toEqual(false); }); @@ -6368,7 +6453,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); expect(canEditReportPolicy(expenseReport, reportPolicy)).toBe(false); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, expenseReport, reportPolicy)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, expenseReport, reportPolicy)).toBe(false); }); }); @@ -6455,11 +6540,11 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); // When the passed reportActions show no forward since the last submit, the submitter can still edit - expect(canEditReportAction(moneyRequestAction, transaction, {[submittedAction.reportActionID]: submittedAction})).toBe(true); + expect(canEditReportAction(moneyRequestAction, transaction, undefined, {[submittedAction.reportActionID]: submittedAction})).toBe(true); // When the passed reportActions show the report was forwarded after the last submit, the submitter can no longer edit expect( - canEditReportAction(moneyRequestAction, transaction, { + canEditReportAction(moneyRequestAction, transaction, undefined, { [submittedAction.reportActionID]: submittedAction, [forwardedAction.reportActionID]: forwardedAction, }), @@ -6482,7 +6567,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(true); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(true); }); it('it should return false for a money request action with a failed transaction', () => { @@ -6516,7 +6601,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(false); + expect(canEditReportAction(moneyRequestAction, transaction, undefined)).toEqual(false); }); it('it should return true for a money request action with a valid linkedTransaction', async () => { @@ -6551,7 +6636,7 @@ describe('ReportUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(true); + expect(canEditReportAction(moneyRequestAction, transaction, undefined)).toEqual(true); }); it('it should return false for a report action by another user', () => { @@ -6570,7 +6655,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(false); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(false); }); it('it should return false for a deleted report action', () => { @@ -6591,7 +6676,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(false); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(false); }); it('it should return false for a report action with pending DELETE', () => { @@ -6611,7 +6696,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(false); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(false); }); it('it should return false for a CREATED action type', () => { @@ -6630,7 +6715,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(false); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(false); }); it('should return false for a money request action on a settled expense report', async () => { @@ -6666,7 +6751,7 @@ describe('ReportUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${settledReport.reportID}`, settledReport); - expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(false); + expect(canEditReportAction(moneyRequestAction, transaction, undefined)).toEqual(false); }); it('should return false for a money request action on an approved expense report', async () => { @@ -6703,7 +6788,7 @@ describe('ReportUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${approvedReport.reportID}`, approvedReport); - expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(false); + expect(canEditReportAction(moneyRequestAction, transaction, undefined)).toEqual(false); }); it('should return false for a money request action on a closed expense report', async () => { @@ -6739,7 +6824,7 @@ describe('ReportUtils', () => { }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${closedReport.reportID}`, closedReport); - expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(false); + expect(canEditReportAction(moneyRequestAction, transaction, undefined)).toEqual(false); }); it('should return false for an optimistic attachment-only action (still uploading)', () => { @@ -6760,7 +6845,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(false); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(false); }); it('should return true for an optimistic attachment+text action (text is editable while uploading)', () => { @@ -6781,7 +6866,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(true); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(true); }); it('should return true for a synced attachment-only action (optimistic flags cleared)', () => { @@ -6803,7 +6888,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(true); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(true); }); it('should return true for a synced attachment+text action', () => { @@ -6824,7 +6909,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(true); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(true); }); it('should return true for an optimistic plain-text comment (no attachment)', () => { @@ -6844,7 +6929,7 @@ describe('ReportUtils', () => { created: '2025-03-05 16:34:27', }; - expect(canEditReportAction(reportAction, transaction)).toEqual(true); + expect(canEditReportAction(reportAction, transaction, undefined)).toEqual(true); }); }); @@ -7077,6 +7162,7 @@ describe('ReportUtils', () => { expect(canEditReportPolicy(openExpenseReport, policyWithWorkflow)).toBe(false); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7143,9 +7229,10 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, openExpenseReport, policyWithWorkflow)).toBe(true); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, openExpenseReport, policyWithWorkflow)).toBe(true); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7155,6 +7242,7 @@ describe('ReportUtils', () => { ).toBe(true); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, @@ -7215,6 +7303,7 @@ describe('ReportUtils', () => { expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, @@ -7225,7 +7314,14 @@ describe('ReportUtils', () => { // A workspace chat is not an expense report, so the approver check fails. expect( - canEditFieldOfMoneyRequest({reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, report: workspaceChat, policy: policyWithWorkflow}), + canEditFieldOfMoneyRequest({ + reportAction: moneyRequestAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction, + report: workspaceChat, + policy: policyWithWorkflow, + rules: undefined, + }), ).toBe(false); }); @@ -7269,7 +7365,7 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, submittedExpenseReport, policyWithWorkflow)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, submittedExpenseReport, policyWithWorkflow)).toBe(false); }); it('should allow workflow approver to edit when managerID already matches (happy path)', async () => { @@ -7312,9 +7408,10 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, openExpenseReport, policyWithWorkflow)).toBe(true); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, openExpenseReport, policyWithWorkflow)).toBe(true); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7374,9 +7471,10 @@ describe('ReportUtils', () => { // Current user is policy.approver, so getManagerAccountID would resolve to them — but Submit & Close has // no real approval flow, so the approver-edit grant must NOT apply. - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, openExpenseReport, submitAndClosePolicy)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, openExpenseReport, submitAndClosePolicy)).toBe(false); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7452,9 +7550,10 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, openExpenseReport, ruleOnlyPolicy)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, openExpenseReport, ruleOnlyPolicy)).toBe(false); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7509,9 +7608,10 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, openExpenseReport, policyWithWorkflow)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, openExpenseReport, policyWithWorkflow)).toBe(false); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7580,9 +7680,10 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); - expect(canEditMoneyRequest(moneyRequestAction, transaction, false, openExpenseReport, failClosedPolicy)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, undefined, false, openExpenseReport, failClosedPolicy)).toBe(false); expect( canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: moneyRequestAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, transaction, @@ -7912,6 +8013,7 @@ describe('ReportUtils', () => { it('should return true when the report has outstanding violations', async () => { const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: '212', policyID: '123', payeeAccountID: 100, @@ -8259,6 +8361,7 @@ describe('ReportUtils', () => { it('should return false when the report is the single transaction thread', async () => { const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: '212', policyID: '123', payeeAccountID: 100, @@ -8904,6 +9007,7 @@ describe('ReportUtils', () => { it('should not return HAS_IOU_VIOLATIONS for a settled (reimbursed) expense request with violations', async () => { const expenseReport: Report = { ...buildOptimisticExpenseReport({ + rules: undefined, chatReportID: '212', policyID: '123', payeeAccountID: 100, @@ -9177,6 +9281,34 @@ describe('ReportUtils', () => { expect(result.expenseChatReportID).toBe(providedExpenseReportID); expect(result.expenseChatData.reportID).toBe(providedExpenseReportID); }); + + describe('#admins room pinning', () => { + const ownerEmail = 'workspace-owner@expensifail.com'; + + it('should pin the #admins room when the account owns no paid workspace yet', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, ownerEmail, undefined, false); + + expect(result.adminsChatData.isPinned).toBe(true); + }); + + it('should not pin the #admins room when the account already owns a paid workspace', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, ownerEmail, undefined, true); + + expect(result.adminsChatData.isPinned).toBe(false); + }); + + it('should pin the #admins room when the flows that do not go through CreatePolicy omit the flag', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, ownerEmail); + + expect(result.adminsChatData.isPinned).toBe(true); + }); + + it('should never pin the #admins room for an Expensify employee', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, 'employee@expensify.com', undefined, false); + + expect(result.adminsChatData.isPinned).toBe(false); + }); + }); }); describe('getWorkspaceNameUpdatedMessage', () => { @@ -9563,7 +9695,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)).toBe(false); + expect(canDeleteReportAction(moneyRequestAction, expenseReport.reportID, transaction, undefined, undefined, currentUserAccountID, undefined)).toBe(false); }); it('should return true for demo transaction', () => { @@ -9607,7 +9739,7 @@ describe('ReportUtils', () => { }, }; - expect(canDeleteReportAction(moneyRequestAction, '1', transaction, undefined, undefined, currentUserAccountID)).toBe(true); + expect(canDeleteReportAction(moneyRequestAction, '1', transaction, undefined, undefined, currentUserAccountID, undefined)).toBe(true); }); it('should return false for unreported card expense imported with deleting disabled', async () => { @@ -9653,7 +9785,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)).toBe(false); + expect(canDeleteReportAction(trackExpenseAction, selfDMReport.reportID, transaction, undefined, undefined, currentUserAccountID, undefined)).toBe(false); }); it("should return false for ADD_COMMENT report action the current user (admin of the personal policy) didn't comment", async () => { @@ -9680,7 +9812,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)).toBe(false); + expect(canDeleteReportAction(reportAction, report.reportID, undefined, undefined, undefined, currentUserAccountID, undefined)).toBe(false); }); }); @@ -9915,7 +10047,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.EXPENSE, }; - expect(getApprovalChain(policyTest, expenseReport, undefined)).toStrictEqual([]); + expect(getApprovalChain(policyTest, expenseReport, undefined, undefined)).toStrictEqual([]); }); }); describe('basic/advance workflow', () => { @@ -9936,7 +10068,7 @@ describe('ReportUtils', () => { }; Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails).then(() => { const result = ['owner@test.com']; - expect(getApprovalChain(policyTest, expenseReport, undefined)).toStrictEqual(result); + expect(getApprovalChain(policyTest, expenseReport, undefined, undefined)).toStrictEqual(result); }); }); it('should return list contain submitsTo of ownerAccountID and the forwardsTo of them if the policy use advance workflow', () => { @@ -9955,7 +10087,7 @@ describe('ReportUtils', () => { }; Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails).then(() => { const result = ['admin@test.com']; - expect(getApprovalChain(policyTest, expenseReport, personalDetails[employeeAccountID]?.login)).toStrictEqual(result); + expect(getApprovalChain(policyTest, expenseReport, personalDetails[employeeAccountID]?.login, undefined)).toStrictEqual(result); }); }); }); @@ -10000,7 +10132,7 @@ describe('ReportUtils', () => { }, }).then(() => { const result = ['owner@test.com']; - expect(getApprovalChain(policyTest, expenseReport, personalDetails[employeeAccountID]?.login)).toStrictEqual(result); + expect(getApprovalChain(policyTest, expenseReport, personalDetails[employeeAccountID]?.login, undefined)).toStrictEqual(result); }); }); }); @@ -10060,7 +10192,7 @@ describe('ReportUtils', () => { transactions_4: transaction4, }).then(() => { const result = [categoryApprover2Email, categoryApprover1Email, tagApprover2Email, tagApprover1Email, 'admin@test.com']; - expect(getApprovalChain(policyTest, expenseReport, personalDetails[employeeAccountID]?.login)).toStrictEqual(result); + expect(getApprovalChain(policyTest, expenseReport, personalDetails[employeeAccountID]?.login, undefined)).toStrictEqual(result); }); }); }); @@ -10851,7 +10983,7 @@ describe('ReportUtils', () => { login: currentUserEmail, }, }); - expect(isReportOutstanding(report, policy.id)).toBe(true); + expect(isReportOutstanding(report, policy.id, undefined)).toBe(true); }); it('should return false for submitted reports if we specify it', () => { const report: Report = { @@ -10861,7 +10993,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }; - expect(isReportOutstanding(report, policy.id, undefined, false)).toBe(false); + expect(isReportOutstanding(report, policy.id, undefined, undefined, false)).toBe(false); }); it('should return true for submitted reports if top most report ID is processing', async () => { const report: Report = { @@ -10890,7 +11022,7 @@ describe('ReportUtils', () => { }, }); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${activeReport.reportID}`, activeReport); - expect(isReportOutstanding(report, policy.id)).toBe(true); + expect(isReportOutstanding(report, policy.id, undefined)).toBe(true); }); it('should return false for archived report', async () => { const report: Report = { @@ -10902,7 +11034,7 @@ describe('ReportUtils', () => { }; const reportNameValuePair = {private_isArchived: '2024-01-01 00:00:00.000'}; - expect(isReportOutstanding(report, policy.id, reportNameValuePair)).toBe(false); + expect(isReportOutstanding(report, policy.id, undefined, reportNameValuePair)).toBe(false); }); }); @@ -10921,7 +11053,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be added // Simulate how components determined if a report is archived by using this hook const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canAddTransaction(report, isReportArchived.current); + const result = canAddTransaction(report, undefined, isReportArchived.current); // Then the result is true expect(result).toBe(true); @@ -10938,7 +11070,7 @@ describe('ReportUtils', () => { mockedPolicyUtils.isPaidGroupPolicy.mockReturnValue(true); - const result = canAddTransaction(report, false); + const result = canAddTransaction(report, undefined, false); // Then the result is false expect(result).toBe(false); @@ -10985,13 +11117,13 @@ describe('ReportUtils', () => { mockedPolicyUtils.isPaidGroupPolicy.mockReturnValue(true); // If the canAddTransaction is used for the case of adding expense into the report - const result = canAddTransaction(report, isReportArchived.current); + const result = canAddTransaction(report, undefined, isReportArchived.current); // Then the result should be false expect(result).toBe(false); // If the canAddTransaction is used for the case of moving transaction into the report - const result2 = canAddTransaction(report, isReportArchived.current, true); + const result2 = canAddTransaction(report, undefined, isReportArchived.current, true); // Then the result should be true expect(result2).toBe(true); @@ -11032,7 +11164,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be added const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canAddTransaction(report, isReportArchived.current); + const result = canAddTransaction(report, undefined, isReportArchived.current); // Then the result is true because the report has not moved past the first approver yet expect(result).toBe(true); @@ -11073,7 +11205,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be added const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canAddTransaction(report, isReportArchived.current); + const result = canAddTransaction(report, undefined, isReportArchived.current); // Then the result is false because the report is now in a Dynamic External Workflow expect(result).toBe(false); @@ -11091,7 +11223,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be added const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canAddTransaction(report, isReportArchived.current); + const result = canAddTransaction(report, undefined, isReportArchived.current); // Then the result is false expect(result).toBe(false); @@ -11107,7 +11239,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canAddTransaction(report, isReportArchived.current); + const result = canAddTransaction(report, undefined, isReportArchived.current); // Then the result is false expect(result).toBe(false); @@ -11126,7 +11258,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be deleted // Simulate how components determined if a report is archived by using this hook const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canDeleteTransaction(report, isReportArchived.current); + const result = canDeleteTransaction(report, undefined, isReportArchived.current); // Then the result is true expect(result).toBe(true); @@ -11143,7 +11275,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be deleted const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canDeleteTransaction(report, isReportArchived.current); + const result = canDeleteTransaction(report, undefined, isReportArchived.current); // Then the result is false expect(result).toBe(false); @@ -11159,7 +11291,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canDeleteTransaction(report, isReportArchived.current); + const result = canDeleteTransaction(report, undefined, isReportArchived.current); // Then the result is false expect(result).toBe(false); @@ -11176,7 +11308,7 @@ describe('ReportUtils', () => { // When it's checked if the transactions can be deleted const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); - const result = canDeleteTransaction(report, isReportArchived.current); + const result = canDeleteTransaction(report, undefined, isReportArchived.current); // Then the result is true expect(result).toBe(true); @@ -11209,7 +11341,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`, openReport); - expect(canDeleteTransaction(openReport, false)).toBe(true); + expect(canDeleteTransaction(openReport, undefined, false)).toBe(true); }); it('should return false for closed report when workflow is disabled', async () => { @@ -11222,7 +11354,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${closedReport.reportID}`, closedReport); - expect(canDeleteTransaction(closedReport, false)).toBe(false); + expect(canDeleteTransaction(closedReport, undefined, false)).toBe(false); }); }); }); @@ -12068,8 +12200,36 @@ describe('ReportUtils', () => { participants: buildParticipantsFromAccountIDs([currentUserAccountID, OTHER_ACCOUNT_ID]), }; - it('should return the other participant of a 1:1 DM with their login and accountID', () => { - expect(getOneOnOneChatParticipants(dmReport, personalDetailsList, currentUserAccountID)).toEqual([{login: 'other@test.com', accountID: OTHER_ACCOUNT_ID}]); + it('should return the other participant of a 1:1 DM with their login only, even when they have a real accountID', () => { + expect(getOneOnOneChatParticipants(dmReport, personalDetailsList, currentUserAccountID)).toStrictEqual([{login: 'other@test.com'}]); + }); + + it('should not send a locally generated accountID for an invited user who has no account yet', () => { + // An invited (brand-new) email gets a client-generated accountID and an optimistic personal detail. + // That accountID does not exist on the server, so only the login may be passed to OpenReport. + const optimisticAccountID = generateAccountID('new@user.com'); + const optimisticPersonalDetails: PersonalDetailsList = { + [optimisticAccountID]: {accountID: optimisticAccountID, login: 'new@user.com', isOptimisticPersonalDetail: true}, + }; + const optimisticDMReport: Report = { + ...dmReport, + participants: buildParticipantsFromAccountIDs([currentUserAccountID, optimisticAccountID]), + }; + expect(getOneOnOneChatParticipants(optimisticDMReport, optimisticPersonalDetails, currentUserAccountID)).toStrictEqual([{login: 'new@user.com'}]); + }); + + it('should not send a locally generated accountID when the optimistic detail carries no flag', () => { + // sendMoney (Pay someone) writes the recipient's optimistic detail without + // isOptimisticPersonalDetail - see src/libs/actions/IOU/SendMoney.ts + const optimisticAccountID = generateAccountID('new@user.com'); + const unflaggedPersonalDetails: PersonalDetailsList = { + [optimisticAccountID]: {accountID: optimisticAccountID, login: 'new@user.com'}, + }; + const optimisticDMReport: Report = { + ...dmReport, + participants: buildParticipantsFromAccountIDs([currentUserAccountID, optimisticAccountID]), + }; + expect(getOneOnOneChatParticipants(optimisticDMReport, unflaggedPersonalDetails, currentUserAccountID)).toStrictEqual([{login: 'new@user.com'}]); }); it('should return an empty list for reports that are not 1:1 DMs', () => { @@ -13456,7 +13616,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, participants: buildParticipantsFromAccountIDs([currentUserAccountID, 1]), }; - expect(canRequestMoney(report, undefined, [1])).toBe(true); + expect(canRequestMoney(report, undefined, [1], undefined)).toBe(true); }); it('should return false when the explicitly passed currentUserAccountID is neither manager nor owner of an IOU report', () => { @@ -13470,7 +13630,7 @@ describe('ReportUtils', () => { }; // Then account 3 (passed explicitly) cannot request money on it - expect(canRequestMoney(iouReport, undefined, [1, 2], 3)).toBe(false); + expect(canRequestMoney(iouReport, undefined, [1, 2], undefined, 3)).toBe(false); }); }); @@ -13661,7 +13821,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); // Then it should return false because it's not a money request report expect(result).toBe(false); @@ -13672,7 +13832,7 @@ describe('ReportUtils', () => { const nonExistentReportID = 'nonexistent123'; // When we check if the report is eligible for merge - const result = isMoneyRequestReportEligibleForMerge(nonExistentReportID, true); + const result = isMoneyRequestReportEligibleForMerge(nonExistentReportID, true, undefined); // Then it should return false because the report doesn't exist expect(result).toBe(false); @@ -13690,7 +13850,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return true because submitters can merge processing IOU reports expect(result).toBe(false); @@ -13708,7 +13868,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); // Then it should return true because admins can merge open expense reports expect(result).toBe(true); @@ -13725,7 +13885,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); // Then it should return true because admins can merge processing expense reports expect(result).toBe(true); @@ -13742,7 +13902,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); // Then it should return false because approved reports are not eligible for merge expect(result).toBe(false); @@ -13762,7 +13922,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return true because submitters can merge open expense reports expect(result).toBe(true); @@ -13800,7 +13960,7 @@ describe('ReportUtils', () => { }); // When we check if the report is eligible for merge as a submitter - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return true because submitters can merge processing expense reports expect(result).toBe(true); @@ -13818,7 +13978,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then the result depends on the actual approval level logic in the implementation expect(typeof result).toBe('boolean'); @@ -13836,7 +13996,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return false because the user is not the submitter and not an admin expect(result).toBe(false); @@ -13859,7 +14019,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return true because managers can merge processing expense reports expect(result).toBe(true); @@ -13878,7 +14038,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return false because managers can only merge processing expense reports, not open ones expect(result).toBe(false); @@ -13897,7 +14057,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); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); // Then it should return false because the user is not the manager, submitter, or admin expect(result).toBe(false); @@ -14894,6 +15054,21 @@ describe('ReportUtils', () => { it('should return true for CREATE iouType with expense report', () => { expect(shouldEnableNegative(expenseReport, personalPolicy, CONST.IOU.TYPE.CREATE)).toBe(true); }); + + it('should return false for CREATE iouType when a P2P recipient is selected', () => { + const participants = [{accountID: 1, isPolicyExpenseChat: false, isSender: false}]; + expect(shouldEnableNegative(undefined, undefined, CONST.IOU.TYPE.CREATE, participants)).toBe(false); + }); + + it('should return true for CREATE iouType when the only participant is a policy expense chat', () => { + const participants = [{accountID: 1, isPolicyExpenseChat: true, isSender: false}]; + expect(shouldEnableNegative(undefined, undefined, CONST.IOU.TYPE.CREATE, participants)).toBe(true); + }); + + it('should return true for CREATE iouType when the only participant is the sender', () => { + const participants = [{accountID: 1, isPolicyExpenseChat: false, isSender: true}]; + expect(shouldEnableNegative(undefined, undefined, CONST.IOU.TYPE.CREATE, participants)).toBe(true); + }); }); describe('exclusion cases for SPLIT and INVOICE iouTypes', () => { @@ -15251,7 +15426,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, }; - expect(shouldBlockSubmitDueToPreventSelfApproval(report, undefined)).toBe(false); + expect(shouldBlockSubmitDueToPreventSelfApproval(report, undefined, undefined)).toBe(false); }); it('should return false when policy has preventSelfApproval false', () => { @@ -15268,7 +15443,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, }; - expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOff)).toBe(false); + expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOff, undefined)).toBe(false); }); it('should return false when report is not owned by current user even with preventSelfApproval true', () => { @@ -15285,7 +15460,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, }; - expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn)).toBe(false); + expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn, undefined)).toBe(false); }); it('should return true when preventSelfApproval is true, report is open expense, and owner is same as next approver', async () => { @@ -15321,8 +15496,8 @@ describe('ReportUtils', () => { }); await waitForBatchedUpdates(); - expect(getNextApproverAccountID(report)).toBe(currentUserAccountID); - expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn)).toBe(true); + expect(getNextApproverAccountID(report, undefined)).toBe(currentUserAccountID); + expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn, undefined)).toBe(true); }); it('should return true when preventSelfApproval is true, report is processing, and owner is same as manager', async () => { @@ -15341,7 +15516,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }; await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID}); - expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn)).toBe(true); + expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn, undefined)).toBe(true); }); it('should return false when preventSelfApproval is true, report is open, and owner is not same as next approver', async () => { @@ -15382,7 +15557,7 @@ describe('ReportUtils', () => { }); await waitForBatchedUpdates(); - expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn)).toBe(false); + expect(shouldBlockSubmitDueToPreventSelfApproval(report, policyWithPreventOn, undefined)).toBe(false); }); it('should return false when preventSelfApproval is true but report is not open or processing', () => { @@ -15400,7 +15575,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, }; - expect(shouldBlockSubmitDueToPreventSelfApproval(approvedReport, policyWithPreventOn)).toBe(false); + expect(shouldBlockSubmitDueToPreventSelfApproval(approvedReport, policyWithPreventOn, undefined)).toBe(false); }); }); @@ -15885,6 +16060,7 @@ describe('ReportUtils', () => { const total = 100; const currency = CONST.CURRENCY.USD; const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID, policyID: undefined, payeeAccountID: 1, @@ -15920,6 +16096,7 @@ describe('ReportUtils', () => { const total = 100; const currency = CONST.CURRENCY.USD; const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID, policyID, payeeAccountID: 1, @@ -15957,6 +16134,7 @@ describe('ReportUtils', () => { const total = 100; const currency = CONST.CURRENCY.USD; const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID, policyID, payeeAccountID: 1, @@ -17363,6 +17541,180 @@ describe('ReportUtils', () => { await Onyx.clear(); }); + + describe('companyCardRequired on a submitted report', () => { + /** + * 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 = {}) { + const policyID = `policy-rbr-company-card-${scenarioKey}`; + const chatReportID = `chat-rbr-company-card-${scenarioKey}`; + const expenseReportID = `expense-rbr-company-card-${scenarioKey}`; + const transactionID = `transaction-rbr-company-card-${scenarioKey}`; + + const policyData: Policy = { + id: policyID, + name: 'Company Card Required Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + outputCurrency: CONST.CURRENCY.USD, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + employeeList: {[currentUserEmail]: {role: CONST.POLICY.ROLE.USER}}, + owner: currentUserEmail, + }; + + const chatReport: Report = { + ...createPolicyExpenseChat(830), + reportID: chatReportID, + ownerAccountID: currentUserAccountID, + policyID, + iouReportID: expenseReportID, + }; + + const expenseReport: Report = { + ...createExpenseReport(831), + reportID: expenseReportID, + chatReportID, + ownerAccountID: currentUserAccountID, + managerID: 42, + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + currency: CONST.CURRENCY.USD, + total: 2500, + stateNum: isOpen ? CONST.REPORT.STATE_NUM.OPEN : CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: isOpen ? CONST.REPORT.STATUS_NUM.OPEN : CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + + const transaction: Transaction = { + ...createRandomTransaction(830), + transactionID, + reportID: expenseReportID, + amount: 2500, + currency: CONST.CURRENCY.USD, + status: CONST.TRANSACTION.STATUS.POSTED, + reimbursable: true, + ...transactionOverrides, + }; + + const transactionViolationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as OnyxKey; + const transactionViolationsCollection: OnyxCollection = {[transactionViolationsKey]: violations}; + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: currentUserEmail}); + await waitForBatchedUpdates(); + + await Promise.all([ + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policyData), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport), + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction), + Onyx.merge(transactionViolationsKey, violations), + ]); + await waitForBatchedUpdates(); + + return {chatReport, expenseReport, expenseReportID, policyData, transaction, transactionViolationsCollection}; + } + + // The back end owns the violation type, so the LHN exclusion has to hold for every bucket it could land in. + it.each([CONST.VIOLATION_TYPES.VIOLATION, CONST.VIOLATION_TYPES.NOTICE, CONST.VIOLATION_TYPES.WARNING])( + 'should not surface RBR when the only violation is a companyCardRequired %s', + async (violationType) => { + await Onyx.clear(); + + const {chatReport, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario(`only-${violationType}`, [ + {name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: violationType, showInReview: true}, + ]); + + expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBeNull(); + + await Onyx.clear(); + }, + ); + + it('should still surface RBR on an open expense report whose only violation is companyCardRequired', async () => { + await Onyx.clear(); + + const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( + 'open', + [{name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}], + true, + ); + + expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBe(expenseReportID); + + await Onyx.clear(); + }); + + it('should still surface RBR on a 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}, + ]); + + 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`. + const pendingAutoCategorizationTime = new Date().toISOString().replace('T', ' ').slice(0, 19); + + const {chatReport, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( + 'alongside-hidden', + [ + {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, + {category: '', comment: {pendingAutoCategorizationTime}}, + ); + + 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 () => { + await Onyx.clear(); + + const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario('modified-amount-violation', [ + {name: CONST.VIOLATIONS.MODIFIED_AMOUNT, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ]); + + expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBe(expenseReportID); + + await Onyx.clear(); + }); + + // The exclusion is scoped to the RBR decision only. The violation message itself has to stay visible on the + // expense to everyone, which is what keeps the submitter able to see why the report is stuck. + it('should keep the companyCardRequired violation visible on the expense after the report is submitted', async () => { + await Onyx.clear(); + + const {expenseReport, policyData, transaction, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario('still-visible', [ + {name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ]); + + expect(ViolationsUtils.hasVisibleViolationsForUser(expenseReport, transactionViolationsCollection, currentUserEmail, currentUserAccountID, policyData, [transaction])).toBe( + true, + ); + + await Onyx.clear(); + }); + }); }); it('should surface a GBR for the admin who placed the hold on an all-held report requiring approval, and avoid showing an RBR', async () => { @@ -18747,6 +19099,57 @@ describe('ReportUtils', () => { }); }); + describe('getReportFieldMaps', () => { + it('returns persisted invoice fields when the receiver has no policy field list', async () => { + const report = createMock({ + reportID: 'invoiceReceiverWithoutPolicyFields', + type: CONST.REPORT.TYPE.INVOICE, + fieldList: {}, + }); + const invoiceField = createMock({ + fieldID: 'invoice_field', + name: 'Client', + type: CONST.REPORT_FIELD_TYPES.TEXT, + target: CONST.REPORT_FIELD_TARGETS.INVOICE, + value: 'Acme', + }); + + await Onyx.merge( + `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`, + Object.fromEntries([ + ['expensify_invoice_field', invoiceField], + ['invoice_field', invoiceField], + ]), + ); + await waitForBatchedUpdates(); + + const {fieldValues, fieldsByName} = getReportFieldMaps(report, {}); + + expect(fieldValues).toEqual({client: 'Acme'}); + expect(fieldsByName).toEqual({client: invoiceField}); + }); + + it('does not use persisted invoice fields for an expense report', async () => { + const report = createMock({ + reportID: 'expenseReportWithoutPolicyFields', + type: CONST.REPORT.TYPE.EXPENSE, + fieldList: {}, + }); + const invoiceField = createMock({ + fieldID: 'invoice_field', + name: 'Client', + type: CONST.REPORT_FIELD_TYPES.TEXT, + target: CONST.REPORT_FIELD_TARGETS.INVOICE, + value: 'Acme', + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`, Object.fromEntries([['invoice_field', invoiceField]])); + await waitForBatchedUpdates(); + + expect(getReportFieldMaps(report, {})).toEqual({fieldValues: {}, fieldsByName: {}}); + }); + }); + describe('canEditReportTitle', () => { const getTitleField = (deletable: boolean): PolicyReportField => ({ fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, @@ -18782,7 +19185,7 @@ describe('ReportUtils', () => { }; mockedPolicyUtils.isPaidGroupPolicy.mockReturnValueOnce(true); - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(true); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(true); }); it('returns false when title field is disabled', () => { @@ -18798,7 +19201,7 @@ describe('ReportUtils', () => { managerID: 999, }; - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(false); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(false); }); it('returns false for non-expense reports', () => { @@ -18811,7 +19214,7 @@ describe('ReportUtils', () => { managerID: 999, }; - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(false); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(false); }); it('returns false when policy is not a paid group policy', () => { @@ -18825,7 +19228,7 @@ describe('ReportUtils', () => { expect(testPolicy.type).toBe(CONST.POLICY.TYPE.PERSONAL); mockedPolicyUtils.isPaidGroupPolicy.mockReturnValueOnce(false); - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(false); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(false); }); it('returns false when user is not admin, owner, approver, or report owner', () => { @@ -18837,7 +19240,7 @@ describe('ReportUtils', () => { managerID: 999, }; - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(false); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(false); }); it('returns true when policy has custom fields but no title field (uses fallback)', () => { @@ -18868,7 +19271,7 @@ describe('ReportUtils', () => { }; mockedPolicyUtils.isPaidGroupPolicy.mockReturnValueOnce(true); - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(true); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(true); }); it('returns true when policy has empty fieldList (uses fallback)', () => { @@ -18884,7 +19287,7 @@ describe('ReportUtils', () => { }; mockedPolicyUtils.isPaidGroupPolicy.mockReturnValueOnce(true); - expect(canEditReportTitle(report, testPolicy, currentUserAccountID)).toBe(true); + expect(canEditReportTitle(report, testPolicy, currentUserAccountID, undefined)).toBe(true); }); }); @@ -18984,6 +19387,35 @@ describe('ReportUtils', () => { expect(shouldHideSingleReportField(reportField)).toBe(true); }); }); + describe('shouldDisplayReportFields', () => { + it('returns true for an invoice when the receiver policy does not include the invoice fields feature flag', () => { + const report = createMock({type: CONST.REPORT.TYPE.INVOICE}); + const receiverPolicy = createMock({type: CONST.POLICY.TYPE.TEAM}); + + expect(shouldDisplayReportFields(report, receiverPolicy)).toBe(true); + }); + + it('returns true for a workspace expense report when report fields are enabled', () => { + const report = createMock({type: CONST.REPORT.TYPE.EXPENSE}); + const testPolicy = createMock({ + type: CONST.POLICY.TYPE.TEAM, + areReportFieldsEnabled: true, + }); + + expect(shouldDisplayReportFields(report, testPolicy)).toBe(true); + }); + + it('returns false for a workspace expense report when report fields are disabled', () => { + const report = createMock({type: CONST.REPORT.TYPE.EXPENSE}); + const testPolicy = createMock({ + type: CONST.POLICY.TYPE.TEAM, + areReportFieldsEnabled: false, + }); + + expect(shouldDisplayReportFields(report, testPolicy)).toBe(false); + }); + }); + describe('getMissingPaymentMethodForQueuedPayment', () => { const bankAccountListWithDepositAccount: BankAccountList = { 123: { @@ -20161,6 +20593,7 @@ describe('ReportUtils', () => { timeOfCreation, betas, getCurrencyDecimalsLocal, + undefined, ); expect(optimisticReport.reportName).toBe(CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME); }); @@ -20200,6 +20633,7 @@ describe('ReportUtils', () => { timeOfCreation, betas, getCurrencyDecimalsLocal, + undefined, ); expect(optimisticReport.reportName).toBe(CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME); }); @@ -21087,7 +21521,7 @@ describe('ReportUtils', () => { it('should return false when policy does not have areReportFieldsEnabled enabled', () => { const policyWithFieldsDisabled = {...basePolicy, areReportFieldsEnabled: false}; - expect(hasVisibleReportFieldViolations(expenseReport, policyWithFieldsDisabled, currentUserAccountID)).toBe(false); + expect(hasVisibleReportFieldViolations(expenseReport, policyWithFieldsDisabled, currentUserAccountID, undefined)).toBe(false); }); it('should return false when the report is not an expense report or invoice report', () => { @@ -21097,7 +21531,7 @@ describe('ReportUtils', () => { policyID, }; - expect(hasVisibleReportFieldViolations(chatReport, basePolicy, currentUserAccountID)).toBe(false); + expect(hasVisibleReportFieldViolations(chatReport, basePolicy, currentUserAccountID, undefined)).toBe(false); }); it('should return true when expense report has a required field with no value', async () => { @@ -21115,7 +21549,67 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policyWithEmptyField); await waitForBatchedUpdates(); - expect(hasVisibleReportFieldViolations(expenseReport, policyWithEmptyField, currentUserAccountID)).toBe(true); + expect(hasVisibleReportFieldViolations(expenseReport, policyWithEmptyField, currentUserAccountID, undefined)).toBe(true); + }); + + it('should return true when an unpaid invoice report has a required field with no value', async () => { + const fieldWithNoValue: PolicyReportField = { + ...baseField, + target: CONST.REPORT.TYPE.INVOICE, + value: null, + defaultValue: '', + }; + + const policyWithEmptyInvoiceField = { + ...basePolicy, + areReportFieldsEnabled: false, + areInvoiceFieldsEnabled: true, + fieldList: {[`expensify_${fieldWithNoValue.fieldID}`]: fieldWithNoValue}, + }; + + const unpaidInvoiceReport: Report = { + reportID: 'invoice-report-field-violations-unpaid', + type: CONST.REPORT.TYPE.INVOICE, + policyID, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policyWithEmptyInvoiceField); + await waitForBatchedUpdates(); + + expect(hasVisibleReportFieldViolations(unpaidInvoiceReport, policyWithEmptyInvoiceField, currentUserAccountID, undefined)).toBe(true); + }); + + it('should return false when a paid invoice report has a required field with no value', async () => { + const fieldWithNoValue: PolicyReportField = { + ...baseField, + target: CONST.REPORT.TYPE.INVOICE, + value: null, + defaultValue: '', + }; + + const policyWithEmptyInvoiceField = { + ...basePolicy, + areReportFieldsEnabled: false, + areInvoiceFieldsEnabled: true, + fieldList: {[`expensify_${fieldWithNoValue.fieldID}`]: fieldWithNoValue}, + }; + + const paidInvoiceReport: Report = { + reportID: 'invoice-report-field-violations-paid', + type: CONST.REPORT.TYPE.INVOICE, + policyID, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policyWithEmptyInvoiceField); + await waitForBatchedUpdates(); + + expect(hasVisibleReportFieldViolations(paidInvoiceReport, policyWithEmptyInvoiceField, currentUserAccountID, undefined)).toBe(false); }); }); @@ -21972,7 +22466,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const action = {...createRandomReportAction(1)}; - const result = getChatListItemReportName(action, conciergeReport, undefined, conciergeReportID, [], translateLocal, undefined); + const result = getChatListItemReportName(action, conciergeReport, undefined, conciergeReportID, [], translateLocal, convertToDisplayString, undefined); expect(result).toBe(CONST.CONCIERGE_DISPLAY_NAME); }); @@ -21986,7 +22480,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const action = {...createRandomReportAction(2)}; - const result = getChatListItemReportName(action, regularReport, undefined, conciergeReportID, [], translateLocal, undefined); + const result = getChatListItemReportName(action, regularReport, undefined, conciergeReportID, [], translateLocal, convertToDisplayString, undefined); expect(result).not.toBe(CONST.CONCIERGE_DISPLAY_NAME); }); @@ -21996,7 +22490,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, }; const action = {...createRandomReportAction(3), reportName: 'Custom Action Name'}; - const result = getChatListItemReportName(action, conciergeReport, undefined, conciergeReportID, [], translateLocal, undefined); + const result = getChatListItemReportName(action, conciergeReport, undefined, conciergeReportID, [], translateLocal, convertToDisplayString, undefined); expect(result).toBe('Custom Action Name'); }); @@ -22006,7 +22500,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, }; const action = {...createRandomReportAction(4)}; - const result = getChatListItemReportName(action, conciergeReport, undefined, conciergeReportID, [], translateLocal, undefined); + const result = getChatListItemReportName(action, conciergeReport, undefined, conciergeReportID, [], translateLocal, convertToDisplayString, undefined); expect(result).toBe(CONST.CONCIERGE_DISPLAY_NAME); }); @@ -22027,7 +22521,7 @@ describe('ReportUtils', () => { const translateWithMarker: LocalizedTranslate = (path, ...parameters) => (path === 'iou.payerOwesAmount' ? 'PayerOwesMarker' : translateLocal(path, ...parameters)); const action = {...createRandomReportAction(5)}; - const result = getChatListItemReportName(action, invoiceReport, parentChatReport, undefined, [], translateWithMarker, undefined); + const result = getChatListItemReportName(action, invoiceReport, parentChatReport, undefined, [], translateWithMarker, convertToDisplayString, undefined); expect(result).toBe('PayerOwesMarker'); }); @@ -22047,7 +22541,7 @@ describe('ReportUtils', () => { }; const action = {...createRandomReportAction(6)}; - const result = getChatListItemReportName(action, invoiceReport, parentChatReport, undefined, [], translateLocal, undefined); + const result = getChatListItemReportName(action, invoiceReport, parentChatReport, undefined, [], translateLocal, convertToDisplayString, undefined); expect(result).toBe('Invoice #42'); }); @@ -22085,7 +22579,7 @@ describe('ReportUtils', () => { }; const action = {...createRandomReportAction(7)}; - const result = getChatListItemReportName(action, invoiceReport, nonInvoiceParent, undefined, [], translateWithMarker, undefined); + const result = getChatListItemReportName(action, invoiceReport, nonInvoiceParent, undefined, [], translateWithMarker, convertToDisplayString, undefined); expect(result).toBe('PayerOwesMarker'); }); @@ -22100,7 +22594,7 @@ describe('ReportUtils', () => { }; const action = {...createRandomReportAction(8)}; - const result = getChatListItemReportName(action, invoiceReport, invoiceRoom, undefined, [], translateWithMarker, undefined); + const result = getChatListItemReportName(action, invoiceReport, invoiceRoom, undefined, [], translateWithMarker, convertToDisplayString, undefined); expect(result).toBe('PayerOwesMarker'); }); @@ -22114,7 +22608,7 @@ describe('ReportUtils', () => { }; const action = {...createRandomReportAction(9)}; - getChatListItemReportName(action, invoiceReport, invoiceRoom, undefined, [], translateWithMarker, undefined); + getChatListItemReportName(action, invoiceReport, invoiceRoom, undefined, [], translateWithMarker, convertToDisplayString, undefined); expect(invoiceReport.chatReportID).toBeUndefined(); }); @@ -22140,7 +22634,7 @@ describe('ReportUtils', () => { }; const action = {...createRandomReportAction(10)}; - const result = getChatListItemReportName(action, invoiceThread, parentInvoiceReport, undefined, [], translateWithMarker, undefined); + const result = getChatListItemReportName(action, invoiceThread, parentInvoiceReport, undefined, [], translateWithMarker, convertToDisplayString, undefined); expect(result).toBe('PayerOwesMarker'); }); @@ -23020,6 +23514,7 @@ describe('ReportUtils', () => { const buildExpenseReport = () => buildOptimisticExpenseReport({ + rules: undefined, chatReportID, getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '123', @@ -23092,6 +23587,7 @@ describe('ReportUtils', () => { it('forwards currentUserAccountID to the optimistic transaction thread participants', () => { const chatReport: Report = {reportID: '8001'}; const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: chatReport.reportID, getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '123', @@ -23129,6 +23625,7 @@ describe('ReportUtils', () => { it('skips creating a transaction thread when shouldGenerateTransactionThreadReport is false', () => { const chatReport: Report = {reportID: '8002'}; const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: chatReport.reportID, getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '123', @@ -23451,25 +23948,25 @@ describe('ReportUtils', () => { it('returns false for non-money-request reports', () => { const chatReport = makeReport({type: CONST.REPORT.TYPE.CHAT}); - expect(canModifyHoldStatus(chatReport, makeReportAction(), currentUserAccountID, true)).toBe(false); + expect(canModifyHoldStatus(chatReport, makeReportAction(), currentUserAccountID, true, undefined)).toBe(false); }); it('returns true for IOU report when user is action owner', () => { const iouReport = makeReport({type: CONST.REPORT.TYPE.IOU}); const reportAction = makeReportAction({actorAccountID: currentUserAccountID}); - expect(canModifyHoldStatus(iouReport, reportAction, currentUserAccountID, false)).toBe(true); + expect(canModifyHoldStatus(iouReport, reportAction, currentUserAccountID, false, undefined)).toBe(true); }); it('returns true for IOU report when user is manager', () => { const iouReport = makeReport({type: CONST.REPORT.TYPE.IOU, managerID: currentUserAccountID}); const reportAction = makeReportAction({actorAccountID: 999}); - expect(canModifyHoldStatus(iouReport, reportAction, currentUserAccountID, false)).toBe(true); + expect(canModifyHoldStatus(iouReport, reportAction, currentUserAccountID, false, undefined)).toBe(true); }); it('returns false for IOU report when user is neither owner nor manager', () => { const iouReport = makeReport({type: CONST.REPORT.TYPE.IOU, managerID: 888}); const reportAction = makeReportAction({actorAccountID: 999}); - expect(canModifyHoldStatus(iouReport, reportAction, currentUserAccountID, false)).toBe(false); + expect(canModifyHoldStatus(iouReport, reportAction, currentUserAccountID, false, undefined)).toBe(false); }); it('returns true for open expense report when user is action owner', () => { @@ -23479,7 +23976,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.OPEN, }); const reportAction = makeReportAction({actorAccountID: currentUserAccountID}); - expect(canModifyHoldStatus(openExpenseReport, reportAction, currentUserAccountID, false)).toBe(true); + expect(canModifyHoldStatus(openExpenseReport, reportAction, currentUserAccountID, false, undefined)).toBe(true); }); it('returns false for open expense report when user is not action owner', () => { @@ -23489,7 +23986,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.OPEN, }); const reportAction = makeReportAction({actorAccountID: 999}); - expect(canModifyHoldStatus(openExpenseReport, reportAction, currentUserAccountID, false)).toBe(false); + expect(canModifyHoldStatus(openExpenseReport, reportAction, currentUserAccountID, false, undefined)).toBe(false); }); it('returns true for admin on processing expense report', () => { @@ -23499,7 +23996,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }); const reportAction = makeReportAction({actorAccountID: 999}); - expect(canModifyHoldStatus(processingReport, reportAction, currentUserAccountID, true)).toBe(true); + expect(canModifyHoldStatus(processingReport, reportAction, currentUserAccountID, true, undefined)).toBe(true); }); it('returns true for manager on processing expense report', () => { @@ -23510,7 +24007,7 @@ describe('ReportUtils', () => { managerID: currentUserAccountID, }); const reportAction = makeReportAction({actorAccountID: 999}); - expect(canModifyHoldStatus(processingReport, reportAction, currentUserAccountID, false)).toBe(true); + expect(canModifyHoldStatus(processingReport, reportAction, currentUserAccountID, false, undefined)).toBe(true); }); it('returns false for admin on non-processing expense report', () => { @@ -23520,7 +24017,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.APPROVED, }); const reportAction = makeReportAction({actorAccountID: 999}); - expect(canModifyHoldStatus(approvedReport, reportAction, currentUserAccountID, true)).toBe(false); + expect(canModifyHoldStatus(approvedReport, reportAction, currentUserAccountID, true, undefined)).toBe(false); }); }); @@ -23576,6 +24073,7 @@ describe('ReportUtils', () => { const buildExpenseReportForAutoReimbursement = () => buildOptimisticExpenseReport({ + rules: undefined, chatReportID: '1', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '1', @@ -23611,6 +24109,7 @@ describe('ReportUtils', () => { describe('isGroupPolicyExpenseReport', () => { it('returns true when report is an expense report and policy type is a group type', () => { const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: '1', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '1', @@ -23626,6 +24125,7 @@ describe('ReportUtils', () => { it('returns false when report is an expense report but policy type is not a group type', () => { const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, chatReportID: '1', getCurrencyDecimals: getCurrencyDecimalsLocal, policyID: '1', @@ -23706,67 +24206,67 @@ describe('ReportUtils', () => { }); it('returns false for an empty selection', () => { - expect(canMergeReports([], USER_ID)).toBe(false); + expect(canMergeReports([], USER_ID, undefined)).toBe(false); }); it('returns false when only 1 report is selected', () => { - expect(canMergeReports([makeOpenReport()], USER_ID)).toBe(false); + expect(canMergeReports([makeOpenReport()], USER_ID, undefined)).toBe(false); }); it('returns false when currentUserAccountID is 0 (falsy)', () => { - expect(canMergeReports([makeOpenReport(), makeOpenReport()], 0)).toBe(false); + expect(canMergeReports([makeOpenReport(), makeOpenReport()], 0, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([open, processing], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).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)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(false); }); /** @@ -23778,7 +24278,7 @@ describe('ReportUtils', () => { const r1 = makeOpenReport({managerID: undefined}); const r2 = makeOpenReport({managerID: 999}); - expect(canMergeReports([r1, r2], USER_ID)).toBe(true); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(true); }); // Terminal states (settled / approved / closed) @@ -23789,7 +24289,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, isWaitingOnBankAccount: false, } as Report; - expect(canMergeReports([makeOpenReport(), settled], USER_ID)).toBe(false); + expect(canMergeReports([makeOpenReport(), settled], USER_ID, undefined)).toBe(false); }); it('returns false when a report is approved', () => { @@ -23798,7 +24298,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, } as Report; - expect(canMergeReports([makeOpenReport(), approved], USER_ID)).toBe(false); + expect(canMergeReports([makeOpenReport(), approved], USER_ID, undefined)).toBe(false); }); it('returns false when a report is closed', () => { @@ -23806,14 +24306,14 @@ describe('ReportUtils', () => { ...makeOpenReport(), statusNum: CONST.REPORT.STATUS_NUM.CLOSED, } as Report; - expect(canMergeReports([makeOpenReport(), closed], USER_ID)).toBe(false); + expect(canMergeReports([makeOpenReport(), closed], USER_ID, undefined)).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', async () => { const r1 = makeOpenReport({permissions: [CONST.REPORT.PERMISSIONS.READ]}); const r2 = makeOpenReport(); - expect(canMergeReports([r1, r2], USER_ID)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(false); }); // The user must be the report owner, a workspace admin, or the current approver. @@ -23826,7 +24326,7 @@ describe('ReportUtils', () => { // STRANGER_ID is neither admin, owner, nor manager. const r1 = makeOpenReport(); const r2 = makeOpenReport(); - expect(canMergeReports([r1, r2], STRANGER_ID)).toBe(false); + expect(canMergeReports([r1, r2], STRANGER_ID, undefined)).toBe(false); }); // Happy paths @@ -23834,35 +24334,35 @@ describe('ReportUtils', () => { // When the current user is the policy admin const r1 = makeOpenReport(); const r2 = makeOpenReport(); - expect(canMergeReports([r1, r2], USER_ID)).toBe(true); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(true); // When the current user is the submitter await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...policy, role: CONST.POLICY.ROLE.USER}); await waitForBatchedUpdates(); - expect(canMergeReports([r1, r2], USER_ID)).toBe(true); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(true); }); it('returns true for two valid Processing reports when user is the report owner and approver matches', async () => { // When the current user is the policy admin const r1 = makeProcessingReport(); const r2 = makeProcessingReport(); - expect(canMergeReports([r1, r2], USER_ID)).toBe(true); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(true); // When the current user is the submitter await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...policy, role: CONST.POLICY.ROLE.USER}); await waitForBatchedUpdates(); - expect(canMergeReports([r1, r2], USER_ID)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined)).toBe(false); }); it('returns true when the current user is the approver on Processing reports', async () => { const r1 = makeProcessingReport({ownerAccountID: 999, managerID: MANAGER_ID}); const r2 = makeProcessingReport({ownerAccountID: 999, managerID: MANAGER_ID}); - expect(canMergeReports([r1, r2], MANAGER_ID)).toBe(true); + expect(canMergeReports([r1, r2], MANAGER_ID, undefined)).toBe(true); }); it('returns true for three or more valid Open reports', () => { const reports = [makeOpenReport(), makeOpenReport(), makeOpenReport()]; - expect(canMergeReports(reports, USER_ID)).toBe(true); + expect(canMergeReports(reports, USER_ID, undefined)).toBe(true); }); }); }); @@ -23885,7 +24385,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.POLICY.TYPE.TEAM, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: false, report, policy: testPolicy})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: false, report, policy: testPolicy, rules: undefined})).toBe(false); }); it('should return false when policy is not submit-and-close', () => { @@ -23902,7 +24402,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.POLICY.TYPE.TEAM, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy, rules: undefined})).toBe(false); }); it('should return false when user does not own the report', () => { @@ -23919,7 +24419,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.POLICY.TYPE.TEAM, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy, rules: undefined})).toBe(false); }); it('should return false when next approver is different from owner', () => { @@ -23936,7 +24436,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.POLICY.TYPE.TEAM, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy, rules: undefined})).toBe(false); }); it('should return false when isTrackIntentUser is undefined', () => { @@ -23953,7 +24453,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.POLICY.TYPE.TEAM, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: undefined, report, policy: testPolicy})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: undefined, report, policy: testPolicy, rules: undefined})).toBe(false); }); it('should return false when report is undefined', () => { @@ -23963,7 +24463,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.POLICY.TYPE.TEAM, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: true, report: undefined, policy: testPolicy})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: true, report: undefined, policy: testPolicy, rules: undefined})).toBe(false); }); it('should return false when policy is undefined', () => { @@ -23975,7 +24475,7 @@ describe('shouldShowMarkAsDone', () => { type: CONST.REPORT.TYPE.EXPENSE, }); - expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: undefined})).toBe(false); + expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: undefined, rules: undefined})).toBe(false); }); it('should return true when user is track-intent, policy is submit-and-close, user owns report, and submits to self', async () => { @@ -23997,7 +24497,7 @@ describe('shouldShowMarkAsDone', () => { [currentUserAccountID]: {accountID: currentUserAccountID, login: currentUserEmail}, }); await waitForBatchedUpdates(); - expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy})).toBe(true); + expect(shouldShowMarkAsDone({isTrackIntentUser: true, report, policy: testPolicy, rules: undefined})).toBe(true); }); }); @@ -24089,6 +24589,34 @@ describe('hasNonReimbursableTransactions', () => { }); }); +describe('isInvoiceReport', () => { + it('returns true for invoice reports passed as object', () => { + const invoiceReport = { + ...LHNTestUtils.getFakeReport(), + type: CONST.REPORT.TYPE.INVOICE, + }; + expect(isInvoiceReport(invoiceReport)).toBe(true); + }); + + it('returns false for non-invoice reports passed as object', () => { + const expenseReport = { + ...LHNTestUtils.getFakeReport(), + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(isInvoiceReport(expenseReport)).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isInvoiceReport(null)).toBe(false); + expect(isInvoiceReport(undefined)).toBe(false); + }); + + it('returns false for a report with no type', () => { + const report = LHNTestUtils.getFakeReport(); + expect(isInvoiceReport(report)).toBe(false); + }); +}); + describe('getTransactionsWithReceipts', () => { it('returns only transactions that have a receipt (scan receipt or eReceipt)', () => { const withScanReceipt: Transaction = {...createRandomTransaction(1), hasEReceipt: false, receipt: {state: CONST.IOU.RECEIPT_STATE.OPEN}}; diff --git a/tests/unit/ReportWelcomeTextTest.tsx b/tests/unit/ReportWelcomeTextTest.tsx index 73daf7631b4b..055ba69735d6 100644 --- a/tests/unit/ReportWelcomeTextTest.tsx +++ b/tests/unit/ReportWelcomeTextTest.tsx @@ -9,6 +9,7 @@ import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; import type ReactNative from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -92,10 +93,12 @@ const personalDetails: Record = { function renderComponent(report: OnyxEntry, policy?: OnyxEntry) { return render( - , + + + , ); } diff --git a/tests/unit/Search/ChatSearchViewTest.tsx b/tests/unit/Search/ChatSearchViewTest.tsx index 649e8a126d39..5875316a1ccf 100644 --- a/tests/unit/Search/ChatSearchViewTest.tsx +++ b/tests/unit/Search/ChatSearchViewTest.tsx @@ -180,6 +180,7 @@ function renderView(overrides: RenderOverrides = {}) { columns={columns} canSelectMultiple={overrides.canSelectMultiple ?? false} isActionColumnWide={false} + columnSizeOptions={{}} isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} diff --git a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx index 528c96bd0484..b19b808d06a4 100644 --- a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx @@ -195,6 +195,7 @@ function renderView(overrides: RenderOverrides = {}) { columns={columns} canSelectMultiple={overrides.canSelectMultiple ?? false} isActionColumnWide={false} + columnSizeOptions={{}} isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} diff --git a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx index 7d9cea2dface..be0b08371d56 100644 --- a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx @@ -197,6 +197,7 @@ function renderView(overrides: RenderOverrides = {}) { columns={columns} canSelectMultiple={overrides.canSelectMultiple ?? false} isActionColumnWide={false} + columnSizeOptions={{}} isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} diff --git a/tests/unit/Search/ExpenseReportSearchViewTest.tsx b/tests/unit/Search/ExpenseReportSearchViewTest.tsx index 2e40e0ba8679..0414e9f8e374 100644 --- a/tests/unit/Search/ExpenseReportSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseReportSearchViewTest.tsx @@ -182,6 +182,7 @@ function renderView(overrides: RenderOverrides = {}) { columns={columns} canSelectMultiple={overrides.canSelectMultiple ?? false} isActionColumnWide={false} + columnSizeOptions={{}} isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} diff --git a/tests/unit/Search/LazyGroupSelectionTest.tsx b/tests/unit/Search/LazyGroupSelectionTest.tsx index f0f47c33d71b..d1df1978d966 100644 --- a/tests/unit/Search/LazyGroupSelectionTest.tsx +++ b/tests/unit/Search/LazyGroupSelectionTest.tsx @@ -139,6 +139,7 @@ const renderSelection = () => renderHook( () => ({ ...useSearchSelectionContext(), + ...useSearchSelectionActions(), ...useSearchRowSelectionActions(), }), {wrapper: Wrapper}, @@ -228,6 +229,31 @@ describe('Lazily loaded group selection', () => { expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); }); + it('clears the parent exclusion when an expanded group is reselected', async () => { + const {result} = renderSelection(); + + await act(async () => { + result.current.toggleAll(); + result.current.selectAllMatchingItems(true); + await waitForBatchedUpdatesWithAct(); + }); + await act(async () => { + result.current.toggle(categoryGroup, []); + await waitForBatchedUpdatesWithAct(); + }); + expect(result.current.excludedTransactions[GROUP_KEY]).toBeDefined(); + + await act(async () => { + result.current.toggle(categoryGroup, loadedChildren); + await waitForBatchedUpdatesWithAct(); + }); + + expect(result.current.excludedTransactions).toEqual({}); + expect(result.current.selectedTransactions['1']?.isSelected).toBe(true); + expect(result.current.selectedTransactions['2']?.isSelected).toBe(true); + expect(result.current.areAllMatchingItemsSelected).toBe(true); + }); + it('refreshes an excluded expense when its live row changes', async () => { const {result, rerender} = renderFlatSelection(); await excludeFlatExpense(result); diff --git a/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx b/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx new file mode 100644 index 000000000000..8412fb3d8327 --- /dev/null +++ b/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx @@ -0,0 +1,277 @@ +import {act, render} from '@testing-library/react-native'; + +import type {SearchQueryJSON} from '@components/Search/types'; + +import CONST from '@src/CONST'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +// jest.mock factories can't reference imported bindings, but `mock`-prefixed locals are allowed. +const MockView = View; +const mockUseRef = useRef; + +const FILTER_KEYS = CONST.SEARCH.SYNTAX_FILTER_KEYS; + +/** Called once per content instance, so a remount shows up as a second call for the same key. */ +const mockOnContentCreated = jest.fn(); + +/** What each mounted content was last told about deriving itself. */ +let mockReadyByFilter: Record = {}; + +/** How many times each content has rendered, so a render of a hidden one shows up here. */ +let mockRenderCountByFilter: Record = {}; + +/** The filter values each mounted content was last rendered with. */ +let mockValuesByFilter: Record | undefined> = {}; + +// Captured from what the popup hands the filter list, so tests can drive the cursor and read what is highlighted. +let mockOnHoverIn: ((filterKey: string) => void) | undefined; +let mockOnFocus: ((filterKey: string) => void) | undefined; +let mockOnPointerMove: ((event: {clientX: number; clientY: number}) => void) | undefined; +let mockOnPointerLeave: ((event: {clientX: number}) => void) | undefined; +let mockSelectedFilter: string | undefined; + +/** The form served by the useOnyx mock. Reassigned by tests to change filter values. */ +let mockFiltersForm: Partial | undefined; + +// Stand-in for the filter list: it renders no rows and only hands the tests what the popup gave it. +jest.mock('@components/Search/FilterComponents/AdvancedFilters/FilterList', () => ({ + __esModule: true, + default: (props: { + onHoverIn: (filterKey: string) => void; + onFocus: (filterKey: string) => void; + onPointerMove: (event: {clientX: number; clientY: number}) => void; + onPointerLeave: (event: {clientX: number}) => void; + selectedFilter: string; + }) => { + mockOnHoverIn = props.onHoverIn; + mockOnFocus = props.onFocus; + mockOnPointerMove = props.onPointerMove; + mockOnPointerLeave = props.onPointerLeave; + mockSelectedFilter = props.selectedFilter; + + return null; + }, +})); + +// Stand-in for the filter content, the expensive part being mounted or kept alive. +jest.mock('@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent', () => ({ + __esModule: true, + default: ({baseFilterKey, ready, values}: {baseFilterKey: string; ready?: boolean; values?: Partial}) => { + const isCreated = mockUseRef(false); + if (!isCreated.current) { + isCreated.current = true; + mockOnContentCreated(baseFilterKey); + } + mockRenderCountByFilter[baseFilterKey] = (mockRenderCountByFilter[baseFilterKey] ?? 0) + 1; + mockReadyByFilter[baseFilterKey] = !!ready; + mockValuesByFilter[baseFilterKey] = values; + + return ; + }, +})); + +// One callback for the life of the test, so props of an unvisited content stay identical. +const mockUpdateFilterQueryParams = jest.fn(); +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ + __esModule: true, + default: () => ({updateFilterQueryParams: mockUpdateFilterQueryParams}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [mockFiltersForm, {status: 'loaded'}], +})); + +// The popover exists on web only, and jest-expo's resolver prefers the native variant, which renders nothing. +const SearchAdvancedFiltersPopup = require<{ + default: React.ComponentType<{queryJSON: SearchQueryJSON | undefined}>; +}>('../../../src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx').default; + +/** Renders the popup again, which carries a reassigned form into it while the shown filter stays put. */ +let rerenderPopup: () => void; + +// Only forwarded to the mocked query hook, so it is never read. +const queryJSON = undefined; + +/** Moves the cursor onto a filter row without waiting for the hover intent delay. */ +function hover(filterKey: string) { + act(() => { + mockOnHoverIn?.(filterKey); + }); +} + +/** Moves the cursor onto a filter row and leaves it there long enough to release what it withheld. */ +function hoverAndRest(filterKey: string) { + hover(filterKey); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY); + }); +} + +/** Travels the cursor to a point over the filter list. */ +function movePointer(clientX: number, clientY: number) { + act(() => { + mockOnPointerMove?.({clientX, clientY}); + }); +} + +/** Takes the cursor off the filter list at a point, which is to the right of the rows unless a test says otherwise. */ +function leaveList(clientX = Number.MAX_SAFE_INTEGER) { + act(() => { + mockOnPointerLeave?.({clientX}); + }); +} + +/** Moves the keyboard focus onto a filter row. */ +function focus(filterKey: string) { + act(() => { + mockOnFocus?.(filterKey); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockFiltersForm = undefined; + mockReadyByFilter = {}; + mockRenderCountByFilter = {}; + mockValuesByFilter = {}; + const view = render(); + rerenderPopup = () => view.rerender(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('SearchAdvancedFiltersPopup', () => { + it('shows every row the cursor passes over and withholds the contact list from all but the one it stops on', () => { + const peopleFilters = [FILTER_KEYS.FROM, FILTER_KEYS.TO, FILTER_KEYS.ATTENDEE, FILTER_KEYS.ASSIGNEE]; + for (const filterKey of peopleFilters) { + hover(filterKey); + act(() => { + jest.advanceTimersByTime(12); + }); + } + + // The pane kept up with the cursor, and not one of the rows it crossed built a contact list. + expect(mockSelectedFilter).toBe(FILTER_KEYS.ASSIGNEE); + for (const filterKey of peopleFilters) { + expect(mockReadyByFilter[filterKey]).toBe(false); + } + + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY); + }); + + expect(mockReadyByFilter[FILTER_KEYS.ASSIGNEE]).toBe(true); + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(false); + }); + + it('marks a filter outside the contact-list group ready the moment it is pointed at', () => { + hover(FILTER_KEYS.STATUS); + + // No timer is advanced: nothing about this filter is worth waiting for. + expect(mockReadyByFilter[FILTER_KEYS.STATUS]).toBe(true); + }); + + it('treats a cursor that only shakes on the spot as at rest', () => { + hover(FILTER_KEYS.FROM); + movePointer(100, 100); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY - 10); + }); + // Within HOVER_INTENT_REST_RADIUS_PX of where it settled, so it does not restart the wait. + movePointer(104, 103); + act(() => { + jest.advanceTimersByTime(15); + }); + + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); + }); + + it('releases the row the cursor left from when it leaves the list toward the content', () => { + hover(FILTER_KEYS.FROM); + movePointer(100, 100); + leaveList(140); + + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); + }); + + it('releases the row the cursor swept out of away from the content once its wait elapses', () => { + hover(FILTER_KEYS.FROM); + movePointer(100, 100); + leaveList(60); + + // The sweep itself paid for nothing, and the wait left running releases the row that stayed on show. + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(false); + + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY); + }); + + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); + }); + + it('hands the shown content the live form and a hidden one the values it was last shown with', () => { + mockFiltersForm = {from: ['9']}; + // The useOnyx mock cannot re-render on its own, so a visit to another row carries the new form into the popup. + hoverAndRest(FILTER_KEYS.STATUS); + hoverAndRest(FILTER_KEYS.FROM); + + // The form changes while this filter is the shown one, so it follows rather than holding its snapshot. + mockFiltersForm = {from: ['9'], to: ['1']}; + rerenderPopup(); + expect(mockValuesByFilter[FILTER_KEYS.FROM]).toEqual({from: ['9'], to: ['1']}); + + // Hidden, it keeps those values rather than following the form on. + mockFiltersForm = {from: ['9'], to: ['1'], category: ['food']}; + hoverAndRest(FILTER_KEYS.STATUS); + expect(mockValuesByFilter[FILTER_KEYS.FROM]).toEqual({from: ['9'], to: ['1']}); + }); + + it('makes a remounted content wait for the cursor again before it derives itself', () => { + hoverAndRest(FILTER_KEYS.FROM); + + // Someone was picked in this very filter, so its content has to decide its selection pinning again. + mockFiltersForm = {from: ['1']}; + rerenderPopup(); + const rendersBeforeLeaving = mockRenderCountByFilter[FILTER_KEYS.FROM]; + + hoverAndRest(FILTER_KEYS.TO); + // Hiding it hands back what it was already rendering, so the rebuild is the only cost. + expect(mockRenderCountByFilter[FILTER_KEYS.FROM]).toBe(rendersBeforeLeaving); + + hover(FILTER_KEYS.FROM); + expect(mockOnContentCreated.mock.calls.filter(([filterKey]) => filterKey === FILTER_KEYS.FROM)).toHaveLength(2); + // The rebuilt instance is as expensive as a new one, so being shown is not enough for it to derive. + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(false); + + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY); + }); + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); + }); + + it('keeps a mounted content when only a filter it does not read changed', () => { + hoverAndRest(FILTER_KEYS.FROM); + + // Someone was picked in another filter, which the content of this one neither reads nor renders. + mockFiltersForm = {to: ['1']}; + hoverAndRest(FILTER_KEYS.TO); + + // The content is revealed as it was left, so state it holds itself survives. + hoverAndRest(FILTER_KEYS.FROM); + expect(mockOnContentCreated.mock.calls.filter(([filterKey]) => filterKey === FILTER_KEYS.FROM)).toHaveLength(1); + }); + + it('withholds nothing from a row focused with the keyboard', () => { + focus(FILTER_KEYS.FROM); + + // No timer is advanced: moving focus is deliberate and never passes over rows on the way. + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); + }); +}); diff --git a/tests/unit/Search/SearchAdvancedFiltersProviderTest.tsx b/tests/unit/Search/SearchAdvancedFiltersProviderTest.tsx new file mode 100644 index 000000000000..eb1d97f02864 --- /dev/null +++ b/tests/unit/Search/SearchAdvancedFiltersProviderTest.tsx @@ -0,0 +1,191 @@ +import {renderHook} from '@testing-library/react-native'; + +import type {SearchQueryJSON} from '@components/Search/types'; + +import Navigation from '@libs/Navigation/Navigation'; +import {buildQueryStringWithResetFilters, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import SearchAdvancedFiltersProvider, {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; + +import CONST from '@src/CONST'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; + +import {useContext} from 'react'; + +const mockUseOnyx = jest.fn<[Partial | undefined], []>(); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => mockUseOnyx(), +})); + +const mockGetUpdatedFilterFormValues = jest.fn((current: Record, next: Record) => ({...current, ...next})); +const mockSetFilterQueryParams = jest.fn(); + +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ + __esModule: true, + default: () => ({getUpdatedFilterFormValues: mockGetUpdatedFilterFormValues, setFilterQueryParams: mockSetFilterQueryParams}), +})); + +const mockUseSearchQueryContext = jest.fn(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: () => mockUseSearchQueryContext(), +})); + +const mockSetSearchContext = jest.fn(); + +jest.mock('@libs/actions/Search', () => ({ + setSearchContext: (...args: unknown[]) => mockSetSearchContext(...args), +})); + +jest.mock('@libs/Navigation/Navigation'); + +function useFilters() { + return {...useContext(SearchAdvancedFiltersContext), ...useContext(SearchAdvancedFiltersActionContext)}; +} + +function renderProvider() { + return renderHook(useFilters, {wrapper: SearchAdvancedFiltersProvider}); +} + +type QueryContext = { + currentSearchQueryJSON: SearchQueryJSON | undefined; + currentDefaultSearchQueryJSON: SearchQueryJSON | undefined; +}; + +function mockOnyxForm(form: Partial | undefined) { + mockUseOnyx.mockReturnValue([form]); +} + +function mockQueryContext(context: Partial) { + mockUseSearchQueryContext.mockReturnValue({ + currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + ...context, + }); +} + +describe('SearchAdvancedFiltersProvider', () => { + beforeEach(() => { + mockUseOnyx.mockReset(); + mockUseSearchQueryContext.mockReset(); + mockGetUpdatedFilterFormValues.mockClear(); + mockSetFilterQueryParams.mockClear(); + mockSetSearchContext.mockClear(); + jest.mocked(Navigation.setParams).mockClear(); + // Make dismissModal run the afterTransition callback synchronously so resetFilters/applyFilters take effect. + jest.mocked(Navigation.dismissModal).mockImplementation((options?: {afterTransition?: () => void}) => options?.afterTransition?.()); + }); + + describe('shouldShowResetFilters', () => { + it('is true when the default query filters differ from the current query filters', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({ + currentDefaultSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + currentSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon category:Food`), + }); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when the default query filters equal the current query filters', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({ + currentDefaultSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + currentSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + }); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('is false when the queries differ only by the keyword', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({ + currentDefaultSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + currentSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon coffee`), + }); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('is true when there is no default query JSON and non-type filter is applied', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE, merchant: 'Amazon'}); + mockQueryContext({currentDefaultSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when there is no default query JSON and only the type filter is applied', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({currentDefaultSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('is false when there is no default query JSON and no filters', () => { + mockOnyxForm(undefined); + mockQueryContext({currentDefaultSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + }); + + describe('resetFilters', () => { + function parseQuery(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + return queryJSON; + } + + const currentSearchQueryJSON = parseQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon category:Food`); + + it('navigates to the query the filters reset to', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + const currentDefaultSearchQueryJSON = parseQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + mockQueryContext({currentDefaultSearchQueryJSON, currentSearchQueryJSON}); + + const {result} = renderProvider(); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); + expect(mockSetSearchContext).toHaveBeenCalledWith(false); + }); + + it('navigates to the query the filters reset to when there is no default query', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({currentDefaultSearchQueryJSON: undefined, currentSearchQueryJSON}); + + const {result} = renderProvider(); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, undefined), rawQuery: undefined}); + expect(mockSetSearchContext).toHaveBeenCalledWith(false); + }); + + it('does nothing when there is no current query', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.INVOICE}); + mockQueryContext({currentSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + result.current.resetFilters(); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(mockSetSearchContext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 5e876676a443..211ee55dac2d 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -17,8 +17,10 @@ type MockButtonProps = { const mockButtonWithDropdownMenu = jest.fn(() => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchCount: number | undefined; +let mockSearchReportCount: number | undefined; let mockSearchIsLoading = false; let mockIsOffline = false; +let mockAreAllMatchingItemsSelected = true; jest.mock('@components/ButtonWithDropdownMenu', () => ({ __esModule: true, @@ -72,13 +74,13 @@ jest.mock('@hooks/useSearchBulkActions', () => ({ })); jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ - selectedTransactions: {tx1: {isSelected: true}}, + selectedTransactions: {tx1: {isSelected: true, reportID: 'report1'}}, excludedTransactions: mockExcludedTransactions, selectedReports: [], - areAllMatchingItemsSelected: true, + areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, }), useSearchResultsContext: () => ({ - currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, + currentSearchResults: {search: {count: mockSearchCount, reportCount: mockSearchReportCount, isLoading: mockSearchIsLoading}}, }), })); jest.mock('@libs/ReportUtils', () => { @@ -111,6 +113,7 @@ function makeTransaction(): SelectedTransactions[string] { reportID: 'report1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', }; } @@ -128,24 +131,26 @@ describe('SearchBulkActionsButton all-matching label', () => { jest.clearAllMocks(); mockExcludedTransactions = {}; mockSearchCount = undefined; + mockSearchReportCount = undefined; mockSearchIsLoading = false; mockIsOffline = false; + mockAreAllMatchingItemsSelected = true; }); - it('falls back to the selected count and keeps loading while the server count is missing', () => { + it('shows the all-matching label and keeps loading while the server count is missing', () => { mockSearchIsLoading = true; render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: true}); + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); }); - it('shows the server count when it arrives and there are no exclusions', () => { + it('keeps the all-matching label when the server count arrives and there are no exclusions', () => { mockSearchCount = 172; render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:172', isLoading: false}); + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); }); it('shows the exact count after an item is excluded', () => { @@ -157,6 +162,14 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:171', isLoading: false}); }); + it('keeps the numeric label for page-only selection', () => { + mockAreAllMatchingItemsSelected = false; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); + }); + it('keeps loading when an exclusion exists before the count arrives', () => { mockSearchIsLoading = true; mockExcludedTransactions = {tx2: makeTransaction()}; @@ -175,20 +188,30 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); }); - it('retains the expense-report loading behavior while the server count is missing', () => { + it('keeps loading for expense reports while the server report count is missing, falling back to the loaded report count', () => { mockSearchIsLoading = true; render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:0', isLoading: true}); + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: true}); }); - it('uses the unmodified server count for expense reports', () => { + it('labels expense reports with the server report count, not the expense count', () => { + // `count` is the expense total; `reportCount` is the matching-report total the Reports tab must show. mockSearchCount = 320; + mockSearchReportCount = 50; mockExcludedTransactions = {tx2: makeTransaction()}; render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:320', isLoading: false}); + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:50', isLoading: false}); + }); + + it('falls back to the loaded report count for expense reports offline before the report count arrives', () => { + mockIsOffline = true; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); }); }); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index fb757dd45cd8..d8ed6b200426 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -14,7 +14,7 @@ import { buildSearchQueryJSON, buildSearchQueryString, buildUserReadableQueryString, - getAdvancedFiltersToReset, + doesQueryMatchDefaultFilterKeysAndType, getAllPolicyValues, getAllPolicyValuesMap, getConnectedIntegrationNamesForPolicies, @@ -29,9 +29,13 @@ import { getKeywordQueryWithCurrentSearchContext, getLastRouteByName, getParamsState, + buildQueryStringWithResetFilters, + getQueryHashWithoutFilters, getQueryWithUpdatedValues, getRangeBoundariesFromFormValue, getRoutes, + getValidLastQuery, + hasFiltersChangedFromDefault, isFilterNegated, isDefaultExpenseReportsQuery, isDefaultExpensesQuery, @@ -1861,6 +1865,46 @@ describe('SearchQueryUtils', () => { }); }); + describe('has:approved-violation filter', () => { + test('round-trips a positive has:approved-violation filter', () => { + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + has: [CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION], + }; + + const queryString = buildQueryStringFromFilterFormValues(filterValues); + expect(queryString).toBe(`type:expense has:${CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION}`); + + const queryJSON = buildSearchQueryJSON(queryString); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + expect(result.has).toEqual([CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION]); + expect(result.hasNot).toBeUndefined(); + }); + + test('round-trips a negated -has:approved-violation filter', () => { + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hasNot: [CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION], + }; + + const queryString = buildQueryStringFromFilterFormValues(filterValues); + expect(queryString).toBe(`type:expense -has:${CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION}`); + + const queryJSON = buildSearchQueryJSON(queryString); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + expect(result.hasNot).toEqual([CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION]); + expect(result.has).toBeUndefined(); + }); + }); + describe('shouldHighlight', () => { it('returns false if either input is empty', () => { expect(shouldHighlight('', 'test')).toBe(false); @@ -2059,6 +2103,192 @@ describe('SearchQueryUtils', () => { }); }); + describe('buildQueryStringWithResetFilters', () => { + function parse(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + return queryJSON; + } + + const currentQuery = parse('type:expense category:travel merchant:Amazon group-currency:USD sortBy:merchant sortOrder:asc groupBy:from columns:merchant,category coffee'); + + it('restores the filter chips of the default query', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, parse('type:expense status:approved')); + + expect(resetQuery).toContain('status:approved'); + expect(resetQuery).not.toContain('category:travel'); + expect(resetQuery).not.toContain('merchant:Amazon'); + }); + + it('clears every filter chip when there is no default query', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, undefined); + + expect(resetQuery).not.toContain('category:travel'); + expect(resetQuery).not.toContain('merchant:Amazon'); + }); + + it('keeps the keyword, sorting, grouping, and columns, which are not filters', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, parse('type:expense status:approved')); + expect(resetQuery).toContain('sortBy:merchant'); + expect(resetQuery).toContain('sortOrder:asc'); + expect(resetQuery).toContain('groupBy:from'); + expect(resetQuery).toContain('groupCurrency:USD'); + expect(resetQuery).toContain('columns:merchant,category'); + expect(resetQuery).toContain('coffee'); + }); + + it('keeps the keyword and the group currency of the current query over the ones of the default query', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, parse('type:expense group-currency:EUR tea')); + + expect(resetQuery).toContain('groupCurrency:USD'); + expect(resetQuery).toContain('coffee'); + expect(resetQuery).not.toContain('groupCurrency:EUR'); + expect(resetQuery).not.toContain('tea'); + }); + }); + + describe('getQueryHashWithoutFilters', () => { + const noExcludedFilters = new Set(); + const excludedFilters = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); + + it('returns the same hash for identical queries', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + const queryJSONb = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('ignores excluded keyword filters when computing the hash', () => { + const withoutKeyword = buildSearchQueryJSON('type:expense category:travel'); + const withKeyword = buildSearchQueryJSON('type:expense category:travel hello world'); + + if (!withoutKeyword || !withKeyword) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withKeyword, excludedFilters)).toEqual(getQueryHashWithoutFilters(withoutKeyword, excludedFilters)); + }); + + it('ignores excluded group-currency filters when computing the hash', () => { + const withoutGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category'); + const withGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category group-currency:USD'); + + if (!withoutGroupCurrency || !withGroupCurrency) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withGroupCurrency, excludedFilters)).toEqual(getQueryHashWithoutFilters(withoutGroupCurrency, excludedFilters)); + }); + + it('takes filters that are not excluded into account', () => { + const withoutKeyword = buildSearchQueryJSON('type:expense category:travel'); + const withKeyword = buildSearchQueryJSON('type:expense category:travel hello world'); + + if (!withoutKeyword || !withKeyword) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withKeyword, noExcludedFilters)).not.toEqual(getQueryHashWithoutFilters(withoutKeyword, noExcludedFilters)); + }); + + it('only ignores the filters that are excluded', () => { + const onlyKeywordExcluded = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD]); + const withoutGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category hello'); + const withGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category group-currency:USD'); + + if (!withoutGroupCurrency || !withGroupCurrency) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withGroupCurrency, onlyKeywordExcluded)).not.toEqual(getQueryHashWithoutFilters(withoutGroupCurrency, onlyKeywordExcluded)); + }); + + it('is independent of the order in which filters appear', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + const queryJSONb = buildSearchQueryJSON('type:expense merchant:Amazon category:travel'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('is independent of the order of values within a filter', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel,food'); + const queryJSONb = buildSearchQueryJSON('type:expense category:food,travel'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('returns different hashes for queries with different filter values', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel'); + const queryJSONb = buildSearchQueryJSON('type:expense category:food'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).not.toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('returns different hashes for queries with different filter keys', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel'); + const queryJSONb = buildSearchQueryJSON('type:expense merchant:travel'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).not.toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + }); + + describe('hasFiltersChangedFromDefault', () => { + it('returns false when the current query only differs by filters that are kept when resetting', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense groupBy:category category:travel'); + const currentQueryJSON = buildSearchQueryJSON('type:expense groupBy:category category:travel group-currency:USD hello'); + + if (!defaultQueryJSON || !currentQueryJSON) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(currentQueryJSON, defaultQueryJSON)).toBe(false); + }); + + it('returns true when the current query has a different filter value', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); + const currentQueryJSON = buildSearchQueryJSON('type:expense category:food'); + + if (!defaultQueryJSON || !currentQueryJSON) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(currentQueryJSON, defaultQueryJSON)).toBe(true); + }); + + it('returns true when the current query has an extra filter', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); + const currentQueryJSON = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + + if (!defaultQueryJSON || !currentQueryJSON) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(currentQueryJSON, defaultQueryJSON)).toBe(true); + }); + }); + describe('limit filter parsing', () => { it('parses limit value as a number', () => { const queryJSON = buildSearchQueryJSON('type:expense limit:25'); @@ -3684,76 +3914,6 @@ describe('SearchQueryUtils', () => { }); }); - describe('getAdvancedFiltersToReset', () => { - it('should return an empty object when input is empty', () => { - const result = getAdvancedFiltersToReset({}); - expect(result).toEqual({}); - }); - - it('should reset type to EXPENSE when it has a non-EXPENSE value', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.CHAT, - }; - const result = getAdvancedFiltersToReset(form); - expect(result).toEqual({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - }); - }); - - it('should not include type in reset when it is already EXPENSE', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - }; - const result = getAdvancedFiltersToReset(form); - expect(result.type).toBeUndefined(); - }); - - it('should reset other filter keys to undefined', () => { - const form: Partial = { - merchant: 'Marriott', - currency: ['USD', 'EUR'], - dateAfter: '2024-01-01', - keyword: 'hotel', - status: [CONST.SEARCH.STATUS.EXPENSE.DRAFTS], - }; - const result = getAdvancedFiltersToReset(form); - expect(result).toEqual({ - merchant: undefined, - currency: undefined, - dateAfter: undefined, - keyword: undefined, - }); - }); - - it('should exclude columns from being reset if type is expense', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - columns: [CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.DATE, CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.MERCHANT], - merchant: 'test', - }; - const result = getAdvancedFiltersToReset(form); - expect(result.columns).toBeUndefined(); - expect(result).toEqual({ - merchant: undefined, - }); - }); - - it('should exclude columns from being reset', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, - columns: [CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.DATE, CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.MERCHANT], - merchant: 'test', - }; - const result = getAdvancedFiltersToReset(form); - expect(result.columns).toBeUndefined(); - expect(result).toEqual({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - merchant: undefined, - columns: undefined, - }); - }); - }); - describe('isNegated', () => { it('returns true for negated filter keys (ending with the NOT modifier)', () => { expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); @@ -4126,6 +4286,84 @@ describe('SearchQueryUtils', () => { }); }); + describe('getValidLastQuery', () => { + const defaultSearchQuery = 'type:expense status:all'; + + it('returns the default query when the last query is undefined', () => { + expect(getValidLastQuery(undefined, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query is an empty string', () => { + expect(getValidLastQuery('', defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query cannot be parsed', () => { + expect(getValidLastQuery('type:', defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the last query when it contains all default filter keys and matches the type', () => { + const lastQuery = 'type:expense status:all merchant:Amazon'; + expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(lastQuery); + }); + + it('returns the last query when it is identical to the default query', () => { + expect(getValidLastQuery(defaultSearchQuery, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query is missing a default filter key', () => { + const lastQuery = 'type:expense'; + expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query type differs from the default type', () => { + const lastQuery = 'type:invoice status:all'; + expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + }); + + describe('doesQueryMatchDefaultFilterKeysAndType', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon'); + + it('returns true when the query has all default filter keys and the same type', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon category:travel'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(true); + }); + + it('returns true when the query is identical to the default query', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(true); + }); + + it('returns false when the query is missing a default filter key', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(false); + }); + + it('returns false when the query type differs from the default type', () => { + const queryJSON = buildSearchQueryJSON('type:invoice status:all merchant:Amazon'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(false); + }); + + it('returns true when the default query has no extra filter keys to satisfy', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon'); + const bareDefaultQueryJSON = buildSearchQueryJSON('type:expense'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, bareDefaultQueryJSON)).toBe(true); + }); + + it('returns true when both query and default query are undefined', () => { + expect(doesQueryMatchDefaultFilterKeysAndType(undefined, undefined)).toBe(true); + }); + + it('returns true when the query is undefined since there is nothing to compare', () => { + expect(doesQueryMatchDefaultFilterKeysAndType(undefined, defaultQueryJSON)).toBe(true); + }); + + it('returns true when the default query is undefined since there are no default filter keys to enforce', () => { + const queryJSON = buildSearchQueryJSON('type:invoice'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, undefined)).toBe(true); + }); + }); + describe('getDateFilterRange', () => { test('returns start and end for an on-date filter', () => { const queryJSON = buildSearchQueryJSON('type:expense date:2025-03-15'); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index c356272eb005..fd178d0d8499 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -104,6 +104,7 @@ function buildSelectedTransaction(currency: string, groupCurrency?: string, grou action: CONST.SEARCH.ACTION_TYPES.VIEW, policyID: undefined, amount: 100, + displayAmount: 100, currency, groupCurrency, groupAmount, @@ -166,6 +167,42 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 10, total: 36000, currency: CONST.CURRENCY.USD})); }); + it('nets a selected credit against a selected expense instead of summing their magnitudes', async () => { + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 0})); + }); + + it('nets a selected credit against a selected expense when the amounts differ', async () => { + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -4000}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 6000})); + }); + + it('adds back an excluded credit rather than subtracting it from the server total', async () => { + // The server total already counts the credit as -$100, so dropping it from the selection raises the total. + mockSelectedTransactions.current = {}; + mockExcludedTransactions.current = {transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}}; + mockAreAllMatchingItemsSelected.current = true; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 171, total: 46000, currency: CONST.CURRENCY.USD})); + }); + it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense // happens to be in a different currency (JPY) from the live payment currency (GBP). diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index dfad6d4729cf..57397131abf2 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -25,6 +25,8 @@ const queryContextValue: SearchQueryContextValue = { currentSimilarSearchHash: 1, currentSearchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, currentSearchQueryJSON: expenseQueryJSON, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), suggestedSearches: getEmptyObject(), shouldResetSearchQuery: false, }; @@ -47,6 +49,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { reportID: 'report_1', policyID: 'policy_1', amount: 100, + displayAmount: 100, currency: 'USD', }, ]), diff --git a/tests/unit/Search/SearchUIUtils.test.ts b/tests/unit/Search/SearchUIUtils.test.ts index 4cf0dff0d1f6..da6f979d8bb2 100644 --- a/tests/unit/Search/SearchUIUtils.test.ts +++ b/tests/unit/Search/SearchUIUtils.test.ts @@ -24,6 +24,7 @@ const baseGetSectionsParams = { currentAccountID: 1, currentUserEmail: '', bankAccountList: undefined, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: undefined, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index bb9f034c7a97..7988543c0b13 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -17,7 +17,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, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; +import type {SearchColumnType, SearchFilterKey, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; import Navigation from '@navigation/Navigation'; @@ -53,6 +53,11 @@ import getOnyxValue from '../../utils/getOnyxValue'; import {convertToDisplayString, formatPhoneNumber, getCurrencyDecimalsLocal, localeCompare, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +declare global { + var createTransactionThreadReportMock: jest.Mock; + var setOptimisticDataForTransactionThreadPreviewMock: jest.Mock; +} + jest.mock('@src/components/ConfirmedRoute.tsx'); jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -60,11 +65,11 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ })); jest.mock('@userActions/Report', () => ({ ...jest.requireActual('@userActions/Report'), - createTransactionThreadReport: jest.fn(), + createTransactionThreadReport: globalThis.createTransactionThreadReportMock ?? (globalThis.createTransactionThreadReportMock = jest.fn()), })); jest.mock('@userActions/Search', () => ({ ...jest.requireActual('@userActions/Search'), - setOptimisticDataForTransactionThreadPreview: jest.fn(), + setOptimisticDataForTransactionThreadPreview: globalThis.setOptimisticDataForTransactionThreadPreviewMock ?? (globalThis.setOptimisticDataForTransactionThreadPreviewMock = jest.fn()), })); jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); @@ -2256,15 +2261,15 @@ describe('SearchUIUtils', () => { }); describe('Test getAction', () => { test('Should return `View` action for an invalid key', () => { - const action = SearchUIUtils.getActions(searchResults.data, {}, 'invalid_key', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, 'invalid_key', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `Submit` action for transaction on policy with delayed submission and no violations', () => { - let action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}).at(0); + let action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); - action = SearchUIUtils.getActions(searchResults.data, {}, `transactions_${transactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}).at(0); + action = SearchUIUtils.getActions(searchResults.data, {}, `transactions_${transactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); @@ -2298,7 +2303,9 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${submitReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, adminAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${submitReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, adminAccountID, {}, {}, undefined).at( + 0, + ); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); @@ -2321,11 +2328,21 @@ describe('SearchUIUtils', () => { managerID: adminAccountID, }, }; - expect(SearchUIUtils.getActions(localSearchResults, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0)).toStrictEqual( - CONST.SEARCH.ACTION_TYPES.VIEW, - ); expect( - SearchUIUtils.getActions(localSearchResults, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0), + SearchUIUtils.getActions(localSearchResults, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}, undefined).at(0), + ).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); + expect( + SearchUIUtils.getActions( + localSearchResults, + allViolations, + `transactions_${transactionID2}`, + CONST.SEARCH.SEARCH_KEYS.EXPENSES, + '', + submitterAccountID, + {}, + {}, + undefined, + ).at(0), ).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -2344,7 +2361,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAID); }); @@ -2368,7 +2385,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAID); }); @@ -2384,7 +2401,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); @@ -2402,13 +2419,13 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.DONE); }); test('Should return `View` action for non-money request reports', () => { - const action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID4}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID4}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}, undefined).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -2422,7 +2439,17 @@ describe('SearchUIUtils', () => { reportID: 'non_existent_report', }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${orphanedTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions( + localSearchResults, + {}, + `transactions_${orphanedTransactionID}`, + CONST.SEARCH.SEARCH_KEYS.EXPENSES, + '', + submitterAccountID, + {}, + {}, + undefined, + ).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `View` action for a transaction in a multi-transaction report', () => { @@ -2440,14 +2467,24 @@ describe('SearchUIUtils', () => { reportID: multiTransactionReportID, }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${multiTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', submitterAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions( + localSearchResults, + {}, + `transactions_${multiTransactionID}`, + CONST.SEARCH.SEARCH_KEYS.EXPENSES, + '', + submitterAccountID, + {}, + {}, + undefined, + ).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `Pay` action for an IOU report ready to be paid', async () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: adminAccountID}); await waitForBatchedUpdates(); const iouReportKey = `report_${reportID3}`; - const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}, undefined).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); @@ -2482,7 +2519,7 @@ describe('SearchUIUtils', () => { }, }); - const actions = SearchUIUtils.getActions(localSearchResults, {}, `report_${exportReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, adminAccountID, {}, {}); + const actions = SearchUIUtils.getActions(localSearchResults, {}, `report_${exportReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, adminAccountID, {}, {}, undefined); expect(actions).toContain(CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING); }); @@ -2533,6 +2570,7 @@ describe('SearchUIUtils', () => { adminAccountID, {}, {}, + undefined, dewReportActions, ).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); @@ -2580,6 +2618,7 @@ describe('SearchUIUtils', () => { adminAccountID, {}, {}, + undefined, dewReportActions, ).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); @@ -2629,6 +2668,7 @@ describe('SearchUIUtils', () => { adminAccountID, {}, {}, + undefined, nonDewReportActions, ).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); @@ -2646,6 +2686,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: undefined, @@ -2665,6 +2706,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: undefined, @@ -2685,6 +2727,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: undefined, @@ -2705,6 +2748,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2724,6 +2768,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2755,6 +2800,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2795,6 +2841,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2832,6 +2879,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2884,6 +2932,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2913,6 +2962,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2942,6 +2992,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -2971,6 +3022,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -3013,6 +3065,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -3033,6 +3086,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, onyxPersonalDetailsList, convertToDisplayString, @@ -3078,6 +3132,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, onyxPersonalDetailsList, convertToDisplayString, @@ -3126,6 +3181,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -3146,6 +3202,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, onyxPersonalDetailsList, convertToDisplayString, @@ -3185,6 +3242,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -3206,6 +3264,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, onyxPersonalDetailsList, convertToDisplayString, @@ -3252,6 +3311,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, onyxPersonalDetailsList, convertToDisplayString, @@ -3303,6 +3363,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -3316,6 +3377,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -3342,6 +3404,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.FROM, conciergeReportID: undefined, convertToDisplayString, @@ -3362,6 +3425,7 @@ describe('SearchUIUtils', () => { translate: translateWithHiddenMarker, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.FROM, conciergeReportID: undefined, convertToDisplayString, @@ -3390,6 +3454,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CARD, cardFeeds: mockCardFeeds, conciergeReportID: undefined, @@ -3485,6 +3550,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CARD, cardFeeds, cardList, @@ -3517,6 +3583,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, conciergeReportID: undefined, convertToDisplayString, @@ -3546,6 +3613,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, conciergeReportID: undefined, convertToDisplayString, @@ -3568,6 +3636,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, conciergeReportID: undefined, convertToDisplayString, @@ -3603,6 +3672,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, conciergeReportID: undefined, convertToDisplayString, @@ -3674,6 +3744,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MONTH, conciergeReportID: undefined, convertToDisplayString, @@ -3711,6 +3782,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MONTH, conciergeReportID: undefined, convertToDisplayString, @@ -3735,6 +3807,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MONTH, conciergeReportID: undefined, convertToDisplayString, @@ -3822,6 +3895,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.YEAR, conciergeReportID: undefined, convertToDisplayString, @@ -3857,6 +3931,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.YEAR, conciergeReportID: undefined, convertToDisplayString, @@ -3881,6 +3956,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.YEAR, conciergeReportID: undefined, convertToDisplayString, @@ -4268,6 +4344,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.QUARTER, conciergeReportID: undefined, convertToDisplayString, @@ -4305,6 +4382,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.QUARTER, conciergeReportID: undefined, convertToDisplayString, @@ -4329,6 +4407,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.QUARTER, conciergeReportID: undefined, convertToDisplayString, @@ -4398,6 +4477,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.WEEK, conciergeReportID: undefined, convertToDisplayString, @@ -4433,6 +4513,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.WEEK, conciergeReportID: undefined, convertToDisplayString, @@ -4477,6 +4558,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -4542,6 +4624,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, conciergeReportID: undefined, convertToDisplayString, @@ -4589,6 +4672,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, conciergeReportID: undefined, convertToDisplayString, @@ -4625,6 +4709,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, conciergeReportID: undefined, convertToDisplayString, @@ -4666,6 +4751,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, conciergeReportID: undefined, convertToDisplayString, @@ -4695,6 +4781,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, conciergeReportID: undefined, convertToDisplayString, @@ -4731,6 +4818,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, conciergeReportID: undefined, convertToDisplayString, @@ -4766,6 +4854,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -4820,6 +4909,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -4873,6 +4963,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -4926,6 +5017,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -5000,6 +5092,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -5066,6 +5159,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, conciergeReportID: undefined, convertToDisplayString, @@ -5114,6 +5208,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, conciergeReportID: undefined, convertToDisplayString, @@ -5140,6 +5235,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.TAG, conciergeReportID: undefined, convertToDisplayString, @@ -5171,6 +5267,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.TAG, conciergeReportID: undefined, convertToDisplayString, @@ -5212,6 +5309,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.TAG, conciergeReportID: undefined, convertToDisplayString, @@ -5246,6 +5344,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.TAG, conciergeReportID: undefined, convertToDisplayString, @@ -5301,6 +5400,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.TAG, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, @@ -5380,6 +5480,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: '999', convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5452,6 +5553,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: '999', convertToDisplayString, reportAttributesDerivedValue: undefined, @@ -5495,6 +5597,7 @@ describe('SearchUIUtils', () => { translate: translateWithHiddenMarker, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: undefined, @@ -5519,6 +5622,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: '999', convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5591,6 +5695,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: '999', convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5650,6 +5755,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: '999', convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5708,6 +5814,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: '999', convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5768,6 +5875,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5780,6 +5888,233 @@ describe('SearchUIUtils', () => { expect(result.at(0)?.reportName).toBe('Task without concierge'); }); + describe('task status filter uses the live report instead of the stale snapshot', () => { + const staleTaskReportID = 'task_report_700'; + const staleCreatorID = 121212; + const staleAssigneeID = 131313; + + // The snapshot still says the task is open — this is exactly what `completeTask` leaves behind, + // because it only writes to `report_` and never patches `snapshot_`. + const snapshotTask = createMock({ + type: CONST.REPORT.TYPE.TASK, + accountID: staleCreatorID, + reportID: staleTaskReportID, + reportName: 'Stale outstanding task', + description: 'Completed but still in the snapshot as open', + managerID: staleAssigneeID, + parentReportID: 'parent_stale', + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + created: '2025-02-05 10:00:00', + }); + + const staleTaskData = createMock({ + personalDetailsList: { + [staleCreatorID]: { + accountID: staleCreatorID, + avatar: '', + displayName: 'Stale Creator', + login: 'creator@test.com', + }, + [staleAssigneeID]: { + accountID: staleAssigneeID, + avatar: '', + displayName: 'Stale Assignee', + login: 'assignee@test.com', + }, + }, + [`report_${staleTaskReportID}`]: snapshotTask, + }); + + const getStaleTaskSections = (query: string) => + getSectionsByType( + SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.TASK, + data: staleTaskData, + currentAccountID: staleCreatorID, + currentUserEmail: 'creator@test.com', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + conciergeReportID: '999', + convertToDisplayString, + reportAttributesDerivedValue: {}, + queryJSON: buildSearchQueryJSON(query), + }), + SearchUIUtils.isTaskListItemType, + ); + + beforeEach(async () => { + // The live report has been completed, mirroring what `completeTask` merges into Onyx. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${staleTaskReportID}`, { + ...snapshotTask, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${staleTaskReportID}`, null); + await waitForBatchedUpdates(); + }); + + it('drops a completed task from the outstanding filter', () => { + const [result, count] = getStaleTaskSections('type:task status:outstanding'); + + expect(result).toHaveLength(0); + expect(count).toBe(0); + }); + + it('keeps a completed task in the completed filter and reports its live status', () => { + const [result, count] = getStaleTaskSections('type:task status:completed'); + + expect(result).toHaveLength(1); + expect(count).toBe(1); + expect(result.at(0)?.statusNum).toBe(CONST.REPORT.STATUS_NUM.APPROVED); + expect(result.at(0)?.stateNum).toBe(CONST.REPORT.STATE_NUM.APPROVED); + }); + + it('keeps a still-open task in the outstanding filter', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${staleTaskReportID}`, { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + await waitForBatchedUpdates(); + + const [result, count] = getStaleTaskSections('type:task status:outstanding'); + + expect(result).toHaveLength(1); + expect(count).toBe(1); + expect(result.at(0)?.reportName).toBe('Stale outstanding task'); + }); + + // A negated filter (`-status:completed`) keeps every status *except* the excluded ones, so it has to be + // judged against the live report too. `-` is the negation prefix and `status` is negatable, so these + // queries are reachable by typing them into the search router. + it('drops a completed task from a negated completed filter', () => { + const [result, count] = getStaleTaskSections('type:task -status:completed'); + + expect(result).toHaveLength(0); + expect(count).toBe(0); + }); + + it('keeps a completed task in a negated outstanding filter', () => { + const [result, count] = getStaleTaskSections('type:task -status:outstanding'); + + expect(result).toHaveLength(1); + expect(count).toBe(1); + expect(result.at(0)?.statusNum).toBe(CONST.REPORT.STATUS_NUM.APPROVED); + }); + + it('keeps a still-open task in a negated completed filter', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${staleTaskReportID}`, { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + await waitForBatchedUpdates(); + + const [result, count] = getStaleTaskSections('type:task -status:completed'); + + expect(result).toHaveLength(1); + expect(count).toBe(1); + expect(result.at(0)?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); + }); + }); + + describe('reopening a task re-filters against the live report', () => { + const reopenedTaskReportID = 'task_report_701'; + const reopenedCreatorID = 141414; + const reopenedAssigneeID = 151515; + + // Mirror image of the complete case: `reopenTask` writes only to `report_`, so the snapshot is + // left claiming the task is still completed. + const completedSnapshotTask = createMock({ + type: CONST.REPORT.TYPE.TASK, + accountID: reopenedCreatorID, + reportID: reopenedTaskReportID, + reportName: 'Stale completed task', + description: 'Reopened but still in the snapshot as completed', + managerID: reopenedAssigneeID, + parentReportID: 'parent_reopened', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + created: '2025-02-06 10:00:00', + }); + + const reopenedTaskData = createMock({ + personalDetailsList: { + [reopenedCreatorID]: { + accountID: reopenedCreatorID, + avatar: '', + displayName: 'Reopened Creator', + login: 'reopencreator@test.com', + }, + [reopenedAssigneeID]: { + accountID: reopenedAssigneeID, + avatar: '', + displayName: 'Reopened Assignee', + login: 'reopenassignee@test.com', + }, + }, + [`report_${reopenedTaskReportID}`]: completedSnapshotTask, + }); + + const getReopenedTaskSections = (query: string) => + getSectionsByType( + SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.TASK, + data: reopenedTaskData, + currentAccountID: reopenedCreatorID, + currentUserEmail: 'reopencreator@test.com', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + conciergeReportID: '999', + convertToDisplayString, + reportAttributesDerivedValue: {}, + queryJSON: buildSearchQueryJSON(query), + }), + SearchUIUtils.isTaskListItemType, + ); + + beforeEach(async () => { + // The live report has been reopened, mirroring what `reopenTask` merges into Onyx. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reopenedTaskReportID}`, { + ...completedSnapshotTask, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reopenedTaskReportID}`, null); + await waitForBatchedUpdates(); + }); + + it('drops a reopened task from the completed filter', () => { + const [result, count] = getReopenedTaskSections('type:task status:completed'); + + expect(result).toHaveLength(0); + expect(count).toBe(0); + }); + + it('shows a reopened task under the outstanding filter with its live status', () => { + const [result, count] = getReopenedTaskSections('type:task status:outstanding'); + + expect(result).toHaveLength(1); + expect(count).toBe(1); + expect(result.at(0)?.reportName).toBe('Stale completed task'); + expect(result.at(0)?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); + expect(result.at(0)?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + }); + }); + describe('getReportSections computed fields (totalDisplaySpend, nonReimbursableSpend, reimbursableSpend, isAllScanning)', () => { const testReportID = 'spend-test-report'; const testTxID1 = 'spend-tx-1'; @@ -5822,6 +6157,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -5941,6 +6277,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, convertToDisplayString, reportAttributesDerivedValue: {}, @@ -6094,6 +6431,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, ...options, convertToDisplayString, @@ -6131,16 +6469,31 @@ describe('SearchUIUtils', () => { expect(sections.some((s) => s.transactionID === filterTestTxID)).toBe(false); }); - it('should exclude transactions when queryJSON status is an invalid string', () => { + it('should include transactions when queryJSON status is an invalid string', () => { const data = makeFilterTestData({stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}); const [sections] = callGetTransactionsSections(data, {queryJSON: makeExpenseQueryJSON(['not_a_valid_status'])}); - expect(sections.some((s) => s.transactionID === filterTestTxID)).toBe(false); + expect(sections.some((s) => s.transactionID === filterTestTxID)).toBe(true); }); - it('should exclude transactions when queryJSON status array contains only invalid strings', () => { + it('should include transactions when queryJSON status array contains only invalid strings', () => { const data = makeFilterTestData({stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}); const [sections] = callGetTransactionsSections(data, {queryJSON: makeExpenseQueryJSON(['invalid1', 'invalid2'])}); - expect(sections.some((s) => s.transactionID === filterTestTxID)).toBe(false); + expect(sections.some((s) => s.transactionID === filterTestTxID)).toBe(true); + }); + + it('should include transactions when queryJSON status is the backend-only all value', () => { + const data = makeFilterTestData({stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}); + const [sections] = callGetTransactionsSections(data, {queryJSON: makeExpenseQueryJSON(['all'])}); + expect(sections.some((s) => s.transactionID === filterTestTxID)).toBe(true); + }); + + it('should filter on the valid status when queryJSON status mixes a valid status with an invalid one', () => { + const data = makeFilterTestData({stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}); + const [outstandingSections] = callGetTransactionsSections(data, {queryJSON: makeExpenseQueryJSON(['all', CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING])}); + expect(outstandingSections.some((s) => s.transactionID === filterTestTxID)).toBe(true); + + const [draftsSections] = callGetTransactionsSections(data, {queryJSON: makeExpenseQueryJSON(['all', CONST.SEARCH.STATUS.EXPENSE.DRAFTS])}); + expect(draftsSections.some((s) => s.transactionID === filterTestTxID)).toBe(false); }); it('should include transactions when queryJSON status is ALL', () => { @@ -6441,6 +6794,42 @@ describe('SearchUIUtils', () => { const item = sections.find((s) => s.transactionID === filterTestTxID); expect(item?.submitted).toBe(''); }); + + it('should keep the negative sign on formattedTotal for an unreported (tracked) credit', () => { + const data = makeFilterTestData({}, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 5000}); + const [sections] = callGetTransactionsSections(data); + const item = sections.find((s) => s.transactionID === filterTestTxID); + expect(item?.formattedTotal).toBe(-5000); + }); + + // Amounts are stored with the opposite sign, so these three rows render +$80.00, -$40.00 and +$10.00. + function makeAmountSortData() { + const baseTransaction = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + return makeFilterTestData( + {}, + {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: -8000}, + { + [`${ONYXKEYS.COLLECTION.TRANSACTION}sort-credit`]: {...baseTransaction, transactionID: 'sort-credit', reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 4000}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}sort-small`]: {...baseTransaction, transactionID: 'sort-small', reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: -1000}, + }, + ); + } + + function getAmountSortedIDs(sortOrder: SortOrder) { + const [sections] = callGetTransactionsSections(makeAmountSortData()); + const rows = sections.filter((section) => [filterTestTxID, 'sort-credit', 'sort-small'].includes(section.transactionID)); + expect(rows).toHaveLength(3); + const sorted = SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE, rows, localeCompare, translateLocal, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, sortOrder); + return sorted.map((item) => ('transactionID' in item ? item.transactionID : undefined)); + } + + it('should rank a credit below every positive expense when sorting the Amount column descending', () => { + expect(getAmountSortedIDs(CONST.SEARCH.SORT_ORDER.DESC)).toEqual([filterTestTxID, 'sort-small', 'sort-credit']); + }); + + it('should rank a credit above every positive expense when sorting the Amount column ascending', () => { + expect(getAmountSortedIDs(CONST.SEARCH.SORT_ORDER.ASC)).toEqual(['sort-credit', 'sort-small', filterTestTxID]); + }); }); describe('getReportSections filtering and edge cases', () => { @@ -6477,6 +6866,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, conciergeReportID: undefined, ...options, convertToDisplayString, @@ -6514,6 +6904,12 @@ describe('SearchUIUtils', () => { expect(sections.some((s) => s.keyForList === rptFilterReportID)).toBe(false); }); + it('should include report when queryJSON status is the backend-only all value', () => { + const data = makeReportFilterTestData({stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, type: CONST.REPORT.TYPE.EXPENSE}); + const [sections] = callGetReportSections(data, {queryJSON: makeExpenseQueryJSON(['all'])}); + expect(sections.some((s) => s.keyForList === rptFilterReportID)).toBe(true); + }); + it('should include report when negated status excludes a different status than the report state', () => { // Report is OUTSTANDING but we negate DRAFTS, so OUTSTANDING is not excluded and the report is shown. const data = makeReportFilterTestData({stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, type: CONST.REPORT.TYPE.EXPENSE}); @@ -8141,6 +8537,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + rules: undefined, groupBy: CONST.SEARCH.GROUP_BY.TAG, conciergeReportID: undefined, convertToDisplayString, @@ -9588,7 +9985,17 @@ describe('SearchUIUtils', () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: overlimitApproverAccountID}); searchResults.data[`policy_${policyID}`].role = CONST.POLICY.ROLE.USER; return waitForBatchedUpdates().then(() => { - let action = SearchUIUtils.getActions(searchResults.data, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', overlimitApproverAccountID, {}, {}).at(0); + let action = SearchUIUtils.getActions( + searchResults.data, + allViolations, + `report_${reportID2}`, + CONST.SEARCH.SEARCH_KEYS.EXPENSES, + '', + overlimitApproverAccountID, + {}, + {}, + undefined, + ).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); action = SearchUIUtils.getActions( @@ -9600,6 +10007,7 @@ describe('SearchUIUtils', () => { overlimitApproverAccountID, {}, {}, + undefined, ).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -9714,7 +10122,7 @@ describe('SearchUIUtils', () => { }, }; return waitForBatchedUpdates().then(() => { - const action = SearchUIUtils.getActions(result.data, allViolations, 'report_6523565988285061', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}).at(0); + const action = SearchUIUtils.getActions(result.data, allViolations, 'report_6523565988285061', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', adminAccountID, {}, {}, undefined).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.APPROVE); }); }); @@ -10342,48 +10750,6 @@ describe('SearchUIUtils', () => { expect(response.visibility.topSpenders).toBe(true); }); - test('Should collect Top Spenders-eligible policy IDs and scope the suggested search query to them', () => { - const eligiblePolicyID = 'GROUP_POLICY_01'; - const ineligiblePolicyID = 'PERSONAL_POLICY_02'; - - const policies: OnyxCollection = { - [`policy_${eligiblePolicyID}`]: { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - id: eligiblePolicyID, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - 'employee1@policy.com': {submitsTo: '', forwardsTo: ''}, - 'employee2@policy.com': {submitsTo: '', forwardsTo: ''}, - }, - }, - // Personal (non-group) policy is not eligible for Top Spenders and must not be scoped in. - [`policy_${ineligiblePolicyID}`]: { - ...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), - id: ineligiblePolicyID, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - 'employee1@policy.com': {submitsTo: '', forwardsTo: ''}, - 'employee2@policy.com': {submitsTo: '', forwardsTo: ''}, - }, - }, - }; - - const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); - expect(response.visibility.topSpenders).toBe(true); - expect(response.topSpendersPolicyIDs).toEqual([eligiblePolicyID]); - - const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined, undefined, response.topSpendersPolicyIDs); - const topSpendersQuery = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS].searchQuery; - expect(topSpendersQuery).toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID}:${eligiblePolicyID}`); - expect(topSpendersQuery).not.toContain(ineligiblePolicyID); - }); - - test('Should not add a policyID filter to the Top Spenders query when there are no eligible workspaces', () => { - const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined, undefined, []); - const topSpendersQuery = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS].searchQuery; - expect(topSpendersQuery).not.toContain(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID}:`); - }); - test('Should show Spend Over Time for workflow approver (forwardsTo) in paid policy', () => { const workflowApproverEmail = 'workflow-approver@policy.com'; const policyKey = `policy_${policyID}`; @@ -10609,7 +10975,7 @@ describe('SearchUIUtils', () => { const activeExpensifyCardFeedID = 'fund1_Expensify Card'; const getCardAccrualsFeedValues = (activeCardFeedID?: string, defaultFeedID?: string): unknown[] => { - const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, defaultFeedID, undefined, [], activeCardFeedID); + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, defaultFeedID, undefined, activeCardFeedID); const cardAccruals = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]; const feedFilter = cardAccruals.searchQueryJSON?.flatFilters?.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED); return (feedFilter?.filters ?? []).map((f) => f.value).filter(Boolean); @@ -10637,8 +11003,8 @@ describe('SearchUIUtils', () => { // real call sites differ in the `defaultFeedID` and `shouldShowExpensifyCard` args, so mirror that here to // prove Card accruals is driven purely by `activeExpensifyCardFeedID` and doesn't split on those inputs. const otherCompanyFeedID = 'fund2_oauth.chase.com'; - const fromProvider = SearchUIUtils.getSuggestedSearches(adminAccountID, companyFeedID, undefined, [], activeExpensifyCardFeedID); - const fromMenu = SearchUIUtils.getSuggestedSearches(adminAccountID, otherCompanyFeedID, true, [], activeExpensifyCardFeedID); + const fromProvider = SearchUIUtils.getSuggestedSearches(adminAccountID, companyFeedID, undefined, activeExpensifyCardFeedID); + const fromMenu = SearchUIUtils.getSuggestedSearches(adminAccountID, otherCompanyFeedID, true, activeExpensifyCardFeedID); expect(fromMenu[CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD].similarSearchHash).toBe(fromProvider[CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD].similarSearchHash); }); }); @@ -12119,6 +12485,7 @@ describe('SearchUIUtils', () => { // The full reportAction is passed to preserve originalMessage.type for proper expense type detection expect(createTransactionThreadReport).toHaveBeenCalledWith({ introSelected: introSelectedData, + conciergeChat: undefined, currentUserLogin, currentUserAccountID, betas: undefined, @@ -12452,7 +12819,7 @@ describe('SearchUIUtils', () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID}); - expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID)).toBe(true); + expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID, undefined)).toBe(true); }); it('should show delete option for unreported expense which can be deleted', async () => { @@ -12638,7 +13005,7 @@ describe('SearchUIUtils', () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID}); - expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID)).toBe(true); + expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID, undefined)).toBe(true); }); }); describe('getToFieldValueForTransaction', () => { @@ -12872,80 +13239,285 @@ describe('SearchUIUtils', () => { }); describe('getHasOptions', () => { - test('returns expense has options including submitted violation', () => { - const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE); - - expect(result).toEqual([ - {text: translateLocal('common.receipt'), value: CONST.SEARCH.HAS_VALUES.RECEIPT}, - {text: translateLocal('common.attachment'), value: CONST.SEARCH.HAS_VALUES.ATTACHMENT}, - {text: translateLocal('common.tag'), value: CONST.SEARCH.HAS_VALUES.TAG}, - {text: translateLocal('common.category'), value: CONST.SEARCH.HAS_VALUES.CATEGORY}, - {text: translateLocal('search.filters.has.submittedViolation'), value: CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}, - ]); + const getExpenseHasOptions = () => ({ + receipt: {text: translateLocal('common.receipt'), value: CONST.SEARCH.HAS_VALUES.RECEIPT}, + attachment: {text: translateLocal('common.attachment'), value: CONST.SEARCH.HAS_VALUES.ATTACHMENT}, + tag: {text: translateLocal('common.tag'), value: CONST.SEARCH.HAS_VALUES.TAG}, + category: {text: translateLocal('common.category'), value: CONST.SEARCH.HAS_VALUES.CATEGORY}, + submittedViolation: {text: translateLocal('search.filters.has.submittedViolation'), value: CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}, + approvedViolation: {text: translateLocal('search.filters.has.approvedViolation'), value: CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION}, }); - test('returns chat has options without submitted violation', () => { - const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.CHAT); + test('returns all expense has options when shouldShowAllOptions is set (display/validation path)', () => { + const {receipt, attachment, tag, category, submittedViolation, approvedViolation} = getExpenseHasOptions(); + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {shouldShowAllOptions: true}); - expect(result).toEqual([ - {text: translateLocal('common.link'), value: CONST.SEARCH.HAS_VALUES.LINK}, - {text: translateLocal('common.attachment'), value: CONST.SEARCH.HAS_VALUES.ATTACHMENT}, - ]); + expect(result).toEqual([receipt, attachment, tag, category, submittedViolation, approvedViolation]); }); - test('returns empty array for unsupported search types', () => { - expect(SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT)).toEqual([]); - }); - }); + test('hides Tag, Category, Submitted violation, and Approved violation when the user has no workspaces', () => { + const {receipt, attachment} = getExpenseHasOptions(); + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies: {}}); - describe('getSubmittedViolationsForTransaction', () => { - const transactionIDForViolations = 'tx-violations-1'; - const otherTransactionID = 'tx-violations-2'; + expect(result).toEqual([receipt, attachment]); + }); - const createSubmittedAction = ( - actionName: typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED | typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED, - violations?: {transactions: Record>}, - reportActionID = 'submit-action-1', - ): OnyxTypes.ReportAction => - ({ - reportActionID, - actionName, - created: '2025-01-01 00:00:00', - originalMessage: { - amount: 1000, - currency: CONST.CURRENCY.USD, - ...(violations ? {violations} : {}), + test('shows Tag only when at least one accessible workspace has tags enabled', () => { + const {receipt, attachment, tag} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + areTagsEnabled: true, + areCategoriesEnabled: false, + areRulesEnabled: false, }, - }) as OnyxTypes.ReportAction; + }; - test('returns undefined when reportActions or transactionID is missing', () => { - expect(SearchUIUtils.getSubmittedViolationsForTransaction(undefined, transactionIDForViolations, translateLocal)).toBeUndefined(); - expect(SearchUIUtils.getSubmittedViolationsForTransaction([], transactionIDForViolations, translateLocal)).toBeUndefined(); - expect(SearchUIUtils.getSubmittedViolationsForTransaction([createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED)], undefined, translateLocal)).toBeUndefined(); + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result).toEqual([receipt, attachment, tag]); }); - test('ignores non-submit report actions', () => { - const iouAction: OnyxTypes.ReportAction = { - reportActionID: 'iou-1', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2025-01-01 00:00:00', - originalMessage: { - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: transactionIDForViolations, + test('hides Tag when no accessible workspace has tags enabled', () => { + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + areTagsEnabled: false, + areCategoriesEnabled: true, + areRulesEnabled: true, }, }; - expect(SearchUIUtils.getSubmittedViolationsForTransaction([iouAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result.map((option) => option.value)).not.toContain(CONST.SEARCH.HAS_VALUES.TAG); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.CATEGORY); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION); }); - test('returns comma-separated translated violation labels from a SUBMITTED action', () => { - const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { - transactions: { - [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_COMMENT}], + test('shows Category only when at least one accessible workspace has categories enabled', () => { + const {receipt, attachment, category} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + areTagsEnabled: false, + areCategoriesEnabled: true, + areRulesEnabled: false, }, - }); + }; - expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe( + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result).toEqual([receipt, attachment, category]); + }); + + test('hides Category when no accessible workspace has categories enabled', () => { + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + areTagsEnabled: true, + areCategoriesEnabled: false, + areRulesEnabled: true, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result.map((option) => option.value)).not.toContain(CONST.SEARCH.HAS_VALUES.CATEGORY); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.TAG); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION); + }); + + test('shows Submitted and Approved violation when at least one accessible workspace has Rules enabled', () => { + const {receipt, attachment, submittedViolation, approvedViolation} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + areTagsEnabled: false, + areCategoriesEnabled: false, + areRulesEnabled: true, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result).toEqual([receipt, attachment, submittedViolation, approvedViolation]); + }); + + test('hides Submitted and Approved violation when no accessible workspace has Rules enabled', () => { + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + areTagsEnabled: true, + areCategoriesEnabled: true, + areRulesEnabled: false, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result.map((option) => option.value)).not.toContain(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + expect(result.map((option) => option.value)).not.toContain(CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.TAG); + expect(result.map((option) => option.value)).toContain(CONST.SEARCH.HAS_VALUES.CATEGORY); + }); + + test('ignores personal policies when deciding which has options to show', () => { + const {receipt, attachment} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.PERSONAL), + areTagsEnabled: true, + areCategoriesEnabled: true, + areRulesEnabled: true, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result).toEqual([receipt, attachment]); + }); + + test('ignores pending workspace joins when deciding which has options to show', () => { + const {receipt, attachment} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + areTagsEnabled: true, + areCategoriesEnabled: true, + areRulesEnabled: true, + isJoinRequestPending: true, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result).toEqual([receipt, attachment]); + }); + + test('shows Submitted and Approved violation for Collect workspaces with Rules enabled', () => { + const {receipt, attachment, submittedViolation, approvedViolation} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + areTagsEnabled: false, + areCategoriesEnabled: false, + areRulesEnabled: true, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, {policies}); + + expect(result).toEqual([receipt, attachment, submittedViolation, approvedViolation]); + }); + + test('keeps already-selected Tag/Category/Submitted/Approved violation when those workspace features are off', () => { + const {receipt, attachment, tag, category, submittedViolation, approvedViolation} = getExpenseHasOptions(); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + areTagsEnabled: false, + areCategoriesEnabled: false, + areRulesEnabled: false, + }, + }; + + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE, { + policies, + selectedValues: [CONST.SEARCH.HAS_VALUES.TAG, CONST.SEARCH.HAS_VALUES.CATEGORY, CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION, CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION], + }); + + expect(result).toEqual([receipt, attachment, tag, category, submittedViolation, approvedViolation]); + }); + + test('hides Tag, Category, Submitted violation, and Approved violation when no policies are provided', () => { + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE); + + expect(result).toEqual([ + {text: translateLocal('common.receipt'), value: CONST.SEARCH.HAS_VALUES.RECEIPT}, + {text: translateLocal('common.attachment'), value: CONST.SEARCH.HAS_VALUES.ATTACHMENT}, + ]); + }); + + test('returns chat has options without submitted or approved violation', () => { + const result = SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.CHAT); + + expect(result).toEqual([ + {text: translateLocal('common.link'), value: CONST.SEARCH.HAS_VALUES.LINK}, + {text: translateLocal('common.attachment'), value: CONST.SEARCH.HAS_VALUES.ATTACHMENT}, + ]); + }); + + test('returns empty array for unsupported search types', () => { + expect(SearchUIUtils.getHasOptions(translateLocal, CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT)).toEqual([]); + }); + + test('does not include approved-violation for non-expense search types', () => { + const nonExpenseTypes = [ + CONST.SEARCH.DATA_TYPES.CHAT, + CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + CONST.SEARCH.DATA_TYPES.INVOICE, + CONST.SEARCH.DATA_TYPES.TASK, + CONST.SEARCH.DATA_TYPES.TRIP, + ]; + + for (const type of nonExpenseTypes) { + const result = SearchUIUtils.getHasOptions(translateLocal, type); + expect(result.some((option) => option.value === CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION)).toBe(false); + } + }); + }); + + describe('getSubmittedViolationsForTransaction', () => { + const transactionIDForViolations = 'tx-violations-1'; + const otherTransactionID = 'tx-violations-2'; + + const createSubmittedAction = ( + actionName: typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED | typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED | typeof CONST.REPORT.ACTIONS.TYPE.ADD_EXPENSE_ON_SUBMITTED, + violations?: {transactions: Record>}, + reportActionID = 'submit-action-1', + ): OnyxTypes.ReportAction => + ({ + reportActionID, + actionName, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + ...(violations ? {violations} : {}), + }, + }) as OnyxTypes.ReportAction; + + test('returns undefined when reportActions or transactionID is missing', () => { + expect(SearchUIUtils.getSubmittedViolationsForTransaction(undefined, transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getSubmittedViolationsForTransaction([], transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getSubmittedViolationsForTransaction([createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED)], undefined, translateLocal)).toBeUndefined(); + }); + + test('ignores non-submit report actions', () => { + const iouAction: OnyxTypes.ReportAction = { + reportActionID: 'iou-1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2025-01-01 00:00:00', + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: transactionIDForViolations, + }, + }; + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([iouAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + + test('returns comma-separated translated violation labels from a SUBMITTED action', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_COMMENT}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe( `${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.missingComment')}`, ); }); @@ -12962,6 +13534,17 @@ describe('SearchUIUtils', () => { ); }); + test('reads the snapshot recorded when an expense joined an already submitted report', () => { + const addExpenseAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.ADD_EXPENSE_ON_SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.OVER_LIMIT}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([addExpenseAction], transactionIDForViolations, translateLocal)).toBe(translateLocal('violations.shortName.overLimit')); + expect(SearchUIUtils.getSubmittedViolationsForTransaction([addExpenseAction], otherTransactionID, translateLocal)).toBeUndefined(); + }); + test('aggregates across multiple submit actions and dedupes by name', () => { const firstSubmit = createSubmittedAction( CONST.REPORT.ACTIONS.TYPE.SUBMITTED, @@ -13066,6 +13649,251 @@ describe('SearchUIUtils', () => { }); }); + describe('getApprovedViolationsForTransaction', () => { + const transactionIDForViolations = 'tx-approved-violations-1'; + const otherTransactionID = 'tx-approved-violations-2'; + + const createApprovalAction = ( + actionName: typeof CONST.REPORT.ACTIONS.TYPE.FORWARDED | typeof CONST.REPORT.ACTIONS.TYPE.APPROVED, + violations?: {transactions: Record>}, + reportActionID = 'approval-action-1', + ): OnyxTypes.ReportAction => + ({ + reportActionID, + actionName, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + expenseReportID: '1', + ...(violations ? {violations} : {}), + }, + }) as OnyxTypes.ReportAction; + + test('returns undefined when reportActions or transactionID is missing', () => { + expect(SearchUIUtils.getApprovedViolationsForTransaction(undefined, transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getApprovedViolationsForTransaction([], transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getApprovedViolationsForTransaction([createApprovalAction(CONST.REPORT.ACTIONS.TYPE.FORWARDED)], undefined, translateLocal)).toBeUndefined(); + }); + + test('ignores report actions that are not APPROVED or FORWARDED', () => { + const submittedAction = { + reportActionID: 'submit-1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + violations: { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }, + }, + } as OnyxTypes.ReportAction; + + expect(SearchUIUtils.getApprovedViolationsForTransaction([submittedAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + + test('returns comma-separated translated violation labels from a FORWARDED action', () => { + const forwardedAction = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.FORWARDED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_COMMENT}], + }, + }); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([forwardedAction], transactionIDForViolations, translateLocal)).toBe( + `${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.missingComment')}`, + ); + }); + + test('returns comma-separated translated violation labels from an APPROVED action', () => { + const approvedAction = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.APPROVED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.OVER_LIMIT}], + }, + }); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([approvedAction], transactionIDForViolations, translateLocal)).toBe( + `${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.overLimit')}`, + ); + }); + + test('aggregates across APPROVED and FORWARDED actions and dedupes by name', () => { + const forwardedAction = createApprovalAction( + CONST.REPORT.ACTIONS.TYPE.FORWARDED, + { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_TAG}], + }, + }, + 'forward-1', + ); + const approvedAction = createApprovalAction( + CONST.REPORT.ACTIONS.TYPE.APPROVED, + { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.RECEIPT_REQUIRED}], + }, + }, + 'approved-1', + ); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([forwardedAction, approvedAction], transactionIDForViolations, translateLocal)).toBe( + `${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.missingTag')}, ${translateLocal('violations.shortName.receiptRequired')}`, + ); + }); + + test('omits receiptRequired when itemizedReceiptRequired is also present', () => { + const forwardedAction = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.FORWARDED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.RECEIPT_REQUIRED}, {name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED}], + }, + }); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([forwardedAction], transactionIDForViolations, translateLocal)).toBe( + translateLocal('violations.shortName.itemizedReceiptRequired'), + ); + }); + + test('falls back to the raw identifier when no short-name translation exists', () => { + const unknownViolationName = 'unknownApprovedViolation'; + const forwardedAction = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.FORWARDED, { + transactions: { + [transactionIDForViolations]: [{name: unknownViolationName}], + }, + }); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([forwardedAction], transactionIDForViolations, translateLocal)).toBe(unknownViolationName); + }); + + test('ignores violations for other transaction IDs', () => { + const forwardedAction = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.FORWARDED, { + transactions: { + [otherTransactionID]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([forwardedAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + + test('returns undefined when approval actions have no violations for the transaction', () => { + const forwardedAction = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.FORWARDED, { + transactions: { + [transactionIDForViolations]: [], + }, + }); + const approvedActionWithoutViolations = createApprovalAction(CONST.REPORT.ACTIONS.TYPE.APPROVED); + + expect(SearchUIUtils.getApprovedViolationsForTransaction([forwardedAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getApprovedViolationsForTransaction([approvedActionWithoutViolations], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + }); + + describe('getViolationsForTransaction', () => { + const transactionIDForViolations = 'tx-has-violations-1'; + + const createSubmittedAction = (violations?: {transactions: Record>}): OnyxTypes.ReportAction => + ({ + reportActionID: 'submit-action-1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + ...(violations ? {violations} : {}), + }, + }) as OnyxTypes.ReportAction; + + const createApprovedAction = (violations?: {transactions: Record>}): OnyxTypes.ReportAction => + ({ + reportActionID: 'approved-action-1', + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + expenseReportID: '1', + ...(violations ? {violations} : {}), + }, + }) as OnyxTypes.ReportAction; + + test('returns undefined when the has filter does not include submitted or approved violation', () => { + const reportActions = [ + createSubmittedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }), + ]; + + expect(SearchUIUtils.getViolationsForTransaction(reportActions, transactionIDForViolations, undefined, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getViolationsForTransaction(reportActions, transactionIDForViolations, [CONST.SEARCH.HAS_VALUES.RECEIPT], translateLocal)).toBeUndefined(); + }); + + test('returns only submitted violations when the has filter is submitted-violation', () => { + const reportActions = [ + createSubmittedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }), + createApprovedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_COMMENT}], + }, + }), + ]; + + expect(SearchUIUtils.getViolationsForTransaction(reportActions, transactionIDForViolations, [CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION], translateLocal)).toBe( + translateLocal('violations.shortName.missingCategory'), + ); + }); + + test('returns only approved violations when the has filter is approved-violation', () => { + const reportActions = [ + createSubmittedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }), + createApprovedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_COMMENT}], + }, + }), + ]; + + expect(SearchUIUtils.getViolationsForTransaction(reportActions, transactionIDForViolations, [CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION], translateLocal)).toBe( + translateLocal('violations.shortName.missingComment'), + ); + }); + + test('combines submitted and approved violations and removes duplicates', () => { + const reportActions = [ + createSubmittedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_TAG}], + }, + }), + createApprovedAction({ + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.OVER_LIMIT}], + }, + }), + ]; + + expect( + SearchUIUtils.getViolationsForTransaction( + reportActions, + transactionIDForViolations, + [CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION, CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION], + translateLocal, + ), + ).toBe(`${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.missingTag')}, ${translateLocal('violations.shortName.overLimit')}`); + }); + }); + describe('getDisplayValue', () => { test('returns translated has option labels from getHasOptions', () => { const result = SearchUIUtils.getDisplayValue('has', {has: [CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION]}, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); @@ -13073,6 +13901,12 @@ describe('SearchUIUtils', () => { expect(result).toBe(translateLocal('search.filters.has.submittedViolation')); }); + test('returns translated approved violation has option label', () => { + const result = SearchUIUtils.getDisplayValue('has', {has: [CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION]}, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); + + expect(result).toBe(translateLocal('search.filters.has.approvedViolation')); + }); + test('returns multiple has option labels joined by comma', () => { const result = SearchUIUtils.getDisplayValue( 'has', @@ -13099,12 +13933,18 @@ describe('SearchUIUtils', () => { }); test('should filter and return only valid hasValues', () => { - // Valid values for EXPENSE: receipt, attachment, tag, category, submitted-violation + // Valid values for EXPENSE: receipt, attachment, tag, category, submitted-violation, approved-violation // Invalid value: link (only valid for CHAT) - const hasValues = [CONST.SEARCH.HAS_VALUES.RECEIPT, CONST.SEARCH.HAS_VALUES.TAG, CONST.SEARCH.HAS_VALUES.LINK, CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION]; + const hasValues = [ + CONST.SEARCH.HAS_VALUES.RECEIPT, + CONST.SEARCH.HAS_VALUES.TAG, + CONST.SEARCH.HAS_VALUES.LINK, + CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION, + CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION, + ]; const result = SearchUIUtils.filterValidHasValues(hasValues, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal); - expect(result).toEqual([CONST.SEARCH.HAS_VALUES.RECEIPT, CONST.SEARCH.HAS_VALUES.TAG, CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION]); + expect(result).toEqual([CONST.SEARCH.HAS_VALUES.RECEIPT, CONST.SEARCH.HAS_VALUES.TAG, CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION, CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION]); }); }); @@ -13302,6 +14142,53 @@ describe('SearchUIUtils', () => { expect(SearchUIUtils.isTextFilterKey('')).toBe(false); }); }); + + describe('searchKeyToSavedSearchID', () => { + it('strips the prefix to recover the saved search ID', () => { + expect(SearchUIUtils.searchKeyToSavedSearchID(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}12345`)).toBe('12345'); + }); + + it('returns undefined for a non saved-search key', () => { + expect(SearchUIUtils.searchKeyToSavedSearchID(CONST.SEARCH.SEARCH_KEYS.EXPENSES)).toBeUndefined(); + }); + + it('returns undefined when the key is undefined', () => { + expect(SearchUIUtils.searchKeyToSavedSearchID(undefined)).toBeUndefined(); + }); + }); + + describe('savedSearchIDToSearchKey', () => { + it('prefixes a saved search ID to build a search key', () => { + expect(SearchUIUtils.savedSearchIDToSearchKey('12345')).toBe(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}12345`); + }); + }); + + describe('mapFiltersFormToLabelValueList', () => { + const convertToDisplayStringWithoutCurrency = jest.fn((amount = 0) => `${amount}`); + + it('places default filters before non-default filters', () => { + const form = { + [CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD]: 'hotel', + [CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]: 'Amazon', + }; + const defaultKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + + const result = SearchUIUtils.mapFiltersFormToLabelValueList( + form, + defaultKeys, + new Set(), + translateLocal, + undefined, + localeCompare, + convertToDisplayStringWithoutCurrency, + (filterKey, isDefault) => ({isDefault}), + ); + + expect(result.map((filter) => filter.key)).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD]); + expect(result.at(0)?.isDefault).toBe(true); + expect(result.at(1)?.isDefault).toBe(false); + }); + }); }); describe('getCardDescriptionForSearchTable', () => { @@ -13482,6 +14369,54 @@ describe('splitGroupsIntoPairs', () => { }); }); +describe('getLastSearchQuery', () => { + const submitKey = CONST.SEARCH.SEARCH_KEYS.SUBMIT; + const savedSearchKey = SearchUIUtils.savedSearchIDToSearchKey('100'); + const submitQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`; + const savedSearchQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`; + + const searchFilters: OnyxTypes.SearchFilters = { + [submitKey]: {query: submitQuery, timestamp: '2026-08-21 00:00:00.000'}, + [savedSearchKey]: {query: savedSearchQuery, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.EXPENSES]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.REPORTS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} exported:false`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.STATEMENTS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} feed:Expensify`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} reimbursable:true`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} reimbursable:false`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} posted:2026-08`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:from`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:category`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:merchant`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:month`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]: { + query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:${CONST.SEARCH.GROUP_BY.FROM} has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`, + timestamp: '2026-08-21 00:00:00.000', + }, + // Legacy string format, kept for the assertions below. + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, + [CONST.SEARCH.SEARCH_KEYS.PAY]: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Uber`, + }; + + it('returns the query of the filter stored for the search key', () => { + expect(SearchUIUtils.getLastSearchQuery(searchFilters, submitKey)).toBe(submitQuery); + expect(SearchUIUtils.getLastSearchQuery(searchFilters, savedSearchKey)).toBe(savedSearchQuery); + }); + + it('returns undefined for a filter stored in the legacy string format', () => { + expect(SearchUIUtils.getLastSearchQuery(searchFilters, CONST.SEARCH.SEARCH_KEYS.APPROVE)).toBeUndefined(); + expect(SearchUIUtils.getLastSearchQuery(searchFilters, CONST.SEARCH.SEARCH_KEYS.PAY)).toBeUndefined(); + }); + + it('returns undefined when the search key has no filter', () => { + expect(SearchUIUtils.getLastSearchQuery(searchFilters, SearchUIUtils.savedSearchIDToSearchKey('200'))).toBeUndefined(); + }); + + it('returns undefined when there are no filters at all', () => { + expect(SearchUIUtils.getLastSearchQuery(undefined, submitKey)).toBeUndefined(); + }); +}); + describe('getSavedSearchIconName', () => { it.each([ [CONST.SEARCH.DATA_TYPES.EXPENSE, 'type:expense', 'ReceiptBookmark'], @@ -13517,3 +14452,97 @@ describe('getSavedSearchIconName', () => { spy.mockRestore(); }); }); + +describe('hasFilterContentValuesChanged', () => { + const SYNTAX_FILTER_KEYS = CONST.SEARCH.SYNTAX_FILTER_KEYS; + type FilterKey = Parameters[0]; + type FilterValues = Parameters[1]; + type ContentValuesCase = { + /** The kind of content the filter is given, which decides what it reads. */ + kind: string; + + /** The filter whose content is being judged. */ + filterKey: FilterKey; + + /** Two forms differing only in something that content reads. */ + read: [FilterValues, FilterValues]; + + /** Two forms differing only in something it does not, where there is such a thing. */ + ignored?: [FilterValues, FilterValues]; + }; + + // One case per kind of content, because each is handed a different slice of the form. + const contentValuesCases: ContentValuesCase[] = [ + { + kind: 'list', + filterKey: SYNTAX_FILTER_KEYS.FROM, + read: [{[SYNTAX_FILTER_KEYS.FROM]: ['1']}, {[SYNTAX_FILTER_KEYS.FROM]: ['2']}], + ignored: [{[SYNTAX_FILTER_KEYS.FROM]: ['1']}, {[SYNTAX_FILTER_KEYS.FROM]: ['1'], [SYNTAX_FILTER_KEYS.TO]: ['2']}], + }, + { + kind: 'list, negated', + filterKey: SYNTAX_FILTER_KEYS.FROM, + // Built the way the content builds it, so the negated key cannot drift. + read: [SearchQueryUtils.getFilterFormValues(SYNTAX_FILTER_KEYS.FROM, ['1'], false), SearchQueryUtils.getFilterFormValues(SYNTAX_FILTER_KEYS.FROM, ['1'], true)], + }, + { + kind: 'list, reading the search type it offers options for', + filterKey: SYNTAX_FILTER_KEYS.CATEGORY, + read: [{type: CONST.SEARCH.DATA_TYPES.EXPENSE}, {type: CONST.SEARCH.DATA_TYPES.INVOICE}], + }, + { + kind: 'list, reading the workspace that narrows its options', + filterKey: SYNTAX_FILTER_KEYS.CATEGORY, + read: [{[SYNTAX_FILTER_KEYS.POLICY_ID]: ['1']}, {[SYNTAX_FILTER_KEYS.POLICY_ID]: ['2']}], + }, + { + kind: 'text', + filterKey: SYNTAX_FILTER_KEYS.MERCHANT, + read: [{[SYNTAX_FILTER_KEYS.MERCHANT]: 'a'}, {[SYNTAX_FILTER_KEYS.MERCHANT]: 'b'}], + // 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: 'amount', + filterKey: SYNTAX_FILTER_KEYS.AMOUNT, + read: [ + {[`${SYNTAX_FILTER_KEYS.AMOUNT}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`]: '10'}, + {[`${SYNTAX_FILTER_KEYS.AMOUNT}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`]: '20'}, + ], + ignored: [ + {[`${SYNTAX_FILTER_KEYS.AMOUNT}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`]: '10'}, + {[`${SYNTAX_FILTER_KEYS.AMOUNT}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`]: '10', type: CONST.SEARCH.DATA_TYPES.INVOICE}, + ], + }, + { + kind: 'date', + filterKey: SYNTAX_FILTER_KEYS.DATE, + read: [{[`${SYNTAX_FILTER_KEYS.DATE}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`]: '2026-01-01'}, {[`${SYNTAX_FILTER_KEYS.DATE}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`]: '2026-02-01'}], + ignored: [ + {[`${SYNTAX_FILTER_KEYS.DATE}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`]: '2026-01-01'}, + {[`${SYNTAX_FILTER_KEYS.DATE}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`]: '2026-01-01', type: CONST.SEARCH.DATA_TYPES.INVOICE}, + ], + }, + { + // The date content offers card periods only when a feed is picked, so it reads whether one is. + kind: 'date, reading whether a feed is picked', + filterKey: SYNTAX_FILTER_KEYS.DATE, + read: [{}, {[SYNTAX_FILTER_KEYS.FEED]: ['1']}], + ignored: [{[SYNTAX_FILTER_KEYS.FEED]: ['1']}, {[SYNTAX_FILTER_KEYS.FEED]: ['2']}], + }, + { + // The report field content is handed the whole form, so nothing in it is outside what it reads. + kind: 'report field', + filterKey: SYNTAX_FILTER_KEYS.REPORT_FIELD, + read: [{[SYNTAX_FILTER_KEYS.MERCHANT]: 'a'}, {[SYNTAX_FILTER_KEYS.MERCHANT]: 'b'}], + }, + ]; + + it.each(contentValuesCases)('$kind: reports a change to what it reads and none to what it does not', ({filterKey, read, ignored}) => { + expect(SearchUIUtils.hasFilterContentValuesChanged(filterKey, read.at(0), read.at(1))).toBe(true); + + if (ignored) { + expect(SearchUIUtils.hasFilterContentValuesChanged(filterKey, ignored.at(0), ignored.at(1))).toBe(false); + } + }); +}); diff --git a/tests/unit/Search/TaskSearchViewTest.tsx b/tests/unit/Search/TaskSearchViewTest.tsx index 9950eb81df1d..bc82c989b973 100644 --- a/tests/unit/Search/TaskSearchViewTest.tsx +++ b/tests/unit/Search/TaskSearchViewTest.tsx @@ -180,6 +180,7 @@ function renderView(overrides: RenderOverrides = {}) { columns={columns} canSelectMultiple={overrides.canSelectMultiple ?? false} isActionColumnWide={false} + columnSizeOptions={{}} isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 210ac00efb89..044514bcd57b 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -360,6 +360,7 @@ describe('handleActionButtonPress', () => { delegateAccountID: undefined, isTrackIntentUser: false, allViolations: undefined, + rules: undefined, }); expect(goToItem).not.toHaveBeenCalled(); }); @@ -387,6 +388,7 @@ describe('handleActionButtonPress', () => { delegateAccountID: undefined, isTrackIntentUser: false, allViolations: undefined, + rules: undefined, }); expect(onHoldMenuOpen).toHaveBeenCalledWith(mockReportItemWithHold, CONST.IOU.REPORT_ACTION_TYPE.APPROVE); @@ -414,6 +416,7 @@ describe('handleActionButtonPress', () => { delegateAccountID: undefined, isTrackIntentUser: false, allViolations: undefined, + rules: undefined, }); expect(goToItem).toHaveBeenCalledTimes(0); }); @@ -447,6 +450,7 @@ describe('handleActionButtonPress', () => { delegateAccountID: undefined, isTrackIntentUser: false, allViolations, + rules: undefined, conciergeChat: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, }); diff --git a/tests/unit/Search/searchTotalsLoadingDataTest.ts b/tests/unit/Search/searchTotalsLoadingDataTest.ts index 3b2eff1ba32b..6c0585a721c2 100644 --- a/tests/unit/Search/searchTotalsLoadingDataTest.ts +++ b/tests/unit/Search/searchTotalsLoadingDataTest.ts @@ -33,7 +33,7 @@ type SearchSnapshotKey = `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${string}`; type SearchResponse = Response; type SearchLoadingState = Pick & Partial<{ - [TKey in 'offset' | 'count' | 'total' | 'currency']: SearchResultsInfo[TKey] | null; + [TKey in 'offset' | 'count' | 'reportCount' | 'total' | 'currency']: SearchResultsInfo[TKey] | null; }>; function isSearchLoadingState(value: unknown): value is SearchLoadingState { @@ -42,6 +42,7 @@ function isSearchLoadingState(value: unknown): value is SearchLoadingState { typeof value.isLoading === 'boolean' && (value.offset === null || value.offset === undefined || typeof value.offset === 'number') && (value.count === null || value.count === undefined || typeof value.count === 'number') && + (value.reportCount === null || value.reportCount === undefined || typeof value.reportCount === 'number') && (value.total === null || value.total === undefined || typeof value.total === 'number') && (value.currency === null || value.currency === undefined || typeof value.currency === 'string') ); @@ -96,6 +97,7 @@ describe('search loading totals handling', () => { isLoading: true, offset: 0, count: null, + reportCount: null, total: null, currency: null, }); @@ -119,6 +121,7 @@ describe('search loading totals handling', () => { offset: 0, }); expect(loadingSearchData?.count).toBeUndefined(); + expect(loadingSearchData?.reportCount).toBeUndefined(); expect(loadingSearchData?.total).toBeUndefined(); expect(loadingSearchData?.currency).toBeUndefined(); }); @@ -140,6 +143,7 @@ describe('search loading totals handling', () => { offset: 20, }); expect(loadingSearchData?.count).toBeUndefined(); + expect(loadingSearchData?.reportCount).toBeUndefined(); expect(loadingSearchData?.total).toBeUndefined(); expect(loadingSearchData?.currency).toBeUndefined(); }); @@ -187,7 +191,10 @@ describe('search loading totals handling', () => { expect(queuedQuery).toEqual(expect.objectContaining({shouldCalculateTotals: true})); }); - it('keeps the original expense-report deduplication when a totals request collides with an in-flight request', async () => { + it('queues a totals request for expense-report when a non-totals search is already in flight', async () => { + // The totals upgrade is no longer gated to the EXPENSE type: an expense-report totals request that + // collides with an in-flight non-totals request must still re-fire, otherwise reportCount never + // arrives and the bulk-actions button spins forever. const queryJSON = getQueryJSON('type:expense-report'); const response = buildSearchResponse(50, true); let resolveFirstRequest: (value: SearchResponse) => void = () => {}; @@ -218,7 +225,9 @@ describe('search loading totals handling', () => { await firstSearch; await Promise.resolve(); - expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(2); + const queuedQuery: unknown = JSON.parse(getLastSearchRequestJSON()); + expect(queuedQuery).toEqual(expect.objectContaining({shouldCalculateTotals: true})); }); it('does not queue another request when the in-flight search already calculates totals', async () => { diff --git a/tests/unit/Search/selectionBuildersTest.ts b/tests/unit/Search/selectionBuildersTest.ts new file mode 100644 index 000000000000..5cd1d66deada --- /dev/null +++ b/tests/unit/Search/selectionBuildersTest.ts @@ -0,0 +1,43 @@ +import type {TransactionGroupListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; +import {mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; + +import CONST from '@src/CONST'; + +import createMock from '../../utils/createMock'; + +describe('selectionBuilders', () => { + describe('mapEmptyReportToSelectedEntry', () => { + it('takes displayAmount from the report-signed total for a report row', () => { + // totalDisplaySpend is already negated for expense reports, so a credit report keeps its negative sign. + const item = createMock({ + keyForList: 'report1', + reportID: 'report1', + policyID: 'policy1', + currency: CONST.CURRENCY.USD, + groupedBy: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + transactions: [], + total: 10000, + totalDisplaySpend: -10000, + }); + + const [, entry] = mapEmptyReportToSelectedEntry(item); + + expect(entry.displayAmount).toBe(-10000); + }); + + it('takes displayAmount from the group total for a group row', () => { + const item = createMock({ + keyForList: `${CONST.SEARCH.GROUP_PREFIX}category1`, + reportID: undefined, + policyID: 'policy1', + currency: CONST.CURRENCY.USD, + transactions: [], + total: -4000, + }); + + const [, entry] = mapEmptyReportToSelectedEntry(item); + + expect(entry.displayAmount).toBe(-4000); + }); + }); +}); diff --git a/tests/unit/Search/useRowSelectionTest.tsx b/tests/unit/Search/useRowSelectionTest.tsx index a6acb762045b..9cf8ec54f2e0 100644 --- a/tests/unit/Search/useRowSelectionTest.tsx +++ b/tests/unit/Search/useRowSelectionTest.tsx @@ -44,6 +44,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { reportID: 'report_1', policyID: 'policy_1', amount: 100, + displayAmount: 100, currency: 'USD', }; return acc; diff --git a/tests/unit/Search/useSyncSelectedReportsTest.tsx b/tests/unit/Search/useSyncSelectedReportsTest.tsx index 9c4b8f2a0650..275c23aacd67 100644 --- a/tests/unit/Search/useSyncSelectedReportsTest.tsx +++ b/tests/unit/Search/useSyncSelectedReportsTest.tsx @@ -54,6 +54,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { reportID: 'report_1', policyID: 'policy_1', amount: 100, + displayAmount: 100, currency: 'USD', }; return acc; diff --git a/tests/unit/Search/yourSpendQueryBuildersTest.ts b/tests/unit/Search/yourSpendQueryBuildersTest.ts index 056791a439f7..c4eb12223f66 100644 --- a/tests/unit/Search/yourSpendQueryBuildersTest.ts +++ b/tests/unit/Search/yourSpendQueryBuildersTest.ts @@ -6,7 +6,7 @@ * - `cardID` uses the numeric card ID (not the card name) * - Date filter serializes as `date>YYYY-MM-DD` (not `dateAfter:`) */ -import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; +import {buildAwaitingApprovalQuery, buildCardGroupQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; import CONST from '@src/CONST'; import {buildSearchQueryJSON, getFilterFromQuery} from '@src/libs/SearchQueryUtils'; @@ -214,3 +214,55 @@ describe('buildRecentCardTransactionsQuery', () => { expect(diffDays).toBeLessThanOrEqual(31); }); }); + +describe('buildCardGroupQuery', () => { + it('groups by card, so one response carries a per-card total', () => { + // Given the grouped query + const queryString = buildCardGroupQuery(ACCOUNT_ID); + + // When the query is parsed + const queryJSON = buildSearchQueryJSON(queryString); + + // Then it is an expense query grouped by card + expect(queryJSON?.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(queryJSON?.groupBy).toBe(CONST.SEARCH.GROUP_BY.CARD); + }); + + it('carries no cardID filter, so the hash survives a card being added or deleted', () => { + // Given the grouped query + const queryString = buildCardGroupQuery(ACCOUNT_ID); + + // When the cardID filter is read back + const values = getRawFiltersForKey(queryString, CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID); + + // Then there is none, so an optimistic card delete keeps reading the snapshot already loaded + expect(values).toHaveLength(0); + }); + + it('resolves from to the numeric accountID (not literal [me])', () => { + // Given the grouped query + const queryString = buildCardGroupQuery(ACCOUNT_ID); + + // When the from filter is read back + const values = getRawFiltersForKey(queryString, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM).flatMap((f) => (Array.isArray(f.value) ? f.value : [f.value])); + + // Then it carries the numeric accountID + expect(values).toContain(String(ACCOUNT_ID)); + expect(queryString).not.toContain('[me]'); + }); + + it('windows the query to the same 30 days as the per-card tap-through query', () => { + // Given the grouped query + const queryString = buildCardGroupQuery(ACCOUNT_ID); + + // When its date bound is compared against 30 days before today + const dateStr = queryString.match(/date>([0-9]{4}-[0-9]{2}-[0-9]{2})/)?.[1]; + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const diffDays = (today.getTime() - new Date(`${dateStr}T00:00:00Z`).getTime()) / (1000 * 60 * 60 * 24); + + // Then the row total covers the same window the tap-through will show + expect(diffDays).toBeGreaterThanOrEqual(29); + expect(diffDays).toBeLessThanOrEqual(31); + }); +}); diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 18b0a2cfd354..084304ed9cf1 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -1,19 +1,25 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {SelectedReports, SelectedTransactions} from '@components/Search/types'; import { + deleteSavedSearch, exportSearchItemsToCSV, getChatReportWithFallback, getExportTemplates, getFooterConvertedAmounts, + getPayOption, openSearch, queueExportSearchItemsToCSV, queueExportSearchWithTemplate, + saveSearch, + search, } from '@libs/actions/Search'; -import {read, write} from '@libs/API'; +import {makeRequestWithSideEffects, waitForWrites, read, write} from '@libs/API'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import fileDownload from '@libs/fileDownload'; import {translate} from '@libs/Localize'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -21,8 +27,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ExportTemplate, Policy, Report} from '@src/types/onyx'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; +import Onyx from 'react-native-onyx'; + import createRandomPolicy from '../utils/collections/policies'; import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const translateForTest: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); @@ -34,11 +43,11 @@ jest.mock('@libs/Network/enhanceParameters', () => ({ })); const mockWrite = jest.mocked(write); +const mockMakeRequestWithSideEffects = jest.mocked(makeRequestWithSideEffects); +const mockWaitForWrites = jest.mocked(waitForWrites); const mockFileDownload = jest.mocked(fileDownload); const mockRead = jest.mocked(read); -beforeEach(() => jest.clearAllMocks()); - function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} { const options = mockWrite.mock.calls.at(-1)?.at(2); if ( @@ -69,8 +78,8 @@ function getReadOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOny return {optimisticData: options.optimisticData, failureData: options.failureData}; } -function getQueryJSON() { - const queryJSON = buildSearchQueryJSON(''); +function getQueryJSON(query = '') { + const queryJSON = buildSearchQueryJSON(query); if (!queryJSON) { throw new Error('Query JSON should be defined for test setup'); } @@ -78,84 +87,126 @@ function getQueryJSON() { return queryJSON; } -describe('openSearchPage', () => { - it('does not persist a completion flag that a failed request could strand', () => { - openSearch({includePartiallySetupBankAccounts: false, includeLockedBankAccounts: false}); +describe('SearchActions', () => { + beforeEach(() => jest.clearAllMocks()); - expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_SEARCH_PAGE, { - includePartiallySetupBankAccounts: false, - includeLockedBankAccounts: false, + describe('saveSearch', () => { + const savedSearchID = '123456789'; + const queryJSON = getQueryJSON('type:expense status:all'); + + it('keys the optimistic, failure, and success data by the provided savedSearchID', () => { + saveSearch({id: savedSearchID, queryJSON, newName: 'My search'}); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SAVE_SEARCH, + {jsonQuery: JSON.stringify(queryJSON), savedSearchID, newName: 'My search'}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SAVED_SEARCHES, + value: {[savedSearchID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: 'My search', query: queryJSON.inputQuery}}, + }, + ], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: null}}], + successData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: {pendingAction: null}}}], + }, + ); }); - }); -}); -describe('queueExportSearchItemsToCSV', () => { - it('sets optimistic Onyx data with state preparing and returns exportID', () => { - const exportID = queueExportSearchItemsToCSV({ - jsonQuery: '{}', - reportIDList: [], - transactionIDList: [], - isBasicExport: true, - exportColumnLabels: '{}', - exportName: 'Basic export', - }); - - expect(typeof exportID).toBe('string'); - expect(exportID.length).toBeGreaterThan(0); - - expect(mockWrite).toHaveBeenCalledWith( - WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, - expect.objectContaining({exportID}), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), - ); - - const {optimisticData, failureData} = getWriteOptions(); - const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(exportDownloadUpdate).toBeDefined(); - expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); - - const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(failureUpdate).toBeDefined(); - expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + it('falls back to the input query for the name when no name is given', () => { + saveSearch({id: savedSearchID, queryJSON}); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SAVE_SEARCH, + {jsonQuery: JSON.stringify(queryJSON), savedSearchID, newName: queryJSON.inputQuery}, + expect.objectContaining({ + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SAVED_SEARCHES, + value: {[savedSearchID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: queryJSON.inputQuery, query: queryJSON.inputQuery}}, + }, + ], + }), + ); + }); }); - it('includes excluded transaction IDs in the queued CSV payload', () => { - queueExportSearchItemsToCSV({ - jsonQuery: '{}', - reportIDList: [], - transactionIDList: ['tx1'], - excludedTransactionIDList: ['tx2'], - isBasicExport: true, - exportColumnLabels: '{}', - exportName: 'Basic export', + describe('deleteSavedSearch', () => { + it('sends the optimistic DELETE, failure revert, and success removal keyed by the savedSearchID', () => { + const savedSearchID = '987654321'; + deleteSavedSearch(savedSearchID); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.DELETE_SAVED_SEARCH, + {savedSearchID}, + { + optimisticData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}}], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: {pendingAction: null}}}], + successData: [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: null}}, + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SEARCH_FILTERS, value: {[savedSearchIDToSearchKey(savedSearchID)]: null}}, + ], + }, + ); }); - - expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({excludedTransactionIDList: ['tx2']}), expect.any(Object)); }); - it('does not add an exclusion field when there are no exclusions', () => { - queueExportSearchItemsToCSV({ - jsonQuery: '{}', - reportIDList: [], - transactionIDList: ['tx1'], - isBasicExport: true, - exportColumnLabels: '{}', - exportName: 'Basic export', + describe('search', () => { + beforeEach(() => { + mockWaitForWrites.mockResolvedValue(undefined); }); - expect(mockWrite.mock.calls.at(-1)?.at(1)).not.toHaveProperty('excludedTransactionIDList'); - }); -}); + it('optimistically merges the current query into SEARCH_FILTERS for the search key', async () => { + const searchKey = CONST.SEARCH.SEARCH_KEYS.EXPENSES; + const queryJSON = getQueryJSON('type:expense status:all'); -describe('exportSearchItemsToCSV', () => { - beforeEach(() => jest.clearAllMocks()); + await search({queryJSON, searchKey, isLoading: false}); - it('includes excluded transaction IDs in the direct CSV form payload', () => { - const appendSpy = jest.spyOn(FormData.prototype, 'append'); + expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith( + READ_COMMANDS.SEARCH, + expect.anything(), + expect.objectContaining({ + optimisticData: expect.arrayContaining([{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SEARCH_FILTERS, value: {[searchKey]: {query: queryJSON.inputQuery}}}]), + }), + ); + }); + }); - exportSearchItemsToCSV( - { + describe('queueExportSearchItemsToCSV', () => { + it('sets optimistic Onyx data with state preparing and returns exportID', () => { + const exportID = queueExportSearchItemsToCSV({ + jsonQuery: '{}', + reportIDList: [], + transactionIDList: [], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(typeof exportID).toBe('string'); + expect(exportID.length).toBeGreaterThan(0); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, + expect.objectContaining({exportID}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); + + const {optimisticData, failureData} = getWriteOptions(); + const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(exportDownloadUpdate).toBeDefined(); + expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + + const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(failureUpdate).toBeDefined(); + expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + }); + + it('includes excluded transaction IDs in the queued CSV payload', () => { + queueExportSearchItemsToCSV({ jsonQuery: '{}', reportIDList: [], transactionIDList: ['tx1'], @@ -163,21 +214,62 @@ describe('exportSearchItemsToCSV', () => { isBasicExport: true, exportColumnLabels: '{}', exportName: 'Basic export', - }, - jest.fn(), - translateForTest, - ); - - expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); - expect(mockFileDownload).toHaveBeenCalled(); - appendSpy.mockRestore(); + }); + + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({excludedTransactionIDList: ['tx2']}), expect.any(Object)); + }); + + it('does not add an exclusion field when there are no exclusions', () => { + queueExportSearchItemsToCSV({ + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(mockWrite.mock.calls.at(-1)?.at(1)).not.toHaveProperty('excludedTransactionIDList'); + }); }); -}); -describe('queueExportSearchWithTemplate', () => { - it('sets optimistic Onyx data with state preparing and returns exportID when tracking progress', () => { - const exportID = queueExportSearchWithTemplate( - { + describe('queueExportSearchWithTemplate', () => { + it('sets optimistic Onyx data with state preparing and returns exportID when tracking progress', () => { + const exportID = queueExportSearchWithTemplate( + { + templateName: 'Test Template', + templateType: 'csv', + jsonQuery: '{}', + reportIDList: [], + transactionIDList: [], + policyID: 'policy123', + exportName: 'Test Template', + }, + true, + ); + + expect(typeof exportID).toBe('string'); + expect(exportID.length).toBeGreaterThan(0); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_WITH_TEMPLATE, + expect.objectContaining({exportID}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); + + const {optimisticData, failureData} = getWriteOptions(); + const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(exportDownloadUpdate).toBeDefined(); + expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + + const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(failureUpdate).toBeDefined(); + expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + }); + + it('keeps the legacy request shape (no exportID, no optimistic data) when not tracking progress', () => { + queueExportSearchWithTemplate({ templateName: 'Test Template', templateType: 'csv', jsonQuery: '{}', @@ -185,190 +277,377 @@ describe('queueExportSearchWithTemplate', () => { transactionIDList: [], policyID: 'policy123', exportName: 'Test Template', - }, - true, - ); - - expect(typeof exportID).toBe('string'); - expect(exportID.length).toBeGreaterThan(0); - - expect(mockWrite).toHaveBeenCalledWith( - WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_WITH_TEMPLATE, - expect.objectContaining({exportID}), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), - ); - - const {optimisticData, failureData} = getWriteOptions(); - const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(exportDownloadUpdate).toBeDefined(); - expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); - - const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(failureUpdate).toBeDefined(); - expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + }); + + const finalParameters = mockWrite.mock.calls.at(-1)?.at(1); + expect(finalParameters).not.toHaveProperty('exportID'); + + const options = mockWrite.mock.calls.at(-1)?.at(2); + expect(options).toEqual({}); + }); }); - it('keeps the legacy request shape (no exportID, no optimistic data) when not tracking progress', () => { - queueExportSearchWithTemplate({ - templateName: 'Test Template', - templateType: 'csv', - jsonQuery: '{}', - reportIDList: [], - transactionIDList: [], - policyID: 'policy123', - exportName: 'Test Template', + describe('exportSearchItemsToCSV', () => { + beforeEach(() => jest.clearAllMocks()); + + it('includes excluded transaction IDs in the direct CSV form payload', () => { + const appendSpy = jest.spyOn(FormData.prototype, 'append'); + + exportSearchItemsToCSV( + { + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + excludedTransactionIDList: ['tx2'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }, + jest.fn(), + translateForTest, + ); + + expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); + expect(mockFileDownload).toHaveBeenCalled(); + appendSpy.mockRestore(); }); + }); - const finalParameters = mockWrite.mock.calls.at(-1)?.at(1); - expect(finalParameters).not.toHaveProperty('exportID'); + describe('getExportTemplates', () => { + const translateForTemplates = translateLocal; + const localeCompare = (first: string, second: string) => first.localeCompare(second); + const makeTemplate = (name: string): ExportTemplate => ({name, templateName: name, type: '', policyID: undefined, description: ''}); + const makePolicyWithOutputCurrency = (outputCurrency: string): Policy => ({...createRandomPolicy(1), outputCurrency}); - const options = mockWrite.mock.calls.at(-1)?.at(2); - expect(options).toEqual({}); - }); -}); + it('returns the custom templates and the default templates as separate groups, each sorted alphabetically', () => { + const integrationsExportTemplates: ExportTemplate[] = [makeTemplate('Zebra integration'), makeTemplate('Apple integration')]; + const csvExportLayouts: Record = { + mango: makeTemplate('Mango layout'), + banana: makeTemplate('Banana layout'), + }; -describe('getFooterConvertedAmounts', () => { - beforeEach(() => jest.clearAllMocks()); + const {customTemplates, defaultTemplates} = getExportTemplates(integrationsExportTemplates, csvExportLayouts, translateForTemplates, localeCompare); - it('does not call API.read when the target currency is empty', () => { - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: ''}); + // Custom group (custom integrations + in-app templates) is sorted alphabetically + expect(customTemplates.map((template) => template.name)).toEqual(['Apple integration', 'Banana layout', 'Mango layout', 'Zebra integration']); - expect(mockRead).not.toHaveBeenCalled(); - }); + // Default group (expense/report level) is sorted alphabetically + expect(defaultTemplates.map((template) => template.name)).toEqual([translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport')]); + }); - it('requests the whole-search conversion when no transaction or report IDs are given', () => { - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + it('excludes the report level export template when includeReportLevelExport is false', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, false); + const templateNames = defaultTemplates.map((template) => template.templateName); - expect(mockRead).toHaveBeenCalledWith( - READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, - expect.objectContaining({targetCurrency: 'EUR'}), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), - ); + expect(templateNames).toContain(CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT); + expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT); + }); - const params = mockRead.mock.calls.at(-1)?.at(1); - expect(params).not.toHaveProperty('transactionIDList'); - expect(params).not.toHaveProperty('reportIDList'); - }); + it('excludes the basic export template by default', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare); + const templateNames = defaultTemplates.map((template) => template.templateName); - it('scopes the request to the given transaction and report IDs', () => { - getFooterConvertedAmounts({ - queryJSON: getQueryJSON(), - searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, - targetCurrency: 'EUR', - transactionIDList: '1,2', - reportIDList: '3,4', + expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV); }); - expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, expect.objectContaining({transactionIDList: '1,2', reportIDList: '3,4'}), expect.anything()); - }); + it('includes the basic export template in the default group (sorted alphabetically) when includeBasicExport is true', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, true); + const names = defaultTemplates.map((template) => template.name); - it('optimistically stamps the sources and clears any prior failure for the target currency', () => { - const sources = {transactions: {transaction1: {USD: 42.5}}}; + // Basic export is sorted alphabetically alongside the other default templates, not pinned to the bottom + expect(names).toEqual( + [translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport'), translateForTemplates('export.basicExport')].sort(localeCompare), + ); + }); - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR', sources}); + it('includes the Canadian Multiple Tax Export template when the policy outputs in CAD', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD)); - const {optimisticData} = getReadOptions(); - const conversionUpdate = optimisticData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); - expect(conversionUpdate).toBeDefined(); - expect(conversionUpdate?.value).toEqual({sources, failedCurrencies: {EUR: null}}); - }); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); - it('marks the target currency as failed on failureData', () => { - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + it('excludes the Canadian Multiple Tax Export template when the policy outputs in another currency', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.USD)); - const {failureData} = getReadOptions(); - const conversionUpdate = failureData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); - expect(conversionUpdate).toBeDefined(); - expect(conversionUpdate?.value).toEqual({failedCurrencies: {EUR: true}}); - }); -}); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); -describe('getExportTemplates', () => { - const translateForTemplates = translateLocal; - const localeCompare = (first: string, second: string) => first.localeCompare(second); - const makeTemplate = (name: string): ExportTemplate => ({name, templateName: name, type: '', policyID: undefined, description: ''}); - const makePolicyWithOutputCurrency = (outputCurrency: string): Policy => ({...createRandomPolicy(1), outputCurrency}); - - it('returns the custom templates and the default templates as separate groups, each sorted alphabetically', () => { - const integrationsExportTemplates: ExportTemplate[] = [makeTemplate('Zebra integration'), makeTemplate('Apple integration')]; - const csvExportLayouts: Record = { - mango: makeTemplate('Mango layout'), - banana: makeTemplate('Banana layout'), - }; + it('includes the Canadian Multiple Tax Export template when includeMultipleTaxExport is true without a policy', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, true); + + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); - const {customTemplates, defaultTemplates} = getExportTemplates(integrationsExportTemplates, csvExportLayouts, translateForTemplates, localeCompare); + it('excludes the Canadian Multiple Tax Export template when includeMultipleTaxExport is false for a CAD policy', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD), true, false, false); - // Custom group (custom integrations + in-app templates) is sorted alphabetically - expect(customTemplates.map((template) => template.name)).toEqual(['Apple integration', 'Banana layout', 'Mango layout', 'Zebra integration']); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); - // Default group (expense/report level) is sorted alphabetically - expect(defaultTemplates.map((template) => template.name)).toEqual([translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport')]); - }); + it('includes the Reconciliation - All Expenses template when the user is a workspace admin of a policy with company cards enabled', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.ADMIN, + areCompanyCardsEnabled: true, + }); - it('excludes the report level export template when includeReportLevelExport is false', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, false); - const templateNames = defaultTemplates.map((template) => template.templateName); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); - expect(templateNames).toContain(CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT); - expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT); - }); + it('includes the Reconciliation - All Expenses template when the user is a card admin of a policy with the Expensify Card enabled', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.CARD_ADMIN, + areExpensifyCardsEnabled: true, + }); + + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); - it('excludes the basic export template by default', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare); - const templateNames = defaultTemplates.map((template) => template.templateName); + it('excludes the Reconciliation - All Expenses template when the user is a workspace member', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.USER, + areCompanyCardsEnabled: true, + }); - expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); + + it('excludes the Reconciliation - All Expenses template when the admin policy has no card product enabled', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.ADMIN, + areCompanyCardsEnabled: false, + areExpensifyCardsEnabled: false, + }); + + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); + + it('includes the Reconciliation - All Expenses template when includeReconciliationAllExpenses is true without a policy', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, false, true); + + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); + + it('excludes the Reconciliation - All Expenses template when includeReconciliationAllExpenses is false for an admin policy', () => { + const {defaultTemplates} = getExportTemplates( + [], + {}, + translateForTemplates, + localeCompare, + {...createRandomPolicy(1), role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}, + true, + false, + false, + false, + ); + + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); }); - it('includes the basic export template in the default group (sorted alphabetically) when includeBasicExport is true', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, true); - const names = defaultTemplates.map((template) => template.name); + describe('getFooterConvertedAmounts', () => { + it('does not call API.read when the target currency is empty', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: ''}); + + expect(mockRead).not.toHaveBeenCalled(); + }); + + it('requests the whole-search conversion when no transaction or report IDs are given', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + + expect(mockRead).toHaveBeenCalledWith( + READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, + expect.objectContaining({targetCurrency: 'EUR'}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); + + const params = mockRead.mock.calls.at(-1)?.at(1); + expect(params).not.toHaveProperty('transactionIDList'); + expect(params).not.toHaveProperty('reportIDList'); + }); + + it('scopes the request to the given transaction and report IDs', () => { + getFooterConvertedAmounts({ + queryJSON: getQueryJSON(), + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, + targetCurrency: 'EUR', + transactionIDList: '1,2', + reportIDList: '3,4', + }); + + expect(mockRead).toHaveBeenCalledWith( + READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, + expect.objectContaining({transactionIDList: '1,2', reportIDList: '3,4'}), + expect.anything(), + ); + }); + + it('optimistically stamps the sources and clears any prior failure for the target currency', () => { + const sources = {transactions: {transaction1: {USD: 42.5}}}; + + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR', sources}); + + const {optimisticData} = getReadOptions(); + const conversionUpdate = optimisticData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); + expect(conversionUpdate).toBeDefined(); + expect(conversionUpdate?.value).toEqual({sources, failedCurrencies: {EUR: null}}); + }); + + it('marks the target currency as failed on failureData', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); - // Basic export is sorted alphabetically alongside the other default templates, not pinned to the bottom - expect(names).toEqual( - [translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport'), translateForTemplates('export.basicExport')].sort(localeCompare), - ); + const {failureData} = getReadOptions(); + const conversionUpdate = failureData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); + expect(conversionUpdate).toBeDefined(); + expect(conversionUpdate?.value).toEqual({failedCurrencies: {EUR: true}}); + }); }); - it('includes the Canadian Multiple Tax Export template when the policy outputs in CAD', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD)); + describe('openSearchPage', () => { + it('does not persist a completion flag that a failed request could strand', () => { + openSearch({includePartiallySetupBankAccounts: false, includeLockedBankAccounts: false}); - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_SEARCH_PAGE, { + includePartiallySetupBankAccounts: false, + includeLockedBankAccounts: false, + }); + }); }); - it('excludes the Canadian Multiple Tax Export template when the policy outputs in another currency', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.USD)); + describe('getChatReportWithFallback', () => { + const loadedChatReport = {reportID: 'chat1', policyID: 'policyA', type: CONST.REPORT.TYPE.CHAT} as Report; + + it('returns the loaded chat report when it is available', () => { + expect(getChatReportWithFallback(loadedChatReport, 'chat2', 'policyB')).toEqual({chatReport: loadedChatReport, isFallbackChatReport: false}); + }); + + it('builds a fallback chat report from the known IDs when the chat is not loaded', () => { + expect(getChatReportWithFallback(undefined, 'chat2', 'policyB')).toEqual({chatReport: {reportID: 'chat2', policyID: 'policyB'}, isFallbackChatReport: true}); + }); - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + it('returns no chat report when the chat is not loaded and there is no fallback chatReportID', () => { + expect(getChatReportWithFallback(undefined, undefined, 'policyB')).toEqual({chatReport: undefined, isFallbackChatReport: false}); + }); }); +}); - it('includes the Canadian Multiple Tax Export template when includeMultipleTaxExport is true without a policy', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, true); +describe('getPayOption', () => { + function makeSelectedReport(reportID: string, canPay: boolean): SelectedReports { + return { + reportID, + policyID: 'policy1', + chatReportID: 'chat1', + total: 100, + action: canPay ? CONST.SEARCH.ACTION_TYPES.PAY : CONST.SEARCH.ACTION_TYPES.VIEW, + canPay, + canApprove: false, + canSubmit: false, + canChangeApprover: false, + }; + } + + function getShouldEnableBulkPayOption(selectedReports: SelectedReports[]) { + return getPayOption( + selectedReports, + {}, + undefined, + selectedReports.map((report) => report.reportID).filter((reportID) => reportID !== undefined), + undefined, + ).shouldEnableBulkPayOption; + } - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + it('enables bulk pay when every selected report is payable', () => { + expect(getShouldEnableBulkPayOption([makeSelectedReport('1', true), makeSelectedReport('2', true)])).toBe(true); }); - it('excludes the Canadian Multiple Tax Export template when includeMultipleTaxExport is false for a CAD policy', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD), true, false, false); + it('keeps bulk pay enabled when only some of the selected reports are payable', () => { + // Given a selection mixing two payable reports with one the viewer cannot settle (it only offers View) - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + // When the bulk pay eligibility is computed + const shouldEnableBulkPayOption = getShouldEnableBulkPayOption([makeSelectedReport('1', true), makeSelectedReport('2', true), makeSelectedReport('3', false)]); + + // Then bulk pay stays available because it degrades to the payable subset instead of dropping the whole Pay + // group — the ineligible report is skipped at payment time rather than hiding Pay and Mark as paid for all three + expect(shouldEnableBulkPayOption).toBe(true); }); -}); -describe('getChatReportWithFallback', () => { - const loadedChatReport = {reportID: 'chat1', policyID: 'policyA', type: CONST.REPORT.TYPE.CHAT} as Report; + it('disables bulk pay when no selected report is payable', () => { + expect(getShouldEnableBulkPayOption([makeSelectedReport('1', false), makeSelectedReport('2', false)])).toBe(false); + }); - it('returns the loaded chat report when it is available', () => { - expect(getChatReportWithFallback(loadedChatReport, 'chat2', 'policyB')).toEqual({chatReport: loadedChatReport, isFallbackChatReport: false}); + it('keeps transaction selections all-or-nothing', () => { + // Given a transaction-level selection where one transaction cannot be paid. Individual transactions are not + // paid one by one, so unlike whole reports there is no payable subset to degrade to. + function makeSelectedTransaction(reportID: string, action: SelectedTransactions[string]['action']): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action, + reportID, + policyID: 'policy1', + amount: 100, + displayAmount: 100, + currency: 'USD', + isFromOneTransactionReport: false, + }; + } + const selectedTransactions: SelectedTransactions = { + tx1: makeSelectedTransaction('1', CONST.SEARCH.ACTION_TYPES.PAY), + tx2: makeSelectedTransaction('2', CONST.SEARCH.ACTION_TYPES.VIEW), + }; + + // When the bulk pay eligibility is computed + + // Then bulk pay is unavailable, as it was before whole-report selections learned to degrade + expect(getPayOption([], selectedTransactions, undefined, [], undefined).shouldEnableBulkPayOption).toBe(false); }); - it('builds a fallback chat report from the known IDs when the chat is not loaded', () => { - expect(getChatReportWithFallback(undefined, 'chat2', 'policyB')).toEqual({chatReport: {reportID: 'chat2', policyID: 'policyB'}, isFallbackChatReport: true}); + it('enables bulk pay when a payable report is not loaded into Onyx but the selection knows its type', async () => { + // Given a whole-page selection of two payable expense reports where only the first has ever been opened, so + // only it exists in the Onyx report collection. Selecting a page routinely mixes opened and unopened reports. + Onyx.init({keys: ONYXKEYS}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, {reportID: '1', type: CONST.REPORT.TYPE.EXPENSE}); + await waitForBatchedUpdates(); + + const selectedReports = [ + {...makeSelectedReport('1', true), type: CONST.REPORT.TYPE.EXPENSE}, + {...makeSelectedReport('2', true), type: CONST.REPORT.TYPE.EXPENSE}, + ]; + + // When the bulk pay eligibility is computed + const shouldEnableBulkPayOption = getShouldEnableBulkPayOption(selectedReports); + + // Then bulk pay is enabled, because the report type comes from the search snapshot carried on the selection + // rather than from live Onyx, which reports undefined for the report the viewer has never opened + expect(shouldEnableBulkPayOption).toBe(true); + + await Onyx.clear(); }); - it('returns no chat report when the chat is not loaded and there is no fallback chatReportID', () => { - expect(getChatReportWithFallback(undefined, undefined, 'policyB')).toEqual({chatReport: undefined, isFallbackChatReport: false}); + it('disables bulk pay when the payable reports are of different types', async () => { + // Given a payable subset mixing an expense report with an IOU report. Bulk pay resolves one payment method + // for the whole group, so mixed types genuinely cannot be paid together. + Onyx.init({keys: ONYXKEYS}); + await Onyx.clear(); + + const selectedReports = [ + {...makeSelectedReport('1', true), type: CONST.REPORT.TYPE.EXPENSE}, + {...makeSelectedReport('2', true), type: CONST.REPORT.TYPE.IOU}, + ]; + + // When the bulk pay eligibility is computed + + // Then bulk pay stays disabled — scoping the type lookup to the snapshot must not drop the check itself + expect(getShouldEnableBulkPayOption(selectedReports)).toBe(false); }); }); diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index 9d422662c385..74f25d5733a4 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -11,6 +11,8 @@ import Text from '@components/Text'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import * as API from '@libs/API'; +import {READ_COMMANDS} from '@libs/API/types'; import {setHasRadio} from '@libs/NetworkState'; import type * as OptionsListUtilsModule from '@libs/OptionsListUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -173,12 +175,21 @@ const mockedBetas = Object.values(CONST.BETAS); const mockedPersonalDetails = getMockedPersonalDetails(10); const EMPTY_PRIVATE_IS_ARCHIVED_MAP: PrivateIsArchivedMap = {}; const CURRENT_USER_ACCOUNT_ID = 1; -const mockedOptions = createFilteredOptionList(mockedPersonalDetails, mockedReports, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - conciergeReportID: undefined, - isSearching: true, -}); +const mockedOptions = createFilteredOptionList( + mockedPersonalDetails, + mockedReports, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString: TestHelper.convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, +); const OFFLINE_INDICATOR_SAFE_AREA_CONTEXT_ENABLED = {addSafeAreaPadding: true}; const OFFLINE_INDICATOR_SAFE_AREA_CONTEXT_DISABLED = {addSafeAreaPadding: false}; @@ -459,9 +470,11 @@ describe('SearchAutocompleteList', () => { { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, + convertToDisplayString: TestHelper.convertToDisplayString, conciergeReportID: undefined, isSearching: true, }, + undefined, ), isLoading: false, loadMore: jest.fn(), @@ -548,9 +561,11 @@ describe('SearchAutocompleteList', () => { { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, + convertToDisplayString: TestHelper.convertToDisplayString, conciergeReportID: undefined, isSearching: true, }, + undefined, ), isLoading: false, loadMore: jest.fn(), @@ -589,6 +604,25 @@ describe('SearchAutocompleteList', () => { getSearchOptionsSpy.mockRestore(); }); + it('searches reports and users for an active query', async () => { + await waitForBatchedUpdates(); + await Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + }); + + render(); + await flushAllUpdates(); + + const textInput = screen.getByTestId('search-autocomplete-text-input'); + fireEvent.changeText(textInput, 'Alice'); + await flushAllUpdates(); + + const requestedCommands = jest.mocked(API.read).mock.calls.map(([command]) => command); + expect(requestedCommands).toEqual(expect.arrayContaining([READ_COMMANDS.SEARCH_FOR_REPORTS, READ_COMMANDS.SEARCH_FOR_USERS])); + }); + it('should display "Recent chats" section when query is empty', async () => { const recentSearches: Record = {}; recentSearches['2024-01-01T00:00:00'] = {query: 'type:expense', timestamp: '2024-01-01T00:00:00'}; @@ -712,8 +746,13 @@ describe('SearchAutocompleteList', () => { expect(screen.getByText('Recent chats')).toBeTruthy(); }); + const uncachedUserOption = mockedOptions.personalDetails.at(0); + if (!uncachedUserOption) { + throw new Error('Expected a personal detail option fixture'); + } + // Now simulate server results arriving by updating the mock to return results - // in a DIFFERENT order, plus a new server-only result. + // in a DIFFERENT order, plus new server-only report and user results. getSearchOptionsSpy.mockReturnValue({ options: { recentReports: [ @@ -722,7 +761,16 @@ describe('SearchAutocompleteList', () => { {reportID: '102', keyForList: '102', text: 'Bob Report', alternateText: 'bob alt', lastMessageText: 'hi'}, {reportID: '201', keyForList: '201', text: 'NewServer Report', alternateText: 'server alt', lastMessageText: 'new'}, ], - personalDetails: [], + personalDetails: [ + { + ...uncachedUserOption, + accountID: 999, + keyForList: '999', + login: 'uncached.test@example.com', + text: 'Uncached Test User', + alternateText: 'uncached.test@example.com', + }, + ], currentUserOption: null, userToInvite: null, }, @@ -749,6 +797,7 @@ describe('SearchAutocompleteList', () => { // Verify the new server-only result appears expect(screen.getByText('NewServer Report')).toBeTruthy(); + expect(screen.getByText('Uncached Test User')).toBeTruthy(); // Verify that local results maintain their FROZEN order (Alice < Bob < Charlie) // even though the mock now returns them as Charlie, Alice, Bob. diff --git a/tests/unit/SearchAutocompleteUtilsTest.ts b/tests/unit/SearchAutocompleteUtilsTest.ts index d05c26f92f1b..c22a11bc2d87 100644 --- a/tests/unit/SearchAutocompleteUtilsTest.ts +++ b/tests/unit/SearchAutocompleteUtilsTest.ts @@ -558,6 +558,10 @@ describe('SearchAutocompleteUtils', () => { expect(getStandardExportTemplateDisplayName(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT)).toBe(CONST.REPORT.EXPORT_OPTION_LABELS.MULTIPLE_TAX_EXPORT); }); + it('returns display name for the Reconciliation - All Expenses template', () => { + expect(getStandardExportTemplateDisplayName(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES)).toBe(CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES); + }); + it('returns template name as-is when no standard mapping', () => { const customName = 'Custom Export Layout'; expect(getStandardExportTemplateDisplayName(customName)).toBe(customName); @@ -569,6 +573,7 @@ describe('SearchAutocompleteUtils', () => { expect(isStandardExportTemplate(CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT)).toBe(true); expect(isStandardExportTemplate(CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT)).toBe(true); expect(isStandardExportTemplate(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT)).toBe(true); + expect(isStandardExportTemplate(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES)).toBe(true); }); it('returns false for a custom template ID', () => { @@ -581,6 +586,7 @@ describe('SearchAutocompleteUtils', () => { expect(isStandardExportTemplateLabel(CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT)).toBe(true); expect(isStandardExportTemplateLabel(CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT)).toBe(true); expect(isStandardExportTemplateLabel(CONST.REPORT.EXPORT_OPTION_LABELS.MULTIPLE_TAX_EXPORT)).toBe(true); + expect(isStandardExportTemplateLabel(CONST.REPORT.EXPORT_OPTION_LABELS.RECONCILIATION_ALL_EXPENSES)).toBe(true); }); it('returns false for a custom template label', () => { diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index d2b8fc260134..f0686d918a7e 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -935,6 +935,34 @@ const tests = [ }, }, }, + { + query: 'type:expense has:approved-violation', + 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.HAS, + right: CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION, + }, + }, + }, + { + query: 'type:expense -has:approved-violation', + 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.HAS, + right: CONST.SEARCH.HAS_VALUES.APPROVED_VIOLATION, + }, + }, + }, { query: 'type:chat is:READ', expected: { diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 2475e8505b1d..420a0cad5b39 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -15,14 +15,18 @@ import useNavigationSuggestions, { buildDomainNavigationItems, buildSpendNavigationItems, buildTopLevelNavigationItems, + buildWorkspaceNavigationItems, } from '@components/Search/SearchRouter/useNavigationSuggestions'; import {setSearchContext} from '@libs/actions/Search'; +import navigateToDomainRouteWithSidebarSync from '@libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync'; +import navigateToWorkspaceSettingsRoute from '@libs/Navigation/helpers/navigateToWorkspaceSettingsRoute'; import Navigation from '@libs/Navigation/Navigation'; import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import type {MenuData, MenuSection} from '@pages/settings/useSettingsNavigationMenuData'; +import getWorkspaceMenuItems from '@pages/workspace/getWorkspaceMenuItems'; import variables from '@styles/variables'; @@ -30,27 +34,31 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Domain} from '@src/types/onyx'; +import type {Domain, Policy} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import {isValidElement} from 'react'; -type MockSearchTypeMenuSectionsResult = { - typeMenuSections: SearchTypeMenuSection[]; - activeItemIndex: number; - activeKey: string | undefined; -}; +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; + +type GetWorkspaceMenuItems = typeof getWorkspaceMenuItems; -const mockUseSearchTypeMenuSections = jest.fn(); +const mockUseSearchTypeMenuSections = jest.fn(); const mockUseMemoizedLazyExpensifyIcons = jest.fn, []>(); const mockUseCreateNavigationSuggestions = jest.fn(() => []); const mockUseSettingsNavigationMenuData = jest.fn<{accountMenuItemsData: MenuSection; generalMenuItemsData: MenuSection}, []>(); const mockClearSelectedTransactions = jest.fn(); const mockUseOnyx = jest.fn<[unknown], [key: string]>(() => [undefined]); +const mockShouldUseNarrowLayout = jest.fn(() => false); +const mockUseNetwork = jest.fn<{isOffline: boolean}, []>(() => ({isOffline: false})); +const mockIsBetaEnabled = jest.fn(() => false); const currentUserAccountID = 1; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}), + useSearchQueryContext: () => ({}), + useSearchQueryActions: () => ({}), })); jest.mock('@components/Search/SearchRouter/useCreateNavigationSuggestions', () => ({ @@ -63,6 +71,10 @@ jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: () => mockUseMemoizedLazyExpensifyIcons(), })); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({convertToDisplayString: jest.fn(() => '$0.00')}), +})); + jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, default: () => ({accountID: 1}), @@ -93,6 +105,7 @@ jest.mock('@hooks/useLocalize', () => ({ ['initialSettingsPage.help', 'Help'], ['search.tabs.reports', 'Reports'], ['search.tabs.expenses', 'Expenses'], + ['workspace.common.profile', 'Overview'], ]); return translations.get(key) ?? key; }, @@ -104,6 +117,21 @@ jest.mock('@hooks/useOnyx', () => ({ default: (key: string) => mockUseOnyx(key), })); +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => mockUseNetwork(), +})); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: (beta: string) => mockIsBetaEnabled(beta)}), +})); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout()}), +})); + jest.mock('@hooks/useSearchTypeMenuSections', () => ({ __esModule: true, default: (queryParams: unknown, isScreenFocused: boolean) => mockUseSearchTypeMenuSections(queryParams, isScreenFocused), @@ -114,10 +142,20 @@ jest.mock('@pages/settings/useSettingsNavigationMenuData', () => ({ default: () => mockUseSettingsNavigationMenuData(), })); +jest.mock('@pages/workspace/getWorkspaceMenuItems', () => { + const actual = jest.requireActual<{default: GetWorkspaceMenuItems}>('@pages/workspace/getWorkspaceMenuItems'); + return {__esModule: true, ...actual, default: jest.fn(actual.default)}; +}); + jest.mock('@libs/actions/Search', () => ({ setSearchContext: jest.fn(), })); +jest.mock('@libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync', () => ({ + __esModule: true, + default: jest.fn(), +})); + jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { @@ -127,8 +165,11 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })); +jest.mock('@libs/Navigation/helpers/navigateToWorkspaceSettingsRoute', () => jest.fn()); + const localeCompare = (firstValue: string, secondValue: string) => firstValue.localeCompare(secondValue); const mockIcon: IconAsset = () => null; +const workspaceCurrentUserLogin = 'member@example.com'; const domainIcons = { Globe: mockIcon, UserLock: mockIcon, @@ -153,6 +194,44 @@ const spendIcons = { CheckCircle: mockIcon, UserEye: mockIcon, }; +const workspaceIcons = { + Building: mockIcon, + Users: mockIcon, + Hashtag: mockIcon, + Document: mockIcon, + Sync: mockIcon, + Receipt: mockIcon, + Briefcase: mockIcon, + Folder: mockIcon, + Tag: mockIcon, + Coins: mockIcon, + Workflows: mockIcon, + Feed: mockIcon, + Car: mockIcon, + LuggageWithLines: mockIcon, + ExpensifyCard: mockIcon, + CreditCard: mockIcon, + CalendarSolid: mockIcon, + Clock: mockIcon, + InvoiceGeneric: mockIcon, + Gear: mockIcon, + Bolt: mockIcon, + Bot: mockIcon, +}; + +function createWorkspacePolicy(id: string, name: string, overrides: Partial = {}): Policy { + return createMock({ + ...createRandomPolicy(Number(id), CONST.POLICY.TYPE.CORPORATE, name), + id, + name, + role: CONST.POLICY.ROLE.ADMIN, + owner: workspaceCurrentUserLogin, + employeeList: {[workspaceCurrentUserLogin]: {role: CONST.POLICY.ROLE.ADMIN}}, + pendingAction: undefined, + errorFields: {}, + ...overrides, + }); +} function createSpendMenuItem( key: SearchTypeMenuItem['key'], @@ -184,6 +263,9 @@ function createSettingsMenuItem(translationKey: MenuData['translationKey'], scre } beforeEach(() => { + mockUseOnyx.mockImplementation(() => [undefined]); + mockUseNetwork.mockReturnValue({isOffline: false}); + mockIsBetaEnabled.mockReturnValue(false); mockUseSettingsNavigationMenuData.mockReturnValue({ accountMenuItemsData: {sectionTranslationKey: 'initialSettingsPage.account', items: []}, generalMenuItemsData: {sectionTranslationKey: 'initialSettingsPage.general', items: []}, @@ -236,10 +318,12 @@ describe('Search Router navigation query helpers', () => { expect(buildNavigationSuggestions('o inbox', source, localeCompare)).toEqual([]); }); - it('does not include internal matching terms in returned navigation rows', () => { - const source = [[{text: 'Go to Inbox', keyForList: 'inbox', matchTerms: ['Inbox']}]]; + it('does not include internal matching or sorting metadata in returned navigation rows', () => { + const source = [[{text: 'Go to Inbox', keyForList: 'inbox', matchTerms: ['Inbox'], sortText: 'Inbox'}]]; + const item = buildNavigationSuggestions('inbox', source, localeCompare).at(0); - expect(buildNavigationSuggestions('inbox', source, localeCompare).at(0)).not.toHaveProperty('matchTerms'); + expect(item).not.toHaveProperty('matchTerms'); + expect(item).not.toHaveProperty('sortText'); }); it('matches short queries only when they exactly match a localized destination', () => { @@ -418,10 +502,10 @@ describe('Domain Search Router navigation source', () => { for (const item of items) { item.action?.(); } - expect(onSelect).toHaveBeenNthCalledWith(1, ROUTES.DOMAIN_MEMBERS.getRoute(123)); - expect(onSelect).toHaveBeenNthCalledWith(2, ROUTES.DOMAIN_ADMINS.getRoute(123)); - expect(onSelect).toHaveBeenNthCalledWith(3, ROUTES.DOMAIN_GROUPS.getRoute(123)); - expect(onSelect).toHaveBeenNthCalledWith(4, ROUTES.DOMAIN_SAML.getRoute(123)); + expect(onSelect).toHaveBeenNthCalledWith(1, ROUTES.DOMAIN_MEMBERS.getRoute(123), 123); + expect(onSelect).toHaveBeenNthCalledWith(2, ROUTES.DOMAIN_ADMINS.getRoute(123), 123); + expect(onSelect).toHaveBeenNthCalledWith(3, ROUTES.DOMAIN_GROUPS.getRoute(123), 123); + expect(onSelect).toHaveBeenNthCalledWith(4, ROUTES.DOMAIN_SAML.getRoute(123), 123); }); it('matches Domain rows by subpage label or domain name', () => { @@ -469,7 +553,7 @@ describe('Domain Search Router navigation source', () => { Building: mockIcon, Gear: mockIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({typeMenuSections: [], activeItemIndex: -1, activeKey: undefined}); + mockUseSearchTypeMenuSections.mockReturnValue([]); const {result} = renderHook(() => useNavigationSuggestions('members')); @@ -481,6 +565,9 @@ describe('Domain Search Router navigation source', () => { throw new Error('Expected Domain navigation context to be a React element'); } expect(rightElement.props).toMatchObject({text: 'example.com', icon: domainIcons.Globe}); + + result.current.at(0)?.action?.(); + expect(navigateToDomainRouteWithSidebarSync).toHaveBeenCalledWith(ROUTES.DOMAIN_MEMBERS.getRoute(123), 123, false); }); }); @@ -557,6 +644,144 @@ describe('Create Search Router navigation source', () => { }); }); +describe('Workspace Search Router navigation source', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const buildItems = (policies: Policy[], isOffline = false) => + buildWorkspaceNavigationItems({ + policies: Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])), + policyCategories: undefined, + currentUserLogin: workspaceCurrentUserLogin, + icons: workspaceIcons, + isOffline, + isVendorMatchingBetaEnabled: false, + shouldUseNarrowLayout: false, + convertToDisplayString: () => '$0.00', + getItemText: (item) => { + const labels = new Map([ + ['workspace.common.profile', 'Overview'], + ['workspace.common.members', 'Members'], + ['workspace.common.rooms', 'Rooms'], + ['workspace.common.workflows', 'Workflows'], + ['workspace.common.hr', 'HR'], + ]); + return labels.get(item.translationKey) ?? item.translationKey; + }, + getDestinationText: (destination) => `Go to ${destination}`, + }); + + it('matches a workspace name to its Overview row only', () => { + const items = buildItems([createWorkspacePolicy('1', 'Alpha Workspace', {areWorkflowsEnabled: true})]); + + expect(buildNavigationSuggestions('Alpha Workspace', [items], localeCompare).map((item) => item.keyForList)).toEqual([`workspace_1_${SCREENS.WORKSPACE.PROFILE}`]); + expect(buildNavigationSuggestions('Workflows', [items], localeCompare).map((item) => item.keyForList)).toEqual([`workspace_1_${SCREENS.WORKSPACE.WORKFLOWS}`]); + }); + + it('excludes inaccessible policies, pending join requests, pending deletes, and disabled feature pages', () => { + const accessiblePolicy = createWorkspacePolicy('1', 'Accessible Workspace', {areWorkflowsEnabled: false}); + const personalPolicy = createWorkspacePolicy('2', 'Personal Workspace', {type: CONST.POLICY.TYPE.PERSONAL, areWorkflowsEnabled: true}); + const pendingJoinPolicy = createWorkspacePolicy('3', 'Pending Workspace', {isJoinRequestPending: true}); + const pendingDeletePolicy = createWorkspacePolicy('4', 'Deleted Workspace', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}); + const items = buildItems([accessiblePolicy, personalPolicy, pendingJoinPolicy, pendingDeletePolicy], true); + + expect(items.some((item) => item.keyForList?.startsWith('workspace_2_'))).toBe(false); + expect(items.some((item) => item.keyForList?.startsWith('workspace_3_'))).toBe(false); + expect(items.some((item) => item.keyForList?.startsWith('workspace_4_'))).toBe(false); + expect(items.some((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.WORKFLOWS}`)).toBe(false); + }); + + it('supports the short HR query and alphabetizes equal-priority Workspace rows', () => { + const items = buildItems([createWorkspacePolicy('1', 'Beta Workspace', {isHREnabled: true}), createWorkspacePolicy('2', 'Alpha Workspace', {isHREnabled: true})]); + + expect(buildNavigationSuggestions('hr', [items], localeCompare).map((item) => item.keyForList)).toEqual([ + `workspace_2_${SCREENS.WORKSPACE.HR}`, + `workspace_1_${SCREENS.WORKSPACE.HR}`, + ]); + }); + + it('includes workspace identity and navigates through the Workspace synchronization helper', () => { + const policy = createWorkspacePolicy('1', 'Alpha Workspace'); + const overviewItem = buildItems([policy]).find((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.PROFILE}`); + + expect(isValidElement<{policy: Policy}>(overviewItem?.rightElement)).toBe(true); + if (!isValidElement<{policy: Policy}>(overviewItem?.rightElement)) { + throw new Error('Expected Workspace navigation context to be a React element'); + } + expect(overviewItem.rightElement.props.policy).toBe(policy); + + overviewItem?.action?.(); + expect(navigateToWorkspaceSettingsRoute).toHaveBeenCalledWith(ROUTES.WORKSPACE_OVERVIEW.getRoute(policy.id), policy.id, false, SCREENS.WORKSPACE.PROFILE); + }); + + it('composes localized Workspace suggestions after Spend with hook-level filtering and beta flags', () => { + const activePolicy = createWorkspacePolicy('1', 'Active Workspace'); + const deletedPolicy = createWorkspacePolicy('2', 'Deleted Workspace', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}); + const policies = { + [`${ONYXKEYS.COLLECTION.POLICY}${activePolicy.id}`]: activePolicy, + [`${ONYXKEYS.COLLECTION.POLICY}${deletedPolicy.id}`]: deletedPolicy, + }; + mockUseOnyx.mockImplementation((key) => { + if (key === ONYXKEYS.COLLECTION.POLICY) { + return [policies]; + } + if (key === ONYXKEYS.SESSION) { + return [workspaceCurrentUserLogin]; + } + return [undefined]; + }); + mockUseNetwork.mockReturnValue({isOffline: true}); + mockIsBetaEnabled.mockReturnValue(true); + mockUseMemoizedLazyExpensifyIcons.mockReturnValue({ + ...spendIcons, + ...workspaceIcons, + Home: mockIcon, + Inbox: mockIcon, + ReceiptMultiple: mockIcon, + Gear: mockIcon, + }); + mockUseSearchTypeMenuSections.mockReturnValue([ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + ]); + mockUseSettingsNavigationMenuData.mockReturnValue({ + accountMenuItemsData: { + sectionTranslationKey: 'initialSettingsPage.account', + items: [createSettingsMenuItem('initialSettingsPage.security', SCREENS.SETTINGS.SECURITY)], + }, + generalMenuItemsData: {sectionTranslationKey: 'initialSettingsPage.general', items: []}, + }); + const actualGetWorkspaceMenuItems = jest.requireActual<{default: GetWorkspaceMenuItems}>('@pages/workspace/getWorkspaceMenuItems').default; + jest.mocked(getWorkspaceMenuItems).mockImplementationOnce((params) => actualGetWorkspaceMenuItems(params).filter((item) => item.screenName === SCREENS.WORKSPACE.PROFILE)); + + const {result} = renderHook(() => useNavigationSuggestions('go')); + + expect(result.current.map((item) => item.keyForList)).toEqual([ + 'topLevelAccount', + 'topLevelDomains', + 'topLevelHome', + 'topLevelInbox', + 'topLevelSpend', + 'topLevelWorkspaces', + 'spend_reports', + `workspace_1_${SCREENS.WORKSPACE.PROFILE}`, + ]); + expect(result.current.at(7)).toMatchObject({text: 'Go to Overview'}); + expect(result.current.some((item) => item.keyForList?.startsWith('workspace_2_'))).toBe(false); + expect(getWorkspaceMenuItems).toHaveBeenCalledTimes(1); + expect(getWorkspaceMenuItems).toHaveBeenCalledWith( + expect.objectContaining({ + policy: activePolicy, + isVendorMatchingBetaEnabled: true, + }), + ); + expect(mockIsBetaEnabled).toHaveBeenCalledWith(CONST.BETAS.VENDOR_MATCHING); + }); +}); + describe('Spend Search Router navigation source', () => { beforeEach(() => { jest.clearAllMocks(); @@ -602,7 +827,7 @@ describe('Spend Search Router navigation source', () => { expect(items.map((item) => item.matchTerms)).toEqual([['Reports'], ['Expenses']]); items.at(0)?.action?.(); - expect(onSelect).toHaveBeenCalledWith(reportsQuery); + expect(onSelect).toHaveBeenCalledWith(CONST.SEARCH.SEARCH_KEYS.REPORTS, reportsQuery); }); it('does not use the right-side Spend context as a matching term', () => { @@ -638,7 +863,7 @@ describe('Spend Search Router navigation source', () => { const clearSelectedTransactions = jest.fn(); const searchQuery = 'type:expense sortBy:date sortOrder:desc'; - navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions); + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, undefined, clearSelectedTransactions, jest.fn()); expect(clearSelectedTransactions).toHaveBeenCalledTimes(1); expect(setSearchContext).toHaveBeenCalledWith(false); @@ -647,6 +872,37 @@ describe('Spend Search Router navigation source', () => { expect(jest.mocked(setSearchContext).mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(Navigation.navigate).mock.invocationCallOrder.at(0) ?? 0); }); + it('passes the query it navigates to as the search key target, so the update can be deferred until the query changes', () => { + const setCurrentSearchKey = jest.fn(); + const searchQuery = 'type:expense sortBy:date sortOrder:desc'; + // The last query stays valid for the default query, so it is the one we navigate to. + const lastSearchQuery = 'type:expense sortBy:date sortOrder:desc merchant:test'; + + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, lastSearchQuery, jest.fn(), setCurrentSearchKey); + + expect(setCurrentSearchKey).toHaveBeenCalledWith(CONST.SEARCH.SEARCH_KEYS.EXPENSES, lastSearchQuery); + }); + + it('navigates with the last query when it is still valid for the default query', () => { + const searchQuery = 'type:expense sortBy:date sortOrder:desc'; + // The last query adds a filter but keeps the default query's type and (empty) filter keys, so it stays valid. + const lastSearchQuery = 'type:expense sortBy:date sortOrder:desc merchant:test'; + + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, lastSearchQuery, jest.fn(), jest.fn()); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: lastSearchQuery})); + }); + + it('falls back to the default query when the last query drops one of its filters', () => { + const searchQuery = 'type:expense merchant:Amazon'; + // The last query drops the default's merchant filter, so it is no longer valid and the default is used. + const lastSearchQuery = 'type:expense category:Food'; + + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, lastSearchQuery, jest.fn(), jest.fn()); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + }); + it('composes Spend suggestions from the menu hook with icons, context, exclusions, and approval gating', () => { const reportsIcon: IconAsset = () => null; const spendContextIcon: IconAsset = () => null; @@ -659,26 +915,22 @@ describe('Spend Search Router navigation source', () => { Gear: mockIcon, Document: reportsIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({ - typeMenuSections: [ - { - translationPath: 'search.tabs.expenseReports', - menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], - }, - { - translationPath: 'search.savedSearchesMenuItemTitle', - menuItems: [createSpendMenuItem(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`, 'search.tabs.reports', 'Receipt', 'saved-search-query')], - }, - ], - activeItemIndex: -1, - activeKey: undefined, - }); + mockUseSearchTypeMenuSections.mockReturnValue([ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + { + translationPath: 'search.savedSearchesMenuItemTitle', + menuItems: [createSpendMenuItem(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`, 'search.tabs.reports', 'Receipt', 'saved-search-query')], + }, + ]); const {result, rerender} = renderHook(({shouldWatchForApprovals}) => useNavigationSuggestions('reports', shouldWatchForApprovals), { initialProps: {shouldWatchForApprovals: false}, }); - expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, false); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(false, undefined); expect(result.current).toHaveLength(1); expect(result.current.at(0)).toMatchObject({ text: 'Go to Reports', @@ -695,7 +947,7 @@ describe('Spend Search Router navigation source', () => { expect(rightElement.props).toMatchObject({text: 'Spend', icon: spendContextIcon, iconSize: variables.fontSizeLabel, showTooltip: false}); rerender({shouldWatchForApprovals: true}); - expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, true); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(true, undefined); }); it('keeps Create rows reachable when top-level and Spend sources are present', () => { @@ -707,16 +959,12 @@ describe('Spend Search Router navigation source', () => { Building: mockIcon, Gear: mockIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({ - typeMenuSections: [ - { - translationPath: 'search.tabs.expenseReports', - menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], - }, - ], - activeItemIndex: -1, - activeKey: undefined, - }); + mockUseSearchTypeMenuSections.mockReturnValue([ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + ]); mockUseCreateNavigationSuggestions.mockReturnValue( CreateNavigationSuggestions.buildCreateNavigationItems([{visible: true, text: 'Create expense', icon: mockIcon, action: jest.fn(), keyForList: 'create_expense'}]), ); @@ -806,7 +1054,7 @@ describe('Account Search Router navigation source', () => { Building: mockIcon, Gear: accountContextIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({typeMenuSections: [], activeItemIndex: -1, activeKey: undefined}); + mockUseSearchTypeMenuSections.mockReturnValue([]); mockUseSettingsNavigationMenuData.mockReturnValue({ accountMenuItemsData: { sectionTranslationKey: 'initialSettingsPage.account', diff --git a/tests/unit/SelectionListEnterConfirmGateTest.tsx b/tests/unit/SelectionListEnterConfirmGateTest.tsx new file mode 100644 index 000000000000..d96521425426 --- /dev/null +++ b/tests/unit/SelectionListEnterConfirmGateTest.tsx @@ -0,0 +1,264 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import BaseSelectionList from '@components/SelectionList/BaseSelectionList'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import BaseSelectionListWithSections from '@components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections'; +import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; + +import type Navigation from '@libs/Navigation/Navigation'; + +import type ReactNative from 'react-native'; + +import * as NativeNavigation from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; + +// Records what BaseSelectionList / BaseSelectionListWithSections hand to the shortcuts hook, so the tests can assert +// on the gated `focusedIndex` (-1 disables the list's plain-Enter shortcut and lets the keypress reach the confirm button). +const mockUseSelectionListShortcuts = jest.fn(); +jest.mock('@components/SelectionList/hooks/useSelectionListShortcuts', () => ({ + __esModule: true, + default: (params: {focusedIndex: number}) => { + mockUseSelectionListShortcuts(params); + }, +})); + +// Mock FlashList so every row renders synchronously +jest.mock('@shopify/flash-list', () => { + const ReactLocal = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + + const FlashList = ReactLocal.forwardRef< + {scrollToIndex: (params: {index: number}) => void}, + Omit, 'children'> & { + data?: unknown[]; + renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; + keyExtractor?: (item: unknown, index: number) => string; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + getItemType?: unknown; + extraData?: unknown; + initialScrollIndex?: number; + onEndReached?: unknown; + onEndReachedThreshold?: unknown; + ListFooterComponentStyle?: unknown; + } + >( + ( + { + data, + renderItem, + keyExtractor, + ListHeaderComponent, + ListFooterComponent, + getItemType: _getItemType, + extraData: _extraData, + initialScrollIndex: _initialScrollIndex, + onEndReached: _onEndReached, + onEndReachedThreshold: _onEndReachedThreshold, + ListFooterComponentStyle: _ListFooterComponentStyle, + ...scrollViewProps + }, + ref, + ) => { + ReactLocal.useImperativeHandle(ref, () => ({scrollToIndex: jest.fn()})); + + return ReactLocal.createElement( + RN.ScrollView, + scrollViewProps, + ListHeaderComponent ?? null, + ...(data ?? []).map((item, index) => + ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index, target: 'Cell'})), + ), + ListFooterComponent ?? null, + ); + }, + ); + + return {FlashList}; +}); + +jest.mock('@src/components/ConfirmedRoute.tsx'); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: jest.fn(), + useFocusEffect: jest.fn(), + useNavigation: jest.fn(() => ({ + isFocused: jest.fn(() => true), + })), + }; +}); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + })), +); + +jest.mock('@hooks/useKeyboardShortcut', () => jest.fn()); + +/** Mirrors the Workspace Members invite list: one member picked with the mouse, nothing typed in the search field. */ +const mouseSelectedItems: ListItem[] = [ + {text: 'Item 0', keyForList: '0', isSelected: true}, + {text: 'Item 1', keyForList: '1'}, + {text: 'Item 2', keyForList: '2'}, +]; + +const noSelectionItems: ListItem[] = mouseSelectedItems.map((item) => ({...item, isSelected: false})); + +/** A custom footer (like the invite pages' "Next" button) that owns Enter when it is enabled. */ +const footerContent = ; + +/** The `focusedIndex` the list handed to `useSelectionListShortcuts` on its latest render. */ +function getGatedFocusedIndex(): number { + return mockUseSelectionListShortcuts.mock.calls.at(-1)?.[0].focusedIndex ?? Number.NaN; +} + +type ListProps = { + data?: ListItem[]; + confirmButtonOptions?: ConfirmButtonOptions; + footerContent?: React.ReactNode; + searchText?: string; + shouldStopPropagation?: boolean; +}; + +function renderFlatList({data = mouseSelectedItems, confirmButtonOptions, footerContent: footer, searchText = '', shouldStopPropagation = false}: ListProps = {}) { + return render( + + + , + ); +} + +function renderSectionedList({data = mouseSelectedItems, confirmButtonOptions, footerContent: footer, searchText = '', shouldStopPropagation = false}: ListProps = {}) { + return render( + + + , + ); +} + +describe.each([ + ['BaseSelectionList', renderFlatList], + ['BaseSelectionListWithSections', renderSectionedList], +])('%s Enter/confirm-button gate', (_name, renderList) => { + beforeEach(() => { + mockUseSelectionListShortcuts.mockClear(); + jest.mocked(NativeNavigation.useIsFocused).mockReturnValue(true); + }); + + it('yields plain Enter to an enabled custom footer confirm when rows were selected with the mouse and the search field is empty', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('yields plain Enter to an enabled built-in confirm button', () => { + renderList({confirmButtonOptions: {showButton: true, onConfirm: jest.fn(), text: 'Next'}}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps plain Enter on the focused row when a custom footer hides the built-in confirm button and the footer cannot handle Enter', () => { + // Footer renders footerContent instead of the built-in button, so the built-in button's Enter + // listener never mounts. The footer path must govern, otherwise Enter reaches nothing at all. + renderList({footerContent, confirmButtonOptions: {showButton: true, onConfirm: jest.fn(), text: 'Next', isFooterConfirmEnterKeyEnabled: false}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row while a search query is typed', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}, searchText: 'Item 1'}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('treats a whitespace-only search query as empty and still yields plain Enter to the confirm', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}, searchText: ' '}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps plain Enter on the focused row once the user navigates with the keyboard', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}}); + expect(getGatedFocusedIndex()).toBe(-1); + + fireEvent(screen.getByTestId('selection-list-text-input'), 'keyPress', {nativeEvent: {key: 'Tab'}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the built-in confirm button is disabled', () => { + renderList({confirmButtonOptions: {showButton: true, onConfirm: jest.fn(), text: 'Next', isDisabled: true}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the footer confirm cannot handle Enter on this platform', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn(), isFooterConfirmEnterKeyEnabled: false}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the owner reports the footer confirm as disabled', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn(), isFooterConfirmEnabled: false}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('yields plain Enter when the owner reports the footer confirm as enabled even though no rendered row is selected', () => { + renderList({data: noSelectionItems, footerContent, confirmButtonOptions: {onConfirm: jest.fn(), isFooterConfirmEnabled: true}}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps plain Enter on the focused row when nothing is selected, so the footer confirm is inferred to be disabled', () => { + renderList({data: noSelectionItems, footerContent, confirmButtonOptions: {onConfirm: jest.fn()}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row for option-driven onConfirm lists that render no confirm control', () => { + renderList({confirmButtonOptions: {onConfirm: jest.fn()}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the list is configured to stop propagation', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}, shouldStopPropagation: true}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when there is no confirm button at all', () => { + renderList(); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/unit/SequentialQueueReadGateTest.ts b/tests/unit/SequentialQueueReadGateTest.ts new file mode 100644 index 000000000000..b598223d330e --- /dev/null +++ b/tests/unit/SequentialQueueReadGateTest.ts @@ -0,0 +1,243 @@ +import * as NetworkState from '@libs/NetworkState'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type Request from '@src/types/onyx/Request'; + +import Onyx from 'react-native-onyx'; + +import type {MockFetch} from '../utils/TestHelper'; + +import * as SequentialQueue from '../../src/libs/Network/SequentialQueue'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/ActiveClientManager', () => ({ + isClientTheLeader: jest.fn(() => true), + isReady: jest.fn(() => Promise.resolve()), + init: jest.fn(), +})); + +// Kept apart from SequentialQueueTest so no pushed request has moved the shared gate before a test starts. + +const request: Request<'userMetadata'> = { + command: 'ReconnectApp', + successData: [{key: 'userMetadata', onyxMethod: 'set', value: {accountID: 1234}}], + failureData: [{key: 'userMetadata', onyxMethod: 'set', value: {}}], +}; + +let mockFetch: MockFetch; +let offlineSpy: jest.SpyInstance; +let networkStateListeners: Array<() => void>; + +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); +}); + +beforeEach(() => { + mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; + offlineSpy = jest.spyOn(NetworkState, 'getIsOffline').mockReturnValue(false); + networkStateListeners = []; + jest.spyOn(NetworkState, 'subscribe').mockImplementation((callback) => { + networkStateListeners.push(callback); + return () => { + networkStateListeners = networkStateListeners.filter((listener) => listener !== callback); + }; + }); + return Onyx.clear().then(waitForBatchedUpdates); +}); + +afterEach(() => { + jest.restoreAllMocks(); + SequentialQueue.resetQueue(); +}); + +/** Flips the network and notifies the listeners `waitForIdle()` subscribes to, the way NetworkState would. */ +function setOffline(isOffline: boolean) { + offlineSpy.mockReturnValue(isOffline); + for (const listener of [...networkStateListeners]) { + listener(); + } +} + +/** Starts a READ-style wait on the gate. `hasResolved` lets a test assert the wait has *not* settled yet. */ +function trackIdle() { + const state = {hasResolved: false}; + SequentialQueue.waitForIdle().then(() => { + state.hasResolved = true; + }); + return state; +} + +describe('SequentialQueue.claimReadGateForDeferredWrite', () => { + it('parks a READ until the deferred write settles its claim', async () => { + // Given a deferred write holding the gate before reaching the queue + const settleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + + // When a READ consults the gate in that window + const idle = trackIdle(); + await waitForBatchedUpdates(); + + // Then it waits, exactly as it would behind a queued write + expect(idle.hasResolved).toBe(false); + + // When the write never reaches the queue and the claim is settled + settleClaim(); + await waitForBatchedUpdates(); + + // Then the READ proceeds + expect(idle.hasResolved).toBe(true); + }); + + it('keeps waiting on the queue once the deferred write lands there', async () => { + // Given a READ parked behind a deferred write + const settleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + const idle = trackIdle(); + mockFetch.pause(); + + // When the write reaches the queue and the claim settles, as writeWhenReady does on the same tick + SequentialQueue.push(request); + settleClaim(); + await waitForBatchedUpdates(); + + // Then the READ is still parked. The handover has no gap: waitForIdle() only reads the queue's own + // gate after the claim settles, by which point push() has closed it again for the in-flight write. + expect(idle.hasResolved).toBe(false); + + // When the queue drains + await mockFetch.resume(); + await waitForBatchedUpdates(); + + // Then the READ proceeds + expect(idle.hasResolved).toBe(true); + }); + + it('survives unrelated queue activity resolving the queue gate mid-deferral', async () => { + // Given a deferred write holding the gate, with nothing pushed yet + const settleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + const idle = trackIdle(); + + // When something else flushes in that window and finds no requests, because the deferred write has + // not been pushed, so it resolves the queue's own gate + SequentialQueue.flush(); + await waitForBatchedUpdates(); + + // Then the claim is untouched. It is a separate gate precisely so that no queue path can read an + // empty queue as "nothing is coming" and let through the READs this write must be ordered before. + expect(idle.hasResolved).toBe(false); + + settleClaim(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(true); + }); + + it('survives an unrelated write draining to completion mid-deferral', async () => { + // Given a deferred write holding the gate + const settleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + + // When an ordinary write lands during the deferral and runs to completion, so the queue resolves + // its own gate from the drain path rather than from the empty-queue branch above + SequentialQueue.push(request); + await waitForBatchedUpdates(); + await waitForBatchedUpdates(); + + // Then a READ firing afterwards still parks. The drain says nothing about the deferred write, + // which is not on the queue yet - reading it as "all writes are done" is what #99805 was. + const idle = trackIdle(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(false); + + settleClaim(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(true); + }); + + it('waits for every outstanding claim, not just the first one to settle', async () => { + // Given two deferred writes, each holding the gate + const settleFirst = SequentialQueue.claimReadGateForDeferredWrite(); + const settleSecond = SequentialQueue.claimReadGateForDeferredWrite(); + const idle = trackIdle(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(false); + + // When only the first settles + settleFirst(); + await waitForBatchedUpdates(); + + // Then the READ stays parked: the second write is still deferred and would be raced otherwise + expect(idle.hasResolved).toBe(false); + + // When the second settles too + settleSecond(); + await waitForBatchedUpdates(); + + // Then the READ proceeds + expect(idle.hasResolved).toBe(true); + }); + + it('does not park READs while offline', async () => { + // Given the app is offline, where the queue is not running + setOffline(true); + + // When a deferred write claims the gate and a READ consults it + SequentialQueue.claimReadGateForDeferredWrite(); + const idle = trackIdle(); + await waitForBatchedUpdates(); + + // Then the READ is not parked behind a queue that cannot drain, matching push() and flush() + expect(idle.hasResolved).toBe(true); + }); + + it('releases a parked READ when the app goes offline mid-deferral', async () => { + // Given a READ parked behind a deferred write while online + SequentialQueue.claimReadGateForDeferredWrite(); + const idle = trackIdle(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(false); + + // When the network drops before the barrier settles + setOffline(true); + await waitForBatchedUpdates(); + + // Then the READ is let through rather than held for the rest of the deferral + expect(idle.hasResolved).toBe(true); + }); + + it('parks READs again when the app comes back online mid-deferral', async () => { + // Given a deferred write claimed while offline, which parks nothing + setOffline(true); + const settleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + + // When the network comes back while the barrier is still waiting + setOffline(false); + const idle = trackIdle(); + await waitForBatchedUpdates(); + + // Then the claim applies again: the queue can run now, so a READ must not overtake the write + expect(idle.hasResolved).toBe(false); + + settleClaim(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(true); + }); + + it('does not let a claim wiped by resetQueue() settle a later one', async () => { + // Given a claim that outlives a reset, and a fresh claim taken after it + const settleStaleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + SequentialQueue.resetQueue(); + const settleClaim = SequentialQueue.claimReadGateForDeferredWrite(); + const idle = trackIdle(); + + // When the wiped claim finally settles + settleStaleClaim(); + await waitForBatchedUpdates(); + + // Then it does not decrement the live claim's count. Sharing one counter across a reset would open + // the gate here while the second write is still deferred. + expect(idle.hasResolved).toBe(false); + + settleClaim(); + await waitForBatchedUpdates(); + expect(idle.hasResolved).toBe(true); + }); +}); diff --git a/tests/unit/SequentialQueueTest.ts b/tests/unit/SequentialQueueTest.ts index b50ad2370616..cb2ffef20e2a 100644 --- a/tests/unit/SequentialQueueTest.ts +++ b/tests/unit/SequentialQueueTest.ts @@ -1,6 +1,7 @@ import {resolveOpenAppDuplicationConflictAction, resolveReconnectDuplicationConflictAction} from '@libs/actions/RequestConflictUtils'; import {isClientTheLeader} from '@libs/ActiveClientManager'; import {WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; import * as NetworkState from '@libs/NetworkState'; import {clear as clearPersistedRequests, getAll, getLength, getOngoingRequest, updateOngoingRequest} from '@userActions/PersistedRequests'; @@ -418,6 +419,45 @@ describe('SequentialQueue', () => { } }); + it('should log a [Receipt] gaveUp line when a receipt-bearing request is abandoned after exhausting its retries', async () => { + await Onyx.set(ONYXKEYS.NETWORK, {shouldFailAllRequests: false, shouldForceOffline: false}); + await clearPersistedRequests(); + await waitForBatchedUpdates(); + + const offlineSpy = jest.spyOn(NetworkState, 'getIsOffline').mockReturnValue(false); + const sleepSpy = jest.spyOn(SequentialQueue.sequentialQueueRequestThrottle, 'sleep').mockRejectedValue(undefined); + const processSpy = jest.spyOn(RequestModule, 'processWithMiddleware').mockRejectedValue(new Error(CONST.ERROR.FAILED_TO_FETCH)); + const logAlertSpy = jest.spyOn(Log, 'alert').mockImplementation(() => {}); + + try { + const receiptRequest: Request = { + command: WRITE_COMMANDS.REQUEST_MONEY, + data: {transactionID: '5678', receipt: {source: 'file:///Receipts-Upload/receipt.jpg', receiptTraceId: 'trace-abandoned'}}, + }; + SequentialQueue.push(receiptRequest); + await waitForBatchedUpdates(); + + expect(logAlertSpy).toHaveBeenCalledWith( + expect.stringContaining('gaveUp'), + expect.objectContaining({ + event: 'gaveUp', + receiptTraceId: 'trace-abandoned', + transactionID: '5678', + command: WRITE_COMMANDS.REQUEST_MONEY, + }), + ); + + expect(sleepSpy).toHaveBeenCalled(); + + expect(getAll().length).toBe(0); + } finally { + offlineSpy.mockRestore(); + sleepSpy.mockRestore(); + processSpy.mockRestore(); + logAlertSpy.mockRestore(); + } + }); + it('should reset the shared throttle when the queue stops because the app went offline', async () => { const offlineSpy = jest.spyOn(NetworkState, 'getIsOffline').mockReturnValue(false); mockFetch.mockRejectedValue(new Error(CONST.ERROR.FAILED_TO_FETCH)); diff --git a/tests/unit/SidebarLinksTest.tsx b/tests/unit/SidebarLinksTest.tsx new file mode 100644 index 000000000000..07fde6da432d --- /dev/null +++ b/tests/unit/SidebarLinksTest.tsx @@ -0,0 +1,104 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; + +import SidebarLinks from '@pages/inbox/sidebar/SidebarLinks'; + +import ROUTES from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; + +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 null; + } + + return ReactModule.createElement('button', {testID: 'lhn-row', onPress: () => onSelectRow(firstOption)}, 'row'); + }; +}); + +let mockShouldUseNarrowLayout = true; +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout, isInLandscapeMode: false}), +})); + +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()})})); +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => [false]})); +jest.mock('@libs/actions/App', () => ({setSidebarLoaded: jest.fn()})); +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({hideContextMenu: jest.fn()})); +jest.mock('@libs/telemetry/activeSpans', () => ({cancelSpan: jest.fn()})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + getActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getTopmostReportActionId: jest.fn(() => undefined), + navigate: jest.fn(), + }, +})); + +const REPORT_ID = '123'; +const mockNavigation = jest.mocked(Navigation); + +function renderSidebarLinks() { + render( + , + ); +} + +describe('SidebarLinks showReportPage navigation guard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigation.getTopmostReportActionId.mockReturnValue(undefined); + }); + + // Regression test for the archived-chat dead tap: after opening a report, jumping to a Settings RHP, then + // returning to Inbox, the report stays topmost in the reports split stack. On narrow layout the sidebar is + // only shown on the Inbox screen (no report is displayed), so a tap must always navigate rather than be + // treated as re-opening the active report. + it('navigates from the sidebar on narrow layout even when the tapped report is still topmost', () => { + mockShouldUseNarrowLayout = true; + mockNavigation.getActiveRoute.mockReturnValue(`/${ROUTES.INBOX}`); + mockNavigation.getTopmostReportId.mockReturnValue(REPORT_ID); + + renderSidebarLinks(); + fireEvent.press(screen.getByTestId('lhn-row')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID, undefined)); + }); + + it('still blocks re-opening the active report on wide layout', () => { + mockShouldUseNarrowLayout = false; + mockNavigation.getActiveRoute.mockReturnValue(`/r/${REPORT_ID}`); + mockNavigation.getTopmostReportId.mockReturnValue(REPORT_ID); + + renderSidebarLinks(); + fireEvent.press(screen.getByTestId('lhn-row')); + + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('still blocks a quick second tap while a report is transitioning on narrow layout', () => { + mockShouldUseNarrowLayout = true; + mockNavigation.getActiveRoute.mockReturnValue('/r/999'); + mockNavigation.getTopmostReportId.mockReturnValue(undefined); + + renderSidebarLinks(); + fireEvent.press(screen.getByTestId('lhn-row')); + + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 27d54cb59572..db2d51ca0cd9 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -41,7 +41,7 @@ import {createSidebarReportsCollection, createSidebarTestData} from '../utils/co import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; import * as LHNTestUtils from '../utils/LHNTestUtils'; -import {convertToDisplayString, getCurrencyDecimalsLocal, localeCompare, translateLocal, formatPhoneNumber} from '../utils/TestHelper'; +import {convertToDisplayString, convertToDisplayStringWithoutCurrency, getCurrencyDecimalsLocal, localeCompare, translateLocal, formatPhoneNumber} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -380,6 +380,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: undefined, lastActionReport: undefined, @@ -390,6 +391,7 @@ describe('SidebarUtils', () => { reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); const optionDataUnpinned = SidebarUtils.getOptionData({ dateFnsLocale: undefined, @@ -405,6 +407,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: undefined, lastActionReport: undefined, @@ -415,6 +418,7 @@ describe('SidebarUtils', () => { reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(optionDataPinned?.isPinned).toBe(true); @@ -1532,6 +1536,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -1540,6 +1545,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); // Then the alternate text should be equal to the message of the last action prepended with the last actor display name. @@ -1601,6 +1607,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -1609,6 +1616,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); // Then the alternate text should be equal to the message of the last action prepended with the last actor display name. @@ -1645,6 +1653,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -1653,6 +1662,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed the custom tax name to "VAT" (previously "Sales Tax")'); @@ -1688,6 +1698,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -1696,6 +1707,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed the workspace currency default tax rate to "Reduced Rate" (previously "Standard Rate")'); @@ -1731,6 +1743,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -1739,6 +1752,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed the foreign currency default tax rate to "Foreign Tax (10%)" (previously "Foreign Tax (15%)")'); @@ -1780,6 +1794,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -1788,6 +1803,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")'); @@ -1823,6 +1839,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: enabledAction, lastActionReport: undefined, @@ -1831,6 +1848,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(enabledResult?.alternateText).toBe('enabled the company card purchases requirement'); @@ -1859,6 +1877,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: disabledAction, lastActionReport: undefined, @@ -1867,6 +1886,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(disabledResult?.alternateText).toBe('disabled the company card purchases requirement'); @@ -1902,6 +1922,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: enabledAction, lastActionReport: undefined, @@ -1910,6 +1931,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(enabledResult?.alternateText).toBe('enabled the expense categorization requirement'); @@ -1938,6 +1960,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: disabledAction, lastActionReport: undefined, @@ -1946,6 +1969,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(disabledResult?.alternateText).toBe('disabled the expense categorization requirement'); @@ -1987,6 +2011,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: action, lastActionReport: undefined, @@ -1995,6 +2020,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed the tax reclaimable portion on the distance rate "Default Rate" to "70%" (previously "50%")'); @@ -2030,6 +2056,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: enabledAction, lastActionReport: undefined, @@ -2038,6 +2065,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(enabledResult?.alternateText).toBe('enabled the expense tagging requirement'); @@ -2066,6 +2094,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: disabledAction, lastActionReport: undefined, @@ -2074,6 +2103,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(disabledResult?.alternateText).toBe('disabled the expense tagging requirement'); @@ -2108,6 +2138,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: companyPaysAction, lastActionReport: undefined, @@ -2116,6 +2147,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(companyPaysResult?.alternateText).toBe('updated the currency conversion fee setting to "Company pays"'); @@ -2143,6 +2175,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: employeePaysAction, lastActionReport: undefined, @@ -2151,6 +2184,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(employeePaysResult?.alternateText).toBe('updated the currency conversion fee setting to "Employee pays"'); @@ -2186,6 +2220,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, formatPhoneNumber, lastAction: enabledAction, @@ -2194,6 +2229,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, + rules: undefined, }); expect(enabledResult?.alternateText).toBe('enabled submissions'); @@ -2222,6 +2258,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, formatPhoneNumber, lastAction: disabledAction, @@ -2230,6 +2267,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, + rules: undefined, }); expect(disabledResult?.alternateText).toBe('disabled submissions'); @@ -2265,6 +2303,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2273,6 +2312,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('added card feed "Visa Commercial"'); @@ -2308,6 +2348,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2316,6 +2357,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('removed card feed "Amex Corporate"'); @@ -2351,6 +2393,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2359,6 +2402,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('renamed card feed to "New Feed" (previously "Old Feed")'); @@ -2394,6 +2438,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2402,6 +2447,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('assigned user@example.com "US Bank" company card ending in 1234'); @@ -2437,6 +2483,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2445,6 +2492,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('unassigned user@example.com "US Bank" company card ending in 5678'); @@ -2480,6 +2528,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2488,6 +2537,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('enabled cardholders to delete card transactions for card feed "Visa Commercial"'); @@ -2523,6 +2573,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2531,6 +2582,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed card feed "Visa Commercial" statement period end day to "15" (previously "20")'); @@ -2594,6 +2646,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -2602,6 +2655,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); // Then the alternate text should show @Hidden. @@ -2652,12 +2706,14 @@ describe('SidebarUtils', () => { lastAction: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(optionData?.alternateText).toBe(`test message`); @@ -2699,6 +2755,7 @@ describe('SidebarUtils', () => { lastAction: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, isReportArchived: true, lastActionReport: undefined, @@ -2707,6 +2764,7 @@ describe('SidebarUtils', () => { reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(optionData?.alternateText).toBe(`test message`); @@ -2745,12 +2803,14 @@ describe('SidebarUtils', () => { lastAction: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(optionData?.alternateText).toBe(`test message`); @@ -2888,6 +2948,7 @@ describe('SidebarUtils', () => { lastAction: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastActionReport: undefined, isReportArchived: undefined, @@ -2897,6 +2958,7 @@ describe('SidebarUtils', () => { formatPhoneNumber, reportAttributesDerived: mockReportAttributesDerived, + rules: undefined, }); expect(optionData?.alternateText).toBe(formatReportLastMessageText(iouReport.reportName)); @@ -2940,12 +3002,14 @@ describe('SidebarUtils', () => { lastAction: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(optionData?.alternateText).toBe(`${policy.name} ${CONST.DOT_SEPARATOR} test message`); @@ -3017,6 +3081,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -3024,6 +3089,7 @@ describe('SidebarUtils', () => { currentUserAccountID: session.accountID, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); // Then the alternate text should be equal to the message of the last action prepended with the last actor display name. @@ -3084,6 +3150,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -3091,6 +3158,7 @@ describe('SidebarUtils', () => { currentUserAccountID: session.accountID, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe(`You: moved this report to the Three's Workspace workspace`); @@ -3141,6 +3209,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: undefined, lastActionReport: undefined, @@ -3149,6 +3218,7 @@ describe('SidebarUtils', () => { currentUserAccountID: session.accountID, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('You: someMessage'); @@ -3236,6 +3306,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -3249,6 +3320,7 @@ describe('SidebarUtils', () => { }, }, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe(`You: ${getReportActionMessageText(lastAction)}`); @@ -3367,6 +3439,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -3375,6 +3448,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toContain(`${getReportActionMessageText(lastAction)}`); @@ -3460,6 +3534,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -3467,6 +3542,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe(`One: submitted`); @@ -3565,12 +3641,14 @@ describe('SidebarUtils', () => { lastAction: lastReportPreviewAction, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: managerID, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); const reportPreviewMessage = getReportPreviewReportActionMessage( @@ -3681,12 +3759,14 @@ describe('SidebarUtils', () => { lastAction: lastReportPreviewAction, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: managerID, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); const reportPreviewMessage = getReportPreviewReportActionMessage( @@ -3727,6 +3807,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: undefined, lastActionReport: undefined, @@ -3735,6 +3816,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); // Then isConciergeChat should be true @@ -3762,6 +3844,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: undefined, lastActionReport: undefined, @@ -3770,6 +3853,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); // Then isConciergeChat should be false @@ -3796,6 +3880,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction: undefined, lastActionReport: undefined, @@ -3804,6 +3889,7 @@ describe('SidebarUtils', () => { currentUserAccountID: 0, currentUserLogin: CURRENT_USER_LOGIN, formatPhoneNumber, + rules: undefined, }); // Then isConciergeChat should be false @@ -3848,6 +3934,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -3856,6 +3943,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); expect(result?.alternateText).toBe('changed the default spend category for "Airlines" to "Travel" (previously "Insurance")'); @@ -4649,6 +4737,7 @@ describe('SidebarUtils', () => { card: undefined, translate: translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, localeCompare, lastAction, lastActionReport: undefined, @@ -4657,6 +4746,7 @@ describe('SidebarUtils', () => { currentUserLogin: '', reportAttributesDerived: undefined, formatPhoneNumber, + rules: undefined, }); } diff --git a/tests/unit/StartupDataSpansTest.ts b/tests/unit/StartupDataSpansTest.ts index 2cf9a12fd15e..40bcc732fbb9 100644 --- a/tests/unit/StartupDataSpansTest.ts +++ b/tests/unit/StartupDataSpansTest.ts @@ -110,7 +110,7 @@ describe('StartupData.Apply / StartupData.Render placement', () => { it('records a single apply and render phase when a 407 forces reauthentication', () => { // handleExpiredSession retries by calling processWithMiddleware again from inside the still-pending // original call, so one login must not produce one pair of spans per attempt. - jest.mocked(reauthenticate).mockResolvedValue(true); + jest.mocked(reauthenticate).mockResolvedValue({wasSuccessful: true}); let openAppCalls = 0; mockFetch.mockAPICommand(WRITE_COMMANDS.OPEN_APP, () => { openAppCalls += 1; diff --git a/tests/unit/SuggestionMentionTest.tsx b/tests/unit/SuggestionMentionTest.tsx index 25e297c4e3d2..8d10ab162640 100644 --- a/tests/unit/SuggestionMentionTest.tsx +++ b/tests/unit/SuggestionMentionTest.tsx @@ -2,7 +2,7 @@ import {act, render, waitFor} from '@testing-library/react-native'; import type {TextSelection} from '@components/Composer/types'; import PlaceholderIcon from '@components/Icon/PlaceholderIcon'; -import type {Mention} from '@components/MentionSuggestions'; +import type Mention from '@components/MentionSuggestions/types'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -13,6 +13,8 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; +import {searchUserInServer} from '@libs/actions/Report'; + import SuggestionMention from '@pages/inbox/report/ReportActionCompose/SuggestionMention'; import CONST from '@src/CONST'; @@ -89,6 +91,10 @@ jest.mock('@hooks/useLazyAsset', () => ({ })); jest.mock('@hooks/useLocalize', () => jest.fn()); jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@libs/actions/Report', () => ({ + searchInServer: jest.fn(), + searchUserInServer: jest.fn(), +})); const mockUsePersonalDetails = jest.mocked(usePersonalDetails); const mockUseArrowKeyFocusManager = jest.mocked(useArrowKeyFocusManager); @@ -98,11 +104,11 @@ const mockUseDebounce = jest.mocked(useDebounce); const mockUseMemoizedLazyExpensifyIcons = jest.mocked(useMemoizedLazyExpensifyIcons); const mockUseLocalize = jest.mocked(useLocalize); const mockUsePolicy = jest.mocked(usePolicy); +const mockSearchUserInServer = jest.mocked(searchUserInServer); function renderSuggestionMention(value: string, updateComment = jest.fn(), selection: TextSelection = {start: value.length, end: value.length}) { const setSelection = jest.fn(); - - render( + const suggestionMention = ( , + /> ); + const {rerender} = render(suggestionMention); - return {setSelection, updateComment}; + return {setSelection, updateComment, rerender: () => rerender(React.cloneElement(suggestionMention))}; } function getLastMentionSuggestionsProps(): MentionSuggestionsProps { @@ -132,6 +139,7 @@ describe('SuggestionMention', () => { beforeEach(() => { mockMentionSuggestionsSpy.mockClear(); mockSetHighlightedMentionIndex.mockClear(); + mockSearchUserInServer.mockClear(); mockPersonalDetails = {}; mockUsePersonalDetails.mockImplementation(() => mockPersonalDetails); @@ -154,7 +162,94 @@ describe('SuggestionMention', () => { }); }); + it('searches for uncached users while typing an @ mention', async () => { + // Given an uncached user mention + const value = '@alice'; + + // When the mention suggestions are rendered + renderSuggestionMention(value); + + // Then the user is searched for on the server + await waitFor(() => expect(mockSearchUserInServer).toHaveBeenCalledWith('alice')); + }); + + it('does not search for the built-in @here mention', async () => { + // Given the built-in @here mention + const value = CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT; + + // When the mention suggestions are rendered + renderSuggestionMention(value); + + // Then suggestions are shown without searching the server + await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); + expect(mockSearchUserInServer).not.toHaveBeenCalled(); + }); + + it('does not search the server for a multi-word mention', () => { + // Given a rendered multi-word mention + renderSuggestionMention('@alice are'); + const calculateMentionSuggestions = mockUseDebounce.mock.calls.at(-1)?.[0]; + if (!calculateMentionSuggestions) { + throw new Error('Expected the mention calculation callback to be available'); + } + + // When the mention suggestions are calculated + act(() => calculateMentionSuggestions('@alice are', 10, 10)); + + // Then the server is not searched + expect(mockSearchUserInServer).not.toHaveBeenCalled(); + }); + + it('does not update mention state while typing ordinary text', () => { + // Given ordinary text with no active mention + renderSuggestionMention('hello friend'); + const calculateMentionSuggestions = mockUseDebounce.mock.calls.at(-1)?.[0]; + if (!calculateMentionSuggestions) { + throw new Error('Expected the mention calculation callback to be available'); + } + mockSetHighlightedMentionIndex.mockClear(); + + // When more ordinary text is typed + act(() => calculateMentionSuggestions('hello friends', 13, 13)); + + // Then the highlighted mention is not updated + expect(mockSetHighlightedMentionIndex).not.toHaveBeenCalled(); + }); + + it('does not search for the same user again when server results hydrate', async () => { + // Given an initial user search + const {rerender} = renderSuggestionMention('@alice'); + await waitFor(() => expect(mockSearchUserInServer).toHaveBeenCalledTimes(1)); + + // When the matching server result hydrates and suggestions are recalculated + mockPersonalDetails[2] = { + accountID: 2, + login: 'alice@example.com', + firstName: 'Alice', + lastName: 'Tester', + }; + rerender(); + + const calculateMentionSuggestions = mockUseDebounce.mock.calls.at(-1)?.[0]; + if (!calculateMentionSuggestions) { + throw new Error('Expected the mention calculation callback to be available'); + } + act(() => calculateMentionSuggestions('@alice', 6, 6)); + + // Then the hydrated result is shown without another server search + await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); + expect(getLastMentionSuggestionsProps().mentions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handle: 'alice@example.com', + }), + ]), + ); + expect(mockSearchUserInServer).toHaveBeenCalledTimes(1); + }); + it('shows user mention suggestions when prefix has a trailing dot', async () => { + // Given a matching personal detail and a mention prefix with a trailing dot mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -163,12 +258,15 @@ describe('SuggestionMention', () => { lastName: 'Tester', }; + // When the mention suggestions are rendered renderSuggestionMention('@a.'); + // Then the prefix is preserved and the matching user is suggested await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {prefix, mentions} = getLastMentionSuggestionsProps(); expect(prefix).toBe('a.'); + expect(mockSearchUserInServer).toHaveBeenCalledWith('a'); expect(mentions).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -180,6 +278,7 @@ describe('SuggestionMention', () => { }); it('preserves trailing punctuation dot and inserts trailing space when selected mention does not include dotted prefix', async () => { + // Given a mention with a trailing dot that is not part of the selected login mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -194,13 +293,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the mention is selected act(() => onSelect(0)); + // Then the dot is preserved and a trailing space is inserted expect(updateComment).toHaveBeenCalledWith('@adam@example.com. ', true); expect(setSelection).toHaveBeenCalledWith({start: 19, end: 19}); }); it('does not append an extra trailing dot and inserts trailing space when selected mention already matches dotted prefix', async () => { + // Given a mention with a trailing dot that is part of the selected login mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -215,13 +317,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the mention is selected act(() => onSelect(0)); + // Then no extra dot is added and a trailing space is inserted expect(updateComment).toHaveBeenCalledWith('@a.smith@example.com ', true); expect(setSelection).toHaveBeenCalledWith({start: 21, end: 21}); }); it('does not insert trailing space when mention is followed by a comma (punctuation)', async () => { + // Given a mention followed by punctuation mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -236,13 +341,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the mention is selected act(() => onSelect(0)); + // Then the punctuation remains directly after the mention expect(updateComment).toHaveBeenCalledWith('@adam@example.com, thanks', true); expect(setSelection).toHaveBeenCalledWith({start: 17, end: 17}); }); it('inserts trailing space when mention is followed by a regular word', async () => { + // Given a mention followed by a regular word mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -257,8 +365,10 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the mention is selected act(() => onSelect(0)); + // Then a space separates the mention from the following word expect(updateComment).toHaveBeenCalledWith('@adam@example.com thanks', true); expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); }); @@ -287,6 +397,7 @@ describe('SuggestionMention', () => { }; it('weights report participants above policy employees and everyone else for a group chat', async () => { + // Given a group chat with a report participant and a policy employee setupPersonalDetails(); const currentReportID = 'group1'; mockUseCurrentReportIDState.mockReturnValue({currentReportID, currentRHPReportID: ''}); @@ -300,8 +411,10 @@ describe('SuggestionMention', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`, report); }); + // When matching mention suggestions are rendered renderSuggestionMention('@u'); + // Then the report participant is weighted above the policy employee and everyone else await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {mentions} = getLastMentionSuggestionsProps(); @@ -309,6 +422,7 @@ describe('SuggestionMention', () => { }); it('weights details when the current report belongs to the active workspace', async () => { + // Given a workspace chat from the active workspace setupPersonalDetails(); const currentReportID = 'wsp1'; mockUseCurrentReportIDState.mockReturnValue({currentReportID, currentRHPReportID: ''}); @@ -323,8 +437,10 @@ describe('SuggestionMention', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`, report); }); + // When matching mention suggestions are rendered renderSuggestionMention('@u'); + // Then report participants and policy employees are weighted first await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {mentions} = getLastMentionSuggestionsProps(); @@ -332,6 +448,7 @@ describe('SuggestionMention', () => { }); it('skips weighting for a 1:1 DM and falls back to alphabetical order', async () => { + // Given a one-to-one direct message setupPersonalDetails(); const currentReportID = 'dm1'; mockUseCurrentReportIDState.mockReturnValue({currentReportID, currentRHPReportID: ''}); @@ -342,8 +459,10 @@ describe('SuggestionMention', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`, report); }); + // When matching mention suggestions are rendered renderSuggestionMention('@u'); + // Then the suggestions remain in alphabetical order await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {mentions} = getLastMentionSuggestionsProps(); @@ -352,6 +471,7 @@ describe('SuggestionMention', () => { }); it('preserves the first mention when a second mention is inserted before it', async () => { + // Given a complete mention after the active mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -372,13 +492,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then both mentions are preserved and separated by a space expect(updateComment).toHaveBeenCalledWith('@bob@example.com @adam@example.com ', true); expect(setSelection).toHaveBeenCalledWith({start: 17, end: 17}); }); it('preserves a trailing @here mention when inserting a new mention before it', async () => { + // Given an @here mention after the active mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -393,13 +516,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the trailing @here mention is preserved expect(updateComment).toHaveBeenCalledWith('@adam@example.com @here', true); expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); }); it('preserves a trailing phone number mention when inserting a new mention before it', async () => { + // Given a phone number mention after the active mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -414,13 +540,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the trailing phone number mention is preserved expect(updateComment).toHaveBeenCalledWith('@adam@example.com @+14404589784', true); expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); }); it('preserves a trailing private domain short mention when inserting a new mention before it', async () => { + // Given a private domain short mention after the active mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -444,13 +573,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the trailing private domain mention is preserved expect(updateComment).toHaveBeenCalledWith('@adam@example.com @charlie', true); expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); }); it('preserves a trailing #room mention when inserting a new mention before it', async () => { + // Given a room mention after the active mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -465,13 +597,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the trailing room mention is preserved expect(updateComment).toHaveBeenCalledWith('@adam@example.com #admins', true); expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); }); it('removes junk between the new mention and a trailing complete mention', async () => { + // Given junk between the active mention and a trailing complete mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -489,13 +624,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the junk is removed and the trailing mention is preserved expect(updateComment).toHaveBeenCalledWith('@alice@example.com @alice@example.com', true); expect(setSelection).toHaveBeenCalledWith({start: 19, end: 19}); }); it('removes junk between the new mention and a trailing private domain short mention', async () => { + // Given junk between the active mention and a trailing private domain mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -521,13 +659,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the junk is removed and the trailing private domain mention is preserved expect(updateComment).toHaveBeenCalledWith('@bob @charlie', true); expect(setSelection).toHaveBeenCalledWith({start: 5, end: 5}); }); it('removes junk between the new mention and a trailing phone number mention', async () => { + // Given junk between the active mention and a trailing phone number mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -544,13 +685,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the junk is removed and the trailing phone number mention is preserved expect(updateComment).toHaveBeenCalledWith('@adam@example.com @+14404589784', true); expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18}); }); it('removes junk between the new mention and a trailing room mention', async () => { + // Given junk between the active mention and a trailing room mention mockPersonalDetails = {}; mockPersonalDetails[2] = { accountID: 2, @@ -567,12 +711,16 @@ describe('SuggestionMention', () => { await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {onSelect} = getLastMentionSuggestionsProps(); + // When the active mention is selected act(() => onSelect(0)); + // Then the junk is removed and the trailing room mention is preserved expect(updateComment).toHaveBeenCalledWith('@alice@example.com #admins', true); expect(setSelection).toHaveBeenCalledWith({start: 19, end: 19}); }); + it('matches a phone contact when searching by unformatted digits', async () => { + // Given a phone contact with a formatted display name // The display name of a phone contact is the formatted number, so the raw login has to stay searchable. mockUseLocalize.mockImplementation(() => createMock>({...mockLocalize, formatPhoneNumber})); mockPersonalDetails = {}; @@ -582,8 +730,10 @@ describe('SuggestionMention', () => { displayName: '+18332403627@expensify.sms', }; + // When searching by unformatted digits renderSuggestionMention('@8332403627'); + // Then the phone contact is included in the suggestions await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); const {mentions} = getLastMentionSuggestionsProps(); diff --git a/tests/unit/TextVariantTest.tsx b/tests/unit/TextVariantTest.tsx index ce1756846a83..cfa9e3ecb998 100644 --- a/tests/unit/TextVariantTest.tsx +++ b/tests/unit/TextVariantTest.tsx @@ -3,8 +3,7 @@ import {render, screen} from '@testing-library/react-native'; import Text from '@components/Text'; import type {TextVariant} from '@styles/typography'; -import {textVariants} from '@styles/typography'; -import variables from '@styles/variables'; +import {fontScale, lineHeightScale, textVariants} from '@styles/typography'; import React from 'react'; @@ -59,8 +58,8 @@ describe('Text variant prop', () => { render(probe); expect(screen.getByText('probe')).toHaveStyle({ - fontSize: variables.fontSizeNormal, - lineHeight: variables.fontSizeNormalHeight, + fontSize: fontScale.text, + lineHeight: lineHeightScale.text, }); }); @@ -68,7 +67,7 @@ describe('Text variant prop', () => { render( probe , @@ -76,7 +75,7 @@ describe('Text variant prop', () => { expect(screen.getByText('probe')).toHaveStyle({ fontSize: textVariants.text.fontSize, - lineHeight: variables.lineHeightXLarge, + lineHeight: lineHeightScale.pageHeader, }); }); }); diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index 425533b4b8ea..8da19f90b7a2 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -127,6 +127,7 @@ const baseParams = { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, login: CURRENT_USER_EMAIL, areTransactionsLoaded: true, + rules: undefined, }; describe('TodosUtils', () => { diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 8683bd74768e..1cde3a4a8ac3 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -1,4 +1,3 @@ -import '@libs/Middleware/register'; import {fireEvent, render, screen} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; @@ -13,6 +12,7 @@ import type { TransactionReportGroupListItemType, } from '@components/Search/SearchList/ListItem/types'; +import registerMiddlewares from '@libs/Middleware/register'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import TransactionGroupListItem from '@src/components/Search/SearchList/ListItem/TransactionGroupListItem'; @@ -29,6 +29,8 @@ import type * as MockUsePaymentContextUtil from '../utils/mockUsePaymentContext' import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +registerMiddlewares(); + jest.mock('@libs/actions/Search', () => ({ search: jest.fn(), handleActionButtonPress: jest.fn(), @@ -39,8 +41,10 @@ jest.mock('@libs/SearchUIUtils', () => ({ isCorrectSearchUserName: jest.fn(() => true), getTableMinWidth: jest.fn(() => 0), getSuggestedSearches: jest.fn(() => ({})), - getSuggestedSearchesVisibility: jest.fn(() => ({topSpendersPolicyIDs: []})), + getSuggestedSearchesVisibility: jest.fn(() => ({shouldShowExpensifyCard: false})), + isTodoSearch: jest.fn(() => false), getSubmittedViolationsForTransaction: jest.fn(() => ''), + getViolationsForTransaction: jest.fn(() => ''), })); jest.mock('@react-navigation/native', () => ({ diff --git a/tests/unit/TransactionPreviewUtils.test.ts b/tests/unit/TransactionPreviewUtils.test.ts index 06abc3eb869c..f2891570c272 100644 --- a/tests/unit/TransactionPreviewUtils.test.ts +++ b/tests/unit/TransactionPreviewUtils.test.ts @@ -54,7 +54,6 @@ const basicProps = { transactionDetails: {}, isBillSplit: false, shouldShowRBR: false, - shouldShowCanceledStatus: false, isReportAPolicyExpenseChat: false, areThereDuplicates: false, currentUserEmail: '', @@ -256,18 +255,12 @@ describe('TransactionPreviewUtils', () => { expect(result.displayAmountText.text).toEqual(convertAmountToDisplayString(modifiedAmount, currency)); }); - it('does not show the canceled status inside a report preview, because the preview header already shows it', () => { - const functionArgs = {...basicProps, iouReport: {...basicProps.iouReport, isCancelledIOU: true}, originalTransaction: undefined, shouldShowCanceledStatus: false}; + it('does not show the canceled status, because a cancelled payment is only recorded by its system message', () => { + const functionArgs = {...basicProps, iouReport: {...basicProps.iouReport, isCancelledIOU: true}, originalTransaction: undefined}; const result = getTransactionPreviewTextAndTranslationPaths(functionArgs); expect(result.previewStatusText).toEqual([]); }); - it('shows the canceled status in a standalone preview, because nothing else on that surface reports it', () => { - const functionArgs = {...basicProps, iouReport: {...basicProps.iouReport, isCancelledIOU: true}, originalTransaction: undefined, shouldShowCanceledStatus: true}; - const result = getTransactionPreviewTextAndTranslationPaths(functionArgs); - expect(result.previewStatusText).toContainEqual({translationPath: 'iou.canceled'}); - }); - it('does not show the approved status when the report is approved, because it is redundant with the report status badge', () => { const functionArgs = { ...basicProps, @@ -538,12 +531,6 @@ describe('TransactionPreviewUtils', () => { expect(result.shouldShowDescription).toBeFalsy(); }); - it("should show tag if it's a policy expense chat and tag is present", () => { - const functionArgs = {...basicProps, isReportAPolicyExpenseChat: true, transactionDetails: {tag: 'Transport'}}; - const result = createTransactionPreviewConditionals(functionArgs); - expect(result.shouldShowTag).toBeTruthy(); - }); - it('should correctly show violation message if there are multiple violations', () => { const functionArgs = { ...basicProps, @@ -645,6 +632,48 @@ describe('TransactionPreviewUtils', () => { const result = createTransactionPreviewConditionals(functionArgs); expect(result.shouldShowRBR).toBeTruthy(); }); + + describe('with an over auto-approval limit notice', () => { + const overLimitPolicy = { + ...createRandomPolicy(1), + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + }; + // The notice arrives from the backend without `showInReview`, which is what keeps it out of the notice-type check. + const overLimitViolations = [{name: CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, type: CONST.VIOLATION_TYPES.NOTICE}] as TransactionViolation[]; + const submittedReport = { + ...basicProps.iouReport, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: overLimitPolicy.id, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: currentUserAccountID, + }; + + it('should show RBR when the submitter is also the approver', () => { + const functionArgs = { + ...basicProps, + policy: overLimitPolicy, + iouReport: {...submittedReport, managerID: currentUserAccountID}, + violations: overLimitViolations, + currentUserAccountID, + }; + const result = createTransactionPreviewConditionals(functionArgs); + expect(result.shouldShowRBR).toBeTruthy(); + }); + + it('should not show RBR when the submitter is not the approver', () => { + const functionArgs = { + ...basicProps, + policy: overLimitPolicy, + iouReport: {...submittedReport, managerID: currentUserAccountID + 1}, + violations: overLimitViolations, + currentUserAccountID, + }; + const result = createTransactionPreviewConditionals(functionArgs); + expect(result.shouldShowRBR).toBeFalsy(); + }); + }); }); }); @@ -990,6 +1019,45 @@ describe('TransactionPreviewUtils', () => { expect(transactionHasRBR(basicProps.transaction, violations, rbrEmail, rbrAccountID, expenseReport, undefined, paidGroupPolicy)).toBe(true); }); + it('should return true for an over auto-approval limit notice when the submitter is also the approver', () => { + const overLimitPolicy = { + ...createRandomPolicy(1), + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + }; + const submittedReport = { + ...basicProps.iouReport, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: overLimitPolicy.id, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: rbrAccountID, + managerID: rbrAccountID, + }; + // The notice arrives from the backend without `showInReview`, which is what keeps it out of the notice-type check. + const violations = [{name: CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, type: CONST.VIOLATION_TYPES.NOTICE}]; + expect(transactionHasRBR(basicProps.transaction, violations, rbrEmail, rbrAccountID, submittedReport, undefined, overLimitPolicy)).toBe(true); + }); + + it('should return false for an over auto-approval limit notice when the submitter is not the approver', () => { + const overLimitPolicy = { + ...createRandomPolicy(1), + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + }; + const submittedReport = { + ...basicProps.iouReport, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: overLimitPolicy.id, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: rbrAccountID, + managerID: rbrAccountID + 1, + }; + const violations = [{name: CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, type: CONST.VIOLATION_TYPES.NOTICE}]; + expect(transactionHasRBR(basicProps.transaction, violations, rbrEmail, rbrAccountID, submittedReport, undefined, overLimitPolicy)).toBe(false); + }); + it('should return true for a distance request with MODIFIED_AMOUNT violation', () => { const distanceTransaction = { ...basicProps.transaction, diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index fc791938f3ed..4bb0154a6f41 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -50,7 +50,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; type LegacyChangeTransactionsReportProps = Omit< Parameters[0], - 'transactions' | 'allTransactionViolation' | 'personalPolicyOutputCurrency' | 'selfDMReportActions' | 'delegateAccountID' | 'getCurrencyDecimals' | 'getCurrencySymbol' + 'transactions' | 'allTransactionViolation' | 'personalPolicyOutputCurrency' | 'selfDMReportActions' | 'delegateAccountID' | 'getCurrencyDecimals' | 'getCurrencySymbol' | 'rules' > & { allTransactions: OnyxCollection; transactionViolations?: OnyxCollection; @@ -88,6 +88,7 @@ function changeTransactionsReport({allTransactions, transactionIDs, transactionV delegateAccountID: undefined, getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, getCurrencySymbol: TestHelper.getCurrencySymbolLocal, + rules: undefined, ...rest, }); } @@ -2074,22 +2075,23 @@ describe('Transaction', () => { expect(updatedTransaction?.comment?.customUnit?.customUnitRateID).toBe(validRateID); }); - it('should not create MOVED_TRANSACTION action when moving expenses from a Draft report', async () => { + it('should not create MOVED_TRANSACTION action when moving expenses into a Draft report', async () => { const mockAPIWrite = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - const draftReport = { + const submittedReport = { ...createRandomReport(10, undefined), - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, currency: CONST.CURRENCY.USD, }; - const transaction = generateTransaction({reportID: draftReport.reportID}); + const transaction = generateTransaction({reportID: submittedReport.reportID}); const oldIOUAction = createIOUAction(transaction); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`, draftReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${draftReport.reportID}`, {[oldIOUAction.reportActionID]: oldIOUAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`, submittedReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${submittedReport.reportID}`, {[oldIOUAction.reportActionID]: oldIOUAction}); + // FAKE_NEW_REPORT_ID is an open (draft) report, which the backend never creates the moved message on const report = await getReportFromUseOnyx(FAKE_NEW_REPORT_ID); const allTransactions = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, @@ -2105,7 +2107,7 @@ describe('Transaction', () => { allTransactions, policyTagList: undefined, transactionViolations: {}, - reports: {[`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport}, + reports: {[`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`]: submittedReport}, isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2120,23 +2122,30 @@ describe('Transaction', () => { mockAPIWrite.mockRestore(); }); - it('should create MOVED_TRANSACTION action when moving expenses from a non-Draft report', async () => { + it('should create MOVED_TRANSACTION action when moving expenses into a non-Draft report', async () => { const mockAPIWrite = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - const submittedReport = { + const draftReport = { ...createRandomReport(11, undefined), + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + currency: CONST.CURRENCY.USD, + }; + const submittedDestinationReport = { + ...createRandomReport(12, undefined), stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, currency: CONST.CURRENCY.USD, }; - const transaction = generateTransaction({reportID: submittedReport.reportID}); + const transaction = generateTransaction({reportID: draftReport.reportID}); const oldIOUAction = createIOUAction(transaction); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`, submittedReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${submittedReport.reportID}`, {[oldIOUAction.reportActionID]: oldIOUAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`, draftReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${submittedDestinationReport.reportID}`, submittedDestinationReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${draftReport.reportID}`, {[oldIOUAction.reportActionID]: oldIOUAction}); - const report = await getReportFromUseOnyx(FAKE_NEW_REPORT_ID); + const report = await getReportFromUseOnyx(submittedDestinationReport.reportID); const allTransactions = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, }; @@ -2151,7 +2160,10 @@ describe('Transaction', () => { allTransactions, policyTagList: undefined, transactionViolations: {}, - reports: {[`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`]: submittedReport}, + reports: { + [`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport, + [`${ONYXKEYS.COLLECTION.REPORT}${submittedDestinationReport.reportID}`]: submittedDestinationReport, + }, isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2760,6 +2772,7 @@ describe('Transaction', () => { allTransactions, currentTransactionViolations: [{transactionID, violations: mockViolations}], isTrackIntentUser: false, + rules: undefined, }); await waitForBatchedUpdates(); @@ -2826,6 +2839,7 @@ describe('Transaction', () => { allTransactions, currentTransactionViolations: [{transactionID, violations: mockViolations}], isTrackIntentUser: false, + rules: undefined, }); await waitForBatchedUpdates(); @@ -2905,6 +2919,7 @@ describe('Transaction', () => { allTransactions: {[transactionKey]: staleTransaction}, currentTransactionViolations: [{transactionID, violations: mockViolations}], isTrackIntentUser: false, + rules: undefined, }); await waitForBatchedUpdates(); @@ -2944,6 +2959,7 @@ describe('Transaction', () => { isASAPSubmitBetaEnabled: false, allTransactions, isTrackIntentUser: false, + rules: undefined, }); await waitForBatchedUpdates(); @@ -3000,6 +3016,7 @@ describe('Transaction', () => { allTransactions, currentTransactionViolations: [{transactionID, violations: mockViolations}], isTrackIntentUser: false, + rules: undefined, }); await waitForBatchedUpdates(); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 0f3061e6a28f..33f73051a9e2 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -806,6 +806,58 @@ describe('TransactionUtils', () => { }, ); + it('does not apply a newly enabled commuter exclusion when a historical manual distance is changed', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 3, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + default: { + customUnitRateID: '1', + currency: CONST.CURRENCY.USD, + rate: 1, + }, + }, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }, + }; + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + comment: { + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + }, + currency: CONST.CURRENCY.USD, + }); + + const updatedTransaction = TransactionUtils.getUpdatedTransaction({ + transaction, + isFromExpenseReport: false, + policy: fakePolicy, + transactionChanges: {distance: 20}, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + expect(updatedTransaction.comment?.customUnit?.quantity).toBe(20); + expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBeUndefined(); + expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBeUndefined(); + expect(updatedTransaction.modifiedAmount).toBe(20); + }); + it('recalculates commuter exclusion data when an alternate route is selected', () => { const fakePolicy: Policy = { ...createRandomPolicy(0), @@ -888,6 +940,116 @@ describe('TransactionUtils', () => { expect(updatedTransaction.modifiedMerchant).not.toContain('20'); }); + it('converts commuter exclusion data when the distance rate unit is changed', () => { + // Given a policy with a 3 mile fixed distance commuter exclusion and a kilometer rate + const fakePolicy: Policy = { + ...createRandomPolicy(0), + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 3, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + // getMileageRates keys its result by the rates map key, so it must match customUnitRateID + ID1: { + customUnitRateID: '1', + currency: CONST.CURRENCY.EUR, + rate: 10, + }, + ID2: { + customUnitRateID: '2', + currency: CONST.CURRENCY.EUR, + rate: 30, + }, + }, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + }, + }, + }; + + // And a 10 km route stored as a rounded mile quantity + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'ID1', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 6.21, + routeDistanceMeters: 10000, + commuterExclusion: 3, + reimbursableDistance: 3.21, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + currency: CONST.CURRENCY.USD, + }); + + const updateRate = (transactionToUpdate: Transaction, policy: Policy | undefined, policies?: OnyxCollection) => + TransactionUtils.getUpdatedTransaction({ + transaction: transactionToUpdate, + isFromExpenseReport: false, + policy, + policies, + transactionChanges: {customUnitRateID: 'ID2'}, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // When the rate is changed + const updatedTransaction = updateRate(transaction, fakePolicy); + + // Then the original distance and commuter exclusion are converted to kilometers + expect(updatedTransaction.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + expect(updatedTransaction.comment?.customUnit?.quantity).toBe(10); + expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBeCloseTo(4.83); + expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBeCloseTo(5.17); + + // And the amount and merchant use the converted reimbursable distance at the kilometer rate + expect(updatedTransaction.modifiedAmount).toBe(155); + expect(updatedTransaction.modifiedMerchant).toBe('5.17 km @ €0.30 / km'); + + const manuallyOverriddenTransaction = { + ...transaction, + comment: {customUnit: {...transaction.comment?.customUnit, quantity: 8}}, + }; + const updatedManuallyOverriddenTransaction = updateRate(manuallyOverriddenTransaction, fakePolicy); + + expect(updatedManuallyOverriddenTransaction.comment?.customUnit?.quantity).toBe(12.87); + expect(updatedManuallyOverriddenTransaction.comment?.customUnit?.reimbursableDistance).toBeCloseTo(8.04); + expect(updatedManuallyOverriddenTransaction.modifiedAmount).toBe(241); + expect(updatedManuallyOverriddenTransaction.modifiedMerchant).toBe('8.04 km @ €0.30 / km'); + + const transactionWithoutAppliedCommuterExclusion = { + ...transaction, + comment: { + customUnit: { + ...transaction.comment?.customUnit, + commuterExclusion: undefined, + reimbursableDistance: undefined, + commuterExclusionMethod: undefined, + }, + }, + }; + expect(updateRate(transactionWithoutAppliedCommuterExclusion, fakePolicy).comment?.customUnit?.quantity).toBe(9.99); + + const policies = {[`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`]: fakePolicy}; + expect(updateRate(transaction, undefined, policies).comment?.customUnit?.quantity).toBe(10); + expect(updateRate(manuallyOverriddenTransaction, undefined, policies).comment?.customUnit?.quantity).toBe(12.87); + + const legacyTransaction = { + ...transaction, + comment: {customUnit: {...transaction.comment?.customUnit, routeDistanceMeters: undefined, quantity: 10}}, + }; + expect(updateRate(legacyTransaction, fakePolicy).comment?.customUnit?.quantity).toBe(16.09); + }); + it('threads personalPolicyOutputCurrency into the recalculated rate for a P2P distance expense with no policy', async () => { // A P2P distance expense (FAKE_P2P_ID) has no policy rate, so the mileage currency comes from the // resolved personal-policy currency that getUpdatedTransaction forwards to getRate. getRateForP2P only @@ -2431,6 +2593,44 @@ describe('TransactionUtils', () => { TransactionUtils.hasNoticeTypeViolation(transaction, visibleNoticeViolations, CURRENT_USER_EMAIL, CURRENT_USER_ID, processingReport, CURRENT_USER_EMAIL, policy, true), ).toBe(true); }); + + it('should not hide missing category violation for invoice report even when category is being analyzed', () => { + const invoiceReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.INVOICE, + }; + + const policy: Policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + const transaction = generateTransaction({ + category: '', + merchant: 'Some Merchant', + amount: 100, + reportID: invoiceReport.reportID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }); + + expect(TransactionUtils.shouldShowViolation(invoiceReport, policy, CONST.VIOLATIONS.MISSING_CATEGORY, 'test@example.com', CURRENT_USER_ID, true, transaction)).toBe(true); + }); + + it('should hide missing category violation for expense report when category is being analyzed', () => { + const expenseReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const policy: Policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + const transaction = generateTransaction({ + category: '', + merchant: 'Some Merchant', + amount: 100, + reportID: expenseReport.reportID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }); + + expect(TransactionUtils.shouldShowViolation(expenseReport, policy, CONST.VIOLATIONS.MISSING_CATEGORY, 'test@example.com', CURRENT_USER_ID, true, transaction)).toBe(false); + }); }); describe('getReportOwnerAsAttendee', () => { @@ -2820,7 +3020,7 @@ describe('TransactionUtils', () => { describe('isCategoryBeingAnalyzed', () => { it('should return false for undefined transaction', () => { - expect(TransactionUtils.isCategoryBeingAnalyzed(undefined)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(undefined, undefined)).toBe(false); }); it('should return false when category is not missing', () => { @@ -2828,7 +3028,7 @@ describe('TransactionUtils', () => { category: 'Food', }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(false); }); it('should return false for partial transactions (empty merchant and zero amount)', () => { @@ -2839,7 +3039,7 @@ describe('TransactionUtils', () => { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(false); }); it('should return true when pendingAction is ADD and category is missing', () => { @@ -2850,7 +3050,7 @@ describe('TransactionUtils', () => { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(true); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(true); }); it('should return true when within auto-categorization grace period', () => { @@ -2867,7 +3067,7 @@ describe('TransactionUtils', () => { }, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(true); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(true); }); it('should return false when auto-categorization grace period has passed', () => { @@ -2885,7 +3085,7 @@ describe('TransactionUtils', () => { }, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(false); }); it('should return false when pendingAutoCategorizationTime is invalid', () => { @@ -2899,7 +3099,7 @@ describe('TransactionUtils', () => { }, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(false); }); it('should return false when category is Uncategorized but no pending action or auto-categorization', () => { @@ -2910,7 +3110,7 @@ describe('TransactionUtils', () => { pendingAction: undefined, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(false); }); it('should return false for unreported expenses', () => { @@ -2922,25 +3122,41 @@ describe('TransactionUtils', () => { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, undefined)).toBe(false); }); - it('should return false for invoice expenses', async () => { - const invoiceReportID = 'invoice123'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${invoiceReportID}`, { - reportID: invoiceReportID, - type: CONST.REPORT.TYPE.INVOICE, + it('should return true for expense report with pendingAction ADD', () => { + const expenseReport = { + reportID: 'expense123', + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const transaction = generateTransaction({ + category: '', + merchant: 'Some Merchant', + amount: 100, + reportID: expenseReport.reportID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, expenseReport)).toBe(true); + }); + + it('should return false for invoice expenses', () => { + const invoiceReport = { + reportID: 'invoice123', + type: CONST.REPORT.TYPE.INVOICE, + }; + const transaction = generateTransaction({ category: '', merchant: 'Some Merchant', amount: 100, - reportID: invoiceReportID, + reportID: invoiceReport.reportID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); - expect(TransactionUtils.isCategoryBeingAnalyzed(transaction)).toBe(false); + expect(TransactionUtils.isCategoryBeingAnalyzed(transaction, invoiceReport)).toBe(false); }); }); @@ -5278,6 +5494,77 @@ describe('showHeldExpensesBlockModal', () => { }); }); +describe('shouldSplitScanFailedTransactions', () => { + const report = {...createRandomReport(1), type: CONST.REPORT.TYPE.EXPENSE, currency: 'USD'} as Report; + const scanFailedTransaction = generateTransaction({ + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, source: 'receipt.jpg'}, + }); + const scanFailedTransactionWithAmount = generateTransaction({ + amount: -5000, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, source: 'receipt.jpg'}, + }); + const validTransaction = generateTransaction({merchant: 'Valid merchant'}); + + it('returns true when a scan-failed expense can be moved out and another expense stays behind', () => { + expect(TransactionUtils.shouldSplitScanFailedTransactions([validTransaction, scanFailedTransaction], report)).toBe(true); + }); + + it('returns false when every expense in the report is scan-failed', () => { + expect(TransactionUtils.shouldSplitScanFailedTransactions([scanFailedTransaction], report)).toBe(false); + }); + + it('returns false when the report has no scan-failed expense', () => { + expect(TransactionUtils.shouldSplitScanFailedTransactions([validTransaction], report)).toBe(false); + }); + + it('returns false for an empty report', () => { + expect(TransactionUtils.shouldSplitScanFailedTransactions([], report)).toBe(false); + }); + + it('returns false when the scan-failed expense has an amount, because the backend leaves it in the report', () => { + expect(TransactionUtils.shouldSplitScanFailedTransactions([validTransaction, scanFailedTransactionWithAmount], report)).toBe(false); + }); +}); + +describe('isScanFailedTransactionMovedOnPayment', () => { + const report = {...createRandomReport(1), type: CONST.REPORT.TYPE.EXPENSE, currency: 'USD'} as Report; + const buildScanFailedTransaction = (values: Partial) => + generateTransaction({ + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, source: 'receipt.jpg'}, + ...values, + }); + + it('returns true when both the merchant and the amount are unset', () => { + expect(TransactionUtils.isScanFailedTransactionMovedOnPayment(buildScanFailedTransaction({}), report)).toBe(true); + }); + + it('returns false when the expense has an amount', () => { + expect(TransactionUtils.isScanFailedTransactionMovedOnPayment(buildScanFailedTransaction({amount: -5000}), report)).toBe(false); + }); + + it('returns false when the expense has a modified amount', () => { + expect(TransactionUtils.isScanFailedTransactionMovedOnPayment(buildScanFailedTransaction({modifiedAmount: -5000}), report)).toBe(false); + }); + + it('returns false when the expense has a merchant', () => { + expect(TransactionUtils.isScanFailedTransactionMovedOnPayment(buildScanFailedTransaction({modifiedMerchant: 'Modified merchant'}), report)).toBe(false); + }); + + it('returns false when the scan did not fail', () => { + expect( + TransactionUtils.isScanFailedTransactionMovedOnPayment(buildScanFailedTransaction({receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, source: 'receipt.jpg'}}), report), + ).toBe(false); + }); +}); + describe('getSelectedRouteDistance', () => { const routes: Routes = { route0: {distance: 1000, geometry: {type: 'LineString', coordinates: [[0, 0]]}}, @@ -5431,4 +5718,48 @@ describe('getDistanceInMeters', () => { const transaction = generateTransaction({comment: {selectedRouteKey: 'route1'}, routes: {route0: routes.route0}}); expect(TransactionUtils.getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(1000); }); + + it('falls back to routeDistanceMeters when the routes are gone and the quantity is not written yet', () => { + const transaction = generateTransaction({ + comment: {customUnit: {quantity: 0, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, routeDistanceMeters: 4680656}}, + routes: undefined, + }); + expect(TransactionUtils.getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(4680656); + }); + + it('prefers the quantity over routeDistanceMeters once the quantity is written', () => { + const transaction = generateTransaction({ + comment: {customUnit: {quantity: 10, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, routeDistanceMeters: 4680656}}, + routes: undefined, + }); + expect(TransactionUtils.getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).not.toBe(4680656); + }); +}); + +describe('getReservationNights', () => { + const originalTimezone = process.env.TZ; + + afterEach(() => { + process.env.TZ = originalTimezone; + }); + + it('returns 0 when the receipt has no reservation dates', () => { + expect(TransactionUtils.getReservationNights(generateTransaction({receipt: {}}))).toBe(0); + expect(TransactionUtils.getReservationNights(generateTransaction({receipt: {hotelReservationStartDate: '2026-03-01'}}))).toBe(0); + }); + + it('returns 0 when the reservation ends on or before it starts', () => { + expect(TransactionUtils.getReservationNights(generateTransaction({receipt: {hotelReservationStartDate: '2026-03-06', hotelReservationEndDate: '2026-03-06'}}))).toBe(0); + expect(TransactionUtils.getReservationNights(generateTransaction({receipt: {hotelReservationStartDate: '2026-03-06', hotelReservationEndDate: '2026-03-01'}}))).toBe(0); + }); + + it('counts the calendar days between check-in and check-out', () => { + expect(TransactionUtils.getReservationNights(generateTransaction({receipt: {hotelReservationStartDate: '2026-03-01', hotelReservationEndDate: '2026-03-06'}}))).toBe(5); + }); + + it('counts a one-night stay that crosses a DST change west of UTC', () => { + // Los Angeles falls back on 2026-11-01, so anchoring these dates to UTC would put check-out an hour before check-in + process.env.TZ = 'America/Los_Angeles'; + expect(TransactionUtils.getReservationNights(generateTransaction({receipt: {hotelReservationStartDate: '2026-11-01', hotelReservationEndDate: '2026-11-02'}}))).toBe(1); + }); }); diff --git a/tests/unit/TranslateTest.ts b/tests/unit/TranslateTest.ts index 068d98f5d9fc..3e7b6c20508e 100644 --- a/tests/unit/TranslateTest.ts +++ b/tests/unit/TranslateTest.ts @@ -1,16 +1,27 @@ /* eslint-disable @typescript-eslint/naming-convention */ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import de from '@src/languages/de'; import en from '@src/languages/en'; import es from '@src/languages/es'; import flattenObject from '@src/languages/flattenObject'; +import fr from '@src/languages/fr'; +import itTranslations from '@src/languages/it'; +import ja from '@src/languages/ja'; import type {FlatTranslationsObject} from '@src/languages/types'; +import zhHans from '@src/languages/zh-hans'; import {translate} from '@src/libs/Localize'; import asMutable from '@src/types/utils/asMutable'; import arrayDifference from '@src/utils/arrayDifference'; jest.mock('@src/languages/IntlStore'); +// The unit-test environment does not register this native module, but CONFIG imports it during test initialization. +jest.mock('@expensify/react-native-hybrid-app', () => ({ + __esModule: true, + default: {isHybridApp: jest.fn(() => false)}, +})); + const originalTranslations = { [CONST.LOCALES.EN]: flattenObject(en), [CONST.LOCALES.ES]: flattenObject(es), @@ -46,6 +57,37 @@ describe('TranslateTest', () => { expect(en.workspace.accounting.disconnectPrompt(connectionName)).toBe('Are you sure you want to disconnect Intuit Enterprise Suite?'); }); + it('only names a fallback vendor when the integration auto-creates one', () => { + // QBO card exports and Intacct credit-card charges auto-create the vendor, and its name follows the card type. + expect(en.workspace.accounting.defaultVendorHelperText(false, CONST.NON_REIMBURSABLE_FALLBACK_VENDOR_NAME.CREDIT_CARD)).toBe( + "Expenses that don't auto-match will default to this vendor. Otherwise, they'll export as Credit Card Misc.", + ); + expect(en.workspace.accounting.defaultVendorHelperText(false, CONST.NON_REIMBURSABLE_FALLBACK_VENDOR_NAME.DEBIT_CARD)).toBe( + "Expenses that don't auto-match will default to this vendor. Otherwise, they'll export as Debit Card Misc.", + ); + + // Xero posts card expenses as bank transactions and never creates a stand-in vendor, so it passes no name. + expect(en.workspace.accounting.defaultVendorHelperText(false)).toBe("Expenses that don't auto-match will default to this vendor."); + expect(en.workspace.accounting.defaultVendorHelperText(true, CONST.NON_REIMBURSABLE_FALLBACK_VENDOR_NAME.CREDIT_CARD)).toBe( + "Expenses that don't auto-match will default to this vendor.", + ); + }); + + it('uses the QBO fallback vendor name and locale punctuation in default-vendor helper text', () => { + const creditCardFallbackVendorName = CONST.NON_REIMBURSABLE_FALLBACK_VENDOR_NAME.CREDIT_CARD; + + expect(de.workspace.accounting.defaultVendorHelperText(false, creditCardFallbackVendorName)).toContain('„Credit Card Misc.“'); + expect(de.workspace.accounting.defaultVendorHelperText(false, creditCardFallbackVendorName)).toMatch(/\.$/); + expect(fr.workspace.accounting.defaultVendorHelperText(false, creditCardFallbackVendorName)).toContain('Credit Card Misc.'); + expect(itTranslations.workspace.accounting.defaultVendorHelperText(false, creditCardFallbackVendorName)).toContain('Credit Card Misc.'); + expect(ja.workspace.accounting.defaultVendorHelperText(false, creditCardFallbackVendorName)).toBe( + '自動照合されない経費はデフォルトでこのベンダーに割り当てられます。それ以外は「Credit Card Misc.」としてエクスポートされます。', + ); + expect(zhHans.workspace.accounting.defaultVendorHelperText(false, creditCardFallbackVendorName)).toBe( + '未自动匹配的报销将默认归属于此供应商。否则,它们将按“Credit Card Misc.”导出。', + ); + }); + it('Test when count value passed to function but output is string', () => { const expectedValue = 'Count value is 10'; const count = 10; diff --git a/tests/unit/TripReservationUtilsTest.ts b/tests/unit/TripReservationUtilsTest.ts index 5a2243a2a042..08f8852404ff 100644 --- a/tests/unit/TripReservationUtilsTest.ts +++ b/tests/unit/TripReservationUtilsTest.ts @@ -2,6 +2,7 @@ import {formatTransitLocationLabel, getAirReservations, getPNRReservationDataFromTripReport, getReservationsFromTripReport, isPnrCancelled} from '@libs/TripReservationUtils'; import CONST from '@src/CONST'; +import type {ReportNameValuePairs} from '@src/types/onyx'; import type {Pnr, TripData} from '@src/types/onyx/TripData'; import {airReservationPnrData, airReservationTravelers} from '../data/TripAirReservationData'; @@ -2458,18 +2459,20 @@ describe('TripReservationUtils', () => { describe('getReservationsFromTripReport', () => { it('should return an empty array when there are no transactions and trip payload', () => { const report = createRandomReport(1, undefined); - const result = getReservationsFromTripReport(report, []); + const reportNameValuePairs: ReportNameValuePairs = {}; + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toEqual([]); }); it('should return reservations from tripPayload', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: tripWithAllReservations, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(7); expect(result.at(0)?.reservation.reservationID).toEqual('PNR_AIR_789'); expect(result.at(1)?.reservation.reservationID).toEqual('PNR_RAIL_789'); @@ -2479,14 +2482,14 @@ describe('TripReservationUtils', () => { expect(result.at(5)?.reservation.reservationID).toEqual('PNR_AIR_CONNECTING_789'); expect(result.at(6)?.reservation.reservationID).toEqual('PNR_AIR_CONNECTING_789'); - report.tripData = { + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, pnrs: [hotelPnr], }, }; - const resultWithSingleReservation = getReservationsFromTripReport(report, []); + const resultWithSingleReservation = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(resultWithSingleReservation).toHaveLength(1); expect(resultWithSingleReservation.at(0)?.reservation.reservationID).toEqual('PNR_HOTEL_789'); @@ -2507,7 +2510,8 @@ describe('TripReservationUtils', () => { }; const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2515,7 +2519,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(3); const cancelledReservation = result.find((r) => r.reservation.reservationID === 'PNR_HOTEL_CANCELLED'); expect(cancelledReservation?.isCancelled).toBe(true); @@ -2536,7 +2540,8 @@ describe('TripReservationUtils', () => { }; const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2544,7 +2549,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(3); const cancelledReservation = result.find((r) => r.reservation.reservationID === 'PNR_CAR_CANCELLED'); expect(cancelledReservation?.isCancelled).toBe(true); @@ -2574,7 +2579,8 @@ describe('TripReservationUtils', () => { }; const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2582,7 +2588,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(2); expect(result.every((r) => r.isCancelled)).toBe(true); }); @@ -2591,7 +2597,8 @@ describe('TripReservationUtils', () => { describe('cityName mapping', () => { it('should set hotel cityName to address.locality, not chainName', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2599,7 +2606,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(1); const hotelReservation = result.at(0)?.reservation; @@ -2611,7 +2618,8 @@ describe('TripReservationUtils', () => { it('should set car cityName to pickup and dropoff location locality', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2619,7 +2627,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(1); const carReservation = result.at(0)?.reservation; @@ -2630,7 +2638,8 @@ describe('TripReservationUtils', () => { it('should preserve flight cityName in "CityName, StateCode, CountryName" format', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2638,7 +2647,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(1); const flightReservation = result.at(0)?.reservation; @@ -2649,7 +2658,8 @@ describe('TripReservationUtils', () => { it('should preserve train cityName as clean city name', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2657,7 +2667,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(1); const trainReservation = result.at(0)?.reservation; @@ -2668,12 +2678,13 @@ describe('TripReservationUtils', () => { it('should set correct cityName for all reservation types in a mixed trip', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: tripWithAllReservations, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); const hotelReservation = result.find((r) => r.reservation.type === CONST.RESERVATION_TYPE.HOTEL); expect(hotelReservation?.reservation.start?.cityName).toEqual('New York'); @@ -2716,7 +2727,8 @@ describe('TripReservationUtils', () => { }; const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2724,7 +2736,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(1); const trainReservation = result.at(0)?.reservation; @@ -2743,7 +2755,8 @@ describe('TripReservationUtils', () => { it('should preserve a clean station code in rail shortName', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, @@ -2751,7 +2764,7 @@ describe('TripReservationUtils', () => { }, }; - const result = getReservationsFromTripReport(report, []); + const result = getReservationsFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(1); const trainReservation = result.at(0)?.reservation; @@ -2764,18 +2777,20 @@ describe('TripReservationUtils', () => { describe('getPNRReservationDataFromTripReport', () => { it('should return an empty array when there are no transactions and trip payload', () => { const report = createRandomReport(1, undefined); - const result = getPNRReservationDataFromTripReport(report, []); + const reportNameValuePairs: ReportNameValuePairs = {}; + const result = getPNRReservationDataFromTripReport(report, reportNameValuePairs, []); expect(result).toEqual([]); }); it('should return PNR reservation data from tripPayload', () => { const report = createRandomReport(1, undefined); - report.tripData = { + const reportNameValuePairs: ReportNameValuePairs = {}; + reportNameValuePairs.tripData = { tripID: 'trip123', payload: tripWithAllReservations, }; - const result = getPNRReservationDataFromTripReport(report, []); + const result = getPNRReservationDataFromTripReport(report, reportNameValuePairs, []); expect(result).toHaveLength(5); expect(result.at(0)?.pnrID).toEqual('PNR_AIR_789'); expect(result.at(1)?.pnrID).toEqual('PNR_RAIL_789'); @@ -2783,14 +2798,14 @@ describe('TripReservationUtils', () => { expect(result.at(3)?.pnrID).toEqual('PNR_HOTEL_789'); expect(result.at(4)?.pnrID).toEqual('PNR_AIR_CONNECTING_789'); - report.tripData = { + reportNameValuePairs.tripData = { tripID: 'trip123', payload: { ...basicTripData, pnrs: [airPnrConnecting], }, }; - const resultWithSingleReservation = getPNRReservationDataFromTripReport(report, []); + const resultWithSingleReservation = getPNRReservationDataFromTripReport(report, reportNameValuePairs, []); expect(resultWithSingleReservation).toHaveLength(1); expect(resultWithSingleReservation.at(0)?.pnrID).toEqual('PNR_AIR_CONNECTING_789'); diff --git a/tests/unit/UserSelectionListItemTest.tsx b/tests/unit/UserSelectionListItemTest.tsx index 2e421e3b6347..09e4ba9fa47b 100644 --- a/tests/unit/UserSelectionListItemTest.tsx +++ b/tests/unit/UserSelectionListItemTest.tsx @@ -1,11 +1,13 @@ -import {render} from '@testing-library/react-native'; +import {render, screen} from '@testing-library/react-native'; +import SelectableListItem from '@components/SelectionList/ListItem/SelectableListItem'; import type {ListItem} from '@components/SelectionList/ListItem/types'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; import React from 'react'; +import {View} from 'react-native'; const mockTranslate = jest.fn((path: string) => path); const mockFormatPhoneNumber = jest.fn((value: string) => value); @@ -28,6 +30,7 @@ jest.mock('@libs/ReportUtils', () => { }); const mockGetDisplayNameForParticipant = jest.mocked(getDisplayNameForParticipant); +const mockSelectableListItem = jest.mocked(SelectableListItem); const ITEM_ACCOUNT_ID = 424242; @@ -51,4 +54,33 @@ describe('UserSelectionListItem', () => { // The row's display name resolves via getDisplayNameForParticipant, which must receive the translate from useLocalize. expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ITEM_ACCOUNT_ID, translate: mockTranslate})); }); + + describe('user handle', () => { + // Render the row content so the handle text is reachable; the mock from the module factory renders nothing by default. + beforeEach(() => { + mockSelectableListItem.mockImplementation(({children}) => {typeof children === 'function' ? children(false) : children}); + }); + + 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) => { + const item: ListItem = { + keyForList: 'user-item', + accountID: ITEM_ACCOUNT_ID, + login, + }; + + render( + , + ); + + expect(screen.getByText(expectedHandle)).toBeOnTheScreen(); + }); + }); }); diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index 0835ddafff7c..30a21fbc78fe 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -207,6 +207,22 @@ describe('validateAttachmentFile', () => { expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); }); + it.each(['folder', 'receipts.pdf'])('returns FOLDER_NOT_ALLOWED for a receipt directory named %s', async (name) => { + const mockItem = createMock({ + kind: 'file' as const, + webkitGetAsEntry: jest.fn(() => createMock({isDirectory: true})), + }); + + const file = createMockFile(name, 0); + const error = await validateAttachmentFile(file, mockItem, true); + + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + }); + it('returns valid result when DataTransferItem is not a directory', async () => { const mockItem = createMock({ kind: 'file' as const, diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index d0128dd6c15e..302520102bb4 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -1,8 +1,6 @@ import {mapFormFieldsToRuleForAPI, mapFormFieldsToRuleForOnyx} from '@libs/actions/Policy/Rules'; import {getMerchantCodingRulesTableData} from '@libs/MerchantTypeRulesUtils'; -import {hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; - -import {getRuleDescription} from '@pages/workspace/rules/MerchantRulesSection'; +import {hasVendorFeature} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -19,20 +17,21 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; * A minimal merchant rule form. Individual tests override only the fields they exercise, so the * mappers are validated against a realistic full form rather than a hand-picked subset. */ -const buildForm = (overrides: Partial = {}): MerchantRuleForm => - ({ - merchantToMatch: 'Coffee Shop', - matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - merchant: '', - category: '', - tag: '', - tax: '', - vendorID: '', - comment: '', - reimbursable: false, - billable: false, - ...overrides, - }) as MerchantRuleForm; +const buildForm = (overrides: Partial = {}): MerchantRuleForm => ({ + merchantToMatch: 'Coffee Shop', + matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + merchant: '', + category: '', + categoriesToMatch: [], + ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, + tag: '', + tax: '', + vendorID: '', + comment: '', + reimbursable: false, + billable: false, + ...overrides, +}); /** QBO policy whose non-reimbursable export destination scopes vendor matching to QBO. */ const buildQBOPolicy = (vendors: Array<{id: string; name: string; currency: string}> | undefined): Policy => @@ -192,65 +191,6 @@ describe('Vendor matching on merchant rules', () => { }); }); - describe('legacy MerchantRulesSection.getRuleDescription vendor summary', () => { - beforeEach(() => { - IntlStore.load(CONST.LOCALES.EN); - return waitForBatchedUpdates(); - }); - - const buildLabels = (policy: Policy) => ({ - category: translateLocal('common.category').toLowerCase(), - tag: translateLocal('common.tag').toLowerCase(), - description: translateLocal('common.description').toLowerCase(), - tax: translateLocal('common.tax').toLowerCase(), - vendor: translateLocal(isXeroActiveMatchingSource(policy) ? 'common.supplier' : 'common.vendor').toLowerCase(), - }); - - const describeRule = (policy: Policy, vendorID: string) => getRuleDescription(buildVendorRule(vendorID), translateLocal, buildLabels(policy), policy); - - it('resolves the vendor name when the vendor is in the loaded list', () => { - const policy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); - expect(describeRule(policy, 'v-1')).toContain('Update vendor to "Acme Co"'); - }); - - it('shows "Vendor unavailable" when the list is loaded but the vendor is missing', () => { - expect(describeRule(buildQBOPolicy([]), 'v-1')).toContain('Update vendor to "Vendor unavailable"'); - }); - - it('preserves the raw external ID while the active vendor list is not hydrated', () => { - expect(describeRule(buildQBOPolicy(undefined), 'v-1')).toContain('Update vendor to "v-1"'); - }); - - it('renders "Vendor unavailable" when no matching integration remains', () => { - const description = describeRule(createRandomPolicy(0), 'v-1'); - expect(description).toContain('Update vendor to "Vendor unavailable"'); - expect(description).not.toContain('"v-1"'); - }); - - it('resolves the historical vendor name when the workspace has switched its export mode away from vendor-matching mode', () => { - const policy = buildQBOWithVendorBillExportPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); - expect(describeRule(policy, 'v-1')).toContain('Update vendor to "Acme Co"'); - }); - - it('shows "Vendor unavailable" when the vendorID only resolves against a stale/inactive connection', () => { - const policy = buildQBOWithStaleXeroPolicy([], {xeroVendor: {id: 'xeroVendor', name: 'Stale Xero Vendor', email: 'stale@example.com'}}); - const description = describeRule(policy, 'xeroVendor'); - expect(description).toContain('Update vendor to "Vendor unavailable"'); - expect(description).not.toContain('Stale Xero Vendor'); - }); - - it('uses "supplier" wording and "Supplier unavailable" on Xero workspaces', () => { - const resolved = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); - expect(describeRule(resolved, 'xc1')).toContain('Update supplier to "Acme Xero"'); - - const missing = buildXeroPolicy({}); - expect(describeRule(missing, 'xc1')).toContain('Update supplier to "Supplier unavailable"'); - - const pendingHydration = buildXeroPolicy(undefined); - expect(describeRule(pendingHydration, 'xc1')).toContain('Update supplier to "xc1"'); - }); - }); - describe('vendor row gating (hasVendorFeature governs MerchantRulePageBase row visibility)', () => { it('is visible when the beta is on and a vendor integration is connected', () => { expect(hasVendorFeature(buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), true)).toBe(true); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 59e3f7111ea3..1bb9239661df 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1062,6 +1062,200 @@ describe('getViolationsOnyxData', () => { const itemizedReceiptViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); expect(itemizedReceiptViolation).toBeUndefined(); }); + + describe('multi-day reservations are measured against the nightly rate', () => { + const getViolations = (): TransactionViolation[] => { + const result = ViolationsUtils.getViolationsOnyxData({ + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + return Array.isArray(result.value) ? result.value : []; + }; + + const findOverLimit = (violations: TransactionViolation[]) => violations.find((violation) => violation.name === CONST.VIOLATIONS.OVER_LIMIT); + + it('should add overLimit violation when the total amount exceeds the workspace limit, regardless of nightly average', () => { + // 5 nights: nightly average equals the limit, but the total is 5x over it + transaction.amount = -1000000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-06', + }; + policy.maxExpenseAmount = 200000; + + expect(findOverLimit(getViolations())).toBeDefined(); + }); + + it('should add overLimit violation for a one-night stay over the limit without a night count', () => { + transaction.amount = -200001; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-02', + }; + policy.maxExpenseAmount = 200000; + + const overLimit = findOverLimit(getViolations()); + expect(overLimit).toBeDefined(); + expect(overLimit?.data?.nights).toBeUndefined(); + }); + + it('should flag the full amount and omit the night count when the receipt has no reservation dates', () => { + transaction.amount = -1000000; + policy.maxExpenseAmount = 200000; + + const overLimit = findOverLimit(getViolations()); + expect(overLimit).toBeDefined(); + expect(overLimit?.data?.nights).toBeUndefined(); + }); + + it('should flag the full amount when only one of the two reservation dates is present', () => { + transaction.amount = -1000000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + }; + policy.maxExpenseAmount = 200000; + + const overLimit = findOverLimit(getViolations()); + expect(overLimit).toBeDefined(); + expect(overLimit?.data?.nights).toBeUndefined(); + }); + + it('should flag the full amount when the reservation ends on or before it starts', () => { + transaction.amount = -1000000; + policy.maxExpenseAmount = 200000; + + transaction.receipt = { + hotelReservationStartDate: '2026-03-06', + hotelReservationEndDate: '2026-03-06', + }; + const sameDay = findOverLimit(getViolations()); + expect(sameDay).toBeDefined(); + expect(sameDay?.data?.nights).toBeUndefined(); + + transaction.receipt = { + hotelReservationStartDate: '2026-03-06', + hotelReservationEndDate: '2026-03-01', + }; + const inverted = findOverLimit(getViolations()); + expect(inverted).toBeDefined(); + expect(inverted?.data?.nights).toBeUndefined(); + }); + + it('should keep using the full amount for the receipt-required threshold', () => { + // 5 nights, so the nightly average (200000) is under the threshold while the total (1000000) is over it + transaction.amount = -1000000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-06', + }; + policy.maxExpenseAmountNoReceipt = 500000; + + expect(getViolations().find((violation) => violation.name === CONST.VIOLATIONS.RECEIPT_REQUIRED)).toBeDefined(); + }); + + it('should keep using the full amount for the itemized-receipt-required threshold', () => { + // Same 5-night stay: the nightly average is under the threshold, the total is over it + transaction.amount = -1000000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-06', + }; + policy.maxExpenseAmountNoItemizedReceipt = 500000; + + expect(getViolations().find((violation) => violation.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED)).toBeDefined(); + }); + }); + + describe('multi-day reservations are measured against the category nightly rate', () => { + const getViolations = (): TransactionViolation[] => { + const result = ViolationsUtils.getViolationsOnyxData({ + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + return Array.isArray(result.value) ? result.value : []; + }; + + const findCategoryOverLimit = (violations: TransactionViolation[]) => violations.find((violation) => violation.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); + + beforeEach(() => { + transaction.category = 'Hotel'; + policyCategories = { + Hotel: { + name: 'Hotel', + enabled: true, + maxExpenseAmount: 150000, + }, + }; + }); + + it('should add overCategoryLimit violation when the nightly rate exceeds the category limit', () => { + // 5 nights: nightly average (200000) exceeds category limit (150000) + transaction.amount = -1000000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-06', + }; + + const violation = findCategoryOverLimit(getViolations()); + expect(violation).toBeDefined(); + expect(violation?.data?.nights).toBe(5); + }); + + it('should not add overCategoryLimit violation when the nightly rate is within the category limit', () => { + // 5 nights: nightly average (120000) is under category limit (150000), even though total (600000) exceeds it + transaction.amount = -600000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-06', + }; + + expect(findCategoryOverLimit(getViolations())).toBeUndefined(); + }); + + it('should flag the full amount and omit the night count when the receipt has no reservation dates', () => { + // No hotel dates: full amount (200000) is compared, exceeds category limit (150000) + transaction.amount = -200000; + + const violation = findCategoryOverLimit(getViolations()); + expect(violation).toBeDefined(); + expect(violation?.data?.nights).toBeUndefined(); + }); + + it('should replace a stale overCategoryLimit violation when the night count changes', () => { + // Existing violation recorded 3 nights; receipt now reflects a 5-night stay + transactionViolations = [ + { + name: CONST.VIOLATIONS.OVER_CATEGORY_LIMIT, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + data: {amount: 150000, currency: CONST.CURRENCY.USD, nights: 3}, + }, + ]; + transaction.amount = -1000000; + transaction.receipt = { + hotelReservationStartDate: '2026-03-01', + hotelReservationEndDate: '2026-03-06', + }; + + const violations = getViolations(); + const violation = findCategoryOverLimit(violations); + expect(violation).toBeDefined(); + expect(violation?.data?.nights).toBe(5); + expect(violations.filter((v) => v.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT)).toHaveLength(1); + }); + }); }); describe('policyCategoryRules', () => { @@ -3528,6 +3722,26 @@ describe('getViolationTranslation', () => { ); }); + describe('per-night over limit messages', () => { + const limit = {amount: 200000, currency: CONST.CURRENCY.USD}; + const formattedLimit = convertToDisplayString(limit.amount, limit.currency); + + it.each([[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT, 'violations.overCategoryLimitPerNight', 'violations.overCategoryLimit'] as const])( + 'should use the per-night copy for %s only when the violation carries a night count', + (name, perNightKey, defaultKey) => { + const withNights: TransactionViolation = {name, type: CONST.VIOLATION_TYPES.VIOLATION, data: {...limit, nights: 3}}; + const withoutNights: TransactionViolation = {name, type: CONST.VIOLATION_TYPES.VIOLATION, data: limit}; + + expect(ViolationsUtils.getViolationTranslation({dateFnsLocale: undefined, violation: withNights, translate: translateLocal, convertToDisplayString})).toBe( + translateLocal(perNightKey, formattedLimit), + ); + expect(ViolationsUtils.getViolationTranslation({dateFnsLocale: undefined, violation: withoutNights, translate: translateLocal, convertToDisplayString})).toBe( + translateLocal(defaultKey, formattedLimit), + ); + }, + ); + }); + it('should return the temporary retry-later message for a 531 broken card connection', async () => { IntlStore.load(CONST.LOCALES.EN); await waitForBatchedUpdates(); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index de9e6c8a7c28..c0138d1fec10 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -35,7 +35,7 @@ import type Rule from '@src/types/onyx/Rule'; import createRandomPolicy from '../utils/collections/policies'; import createMock from '../utils/createMock'; -import {buildPersonalDetails, convertToDisplayString, localeCompare, translateLocal} from '../utils/TestHelper'; +import {buildPersonalDetails, convertToDisplayString, formatPhoneNumber, localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const personalDetails: PersonalDetailsList = {}; @@ -1434,6 +1434,7 @@ describe('WorkflowUtils', () => { approver: undefined, currency: 'USD', translate: translateLocal, + formatPhoneNumber, convertToDisplayString, }); @@ -1447,6 +1448,7 @@ describe('WorkflowUtils', () => { approver, currency: 'USD', translate: translateLocal, + formatPhoneNumber, convertToDisplayString, }); @@ -1460,6 +1462,7 @@ describe('WorkflowUtils', () => { approver, currency: 'USD', translate: translateLocal, + formatPhoneNumber, convertToDisplayString, }); @@ -1473,6 +1476,7 @@ describe('WorkflowUtils', () => { approver, currency: 'USD', translate: translateLocal, + formatPhoneNumber, convertToDisplayString, }); @@ -1486,6 +1490,7 @@ describe('WorkflowUtils', () => { approver, currency: 'USD', translate: translateLocal, + formatPhoneNumber, convertToDisplayString, }); @@ -1503,6 +1508,7 @@ describe('WorkflowUtils', () => { approver, currency: 'USD', translate: translateLocal, + formatPhoneNumber, convertToDisplayString, }); @@ -1867,6 +1873,36 @@ describe('WorkflowUtils', () => { }); }); + describe('getRulesSubmitterToFirstApprover, excluding the default workflow', () => { + it('Should drop submitters routed to the default approver', () => { + // The default workflow is rule-backed once it has been edited, and its members must not read as + // "already in a workflow" when they are picked for a brand new one. + const rules = keyRules([...buildApprovalWorkflowRules(buildWorkflow([5], [1])), ...buildApprovalWorkflowRules(buildWorkflow([6], [2]))]); + + expect(getRulesSubmitterToFirstApprover(rules, {}, '1@example.com')).toEqual({'6@example.com': '2@example.com'}); + }); + + it('Should drop submitters whose multi-approver chain starts at the default approver', () => { + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); + + expect(getRulesSubmitterToFirstApprover(rules, {}, '1@example.com')).toEqual({}); + }); + + it('Should keep every submitter when none route to the default approver', () => { + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1]))); + + expect(getRulesSubmitterToFirstApprover(rules, {}, 'owner@example.com')).toEqual({'5@example.com': '1@example.com'}); + }); + + it('Should prefer what the rules declare over the first approver when they declare a default', () => { + // 5 is in the rule-backed default workflow. 6 is in a custom workflow starting at the same approver. + // Matching on the first approver alone would wrongly drop 6 along with 5. + const rules = keyRules([...buildApprovalWorkflowRules(buildWorkflow([5], [1], {isDefault: true})), ...buildApprovalWorkflowRules(buildWorkflow([6], [1, 2]))]); + + expect(getRulesSubmitterToFirstApprover(rules, {}, '1@example.com')).toEqual({'6@example.com': '1@example.com'}); + }); + }); + describe('getRulesSubmitterToWorkflowKey', () => { // Submitters 2 and 3 share the full chain 1 -> 8, while 4 shares only the first approver (1) but // then diverges to 9. The workflow key must group 2 and 3 together and set 4 apart, so callers can @@ -1963,8 +1999,71 @@ describe('WorkflowUtils', () => { expect(approvers.at(1)?.approvalLimit).toBeNull(); }); + it('Should keep a custom workflow that starts at the default approver separate from the default one', () => { + // Both chains start at approver 1 (the policy's default approver) and diverge after it. Only the + // chain whose rules declare it is the default. The other must render as its own workflow. + const rules = keyRules([...buildApprovalWorkflowRules(buildWorkflow([5], [1], {isDefault: true})), ...buildApprovalWorkflowRules(buildWorkflow([6], [1, 2]))]); + const employees: PolicyEmployeeList = { + '5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}, + '6@example.com': {email: '6@example.com', submitsTo: '1@example.com'}, + }; + const policy = createPolicy(employees, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); + + expect(approvalWorkflows).toHaveLength(2); + expect(approvalWorkflows.filter((workflow) => workflow.isDefault)).toHaveLength(1); + + const defaultWorkflow = approvalWorkflows.find((workflow) => workflow.isDefault); + expect(defaultWorkflow?.members.map((member) => member.email)).toEqual(['5@example.com']); + expect(defaultWorkflow?.approvers.map((approver) => approver.email)).toEqual(['1@example.com']); + + const customWorkflow = approvalWorkflows.find((workflow) => !workflow.isDefault); + expect(customWorkflow?.members.map((member) => member.email)).toEqual(['6@example.com']); + expect(customWorkflow?.approvers.map((approver) => approver.email)).toEqual(['1@example.com', '2@example.com']); + }); + + it('Should not treat an unmarked rule-based chain as the default just because it starts at the default approver', () => { + // The default workflow still lives in `employeeList`, and 6 has a rule-based chain that starts at + // the same approver but splits on an approval limit. Only the employeeList chain is the default. + const limitedApprover = buildApprover(1, {approvalLimit: 100, overLimitForwardsTo: '2@example.com'}); + const rules = keyRules(buildApprovalWorkflowRules({members: [buildMember(6)], approvers: [limitedApprover], isDefault: false})); + const employees: PolicyEmployeeList = { + '5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}, + '6@example.com': {email: '6@example.com', submitsTo: '1@example.com'}, + '1@example.com': {email: '1@example.com', submitsTo: '1@example.com'}, + }; + const policy = createPolicy(employees, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); + + expect(approvalWorkflows.filter((workflow) => workflow.isDefault)).toHaveLength(1); + + const customWorkflow = approvalWorkflows.find((workflow) => !workflow.isDefault); + expect(customWorkflow?.members.map((member) => member.email)).toEqual(['6@example.com']); + expect(customWorkflow?.approvers.at(0)?.overLimitForwardsTo).toBe('2@example.com'); + + // The default workflow keeps the members that route through `employeeList`, not the custom one. + const defaultWorkflow = approvalWorkflows.find((workflow) => workflow.isDefault); + expect(defaultWorkflow?.members.map((member) => member.email)).not.toContain('6@example.com'); + }); + + it('Should fall back to the default approver when no rule declares itself the default workflow', () => { + // Policies whose default workflow has never been saved through the rules backend have no such rule. + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1]))); + const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; + const policy = createPolicy(employees, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); + + expect(approvalWorkflows).toHaveLength(1); + expect(approvalWorkflows.at(0)?.isDefault).toBe(true); + }); + it('Should round-trip a workflow through rules and back', () => { - const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); + // Built as the default workflow so its rules declare themselves default, which the rebuilt rules + // must reproduce for the round-trip to be lossless. + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2], {isDefault: true}))); const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; const policy = createPolicy(employees, '1@example.com'); diff --git a/tests/unit/WorkspaceReportFieldUtilsTest.ts b/tests/unit/WorkspaceReportFieldUtilsTest.ts index f08e4e887682..06b0aa499eb0 100644 --- a/tests/unit/WorkspaceReportFieldUtilsTest.ts +++ b/tests/unit/WorkspaceReportFieldUtilsTest.ts @@ -1,6 +1,8 @@ -import {getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue, isReportFieldNameExisting} from '@libs/WorkspaceReportFieldUtils'; +import {getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue, isReportFieldImportedFromIntegration, isReportFieldNameExisting} from '@libs/WorkspaceReportFieldUtils'; -import type {PolicyReportField} from '@src/types/onyx/Policy'; +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName, PolicyReportField} from '@src/types/onyx/Policy'; import createMock from '../utils/createMock'; @@ -103,4 +105,55 @@ describe('WorkspaceReportFieldUtils.isReportFieldNameExisting', () => { expect(isReportFieldNameExisting(fieldList, 'FIELD1')).toBe(true); expect(isReportFieldNameExisting(fieldList, 'field1')).toBe(true); }); + + it('should return true across targets when no expectedTarget is passed, since the backend keys fields by name regardless of target', () => { + const mixedTargetFieldList: Record = { + invoiceField: createMock({name: 'Test', type: 'text', target: CONST.REPORT_FIELD_TARGETS.INVOICE}), + expenseField: createMock({name: 'Other', type: 'text', target: CONST.REPORT_FIELD_TARGETS.EXPENSE}), + }; + + expect(isReportFieldNameExisting(mixedTargetFieldList, 'Test')).toBe(true); + expect(isReportFieldNameExisting(mixedTargetFieldList, 'Other')).toBe(true); + }); +}); + +describe('WorkspaceReportFieldUtils.isReportFieldImportedFromIntegration', () => { + const reportFieldWithOrigin = (origin: string | undefined) => createMock({name: 'Field', type: 'text', origin}); + const policyConnectedTo = (...connectionNames: ConnectionName[]) => createMock({connections: Object.fromEntries(connectionNames.map((connectionName) => [connectionName, {}]))}); + + it('should return true for every origin an accounting integration stamps while that integration is connected', () => { + for (const connectionName of CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES) { + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin(CONST.POLICY.CONNECTIONS.REPORT_FIELD_ORIGIN[connectionName]), policyConnectedTo(connectionName))).toBe(true); + } + }); + + it('should return false once the integration that stamped the origin is no longer connected', () => { + const netSuiteField = reportFieldWithOrigin(CONST.POLICY.CONNECTIONS.REPORT_FIELD_ORIGIN[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]); + + // The workspace disconnected accounting entirely. + expect(isReportFieldImportedFromIntegration(netSuiteField, policyConnectedTo())).toBe(false); + expect(isReportFieldImportedFromIntegration(netSuiteField, undefined)).toBe(false); + + // The workspace swapped NetSuite out for a different integration, so the leftover NetSuite field is no longer imported. + expect(isReportFieldImportedFromIntegration(netSuiteField, policyConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBO))).toBe(false); + }); + + it('should return false for a connection name that is not the stamped origin', () => { + // The backend stamps `qbo`/`qbd`/`dualentry`, not the connection names, so these must not match. + const connectedPolicy = policyConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.POLICY.CONNECTIONS.NAME.QBD, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY); + + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin(CONST.POLICY.CONNECTIONS.NAME.QBO), connectedPolicy)).toBe(false); + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin(CONST.POLICY.CONNECTIONS.NAME.QBD), connectedPolicy)).toBe(false); + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY), connectedPolicy)).toBe(false); + }); + + it('should return false for a manually-created field, an origin from a non-integration automated action, or no field', () => { + const connectedPolicy = policyConnectedTo(CONST.POLICY.CONNECTIONS.NAME.NETSUITE); + + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin(undefined), connectedPolicy)).toBe(false); + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin(''), connectedPolicy)).toBe(false); + expect(isReportFieldImportedFromIntegration(reportFieldWithOrigin('automatedAction'), connectedPolicy)).toBe(false); + expect(isReportFieldImportedFromIntegration(undefined, connectedPolicy)).toBe(false); + expect(isReportFieldImportedFromIntegration(null, connectedPolicy)).toBe(false); + }); }); diff --git a/tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap b/tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap index 5c161a549ec4..8b5d810f6cf5 100644 --- a/tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap +++ b/tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap @@ -1,291 +1,291 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads ACTIONABLE_CARD_FRAUD_ALERT with resolution 1`] = `"cleared the suspicious activity and reactivated card x4444. All set to keep expensing!"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for ACTIONABLE_CARD_FRAUD_ALERT with resolution 1`] = `"cleared the suspicious activity and reactivated card x4444. All set to keep expensing!"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads ADD_INTEGRATION with connection name 1`] = `"connected to QuickBooks Online"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for ADD_INTEGRATION with connection name 1`] = `"connected to QuickBooks Online"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads CARD_ISSUED with card 1`] = `"issued @Hidden an Expensify Card! The card will arrive in 2-3 business days."`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for CARD_ISSUED with card 1`] = `"issued @Hidden an Expensify Card! The card will arrive in 2-3 business days."`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads CARD_ISSUED without card 1`] = `"issued @Hidden an Expensify Card! The card will arrive in 2-3 business days."`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for CARD_ISSUED without card 1`] = `"issued @Hidden an Expensify Card! The card will arrive in 2-3 business days."`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads DELETE_INTEGRATION with connection name 1`] = `"removed connection to Xero"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for DELETE_INTEGRATION with connection name 1`] = `"removed connection to Xero"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads MOVED_TRANSACTION with derived report name 1`] = `"moved this expense from another report"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for MOVED_TRANSACTION with derived report name 1`] = `"moved this expense from another report"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads OldDot action SELECTED_FOR_RANDOM_AUDIT (markdown leak preserved) 1`] = `"[randomly selected](https://help.expensify.com/articles/expensify-classic/reports/Set-a-random-report-audit-schedule) for review"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for OldDot action SELECTED_FOR_RANDOM_AUDIT (markdown leak preserved) 1`] = `"[randomly selected](https://help.expensify.com/articles/expensify-classic/reports/Set-a-random-report-audit-schedule) for review"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads POLICY remove with single target 1`] = `"Alice: removed 1 member"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for POLICY remove with single target 1`] = `"Alice: removed 1 member"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads REASSIGN_APPROVER with new approver 1`] = `"reassigned the approver to @Hidden via a workflow update"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for REASSIGN_APPROVER with new approver 1`] = `"reassigned the approver to @Hidden via a workflow update"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads RENAMED with old/new name 1`] = `"Alice renamed this room to "New room name" (previously "Old room name")"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for RENAMED with old/new name 1`] = `"Alice renamed this room to "New room name" (previously "Old room name")"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads REPORT_PREVIEW skips actor prefix and falls to else branch 1`] = `"owes $1.00"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for ROOM invite with targetAccountIDs and room name from originalMessage 1`] = `"Alice: invited 2 members to general"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads ROOM invite with targetAccountIDs and room name from originalMessage 1`] = `"Alice: invited 2 members to general"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for TRAVEL_UPDATE booking ticketed 1`] = `"Your flight LO 3925 (KRK → SFO) on 2024-03-01 10:00:00 has been booked. Confirmation code: ABC123"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads ROOM invite without targetAccountIDs falls back to mention-user count in lastMessageHtml 1`] = `"Alice: invited 2 members"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for UPDATE_EMPLOYEE with role change 1`] = `"updated the role of bob@test.com to workspace admin (previously member)"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads TRAVEL_UPDATE booking ticketed 1`] = `"Your flight LO 3925 (KRK → SFO) on 2024-03-01 10:00:00 has been booked. Confirmation code: ABC123"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for generic ADD_COMMENT from current user in room 1`] = `"You: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads UPDATE_EMPLOYEE with role change 1`] = `"updated the role of bob@test.com to workspace admin (previously member)"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot for invite with room name resolved from lastActionReport 1`] = `"Alice: invited 1 member to #target-room"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads generic ADD_COMMENT from current user in room 1`] = `"You: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot when REPORT_PREVIEW skips actor prefix and falls to else branch 1`] = `"owes $1.00"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads generic ADD_COMMENT in room gets actor prefix 1`] = `"Alice: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot when ROOM invite without targetAccountIDs falls back to mention-user count in lastMessageHtml 1`] = `"Alice: invited 2 members"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads generic branch resolves actor from person[0].text when personalDetails miss the actor 1`] = `"Mystery Person: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot when generic ADD_COMMENT in room gets actor prefix 1`] = `"Alice: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots branches with bespoke payloads invite with room name resolved from lastActionReport 1`] = `"Alice: invited 1 member to #target-room"`; +exports[`getOptionData alternateText parity snapshots branches with bespoke payloads should match snapshot when generic branch resolves actor from person[0].text when personalDetails miss the actor 1`] = `"Mystery Person: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants DM last message from current user has no actor prefix 1`] = `"You: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot for empty self-DM with isTrackIntentUser false 1`] = `"This is your personal space. Use it for notes, tasks, drafts, and reminders."`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants DM with last message shows actor prefix via shouldShowLastActorDisplayName 1`] = `"Alice: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot for empty self-DM with isTrackIntentUser true 1`] = `"This is your personal space. Use it for notes, tasks, drafts, and reminders."`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants SMS domain is stripped from last message 1`] = `"Alice: ping +15551234567 please"`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot for group chat with last comment 1`] = `"Alice: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants archived room skips the special-action chain 1`] = `"Fixture last message"`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when DM last message from current user has no actor prefix 1`] = `"You: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants empty DM falls back to welcome message (non-chat else branch) 1`] = `"This chat is with alice@test.com and test@example.com."`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when DM with last message shows actor prefix via shouldShowLastActorDisplayName 1`] = `"Alice: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants empty policy expense chat falls back to welcome message 1`] = `"This is where will submit expenses to Unavailable workspace. Just use the + button."`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when SMS domain is stripped from last message 1`] = `"Alice: ping +15551234567 please"`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants empty room falls back to welcome message 1`] = `"This chat is with Unavailable workspace admin. Use it to chat about workspace setup and more."`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when archived room skips the special-action chain 1`] = `"Fixture last message"`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants empty self-DM with isTrackIntentUser false 1`] = `"This is your personal space. Use it for notes, tasks, drafts, and reminders."`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when empty DM falls back to welcome message (non-chat else branch) 1`] = `"This chat is with alice@test.com and test@example.com."`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants empty self-DM with isTrackIntentUser true 1`] = `"This is your personal space. Use it for notes, tasks, drafts, and reminders."`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when empty policy expense chat falls back to welcome message 1`] = `"This is where will submit expenses to Unavailable workspace. Just use the + button."`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants group chat with last comment 1`] = `"Alice: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when empty room falls back to welcome message 1`] = `"This chat is with Unavailable workspace admin. Use it to chat about workspace setup and more."`; -exports[`getOptionData alternateText parity snapshots report-type and fallback variants multiline last message collapses line breaks 1`] = `"Alice: line one line two line three"`; +exports[`getOptionData alternateText parity snapshots report-type and fallback variants should match snapshot when multiline last message collapses line breaks 1`] = `"Alice: line one line two line three"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL 1`] = `"Alice: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL 1`] = `"Alice: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) COMPANYCARDCONNECTIONBROKEN 1`] = `"The connection is broken. To restore card imports, log into your bank."`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for COMPANYCARDCONNECTIONBROKEN 1`] = `"The connection is broken. To restore card imports, log into your bank."`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) DELEGATESUBMIT 1`] = `"Alice: Fixture last message"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for DELEGATESUBMIT 1`] = `"Alice: Fixture last message"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) INTEGRATIONSYNCFAILED 1`] = `"there was a problem syncing with . Please fix the issue in workspace settings."`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for INTEGRATIONSYNCFAILED 1`] = `"there was a problem syncing with . Please fix the issue in workspace settings."`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) LEAVEROOM 1`] = `"Alice: left the chat"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for LEAVEROOM 1`] = `"Alice: left the chat"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) PLAIDBALANCEFAILURE 1`] = `"the Plaid connection to your business bank account is broken. Please reconnect your bank account so you can continue to use your Expensify Cards."`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for PLAIDBALANCEFAILURE 1`] = `"the Plaid connection to your business bank account is broken. Please reconnect your bank account so you can continue to use your Expensify Cards."`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_AGENT_RULE 1`] = `"added an agent rule: "`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_AGENT_RULE 1`] = `"added an agent rule: "`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_APPROVER_RULE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_APPROVER_RULE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_BUDGET 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_BUDGET 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_CARD_FEED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_CARD_FEED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_CATEGORY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_CATEGORY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_CUSTOM_UNIT_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_CUSTOM_UNIT_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_EMPLOYEE 1`] = `"added as a member"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_EMPLOYEE 1`] = `"added as a member"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_EXPENSIFY_CARD_RULE 1`] = `"allowed on the card"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_EXPENSIFY_CARD_RULE 1`] = `"allowed on the card"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_REPORT_FIELD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_REPORT_FIELD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_RULE 1`] = `"added a rule"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_RULE 1`] = `"added a rule"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_TAG 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_TAG 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ADD_TAX 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ADD_TAX 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_ASSIGN_COMPANY_CARD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_ASSIGN_COMPANY_CARD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_CORPORATE_FORCE_UPGRADE 1`] = `"This workspace has been upgraded to the Control plan. Click here for more information."`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_CORPORATE_FORCE_UPGRADE 1`] = `"This workspace has been upgraded to the Control plan. Click here for more information."`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_CORPORATE_UPGRADE 1`] = `"upgraded this workspace to the Control plan"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_CORPORATE_UPGRADE 1`] = `"upgraded this workspace to the Control plan"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_AGENT_RULE 1`] = `"removed an agent rule"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_AGENT_RULE 1`] = `"removed an agent rule"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_ALL_TAGS 1`] = `"deleted all tags"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_ALL_TAGS 1`] = `"deleted all tags"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_APPROVER_RULE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_APPROVER_RULE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_BUDGET 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_BUDGET 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_CARD_FEED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_CARD_FEED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_CATEGORY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_CATEGORY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_CUSTOM_UNIT_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_CUSTOM_UNIT_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_CUSTOM_UNIT_SUB_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_CUSTOM_UNIT_SUB_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_EMPLOYEE 1`] = `"removed member "`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_EMPLOYEE 1`] = `"removed member "`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_REPORT_FIELD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_REPORT_FIELD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_TAG 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_TAG 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_DELETE_TAX 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_DELETE_TAX 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_IMPORT_CUSTOM_UNIT_RATES 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_IMPORT_CUSTOM_UNIT_RATES 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_IMPORT_TAGS 1`] = `"imported tags from a spreadsheet"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_IMPORT_TAGS 1`] = `"imported tags from a spreadsheet"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_INDIVIDUAL_BUDGET_NOTIFICATION 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_INDIVIDUAL_BUDGET_NOTIFICATION 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_LEAVE_POLICY 1`] = `"Alice: left the workspace"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_LEAVE_POLICY 1`] = `"Alice: left the workspace"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_LEAVEROOM 1`] = `"Alice: Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_LEAVEROOM 1`] = `"Alice: Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_REMOVE_EXPENSIFY_CARD_RULE 1`] = `"removed spend rule from the card"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_REMOVE_EXPENSIFY_CARD_RULE 1`] = `"removed spend rule from the card"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_REMOVE_RULE 1`] = `"removed a rule"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_REMOVE_RULE 1`] = `"removed a rule"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_RENAME_CARD_FEED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_RENAME_CARD_FEED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_SET_AUTO_JOIN 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_SET_AUTO_JOIN 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_SHARED_BUDGET_NOTIFICATION 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_SHARED_BUDGET_NOTIFICATION 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_TEAM_DOWNGRADE 1`] = `"downgraded this workspace to the Collect plan"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_TEAM_DOWNGRADE 1`] = `"downgraded this workspace to the Collect plan"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UNASSIGN_COMPANY_CARD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UNASSIGN_COMPANY_CARD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_ACH_ACCOUNT 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_ACH_ACCOUNT 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_ADDRESS 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_ADDRESS 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_AGENT_RULE 1`] = `"updated an agent rule to: "`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_AGENT_RULE 1`] = `"updated an agent rule to: "`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_APPROVER_RULE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_APPROVER_RULE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_AUDIT_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_AUDIT_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED 1`] = `"disabled auto-pay approved reports"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED 1`] = `"disabled auto-pay approved reports"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_AUTOHARVESTING 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_AUTOHARVESTING 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_AUTOREIMBURSEMENT 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_AUTOREIMBURSEMENT 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_BUDGET 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_BUDGET 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CARD_FEED_LIABILITY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CARD_FEED_LIABILITY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CARD_FEED_STATEMENT_PERIOD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CARD_FEED_STATEMENT_PERIOD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CATEGORIES 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CATEGORIES 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CATEGORY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CATEGORY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CATEGORY_TAX_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CATEGORY_TAX_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CURRENCY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CURRENCY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CURRENCY_DEFAULT_TAX 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CURRENCY_DEFAULT_TAX 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CUSTOM_TAX_NAME 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CUSTOM_TAX_NAME 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CUSTOM_UNIT 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CUSTOM_UNIT 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_SUB_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_SUB_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_DEFAULT_APPROVER 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_DEFAULT_APPROVER 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_DEFAULT_BILLABLE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_DEFAULT_BILLABLE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_DEFAULT_REIMBURSABLE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_DEFAULT_REIMBURSABLE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_DEFAULT_TITLE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_DEFAULT_TITLE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_DEFAULT_TITLE_ENFORCED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_DEFAULT_TITLE_ENFORCED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_DESCRIPTION 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_DESCRIPTION 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_EXPENSIFY_CARD_RULE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_EXPENSIFY_CARD_RULE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_FEATURE_ENABLED 1`] = `"disabled "`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_FEATURE_ENABLED 1`] = `"disabled "`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_FIELD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_FIELD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_FORWARDS_TO 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_FORWARDS_TO 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_GLOBAL_REIMBURSEMENTS_FX_PREFERENCE 1`] = `"updated the currency conversion fee setting to "Employee pays""`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_GLOBAL_REIMBURSEMENTS_FX_PREFERENCE 1`] = `"updated the currency conversion fee setting to "Employee pays""`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_INVOICE_COMPANY_NAME 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_INVOICE_COMPANY_NAME 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_INVOICE_COMPANY_WEBSITE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_INVOICE_COMPANY_WEBSITE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_IS_ATTENDEE_TRACKING_ENABLED 1`] = `"disabled attendee tracking"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_IS_ATTENDEE_TRACKING_ENABLED 1`] = `"disabled attendee tracking"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_MANUAL_APPROVAL_THRESHOLD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_MANUAL_APPROVAL_THRESHOLD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AGE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AGE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_MCC_GROUP_CATEGORY 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_MCC_GROUP_CATEGORY 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_NAME 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_NAME 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_OWNERSHIP 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_OWNERSHIP 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_PROHIBITED_EXPENSES 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_PROHIBITED_EXPENSES 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REIMBURSEMENT_ENABLED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REIMBURSEMENT_ENABLED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REIMBURSER 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REIMBURSER 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REPORT_FIELD 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REPORT_FIELD 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REQUIRE_COMPANY_CARDS_ENABLED 1`] = `"disabled the company card purchases requirement"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REQUIRE_COMPANY_CARDS_ENABLED 1`] = `"disabled the company card purchases requirement"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REQUIRES_CATEGORY 1`] = `"disabled the expense categorization requirement"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REQUIRES_CATEGORY 1`] = `"disabled the expense categorization requirement"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_REQUIRES_TAG 1`] = `"disabled the expense tagging requirement"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_REQUIRES_TAG 1`] = `"disabled the expense tagging requirement"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_RULE 1`] = `"updated a rule"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_RULE 1`] = `"updated a rule"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_SUBMITS_TO 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_SUBMITS_TO 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TAG 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TAG 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TAG_LIST 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TAG_LIST 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TAG_LIST_NAME 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TAG_LIST_NAME 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TAG_LIST_REQUIRED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TAG_LIST_REQUIRED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TAX 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TAX 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TIME_ENABLED 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TIME_ENABLED 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) POLICYCHANGELOG_UPDATE_TIME_RATE 1`] = `"Fixture message html"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for POLICYCHANGELOG_UPDATE_TIME_RATE 1`] = `"Fixture message html"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) REOPENED 1`] = `"reopened"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for REOPENED 1`] = `"reopened"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) REROUTE 1`] = `"changed the approver to @Hidden"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for REROUTE 1`] = `"changed the approver to @Hidden"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) RETRACTED 1`] = `"retracted"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for RETRACTED 1`] = `"retracted"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) SETTLEMENTACCOUNTLOCKED 1`] = `"business bank account has been automatically locked due to an issue with either Reimbursement or Expensify Card settlement. Please fix the issue in your workspace settings."`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for SETTLEMENTACCOUNTLOCKED 1`] = `"business bank account has been automatically locked due to an issue with either Reimbursement or Expensify Card settlement. Please fix the issue in your workspace settings."`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) TAKECONTROL 1`] = `"changed the approver to @Hidden"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for TAKECONTROL 1`] = `"changed the approver to @Hidden"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) TASKCOMPLETED 1`] = `"marked as complete"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for TASKCOMPLETED 1`] = `"marked as complete"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) UNREPORTEDTRANSACTION 1`] = `"moved this expense from another report"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for UNREPORTEDTRANSACTION 1`] = `"moved this expense from another report"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) UPDATEROOMAVATAR 1`] = `"removed the room avatar"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for UPDATEROOMAVATAR 1`] = `"removed the room avatar"`; -exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) UPDATEROOMDESCRIPTION 1`] = `"cleared the room description"`; +exports[`getOptionData alternateText parity snapshots special-action chain branches (policy room) should match snapshot for UPDATEROOMDESCRIPTION 1`] = `"cleared the room description"`; diff --git a/tests/unit/activeSpansSweepOrderTest.ts b/tests/unit/activeSpansSweepOrderTest.ts new file mode 100644 index 000000000000..cf629ed29eae --- /dev/null +++ b/tests/unit/activeSpansSweepOrderTest.ts @@ -0,0 +1,87 @@ +import {cancelAllSpans, cancelSpansByPrefix, getSpan, getUniqueSpanByPrefix, startSpan} from '@libs/telemetry/activeSpans'; + +import * as Sentry from '@sentry/react-native'; + +jest.mock('@sentry/react-native', () => { + const endOrder: string[] = []; + return { + startInactiveSpan: (options: {name?: string}) => ({ + name: options?.name, + attributes: {} as Record, + setAttribute(key: string, value: unknown) { + this.attributes[key] = value; + }, + setAttributes(attrs: Record) { + Object.assign(this.attributes, attrs); + }, + setStatus() {}, + end() { + endOrder.push(this.name ?? ''); + }, + }), + spanToJSON: (span: {attributes: Record}) => ({data: span.attributes}), + endOrder, + }; +}); + +function getEndOrder() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (Sentry as unknown as {endOrder: string[]}).endOrder; +} + +function startFamily(prefix: string) { + startSpan(`${prefix}Parent_1`, {name: `${prefix}Parent`}); + startSpan(`${prefix}ChildA_1`, {name: `${prefix}ChildA`}); + startSpan(`${prefix}ChildB_1`, {name: `${prefix}ChildB`}); +} + +afterEach(() => { + cancelAllSpans(); + getEndOrder().length = 0; +}); + +describe('cancelSpansByPrefix', () => { + it('ends children before their parent', () => { + startFamily('Manual'); + + cancelSpansByPrefix('Manual'); + + // Sentry drops a child still running when its parent ends. + expect(getEndOrder()).toEqual(['ManualChildB', 'ManualChildA', 'ManualParent']); + expect(getSpan('ManualParent_1')).toBeUndefined(); + expect(getSpan('ManualChildA_1')).toBeUndefined(); + expect(getSpan('ManualChildB_1')).toBeUndefined(); + }); + + it('leaves spans outside the prefix alone', () => { + startFamily('Manual'); + startSpan('OtherParent_1', {name: 'OtherParent'}); + + cancelSpansByPrefix('Manual'); + + expect(getEndOrder()).not.toContain('OtherParent'); + expect(getSpan('OtherParent_1')).toBeDefined(); + }); +}); + +describe('getUniqueSpanByPrefix', () => { + it('returns the only span matching the prefix', () => { + startSpan('ManualSendMessageVisible_1', {name: 'send-message-visible'}); + startSpan('ManualOther_1', {name: 'other'}); + + expect(getUniqueSpanByPrefix('ManualSendMessageVisible')).toBe(getSpan('ManualSendMessageVisible_1')); + }); + + it('returns nothing when several spans match, so an ambiguous parent is never picked', () => { + startSpan('ManualSendMessageVisible_1', {name: 'send-message-visible'}); + startSpan('ManualSendMessageVisible_2', {name: 'send-message-visible'}); + + expect(getUniqueSpanByPrefix('ManualSendMessageVisible')).toBeUndefined(); + }); + + it('returns nothing when no span matches', () => { + startSpan('ManualOther_1', {name: 'other'}); + + expect(getUniqueSpanByPrefix('ManualSendMessageVisible')).toBeUndefined(); + }); +}); diff --git a/tests/unit/canEditFieldOfMoneyRequestTest.ts b/tests/unit/canEditFieldOfMoneyRequestTest.ts index fbd5ec88a779..eb3150767126 100644 --- a/tests/unit/canEditFieldOfMoneyRequestTest.ts +++ b/tests/unit/canEditFieldOfMoneyRequestTest.ts @@ -4,7 +4,7 @@ import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; +import type {Policy, Report} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import Onyx from 'react-native-onyx'; @@ -110,6 +110,7 @@ describe('canEditFieldOfMoneyRequest', () => { const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, outstandingReportsByPolicyID, @@ -124,6 +125,7 @@ describe('canEditFieldOfMoneyRequest', () => { const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, outstandingReportsByPolicyID, @@ -140,12 +142,12 @@ describe('canEditFieldOfMoneyRequest', () => { }); await waitForBatchedUpdates(); - const canEditBillable = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.BILLABLE, transaction: moneyRequestTransaction}); + const canEditBillable = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.BILLABLE, transaction: moneyRequestTransaction, rules: undefined}); expect(canEditBillable).toBe(false); }); it('should return true for invoice report action when billable field is edited on an unapproved invoice report', () => { - const canEditBillable = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.BILLABLE, transaction: moneyRequestTransaction}); + const canEditBillable = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.BILLABLE, transaction: moneyRequestTransaction, rules: undefined}); expect(canEditBillable).toBe(true); }); }); @@ -244,8 +246,13 @@ describe('canEditFieldOfMoneyRequest', () => { // If it is the submitter of a distance request const distanceTransaction = {...moneyRequestTransaction, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE}; - const canEditReportFieldAmount = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, transaction: distanceTransaction}); - const canEditReportFieldCurrency = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.CURRENCY, transaction: distanceTransaction}); + const canEditReportFieldAmount = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, transaction: distanceTransaction, rules: undefined}); + const canEditReportFieldCurrency = canEditFieldOfMoneyRequest({ + reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.CURRENCY, + transaction: distanceTransaction, + rules: undefined, + }); // Then we should allow editing amount and currency fields. expect(canEditReportFieldAmount).toBe(true); @@ -269,6 +276,7 @@ describe('canEditFieldOfMoneyRequest', () => { // When the submitter tries to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, outstandingReportsByPolicyID, @@ -296,6 +304,7 @@ describe('canEditFieldOfMoneyRequest', () => { // When a user tries to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, outstandingReportsByPolicyID, @@ -329,6 +338,7 @@ describe('canEditFieldOfMoneyRequest', () => { // When trying to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, outstandingReportsByPolicyID, @@ -349,6 +359,7 @@ describe('canEditFieldOfMoneyRequest', () => { // When the submitter tries to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, outstandingReportsByPolicyID, @@ -416,7 +427,12 @@ describe('canEditFieldOfMoneyRequest', () => { }); await waitForBatchedUpdates(); - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: dewReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: dewTransaction}); + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction: dewReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: dewTransaction, + rules: undefined, + }); expect(canEditReceipt).toBe(false); }); @@ -439,7 +455,12 @@ describe('canEditFieldOfMoneyRequest', () => { }); await waitForBatchedUpdates(); - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: dewReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: dewTransaction}); + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction: dewReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: dewTransaction, + rules: undefined, + }); expect(canEditReceipt).toBe(true); }); @@ -466,7 +487,12 @@ describe('canEditFieldOfMoneyRequest', () => { }); await waitForBatchedUpdates(); - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: dewReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: dewTransaction}); + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction: dewReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: dewTransaction, + rules: undefined, + }); expect(canEditReceipt).toBe(true); }); @@ -490,6 +516,7 @@ describe('canEditFieldOfMoneyRequest', () => { await waitForBatchedUpdates(); const canDeleteReceipt = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: dewReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, isDeleteAction: true, @@ -529,6 +556,7 @@ describe('canEditFieldOfMoneyRequest', () => { await waitForBatchedUpdates(); const canDeleteReceipt = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: nonRequestorReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, isDeleteAction: true, @@ -539,6 +567,156 @@ describe('canEditFieldOfMoneyRequest', () => { }); }); + describe('receipt on an approved expense', () => { + const APPROVED_POLICY_ID = '55'; + const APPROVED_REPORT_ID = '66'; + const APPROVED_TRANSACTION_ID = '77'; + const EXPENSE_AMOUNT = 500; + + const approvedReportAction = { + ...createRandomReportAction(9), + reportID: APPROVED_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + originalMessage: { + IOUTransactionID: APPROVED_TRANSACTION_ID, + type: CONST.IOU.ACTION.CREATE, + amount: EXPENSE_AMOUNT, + currency: CONST.CURRENCY.USD, + }, + }; + + const approvedTransaction = { + ...createRandomTransaction(Number(APPROVED_TRANSACTION_ID)), + transactionID: APPROVED_TRANSACTION_ID, + reportID: APPROVED_REPORT_ID, + amount: EXPENSE_AMOUNT, + }; + + const memberPolicy: Policy = { + ...createRandomPolicy(Number(APPROVED_POLICY_ID), CONST.POLICY.TYPE.CORPORATE), + id: APPROVED_POLICY_ID, + role: CONST.POLICY.ROLE.USER, + }; + + const adminPolicy: Policy = {...memberPolicy, role: CONST.POLICY.ROLE.ADMIN}; + + const approvedReport = { + ...createExpenseReport(Number(APPROVED_REPORT_ID)), + policyID: APPROVED_POLICY_ID, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }; + + const setUpOnyx = async (reportPolicy: Policy, report: Report = approvedReport, reportAction = approvedReportAction) => { + const policyCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.POLICY, [reportPolicy], (p) => p.id); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${APPROVED_TRANSACTION_ID}`]: approvedTransaction, + [`${ONYXKEYS.COLLECTION.REPORT}${APPROVED_REPORT_ID}`]: report, + ...policyCollectionDataSet, + }); + await waitForBatchedUpdates(); + return reportAction; + }; + + afterEach(() => { + Onyx.clear(); + return waitForBatchedUpdates(); + }); + + it('should return true for an admin replacing a receipt on an approved report', async () => { + const reportAction = await setUpOnyx(adminPolicy); + + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: approvedTransaction, + rules: undefined, + }); + + expect(canEditReceipt).toBe(true); + }); + + it('should return false for the non-admin submitter replacing a receipt on an approved report', async () => { + const reportAction = await setUpOnyx(memberPolicy); + + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: approvedTransaction, + rules: undefined, + }); + + expect(canEditReceipt).toBe(false); + }); + + it('should return false for a non-admin manager replacing a receipt on an approved report', async () => { + const reportAction = await setUpOnyx(memberPolicy, { + ...approvedReport, + ownerAccountID: secondUserAccountID, + managerID: currentUserAccountID, + }); + + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: approvedTransaction, + rules: undefined, + }); + + expect(canEditReceipt).toBe(false); + }); + + it('should return false for an admin deleting a receipt on an approved report', async () => { + const reportAction = await setUpOnyx(adminPolicy); + + const canDeleteReceipt = canEditFieldOfMoneyRequest({ + reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + isDeleteAction: true, + transaction: approvedTransaction, + rules: undefined, + }); + + expect(canDeleteReceipt).toBe(false); + }); + + it('should return false for an admin replacing a receipt on a reimbursed report', async () => { + const reportAction = await setUpOnyx(adminPolicy, { + ...approvedReport, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + }); + + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: approvedTransaction, + rules: undefined, + }); + + expect(canEditReceipt).toBe(false); + }); + + it('should keep the other restricted fields locked for an admin on an approved report', async () => { + const reportAction = await setUpOnyx(adminPolicy); + + const restrictedFields = [ + CONST.EDIT_REQUEST_FIELD.AMOUNT, + CONST.EDIT_REQUEST_FIELD.CURRENCY, + CONST.EDIT_REQUEST_FIELD.MERCHANT, + CONST.EDIT_REQUEST_FIELD.DATE, + CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, + CONST.EDIT_REQUEST_FIELD.BILLABLE, + ]; + + for (const fieldToEdit of restrictedFields) { + expect(canEditFieldOfMoneyRequest({reportAction, fieldToEdit, transaction: approvedTransaction, rules: undefined})).toBe(false); + } + }); + }); + describe('legacy unreported expense (no report action)', () => { const LEGACY_TRANSACTION_ID = '777'; const LEGACY_CUSTOM_UNIT_ID = 'legacyPerDiemUnit'; @@ -597,6 +775,7 @@ describe('canEditFieldOfMoneyRequest', () => { it('should return true for a regular legacy unreported expense with no report action', () => { const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: undefined, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, transaction: legacyTransaction, @@ -610,6 +789,7 @@ describe('canEditFieldOfMoneyRequest', () => { await waitForBatchedUpdates(); const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: undefined, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, transaction: legacyPerDiemTransaction, @@ -623,6 +803,7 @@ describe('canEditFieldOfMoneyRequest', () => { await waitForBatchedUpdates(); const canEditReportField = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: undefined, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, transaction: legacyPerDiemTransaction, @@ -632,6 +813,7 @@ describe('canEditFieldOfMoneyRequest', () => { it('should return false for a legacy unreported expense when field is not REPORT', () => { const canEditDescription = canEditFieldOfMoneyRequest({ + rules: undefined, reportAction: undefined, fieldToEdit: CONST.EDIT_REQUEST_FIELD.DESCRIPTION, transaction: legacyTransaction, @@ -721,7 +903,12 @@ describe('canEditFieldOfMoneyRequest', () => { }); await waitForBatchedUpdates(); - const canEditReportField = canEditFieldOfMoneyRequest({reportAction: perDiemReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, transaction: perDiemTransaction}); + const canEditReportField = canEditFieldOfMoneyRequest({ + reportAction: perDiemReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + transaction: perDiemTransaction, + rules: undefined, + }); expect(canEditReportField).toBe(true); }); @@ -734,7 +921,12 @@ describe('canEditFieldOfMoneyRequest', () => { }); await waitForBatchedUpdates(); - const canEditReportField = canEditFieldOfMoneyRequest({reportAction: perDiemReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, transaction: perDiemTransaction}); + const canEditReportField = canEditFieldOfMoneyRequest({ + reportAction: perDiemReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + transaction: perDiemTransaction, + rules: undefined, + }); expect(canEditReportField).toBe(false); }); @@ -815,7 +1007,7 @@ describe('canEditFieldOfMoneyRequest', () => { await waitForBatchedUpdates(); // When the admin tries to edit the receipt field - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: moneyRequestTransaction}); + const canEditReceipt = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: moneyRequestTransaction, rules: undefined}); // Then they should not be able to edit the receipt on a closed report expect(canEditReceipt).toBe(false); @@ -836,7 +1028,7 @@ describe('canEditFieldOfMoneyRequest', () => { await waitForBatchedUpdates(); // When the admin tries to edit the receipt field - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: moneyRequestTransaction}); + const canEditReceipt = canEditFieldOfMoneyRequest({reportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction: moneyRequestTransaction, rules: undefined}); // Then they should be able to edit the receipt on an open report expect(canEditReceipt).toBe(true); diff --git a/tests/unit/canEditMultipleTransactionsTest.ts b/tests/unit/canEditMultipleTransactionsTest.ts index 8d4f24e75ce2..3de2540352ca 100644 --- a/tests/unit/canEditMultipleTransactionsTest.ts +++ b/tests/unit/canEditMultipleTransactionsTest.ts @@ -128,7 +128,7 @@ describe('canEditMultipleTransactions', () => { it('returns false when fewer than two transactions are selected', () => { const {transaction1, reports, policies, reportActions} = buildTestData(); - const result = canEditMultipleTransactions([transaction1], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1], reportActions, reports, policies, undefined); expect(result).toBe(false); }); @@ -136,7 +136,7 @@ describe('canEditMultipleTransactions', () => { it('returns false when any selected transaction has no editable fields', () => { const {transaction1, transaction2, reports, policies, reportActions} = buildTestData({disableSecondAction: true}); - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(false); }); @@ -152,7 +152,7 @@ describe('canEditMultipleTransactions', () => { reportAction2.originalMessage = {...reportAction2.originalMessage, type: actionType}; - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(false); }); @@ -166,7 +166,7 @@ describe('canEditMultipleTransactions', () => { IOUDetails: undefined, }; - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(false); }); @@ -179,7 +179,7 @@ describe('canEditMultipleTransactions', () => { deleted: '2024-01-01', }; - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(false); }); @@ -189,7 +189,7 @@ describe('canEditMultipleTransactions', () => { report2.statusNum = CONST.REPORT.STATUS_NUM.REIMBURSED; - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(false); }); @@ -197,7 +197,7 @@ describe('canEditMultipleTransactions', () => { it('returns false when selected entities are reports', () => { const {transaction1, transaction2, reports, policies, reportActions} = buildTestData(); - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, true); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined, true); expect(result).toBe(false); }); @@ -205,7 +205,7 @@ describe('canEditMultipleTransactions', () => { it('returns true when all selected transactions have an editable field', () => { const {transaction1, transaction2, reports, policies, reportActions} = buildTestData(); - const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(true); }); @@ -216,7 +216,7 @@ describe('canEditMultipleTransactions', () => { const unreportedTransaction1: Transaction = {...transaction1, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; const unreportedTransaction2: Transaction = {...transaction2, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; - const result = canEditMultipleTransactions([unreportedTransaction1, unreportedTransaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([unreportedTransaction1, unreportedTransaction2], reportActions, reports, policies, undefined); expect(result).toBe(true); }); @@ -226,7 +226,7 @@ describe('canEditMultipleTransactions', () => { const unreportedTransaction2: Transaction = {...transaction2, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; - const result = canEditMultipleTransactions([transaction1, unreportedTransaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, unreportedTransaction2], reportActions, reports, policies, undefined); expect(result).toBe(true); }); @@ -236,7 +236,7 @@ describe('canEditMultipleTransactions', () => { const splitTransaction: Transaction = {...transaction2, comment: {source: CONST.IOU.TYPE.SPLIT}}; - const result = canEditMultipleTransactions([transaction1, splitTransaction], reportActions, reports, policies); + const result = canEditMultipleTransactions([transaction1, splitTransaction], reportActions, reports, policies, undefined); expect(result).toBe(true); }); @@ -247,11 +247,11 @@ describe('canEditMultipleTransactions', () => { const splitTransaction: Transaction = {...transaction2, comment: {source: CONST.IOU.TYPE.SPLIT}}; // Unreported first, split second - const result = canEditMultipleTransactions([unreportedTransaction, splitTransaction], reportActions, reports, policies); + const result = canEditMultipleTransactions([unreportedTransaction, splitTransaction], reportActions, reports, policies, undefined); expect(result).toBe(true); // Split first, unreported second - const resultReversed = canEditMultipleTransactions([splitTransaction, unreportedTransaction], reportActions, reports, policies); + const resultReversed = canEditMultipleTransactions([splitTransaction, unreportedTransaction], reportActions, reports, policies, undefined); expect(resultReversed).toBe(true); }); @@ -262,7 +262,7 @@ describe('canEditMultipleTransactions', () => { report2.stateNum = CONST.REPORT.STATE_NUM.APPROVED; report2.statusNum = CONST.REPORT.STATUS_NUM.APPROVED; - const result = canEditMultipleTransactions([unreportedTransaction, transaction2], reportActions, reports, policies); + const result = canEditMultipleTransactions([unreportedTransaction, transaction2], reportActions, reports, policies, undefined); expect(result).toBe(false); }); }); diff --git a/tests/unit/cleanupPreMountedDraftReportsOrchestrationTest.ts b/tests/unit/cleanupPreMountedDraftReportsOrchestrationTest.ts new file mode 100644 index 000000000000..71c63bae77a3 --- /dev/null +++ b/tests/unit/cleanupPreMountedDraftReportsOrchestrationTest.ts @@ -0,0 +1,79 @@ +import cleanupPreMountedDraftReports from '@libs/cleanupPreMountedDraftReports'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('cleanupPreMountedDraftReports (connectWithoutView orchestration)', () => { + beforeEach(() => Onyx.clear()); + + it('removes an interrupted pre-mounted report and its stale marker when the draft still exists', async () => { + // Given a pre-mount interrupted before its draft was submitted + const reportID = '123'; + await Promise.all([ + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`, true), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`, {reportID} as Report), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID} as Report), + ]); + + // When startup cleanup reconciles stale pre-mount state + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then speculative data is removed because the draft remains authoritative + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + }); + + it('preserves a submitted report and only clears the marker when its draft no longer exists', async () => { + // Given a pre-mounted report whose draft disappeared after successful submission + const reportID = '456'; + await Promise.all([Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`, true), Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID} as Report)]); + + // When startup cleanup reconciles the leftover pre-mount marker + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then the real report survives because the missing draft signals completion + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual({reportID}); + }); + + it('is a no-op when there are no stale pre-mount markers', async () => { + // Given startup state without interrupted markers + const multiSetSpy = jest.spyOn(Onyx, 'multiSet'); + + // When pre-mount cleanup runs + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then Onyx is not rewritten because there is nothing to reconcile + expect(multiSetSpy).not.toHaveBeenCalled(); + multiSetSpy.mockRestore(); + }); + + it('handles multiple interrupted markers independently in a single pass', async () => { + // Given interrupted markers with both pending-draft and submitted outcomes + await Promise.all([ + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}withDraft`, true), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}withDraft`, {reportID: 'withDraft'} as Report), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}withDraft`, {reportID: 'withDraft'} as Report), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}submitted`, true), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}submitted`, {reportID: 'submitted'} as Report), + ]); + + // When startup cleanup reconciles the collection in one pass + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then each report follows its own draft state instead of sharing one outcome + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}withDraft`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}withDraft`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}submitted`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}submitted`)).toEqual({reportID: 'submitted'}); + }); +}); diff --git a/tests/unit/cleanupPreMountedDraftReportsTest.ts b/tests/unit/cleanupPreMountedDraftReportsTest.ts new file mode 100644 index 000000000000..1e7d0a09d3f3 --- /dev/null +++ b/tests/unit/cleanupPreMountedDraftReportsTest.ts @@ -0,0 +1,34 @@ +import {getPreMountedDraftReportCleanupData} from '@libs/cleanupPreMountedDraftReports'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +describe('getPreMountedDraftReportCleanupData', () => { + it('removes an interrupted pre-mounted report and its marker when the draft still exists', () => { + // Given a stale pre-mount marker whose original draft still exists + const reportID = '123'; + + // When cleanup data is derived after an interrupted pre-mount + // Then both speculative records are cleared because submission never completed + expect( + getPreMountedDraftReportCleanupData( + {[`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: true}, + {[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]: {reportID} as Report}, + ), + ).toEqual({ + [`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: null, + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + }); + }); + + it('preserves a submitted report when its draft no longer exists', () => { + // Given a stale pre-mount marker whose draft was removed by successful submission + const reportID = '123'; + + // When cleanup data is derived after the pre-mount completes + // Then only the marker is cleared because the report is now authoritative + expect(getPreMountedDraftReportCleanupData({[`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: true}, {})).toEqual({ + [`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: null, + }); + }); +}); diff --git a/tests/unit/components/BookTravelButtonTest.tsx b/tests/unit/components/BookTravelButtonTest.tsx index b2e1e805769c..c558c157cc76 100644 --- a/tests/unit/components/BookTravelButtonTest.tsx +++ b/tests/unit/components/BookTravelButtonTest.tsx @@ -5,16 +5,16 @@ import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import {cleanupTravelProvisioningSession, setTravelProvisioningNextStep} from '@libs/actions/Travel'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {cleanupTravelProvisioningSession, requestTravelAccess, setTravelProvisioningNextStep} from '@libs/actions/Travel'; import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; +import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -36,6 +36,14 @@ jest.mock('@libs/Navigation/Navigation', () => ({ getActiveRouteWithoutParams: jest.fn(() => ''), isNavigationReady: jest.fn(() => Promise.resolve()), goBack: jest.fn(), + runAfterTransition: jest.fn((callback: () => void) => { + callback(); + return {cancel: jest.fn()}; + }), + runAfterUpcomingTransition: jest.fn((callback: () => void) => { + callback(); + return {cancel: jest.fn()}; + }), }, })); @@ -92,6 +100,11 @@ const travelEnabledPolicy: Policy = { }, }; +const unprovisionedPolicy: Policy = { + ...provisionedPolicy, + travelSettings: undefined, +}; + const workspaceWithoutTravel: Policy = { ...createRandomPolicy(456, CONST.POLICY.TYPE.CORPORATE), id: DEFAULT_POLICY_ID, @@ -105,19 +118,22 @@ const workspaceWithoutTravel: Policy = { travelSettings: undefined, }; -const renderBookTravelButton = () => +const renderBookTravelButton = (shouldShowVerifyAccountModal = true) => render( - - - , + + + + + , ); -const seedOnyx = async (isValidated: boolean) => { +const seedOnyx = async (isValidated: boolean, policy: Policy = provisionedPolicy) => { await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, provisionedPolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: isValidated, primaryLogin: USER_LOGIN}); await Onyx.merge(ONYXKEYS.NVP_TRAVEL_SETTINGS, {hasAcceptedTerms: false}); await Onyx.merge(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {legalFirstName: 'Test', legalLastName: 'User'}); @@ -164,12 +180,72 @@ describe('BookTravelButton', () => { fireEvent.press(screen.getByText('Book a trip')); await waitForBatchedUpdatesWithAct(); - // Then it routes to verify-account instead of the stepper directly, recording the stepper as where to - // forward-navigate back to once validated (this avoids a URL blink from double-navigating through the - // stepper, which would otherwise immediately redirect to this same verify-account page anyway) - expect(setTravelProvisioningNextStep).toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); - expect(Navigation.navigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(POLICY_ID))); + // Then it routes to verify-account instead of the stepper directly + expect(Navigation.navigate).toHaveBeenCalledWith(expect.stringContaining('verify-account')); expect(Navigation.navigate).not.toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); + expect(setTravelProvisioningNextStep).not.toHaveBeenCalled(); + + // When the account becomes validated + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: true}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the booking resumes and hands off to the enablement stepper + expect(Navigation.navigate).toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); + }); + }); + + describe('when the workspace is not provisioned and the self-serve provisioning beta is off (legacy request-access path)', () => { + it.each([ + {shouldShowVerifyAccountModal: true, modalExpectation: 'shows the verify-company modal'}, + {shouldShowVerifyAccountModal: false, modalExpectation: 'skips the verify-company modal'}, + ])('requests travel access for a validated admin and $modalExpectation', async ({shouldShowVerifyAccountModal}) => { + // Given an unprovisioned workspace and a validated admin + await seedOnyx(true, unprovisionedPolicy); + renderBookTravelButton(shouldShowVerifyAccountModal); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses the book travel button + fireEvent.press(screen.getByText('Book a trip')); + await waitForBatchedUpdatesWithAct(); + + // Then travel access is requested, with the confirm modal only when the entry point asks for it + expect(requestTravelAccess).toHaveBeenCalled(); + if (shouldShowVerifyAccountModal) { + expect(mockShowConfirmModal).toHaveBeenCalled(); + expect(mockShowConfirmModal.mock.lastCall?.[0].prompt).toContain('verify your account is ready for Expensify Travel'); + } else { + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + } + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('routes an unvalidated admin to verify their account, then resumes the request and shows the verify-company modal', async () => { + // Given an unprovisioned workspace and an admin who has not validated their account + await seedOnyx(false, unprovisionedPolicy); + renderBookTravelButton(); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses the book travel button + fireEvent.press(screen.getByText('Book a trip')); + await waitForBatchedUpdatesWithAct(); + + // Then they are sent to the verify-account screen and nothing is requested yet + expect(Navigation.navigate).toHaveBeenCalledWith(expect.stringContaining('verify-account')); + expect(requestTravelAccess).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + // When the account becomes validated + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: true}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the legacy branch resumes in full: confirm modal shown and travel access requested + expect(mockShowConfirmModal).toHaveBeenCalled(); + expect(mockShowConfirmModal.mock.lastCall?.[0].prompt).toContain('verify your account is ready for Expensify Travel'); + expect(requestTravelAccess).toHaveBeenCalled(); }); }); @@ -252,6 +328,52 @@ describe('BookTravelButton', () => { }); }); + describe('when account.primaryLogin is absent from Onyx (e.g. a session restored from storage after a reload)', () => { + it('falls back to the session email instead of blocking the user with the contact-method error', async () => { + // Given a validated admin whose work email is only known from the session, not from account.primaryLogin + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, provisionedPolicy); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: true}); + await Onyx.merge(ONYXKEYS.SESSION, {email: USER_LOGIN}); + await Onyx.merge(ONYXKEYS.NVP_TRAVEL_SETTINGS, {hasAcceptedTerms: false}); + await Onyx.merge(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {legalFirstName: 'Test', legalLastName: 'User'}); + await waitForBatchedUpdatesWithAct(); + }); + renderBookTravelButton(); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses the book travel button + fireEvent.press(screen.getByText('Book a trip')); + await waitForBatchedUpdatesWithAct(); + + // Then travel enablement proceeds rather than surfacing the "add a work email" error + expect(Navigation.navigate).toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); + expect(screen.queryByText(/add a work email as your primary login/)).toBeNull(); + }); + + it('falls back to the session email when primaryLogin is stored as an empty string', async () => { + // Given a validated admin whose account.primaryLogin is present but empty, a state the app writes in practice + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, provisionedPolicy); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: true, primaryLogin: ''}); + await Onyx.merge(ONYXKEYS.SESSION, {email: USER_LOGIN}); + await Onyx.merge(ONYXKEYS.NVP_TRAVEL_SETTINGS, {hasAcceptedTerms: false}); + await Onyx.merge(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {legalFirstName: 'Test', legalLastName: 'User'}); + await waitForBatchedUpdatesWithAct(); + }); + renderBookTravelButton(); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses the book travel button + fireEvent.press(screen.getByText('Book a trip')); + await waitForBatchedUpdatesWithAct(); + + // Then travel enablement proceeds rather than surfacing the "add a work email" error + expect(Navigation.navigate).toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); + expect(screen.queryByText(/add a work email as your primary login/)).toBeNull(); + }); + }); + describe('when the user has a personal-email login', () => { it('shows the public-domain error even when legal details are missing', async () => { // Given a user logged in with a public-domain email and no legal name set yet diff --git a/tests/unit/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.test.tsx b/tests/unit/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.test.tsx index a2b52769dbe2..b9c9c01818fa 100644 --- a/tests/unit/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.test.tsx +++ b/tests/unit/components/ConnectToQuickbooksOnlineFlow/BaseConnectToQuickbooksOnlineFlow.test.tsx @@ -55,7 +55,7 @@ jest.mock('@libs/actions/connections', () => ({ isAuthenticationError: jest.fn(() => false), })); -jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ +jest.mock('@pages/workspace/accounting/AccountingContext/contexts', () => ({ useAccountingState: jest.fn(() => ({popoverAnchorRefs: undefined})), })); diff --git a/tests/unit/components/Image/getImageSource.test.ts b/tests/unit/components/Image/getImageSource.test.ts index 2861c3595e98..33341f26e93d 100644 --- a/tests/unit/components/Image/getImageSource.test.ts +++ b/tests/unit/components/Image/getImageSource.test.ts @@ -98,6 +98,33 @@ describe('getImageSource', () => { ).toEqual({source: undefined, shouldReauthenticate: true}); }); + it('returns an authenticated source for an expired session running on a delegate token', () => { + const propsSource = {uri: MOCK_URI}; + const session: Session = { + encryptedAuthToken: MOCK_TOKEN, + creationDate: NOW.getTime() - CONST.SESSION_EXPIRATION_TIME_MS - 1, + authTokenType: CONST.AUTH_TOKEN_TYPES.DELEGATE, + }; + + expect( + getImageSource({ + propsSource, + session, + isAuthTokenRequired: true, + isOffline: false, + }), + ).toEqual({ + source: { + ...propsSource, + cacheKey: MOCK_URI, + headers: { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: MOCK_TOKEN, + }, + }, + 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}; diff --git a/tests/unit/components/MoneyRequestConfirmationList/DistanceField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DistanceField.test.tsx index d77cf8228cea..f115ad12d39d 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/DistanceField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/DistanceField.test.tsx @@ -1,5 +1,6 @@ import {render, screen} from '@testing-library/react-native'; +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; import DistanceField from '@components/MoneyRequestConfirmationList/sections/DistanceField'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -24,18 +25,20 @@ jest.mock('@hooks/useThemeStyles', () => () => ({})); const defaultProps = { hasRoute: true, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - isManualDistanceRequest: false, - isOdometerDistanceRequest: false, - isGPSDistanceRequest: false, - isReadOnly: false, - didConfirm: false, - transactionID: 'transactionID', - action: CONST.IOU.ACTION.CREATE, - iouType: CONST.IOU.TYPE.SUBMIT, - reportID: 'reportID', - reportActionID: undefined, }; +const renderDistanceField = (props: React.ComponentProps) => + render( + + + , + ); + describe('DistanceField', () => { it.each([ [1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, '1.00 mile'], @@ -43,30 +46,22 @@ describe('DistanceField', () => { [1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, '1.00 kilometer'], [100, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, '100.00 kilometers'], ])('displays the long-form distance unit for %s %s', (distance, unit, expected) => { - render( - , - ); + renderDistanceField({...defaultProps, distance: DistanceRequestUtils.convertToDistanceInMeters(distance, unit), unit}); expect(screen.getByText(expected)).toBeOnTheScreen(); }); it('displays the commuter exclusion hint', () => { - render( - , - ); + renderDistanceField({ + ...defaultProps, + distance: DistanceRequestUtils.convertToDistanceInMeters(3, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + customUnit: { + quantity: 4, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + commuterExclusion: 1, + reimbursableDistance: 3, + }, + }); expect(screen.getByText('distance.commuterExclusion.removedCommuterDistance.mi')).toBeOnTheScreen(); }); diff --git a/tests/unit/components/MoneyRequestConfirmationListFooter.test.tsx b/tests/unit/components/MoneyRequestConfirmationListFooter.test.tsx new file mode 100644 index 000000000000..1ad17af095dd --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationListFooter.test.tsx @@ -0,0 +1,139 @@ +import {render} from '@testing-library/react-native'; + +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import MoneyRequestConfirmationListFooter from '@components/MoneyRequestConfirmationListFooter'; +import type {MoneyRequestConfirmationListFooterProps} from '@components/MoneyRequestConfirmationListFooter/types'; +import DistanceManualFooter from '@components/MoneyRequestConfirmationListFooter/variants/DistanceManualFooter'; +import DistanceMapFooter from '@components/MoneyRequestConfirmationListFooter/variants/DistanceMapFooter'; +import DistanceOdometerFooter from '@components/MoneyRequestConfirmationListFooter/variants/DistanceOdometerFooter'; +import InvoiceFooter from '@components/MoneyRequestConfirmationListFooter/variants/InvoiceFooter'; +import ManualFooter from '@components/MoneyRequestConfirmationListFooter/variants/ManualFooter'; +import PerDiemFooter from '@components/MoneyRequestConfirmationListFooter/variants/PerDiemFooter'; +import ScanFooter from '@components/MoneyRequestConfirmationListFooter/variants/ScanFooter'; +import TimeFooter from '@components/MoneyRequestConfirmationListFooter/variants/TimeFooter'; + +import CONST from '@src/CONST'; +import type {IOUAction} from '@src/CONST'; + +import React from 'react'; + +// Every variant renders nothing, so these tests assert which one the footer called rather than what it drew. +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/InvoiceFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/PerDiemFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/TimeFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/DistanceManualFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/DistanceOdometerFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/DistanceMapFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/ScanFooter', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/ManualFooter', () => jest.fn(() => null)); + +/** The expense-type discriminators the footer selects on. All default to false, as they do in the provider. */ +type ExpenseMode = { + isScanRequest?: boolean; + isPerDiemRequest?: boolean; + isTimeRequest?: boolean; + isDistanceRequest?: boolean; + isManualDistanceRequest?: boolean; + isOdometerDistanceRequest?: boolean; +}; + +const footerProps: MoneyRequestConfirmationListFooterProps = { + isCompactMode: false, + policy: undefined, + policyTags: undefined, + selectedParticipants: [], + distanceData: { + distance: 0, + hasRoute: false, + unit: undefined, + distanceRateName: undefined, + distanceRateCurrency: 'USD', + mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, currency: 'USD'}, + expenseDate: undefined, + customUnitRateID: undefined, + }, + amountDisplay: {amount: 0, formattedAmount: '', formattedAmountPerAttendee: ''}, + requiredFlags: {isCategoryRequired: false, isMerchantRequired: false, isDescriptionRequired: false}, + visibilityFlags: { + shouldShowSmartScanFields: false, + shouldShowAmountField: false, + shouldShowMerchant: false, + shouldShowCategories: false, + shouldShowTax: false, + isParticipantPickerVisible: false, + }, + errorState: {shouldDisplayFieldError: false, formError: '', clearFormErrors: jest.fn(), setFormError: jest.fn()}, + receiptOptions: {receiptFilename: '', receiptPath: '', shouldDisplayReceipt: false}, + compactControls: {showMoreFields: false, setShowMoreFields: jest.fn()}, +}; + +const renderFooter = (mode: ExpenseMode, action: IOUAction = CONST.IOU.ACTION.CREATE, iouType: typeof CONST.IOU.TYPE.SUBMIT | typeof CONST.IOU.TYPE.INVOICE = CONST.IOU.TYPE.SUBMIT) => + render( + + + , + ); + +const cases: Array<[ExpenseMode, React.ComponentType]> = [ + [{isPerDiemRequest: true}, PerDiemFooter], + [{isTimeRequest: true}, TimeFooter], + [{isDistanceRequest: true, isManualDistanceRequest: true}, DistanceManualFooter], + [{isDistanceRequest: true, isOdometerDistanceRequest: true}, DistanceOdometerFooter], + [{isDistanceRequest: true}, DistanceMapFooter], + [{isScanRequest: true}, ScanFooter], + [{}, ManualFooter], +]; + +describe('MoneyRequestConfirmationListFooter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(cases)('should select the matching variant for %s', (mode, expected) => { + // Given an expense whose type is described by those discriminators + + // When the footer is rendered + renderFooter(mode); + + // Then the variant for that type is the one used + expect(jest.mocked(expected)).toHaveBeenCalled(); + }); + + it('should select the invoice footer from the IOU type rather than a mode flag', () => { + // Given an invoice, which carries no request type of its own + + // When the footer is rendered + renderFooter({}, CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.INVOICE); + + // Then the invoice footer is used + expect(jest.mocked(InvoiceFooter)).toHaveBeenCalled(); + }); + + it('should select the manual footer for a per-diem expense being moved off a track expense', () => { + // Given a per-diem expense confirmed with the SUBMIT action, which submits through RequestMoney + + // When the footer is rendered + renderFooter({isPerDiemRequest: true}, CONST.IOU.ACTION.SUBMIT); + + // Then it confirms as a plain expense rather than a per-diem one + expect(jest.mocked(ManualFooter)).toHaveBeenCalled(); + expect(jest.mocked(PerDiemFooter)).not.toHaveBeenCalled(); + }); + + it('should select the manual footer for a time expense being moved off a track expense', () => { + // Given a time expense confirmed with the SUBMIT action, which shows Merchant and no hours or rate + + // When the footer is rendered + renderFooter({isTimeRequest: true}, CONST.IOU.ACTION.SUBMIT); + + // Then it confirms as a plain expense rather than a time one + expect(jest.mocked(ManualFooter)).toHaveBeenCalled(); + expect(jest.mocked(TimeFooter)).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/components/NumericEditingControllerUtilsTest.ts b/tests/unit/components/NumericEditingControllerUtilsTest.ts new file mode 100644 index 000000000000..b7089b321f2a --- /dev/null +++ b/tests/unit/components/NumericEditingControllerUtilsTest.ts @@ -0,0 +1,211 @@ +import type {NumericEditingKeyPressEvent} from '@components/NumericEditingController/types'; +import {clampSelection, collapseSelection, getSelectionAfterEdit, getSelectionAtOffset, isForwardDeleteKeyPress, normalizeNumericInput} from '@components/NumericEditingController/utils'; + +import {isMobileSafari} from '@libs/Browser'; +import getOperatingSystem from '@libs/getOperatingSystem'; + +import CONST from '@src/CONST'; + +jest.mock('@libs/Browser', () => ({isMobileSafari: jest.fn()})); +jest.mock('@libs/getOperatingSystem', () => jest.fn()); + +const mockedIsMobileSafari = jest.mocked(isMobileSafari); +const mockedGetOperatingSystem = jest.mocked(getOperatingSystem); + +const fromLatinDigit = (digit: string) => digit; + +const fromArabicIndicDigit = (digit: string) => { + const index = '٠١٢٣٤٥٦٧٨٩'.indexOf(digit); + + if (index === -1) { + throw new Error(`${digit} is not an Arabic-Indic digit`); + } + + return String(index); +}; + +const buildKeyPressEvent = (key: string, ctrlKey?: boolean): NumericEditingKeyPressEvent => ({nativeEvent: {key, ctrlKey}}); + +describe('NumericEditingController utils', () => { + describe('normalizeNumericInput', () => { + it('strips spaces added when pasting on iOS Safari', () => { + expect(normalizeNumericInput('1 234', {fromLocaleDigit: fromLatinDigit})).toBe('1234'); + }); + + it('converts the comma decimal separator to a period', () => { + expect(normalizeNumericInput('12,34', {fromLocaleDigit: fromLatinDigit})).toBe('12.34'); + }); + + it('drops commas used as thousand separators when a period is already present', () => { + expect(normalizeNumericInput('1,234.56', {fromLocaleDigit: fromLatinDigit})).toBe('1234.56'); + }); + + it('adds a leading zero when only the decimal separator was entered', () => { + expect(normalizeNumericInput('.5', {fromLocaleDigit: fromLatinDigit})).toBe('0.5'); + }); + + it('preserves a negative value that already has an integer part when negative input is allowed', () => { + expect(normalizeNumericInput('-1.5', {fromLocaleDigit: fromLatinDigit, allowNegative: true})).toBe('-1.5'); + }); + + it('prepends zero after the minus sign when negative input starts with only the decimal separator', () => { + // Preserve legacy `addLeadingZero` behavior: `-.5` becomes `-0-.5` and is rejected. + expect(normalizeNumericInput('-.5', {fromLocaleDigit: fromLatinDigit, allowNegative: true})).toBe('-0-.5'); + }); + + it('leaves a negative value untouched when negative values are not allowed', () => { + expect(normalizeNumericInput('-.5', {fromLocaleDigit: fromLatinDigit})).toBe('-.5'); + }); + + it('converts locale digits to their canonical counterparts', () => { + expect(normalizeNumericInput('١٢٣', {fromLocaleDigit: fromArabicIndicDigit})).toBe('123'); + }); + + it('preserves characters the locale conversion rejects', () => { + expect(normalizeNumericInput('١٢.٣', {fromLocaleDigit: fromArabicIndicDigit})).toBe('12.3'); + }); + + it('returns an empty string unchanged', () => { + expect(normalizeNumericInput('', {fromLocaleDigit: fromLatinDigit})).toBe(''); + }); + }); + + describe('isForwardDeleteKeyPress', () => { + beforeEach(() => { + mockedIsMobileSafari.mockReturnValue(false); + mockedGetOperatingSystem.mockReturnValue(CONST.OS.WINDOWS); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('detects the dedicated forward-delete key', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent('Delete'))).toBe(true); + }); + + it('ignores backspace', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent('Backspace'))).toBe(false); + }); + + it('detects Control-D on macOS', () => { + mockedGetOperatingSystem.mockReturnValue(CONST.OS.MAC_OS); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d', true))).toBe(true); + }); + + it('detects Control-D on iOS', () => { + mockedGetOperatingSystem.mockReturnValue(CONST.OS.IOS); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d', true))).toBe(true); + }); + + it('ignores Control-D on other operating systems', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d', true))).toBe(false); + }); + + it('ignores the letter d pressed without the control key', () => { + mockedGetOperatingSystem.mockReturnValue(CONST.OS.MAC_OS); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d'))).toBe(false); + }); + + it('anticipates forward-delete for the control key on iOS Safari', () => { + mockedIsMobileSafari.mockReturnValue(true); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent(CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT))).toBe(true); + }); + + it('ignores the control key outside iOS Safari', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent(CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT))).toBe(false); + }); + }); + + describe('getSelectionAtOffset', () => { + it('returns a collapsed selection at the offset', () => { + expect(getSelectionAtOffset(3)).toEqual({start: 3, end: 3}); + }); + }); + + describe('getSelectionAfterEdit', () => { + it('shifts the caret forward by the characters an insertion added', () => { + expect(getSelectionAfterEdit({start: 2, end: 2}, '12', '123', false)).toEqual({start: 3, end: 3}); + }); + + it('keeps the caret at the edit site when text is inserted before it', () => { + expect(getSelectionAfterEdit({start: 1, end: 1}, '13', '123', false)).toEqual({start: 2, end: 2}); + }); + + it('shifts the caret back by the characters a backspace removed', () => { + expect(getSelectionAfterEdit({start: 3, end: 3}, '123', '12', false)).toEqual({start: 2, end: 2}); + }); + + it('shifts the caret back past a repeated character a backspace removed', () => { + // The removed character is identical to the one before it, so only the reported caret tells them apart. + expect(getSelectionAfterEdit({start: 2, end: 2}, '111', '11', false)).toEqual({start: 1, end: 1}); + }); + + it('collapses a replaced range onto the end of what replaced it', () => { + expect(getSelectionAfterEdit({start: 0, end: 4}, '1234', '9', false)).toEqual({start: 1, end: 1}); + }); + + it('collapses a deleted range onto its start', () => { + expect(getSelectionAfterEdit({start: 1, end: 3}, '12345', '145', false)).toEqual({start: 1, end: 1}); + }); + + it('leaves the caret in place after a forward-delete, which removes the character after it', () => { + expect(getSelectionAfterEdit({start: 1, end: 1}, '123', '13', true)).toEqual({start: 1, end: 1}); + }); + + it('ignores a forward-delete key press when the edit added characters', () => { + expect(getSelectionAfterEdit({start: 2, end: 2}, '12', '123', true)).toEqual({start: 3, end: 3}); + }); + + it('ignores a forward-delete key press when a range was replaced by shorter text', () => { + // A stale flag from a forward-delete that removed nothing must not strand the caret before pasted text. + expect(getSelectionAfterEdit({start: 1, end: 4}, '12345', '195', true)).toEqual({start: 2, end: 2}); + }); + + it('ignores a forward-delete key press when the text before the caret changed', () => { + expect(getSelectionAfterEdit({start: 3, end: 3}, '123', '12', true)).toEqual({start: 2, end: 2}); + }); + + it('keeps the caret in place when the length is unchanged', () => { + expect(getSelectionAfterEdit({start: 1, end: 1}, '1.2', '1,2', false)).toEqual({start: 1, end: 1}); + }); + + it('clamps the caret to the start when an edit strips more than sits before it', () => { + // Reducing the accepted precision rewrites the value regardless of where the caret is. + expect(getSelectionAfterEdit({start: 0, end: 0}, '12.34', '12', false)).toEqual({start: 0, end: 0}); + }); + + it('clamps the caret to the end of the remaining text', () => { + // A forward-delete flag held over from a key press that removed nothing must not hold the offset in place. + expect(getSelectionAfterEdit({start: 5, end: 5}, '12.34', '12', true)).toEqual({start: 2, end: 2}); + }); + }); + + describe('collapseSelection', () => { + it('collapses a highlighted range onto its end', () => { + expect(collapseSelection({start: 0, end: 4})).toEqual({start: 4, end: 4}); + }); + + it('leaves an already collapsed selection unchanged', () => { + expect(collapseSelection({start: 2, end: 2})).toEqual({start: 2, end: 2}); + }); + }); + + describe('clampSelection', () => { + it('clamps offsets reported past the end of the displayed text', () => { + expect(clampSelection({start: 6, end: 8}, 4)).toEqual({start: 4, end: 4}); + }); + + it('leaves a selection within the displayed text unchanged', () => { + expect(clampSelection({start: 1, end: 3}, 4)).toEqual({start: 1, end: 3}); + }); + + it('clamps negative offsets onto the start of the text', () => { + expect(clampSelection({start: -2, end: -1}, 4)).toEqual({start: 0, end: 0}); + }); + }); +}); diff --git a/tests/unit/components/ParticipantPicker/BaseParticipantPickerTest.tsx b/tests/unit/components/ParticipantPicker/BaseParticipantPickerTest.tsx new file mode 100644 index 000000000000..dbe78094e094 --- /dev/null +++ b/tests/unit/components/ParticipantPicker/BaseParticipantPickerTest.tsx @@ -0,0 +1,85 @@ +import {render} from '@testing-library/react-native'; + +import BaseParticipantPicker from '@components/ParticipantPicker/BaseParticipantPicker'; + +import type MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector'; + +import CONST from '@src/CONST'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; + +// The picker's chrome isn't under test here — only which callback reaches the selector's referral banner. +jest.mock('@components/ScreenWrapper', () => { + function MockScreenWrapper({children}: {children: React.ReactNode}) { + return children; + } + MockScreenWrapper.displayName = 'ScreenWrapper'; + return MockScreenWrapper; +}); +jest.mock('@components/HeaderWithBackButton', () => { + function MockHeaderWithBackButton() { + return null; + } + MockHeaderWithBackButton.displayName = 'HeaderWithBackButton'; + return MockHeaderWithBackButton; +}); + +type CapturedSelectorProps = Pick, 'onCloseParticipantPicker' | 'onRestrictedParticipantSelected'>; + +let lastSelectorProps: CapturedSelectorProps = {}; +jest.mock('@pages/iou/request/MoneyRequestParticipantsSelector', () => { + function MockMoneyRequestParticipantsSelector({onCloseParticipantPicker, onRestrictedParticipantSelected}: CapturedSelectorProps) { + lastSelectorProps = {onCloseParticipantPicker, onRestrictedParticipantSelected}; + return null; + } + MockMoneyRequestParticipantsSelector.displayName = 'MoneyRequestParticipantsSelector'; + return MockMoneyRequestParticipantsSelector; +}); + +describe('BaseParticipantPicker', () => { + beforeEach(() => { + lastSelectorProps = {}; + }); + + function renderPicker(onClose: () => void, onCloseForReferralNavigation?: () => void) { + render( + , + ); + } + + it('routes the referral banner through onCloseForReferralNavigation, leaving onClose for real dismissals (#96562)', () => { + // Given a picker whose owner wants to tell a referral navigation apart from a dismissal + const onClose = jest.fn(); + const onCloseForReferralNavigation = jest.fn(); + renderPicker(onClose, onCloseForReferralNavigation); + + // When the referral banner closes the picker before navigating + lastSelectorProps.onCloseParticipantPicker?.(); + + // Then only the referral-specific callback fires, so the owner can reopen the picker on the way back + expect(onCloseForReferralNavigation).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + + // And a genuine dismissal still goes to onClose, which must not arm the reopen + lastSelectorProps.onRestrictedParticipantSelected?.(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onCloseForReferralNavigation).toHaveBeenCalledTimes(1); + }); + + it('falls back to onClose when the owner does not distinguish referral navigation', () => { + const onClose = jest.fn(); + renderPicker(onClose); + + lastSelectorProps.onCloseParticipantPicker?.(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/components/Search/SearchQueryProvider.test.tsx b/tests/unit/components/Search/SearchQueryProvider.test.tsx new file mode 100644 index 000000000000..815ff27bcf8b --- /dev/null +++ b/tests/unit/components/Search/SearchQueryProvider.test.tsx @@ -0,0 +1,396 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {SearchQueryActionsContext, SearchQueryContext} from '@components/Search/SearchContextDefinitions'; +import SearchQueryProvider from '@components/Search/SearchQueryProvider'; + +import type * as SearchActions from '@libs/actions/Search'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {useContext} from 'react'; + +const SAVED_SEARCH_ID = '100'; + +// The default query string of the "Reconciliation" suggested search, without its `withdrawn` default filter. +const RECONCILIATION_QUERY_WITHOUT_WITHDRAWN = + `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY}:${CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER}:${CONST.SEARCH.SORT_ORDER.DESC} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW}:${CONST.SEARCH.VIEW.TABLE} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY}:${CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID} ` + + `${CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE}:${CONST.SEARCH.WITHDRAWAL_TYPE.REIMBURSEMENT}`; + +// The default query string of the "Reconciliation" suggested search. +const RECONCILIATION_QUERY = `${RECONCILIATION_QUERY_WITHOUT_WITHDRAWN} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN}:${CONST.SEARCH.DATE_PRESETS.LAST_MONTH}`; + +const mockGetDeepestFocusedScreen = jest.fn<{name: string; params: {q?: string; rawQuery?: string}}, []>(); +const mockUseOnyx = jest.fn<[unknown], [key: string]>(); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {}, + getDeepestFocusedScreen: () => mockGetDeepestFocusedScreen(), +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return {...actual, useNavigation: () => ({getState: () => undefined})}; +}); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: unknown) => unknown) => selector(undefined), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string) => mockUseOnyx(key), +})); + +// A query with a `category` filter makes the provider load the category data, which fires a real API request. +jest.mock('@libs/actions/Search', () => ({ + ...jest.requireActual('@libs/actions/Search'), + openSearchCategoryFiltersPage: jest.fn(), +})); + +function mockNavigationQuery(query: string | undefined, rawQuery?: string) { + mockGetDeepestFocusedScreen.mockReturnValue({name: SCREENS.SEARCH.ROOT, params: {q: query, rawQuery}}); +} + +function mockOnyx(data: Record = {}) { + mockUseOnyx.mockImplementation((key: string) => [data[key]]); +} + +function mockSearchFilter(query: string) { + return {query, timestamp: '2026-08-21 00:00:00.000'}; +} + +function useSearchQuery() { + return {...useContext(SearchQueryContext), ...useContext(SearchQueryActionsContext)}; +} + +function renderProvider() { + return renderHook(useSearchQuery, {wrapper: SearchQueryProvider}); +} + +describe('SearchQueryProvider', () => { + beforeEach(() => { + mockGetDeepestFocusedScreen.mockReset(); + mockUseOnyx.mockReset(); + mockOnyx(); + }); + + describe('initial currentSearchKey', () => { + it('matches a suggested search by its default query', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('matches a suggested search by its last (SEARCH_FILTERS) query', () => { + mockOnyx({[ONYXKEYS.SEARCH_FILTERS]: {[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: mockSearchFilter(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`)}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.SUBMIT); + }); + + it('ignores a last query that is stored in the legacy string format', () => { + mockOnyx({[ONYXKEYS.SEARCH_FILTERS]: {[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('matches a suggested search by its default query even when the last query exists', () => { + mockOnyx({ + [ONYXKEYS.SEARCH_FILTERS]: {[CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: mockSearchFilter(`${RECONCILIATION_QUERY} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:123`)}, + }); + mockNavigationQuery(RECONCILIATION_QUERY); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('matches a saved search by its default query', () => { + mockOnyx({[ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + }); + + it('matches a saved search by its last (SEARCH_FILTERS) query', () => { + const savedSearchKey = savedSearchIDToSearchKey(SAVED_SEARCH_ID); + mockOnyx({ + [ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}, + [ONYXKEYS.SEARCH_FILTERS]: {[savedSearchKey]: mockSearchFilter(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`)}, + }); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchKey); + }); + + it('matches a saved search by its default query even when the last query exists', () => { + const savedSearchKey = savedSearchIDToSearchKey(SAVED_SEARCH_ID); + mockOnyx({ + [ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}, + [ONYXKEYS.SEARCH_FILTERS]: {[savedSearchKey]: mockSearchFilter(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`)}, + }); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchKey); + }); + + it('falls back to the generic expenses key when the type is expense and nothing matches', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + + it('falls back to the generic reports key when the type is expense report and nothing matches', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('is undefined when the type has no generic key and nothing matches', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.INVOICE}`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBeUndefined(); + }); + }); + + describe('currentDefaultSearchQueryJSON', () => { + it('exposes the default query of the current suggested search', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + expect(result.current.currentDefaultSearchQueryJSON?.hash).toBe(buildSearchQueryJSON(RECONCILIATION_QUERY)?.hash); + expect([...result.current.currentDefaultSearchQueryFilterKeys]).toEqual( + expect.arrayContaining([CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN]), + ); + }); + + it('is empty for a saved search because saved searches have no default filters', () => { + mockOnyx({[ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + expect(result.current.currentDefaultSearchQueryJSON).toBeUndefined(); + expect(result.current.currentDefaultSearchQueryFilterKeys.size).toBe(0); + }); + }); + + describe('resetting on hash change', () => { + const savedSearches = {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}; + + it('keeps the search key when the new query still has the default filters and same type', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + // Adding a filter changes the hash but keeps all the default filters + type, so the key must be preserved. + mockNavigationQuery(`${RECONCILIATION_QUERY} merchant:Amazon`); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('resets the search key when the new query drops a default filter', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + mockNavigationQuery(RECONCILIATION_QUERY_WITHOUT_WITHDRAWN); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + + it('resets the search key when the query type changes', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + mockNavigationQuery(RECONCILIATION_QUERY.replace(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`, `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}`)); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('keeps a saved search key when the query changes since there are no default filters to enforce', () => { + mockOnyx({[ONYXKEYS.SAVED_SEARCHES]: savedSearches}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + + // The saved search query filters (and even its type) are not enforced, so the key survives the change. + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} category:Food`); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + }); + + it('recomputes the search key via the resetSearchKey action', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + + act(() => { + result.current.resetSearchKey(result.current.currentSearchQueryJSON); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + + it('always re-resolves the key, even when the target query hash is unchanged', () => { + const OTHER_SAVED_SEARCH_ID = '200'; + const sharedQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`; + // Two saved searches with the exact same query. getInitialCurrentSearchKey resolves to the first + // one (id 100), but the current key is the second (id 200). + mockOnyx({ + [ONYXKEYS.SAVED_SEARCHES]: { + [SAVED_SEARCH_ID]: {query: sharedQuery, name: 'First'}, + [OTHER_SAVED_SEARCH_ID]: {query: sharedQuery, name: 'Second'}, + }, + }); + mockNavigationQuery(sharedQuery); + const {result} = renderProvider(); + + act(() => { + result.current.setCurrentSearchKey(savedSearchIDToSearchKey(OTHER_SAVED_SEARCH_ID)); + }); + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(OTHER_SAVED_SEARCH_ID)); + + // resetSearchKey doesn't special case a target query that resolves to the current search, so it + // switches to whatever getInitialCurrentSearchKey picks (the first saved search, id 100). + act(() => { + result.current.resetSearchKey(buildSearchQueryJSON(sharedQuery)); + }); + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + }); + }); + + describe('pending search key', () => { + it('does not apply a pending setCurrentSearchKey until the query hash changes', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // The target query has a different hash than the current one, so the key must not change yet. + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS, `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // Once the query changes, the pending key is applied alongside the new query. + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + rerender(undefined); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('applies setCurrentSearchKey immediately when no target query is passed', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('applies setCurrentSearchKey immediately when the target query has the current hash', () => { + const query = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`; + mockNavigationQuery(query); + const {result} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // The query isn't changing, so there is nothing to wait for and the key applies right away. + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS, query); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('pending key wins over the recompute-on-hash-change logic', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + // Set a pending key, then change the query so a default filter is dropped. + // Without the pending logic the key would reset to EXPENSES, but the pending key must win. + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS, RECONCILIATION_QUERY_WITHOUT_WITHDRAWN); + }); + mockNavigationQuery(RECONCILIATION_QUERY_WITHOUT_WITHDRAWN); + rerender(undefined); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('does not apply a pending resetSearchKey until the query hash changes', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // The reset targets a different query, so nothing changes until the hash catches up. + const nextQueryJSON = buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + act(() => { + result.current.resetSearchKey(nextQueryJSON); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + rerender(undefined); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('applies resetSearchKey immediately when the target query matches the current hash', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result} = renderProvider(); + + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + + // The queryJSON hash equals the current hash, so it applies right away instead of pending. + act(() => { + result.current.resetSearchKey(result.current.currentSearchQueryJSON); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + }); +}); diff --git a/tests/unit/components/useNumericSelectionTest.ts b/tests/unit/components/useNumericSelectionTest.ts new file mode 100644 index 000000000000..a1be238ebed4 --- /dev/null +++ b/tests/unit/components/useNumericSelectionTest.ts @@ -0,0 +1,405 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useNumericSelection from '@components/NumericEditingController/hooks/useNumericSelection'; +import type {NumericEditingKeyPressEvent} from '@components/NumericEditingController/types'; + +import type ShouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually/types'; + +import * as NativeNavigation from '@react-navigation/native'; + +// Native sets this flag only on native platforms; mock it to exercise the guards owned by this hook. +jest.mock('@libs/shouldIgnoreSelectionWhenUpdatedManually', () => ({ + ...jest.requireActual<{default: ShouldIgnoreSelectionWhenUpdatedManually}>('@libs/shouldIgnoreSelectionWhenUpdatedManually'), + __esModule: true, + default: true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +const mockUseIsFocused = jest.mocked(NativeNavigation.useIsFocused); + +const buildKeyPressEvent = (key: string): NumericEditingKeyPressEvent => ({nativeEvent: {key}}); + +const renderSelection = (displayText = '12') => renderHook((props: {displayText: string}) => useNumericSelection(props), {initialProps: {displayText}}); + +describe('useNumericSelection', () => { + beforeEach(() => { + mockUseIsFocused.mockReturnValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('starts with the caret at the end of the displayed text', () => { + const {result} = renderSelection('123'); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('collapses a highlighted range onto its end', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(0, 3); + }); + act(() => { + result.current.collapse(); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('collapses the selection when the screen regains focus', () => { + mockUseIsFocused.mockReturnValue(false); + const {result, rerender} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(0, 3); + }); + mockUseIsFocused.mockReturnValue(true); + rerender({displayText: '123'}); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('moves the caret to the start when the field is reset', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.reset(); + }); + + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + + it('moves the caret to the end of the given text', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncToEnd('1234'); + }); + + expect(result.current.selection).toEqual({start: 4, end: 4}); + }); + + describe('syncAfterEdit', () => { + it('shifts the caret forward by the characters the edit added', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('shifts the caret back by the characters the edit removed', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.syncAfterEdit({previousText: '123', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('leaves the caret in place after a forward-delete, which removes the character after it', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '123', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('collapses a selection to its start after a forward-delete', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(1, 2); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '123', nextText: '13'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('shifts the caret back when backspace shrinks the text', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Backspace')); + result.current.syncAfterEdit({previousText: '123', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('clamps the caret to the start when the edit strips more characters than sit before it', () => { + const {result} = renderSelection('12.34'); + + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + act(() => { + // Reducing the accepted precision rewrites the value wherever the caret happens to be. + result.current.syncAfterEdit({previousText: '12.34', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + + it('keeps the caret before pasted text when a forward-delete key press removed nothing', () => { + const {result} = renderSelection('12345'); + + act(() => { + result.current.handleNativeSelectionChange(1, 4); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '12345', nextText: '195'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('shifts the caret back past a repeated character a backspace removed', () => { + const {result} = renderSelection('111'); + + act(() => { + result.current.handleNativeSelectionChange(2, 2); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Backspace')); + result.current.syncAfterEdit({previousText: '111', nextText: '11'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('shifts the caret when a forward-delete key was pressed but the edit added characters', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + }); + + describe('syncAfterEdit with an unchanged value', () => { + it('keeps the caret where it was when normalization resolves the edit back to the current value', () => { + const {result} = renderSelection('1.2'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.2'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('drops the event native emits for the character it kept, not the user event after it', () => { + const {result} = renderSelection('1.2'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.2'}); + result.current.handleNativeSelectionChange(2, 2); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + + act(() => { + result.current.handleNativeSelectionChange(2, 2); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('consumes the forward-delete key press so the next edit is not treated as one', () => { + const {result} = renderSelection('1.2'); + + act(() => { + result.current.handleNativeSelectionChange(3, 3); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.2'}); + }); + act(() => { + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + }); + + describe('syncToEnd with the caret already at the end', () => { + it('arms no guard, so the next selection event is applied', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncToEnd('12'); + }); + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + }); + + describe('guards armed before the screen was left', () => { + it('are dropped when the screen regains focus', () => { + const {result, rerender} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + + mockUseIsFocused.mockReturnValue(false); + rerender({displayText: '123'}); + mockUseIsFocused.mockReturnValue(true); + rerender({displayText: '123'}); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + }); + + describe('handleNativeSelectionChange', () => { + it('keeps the selection object when the reported offsets already match, so no render is triggered', () => { + const {result} = renderSelection('123'); + const selectionBefore = result.current.selection; + + act(() => { + result.current.handleNativeSelectionChange(3, 3); + }); + + expect(result.current.selection).toBe(selectionBefore); + }); + + it('applies the reported offsets', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(1, 2); + }); + + expect(result.current.selection).toEqual({start: 1, end: 2}); + }); + + it('clamps offsets reported past the end of the displayed text', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(5, 7); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('clamps to the pending text when the event arrives before the edit renders', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncToEnd('12345'); + }); + act(() => { + result.current.handleNativeSelectionChange(9, 9); + }); + + expect(result.current.selection).toEqual({start: 5, end: 5}); + }); + + it('drops the stale event emitted in the same batch as a manual update', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + result.current.handleNativeSelectionChange(0, 0); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('drops the stale event even when it arrives after the manual update committed', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('applies the event following the dropped stale one', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + }); + + describe('rejectEdit', () => { + it('keeps the caret at its last valid position', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.rejectEdit(); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('drops the selection event native emits for the rejected character', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.rejectEdit(); + result.current.handleNativeSelectionChange(2, 2); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + }); +}); diff --git a/tests/unit/components/useNumericSelectionWebTest.ts b/tests/unit/components/useNumericSelectionWebTest.ts new file mode 100644 index 000000000000..ca7920be6698 --- /dev/null +++ b/tests/unit/components/useNumericSelectionWebTest.ts @@ -0,0 +1,56 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useNumericSelection from '@components/NumericEditingController/hooks/useNumericSelection'; + +import type ShouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually/types'; + +import type * as NativeNavigation from '@react-navigation/native'; + +// On web the flag is false: the browser does not echo a stale selection event after a controlled update, +// so selection events arriving after a manual update must be applied instead of dropped. +jest.mock('@libs/shouldIgnoreSelectionWhenUpdatedManually', () => ({ + ...jest.requireActual<{default: ShouldIgnoreSelectionWhenUpdatedManually}>('@libs/shouldIgnoreSelectionWhenUpdatedManually'), + __esModule: true, + default: false, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +const renderSelection = (displayText = '12') => renderHook((props: {displayText: string}) => useNumericSelection(props), {initialProps: {displayText}}); + +describe('useNumericSelection on web', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('applies the selection event that follows a manual update', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('still drops the selection event native emits for a rejected edit', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.rejectEdit(); + result.current.handleNativeSelectionChange(2, 2); + }); + + // The rejected-input guard is independent of the platform flag. + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); +}); diff --git a/tests/unit/getDomainMenuItemsTest.ts b/tests/unit/getDomainMenuItemsTest.ts index 779bb810393f..b660a70a59da 100644 --- a/tests/unit/getDomainMenuItemsTest.ts +++ b/tests/unit/getDomainMenuItemsTest.ts @@ -69,4 +69,23 @@ describe('getDomainMenuItems', () => { expect(items.find((item) => item.translationKey === 'domain.saml')?.brickRoadIndicator).toBeUndefined(); }); + + it('shows an INFO indicator on the Admins item when there are pending adminship requests', () => { + const items = getDomainMenuItems({domainAccountID, hasPendingAdminRequests: true, icons}); + + expect(items.find((item) => item.translationKey === 'domain.domainAdmins')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); + }); + + it('shows an ERROR indicator on the Admins item when there are both admin errors and pending adminship requests', () => { + const domainErrors: DomainErrors = {errors: {}, adminErrors: {[adminAccountID]: {errors: {adminError: 'Admin error'}}}}; + const items = getDomainMenuItems({domainAccountID, domainErrors, hasPendingAdminRequests: true, icons}); + + expect(items.find((item) => item.translationKey === 'domain.domainAdmins')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + }); + + it('shows no indicator on the Admins item when hasPendingAdminRequests is omitted', () => { + const items = getDomainMenuItems({domainAccountID, icons}); + + expect(items.find((item) => item.translationKey === 'domain.domainAdmins')?.brickRoadIndicator).toBeUndefined(); + }); }); diff --git a/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts b/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts index 467306c9448f..eea3019207fd 100644 --- a/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts +++ b/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts @@ -42,8 +42,11 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns undefined on wide layout', () => { + // Given a wide layout where the RHP does not cover fullscreen content mockGetIsNarrowLayout.mockReturnValue(false); + // When submission evaluates speculative navigation + // Then no route is returned because wide layouts do not need pre-mounting expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -54,6 +57,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -61,6 +65,9 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns undefined when the transaction is not ready', () => { + // Given a transaction that is not ready to submit + // When submission evaluates its speculative destination + // Then no route is returned because navigation must wait for stable transaction data expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: false, @@ -71,6 +78,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -78,6 +86,8 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns Search route for global create expense flows', () => { + // Given a global create flow that will finish in expense Search + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: undefined, @@ -87,14 +97,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then expense Search is prepared so the post-submit transition is immediate expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); }); it('returns report route when the destination is not the report the user is looking at', () => { + // Given submission targets a loaded report different from the visible report + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -104,20 +118,25 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the destination report is prepared because navigation must move there expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns undefined when relocating a tracked expense over a different visible report', () => { + // Given relocation would replace another report in the same tab // The single-workspace "Submit to my employer" shape: the user is reading their self-DM while the expense is // bound to the workspace chat. Both are reports, so there is no tab to switch to and the pre-insert would // replace the report on screen - leaving the cancel path to rebuild it from a snapshot (#97437). mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('456'); + // When submission evaluates whether to pre-mount the workspace destination + // Then it declines because cancellation could not safely restore the visible report expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -128,6 +147,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: true, }), @@ -135,11 +155,13 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('keeps the pre-insert for an in-place expense whose destination is a different visible report', () => { + // Given an in-place expense targets another report without relocating existing data // Same navigation topology as the case above, but the expense is created in place rather than relocated (e.g. the // per-diem chat-report destination), so it keeps the pre-mount instead of being caught by the track-expense guard. mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('456'); + // When submission evaluates whether to pre-mount the destination const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -149,20 +171,24 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then pre-mounting remains safe because cancellation does not need a report snapshot expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('stays eligible once it has pre-inserted, so the hook does not tear down its own insert', () => { + // Given a relocation destination that has already been pre-inserted // After the pre-insert the visible report *is* the destination, so the same-tab check reads as safe on its own. // Assert it through the explicit pre-inserted flag too, since that is what keeps the result stable. mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('456'); jest.mocked(Navigation.getIsFullscreenPreInsertedUnderRHP).mockReturnValue(true); + // When eligibility is recomputed against the updated navigation state const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -172,14 +198,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: true, }); + // Then the route stays stable so the hook does not undo its own transition expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns the report route for a global-create track expense (self-DM target)', () => { + // Given global track expense creation will finish in its self-DM report + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -189,14 +219,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.TRACK, isCreatingTrackExpense: true, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the self-DM is prepared because tracked expenses are report-bound expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns the report route when the sole recipient is the self-DM (CREATE routed through track)', () => { + // Given a create flow that becomes tracking because the user is the sole recipient + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -206,16 +240,20 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.CREATE, isCreatingTrackExpense: false, isSelfDMDestination: true, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the self-DM report is prepared because it owns the tracked expense expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('does NOT pre-insert the self-DM report for a LOOKING_AROUND user (they are routed to Search after submit)', () => { + // Given a restricted user whose self-DM submission will finish in Search mockIsReportTopmostSplitNavigator.mockReturnValue(true); + // When submission evaluates the self-DM as a speculative destination const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -225,14 +263,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.CREATE, isCreatingTrackExpense: false, isSelfDMDestination: true, + isOptimisticNewChatDestination: false, isLookingAroundUser: true, isMovingTransactionFromTrackExpense: false, }); + // Then it skips the report because pre-mounting would conflict with final navigation expect(route).toBeUndefined(); }); it('returns the report route for a report-bound global create (PAY)', () => { + // Given a global pay flow bound to a concrete destination report + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -242,16 +284,20 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.PAY, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the report is prepared because the payment will land there expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('still pre-inserts the report for a LOOKING_AROUND user when the destination is a real report, not the self-DM (PAY)', () => { + // Given a restricted user paying into a real report rather than the self-DM // A LOOKING_AROUND user who later has a workspace and submits to a real report/friend must keep the report // pre-insert - the LOOKING_AROUND gate is scoped to isSelfDMDestination, so it does not fire here. + // When submission evaluates the destination for speculative navigation const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -261,16 +307,21 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.PAY, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: true, isMovingTransactionFromTrackExpense: false, }); + // Then the report remains eligible because only self-DM routing is restricted expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns undefined when the destination report is not loaded in Onyx', () => { + // Given a destination ID without a local report or optimistic-chat guarantee mockIsReportTopmostSplitNavigator.mockReturnValue(true); + // When submission evaluates whether the report can be pre-mounted + // Then it declines because fetching an unknown report could latch a not-found state expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -281,16 +332,43 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), ).toBeUndefined(); }); + it('returns a pending-creation route for an unloaded optimistic chat destination', () => { + // Given a new optimistic chat whose report row does not exist locally yet + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + // When submission chooses a route to pre-mount before chat creation completes + const route = getSubmitExpensePreMountDestinationRoute({ + isTransactionReady: true, + destinationReportID: '123', + destinationReport: undefined, + isFromGlobalCreate: false, + canPreInsertSearch: false, + iouType: CONST.IOU.TYPE.CREATE, + isCreatingTrackExpense: false, + isSelfDMDestination: false, + isOptimisticNewChatDestination: true, + isLookingAroundUser: false, + isMovingTransactionFromTrackExpense: false, + }); + + // Then the route carries a creation guard so fetching waits for the local report + expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123', undefined, undefined, undefined, undefined, true)); + }); + it('returns undefined when report is already topmost', () => { + // Given the final destination report is already visible mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('123'); + // When submission evaluates speculative navigation + // Then no route is returned because navigation is already at the destination expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -301,6 +379,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -308,6 +387,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('keeps returning the report route once it has been pre-inserted, even though it is now the topmost report', () => { + // Given a destination report made topmost by the current pre-insert transaction // After this route is pre-inserted under the RHP, getTopmostReportId() reports the pre-inserted // destination. Without the pre-insert guard this flips hasValidDestination to false and the route // recomputes to undefined, tearing down the just-inserted route. The result must stay stable instead. @@ -315,6 +395,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { jest.mocked(Navigation.getTopmostReportId).mockReturnValue('123'); jest.mocked(Navigation.getIsFullscreenPreInsertedUnderRHP).mockReturnValue(true); + // When eligibility is recomputed after navigation state changes const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -324,17 +405,22 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the route remains stable so the active pre-insert is not torn down expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns undefined when report is open in RHP', () => { + // Given the destination report is already open inside the RHP mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsReportOpenInRHP.mockReturnValue(true); + // When submission evaluates speculative fullscreen navigation + // Then no route is returned because duplicating the open report would be redundant expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -345,6 +431,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -352,11 +439,13 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns Search route when Search is topmost with a different query type', () => { + // Given Search is visible but does not show the expense query needed after submission const {buildSearchQueryJSON} = jest.requireActual('@libs/SearchQueryUtils'); mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); jest.mocked(getCurrentSearchQueryJSON).mockReturnValue(buildSearchQueryJSON('type:invoice')); + // When submission selects the Search destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: undefined, @@ -366,14 +455,17 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then expense Search is prepared because the current query cannot show the result expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); }); it('keeps returning the Search route once it has been pre-inserted, even though Search is now topmost with the same query type', () => { + // Given expense Search is topmost only because the current transaction pre-inserted it // After the Search route is pre-inserted under the RHP, isSearchTopmostFullScreenRoute() reports the pre-inserted Search // as topmost with a matching query type. Without the `|| hasPreInsertedFullscreen` guard, shouldPreInsertSearch flips to // false and the route recomputes to undefined, tearing down the just-inserted route. The result must stay stable instead. @@ -383,6 +475,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { jest.mocked(getCurrentSearchQueryJSON).mockReturnValue(buildSearchQueryJSON('type:expense')); jest.mocked(Navigation.getIsFullscreenPreInsertedUnderRHP).mockReturnValue(true); + // When eligibility is recomputed against the updated Search state const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: undefined, @@ -392,10 +485,12 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the route remains stable so the active pre-insert is not torn down expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); }); }); diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index f32c5c8841b2..ef2afd1fbf39 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -33,6 +33,7 @@ const icons = { InvoiceGeneric: mockIcon, Gear: mockIcon, Bolt: mockIcon, + Bot: mockIcon, }; function buildPolicy(role: Policy['role']): Policy { @@ -338,20 +339,6 @@ describe('getWorkspaceMenuItems', () => { expect(items.find((item) => item.translationKey === 'workspace.common.hr')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - it('uses the existing Rules icon when the Rules revamp beta is disabled', () => { - const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), areRulesEnabled: true}); - - const items = getWorkspaceMenuItems({ - policy, - policyID: policy.id, - currentUserLogin, - icons, - convertToDisplayString: () => '', - }); - - expect(items.find((item) => item.translationKey === 'workspace.common.rules')?.icon).toBe(icons.Feed); - }); - it('preserves the complete enabled Workspace menu order and presentation data', () => { const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), @@ -385,7 +372,6 @@ describe('getWorkspaceMenuItems', () => { policyID: policy.id, currentUserLogin, icons, - isRulesRevampBetaEnabled: true, isVendorMatchingBetaEnabled: true, convertToDisplayString, }); @@ -398,6 +384,7 @@ describe('getWorkspaceMenuItems', () => { 'workspace.common.accounting', 'workspace.common.hr', 'workspace.common.receiptPartners', + 'workspace.common.mcp', 'workspace.common.categories', 'workspace.common.vendors', 'workspace.common.tags', @@ -421,6 +408,7 @@ describe('getWorkspaceMenuItems', () => { ROUTES.POLICY_ACCOUNTING.getRoute(policy.id), ROUTES.WORKSPACE_HR.getRoute(policy.id), ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policy.id), + ROUTES.WORKSPACE_MCP.getRoute(policy.id), ROUTES.WORKSPACE_CATEGORIES.getRoute(policy.id), ROUTES.WORKSPACE_VENDORS.getRoute(policy.id), ROUTES.WORKSPACE_TAGS.getRoute(policy.id), @@ -444,6 +432,7 @@ describe('getWorkspaceMenuItems', () => { SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, SCREENS.WORKSPACE.CATEGORIES, SCREENS.WORKSPACE.VENDORS, SCREENS.WORKSPACE.TAGS, diff --git a/tests/unit/hooks/useAutocompleteSuggestions.test.ts b/tests/unit/hooks/useAutocompleteSuggestions.test.ts index 35440395f6e5..7bfd48a18229 100644 --- a/tests/unit/hooks/useAutocompleteSuggestions.test.ts +++ b/tests/unit/hooks/useAutocompleteSuggestions.test.ts @@ -5,6 +5,7 @@ import useNetwork from '@hooks/useNetwork'; import {openSearchCategoryFiltersPage} from '@libs/actions/Search'; import {getSearchOptions} from '@libs/OptionsListUtils'; +import type * as SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,8 +23,14 @@ jest.mock('@hooks/useOnyx', () => ({ })); jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); + +jest.mock('@components/OnyxListItemProvider', () => ({ + useCardList: () => ({}), + useWorkspaceCardList: () => ({}), +})); jest.mock('@libs/actions/Search', () => ({openSearchCategoryFiltersPage: jest.fn()})); +const mockConvertToDisplayString = jest.fn(() => '$0.00'); jest.mock('@hooks/useCurrencyList', () => ({ useCurrencyListState: () => ({ currencyList: { @@ -35,6 +42,7 @@ jest.mock('@hooks/useCurrencyList', () => ({ RETIRED_CURRENCY: {symbol: 'X', name: 'Retired', retired: true}, }, }), + useCurrencyListActions: () => ({convertToDisplayString: mockConvertToDisplayString}), })); jest.mock('@libs/SearchAutocompleteUtils', () => ({ @@ -47,6 +55,7 @@ jest.mock('@libs/SearchAutocompleteUtils', () => ({ {taxRateName: 'VAT 20%', taxRateIds: ['vat20']}, {taxRateName: 'GST 10%', taxRateIds: ['gst10']}, ]), + CONTINUATION_DETECTION_SEARCH_FILTER_KEYS: jest.requireActual('@libs/SearchAutocompleteUtils').CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, })); jest.mock('@libs/OptionsListUtils', () => ({ diff --git a/tests/unit/hooks/useBlockDistanceRequest.test.ts b/tests/unit/hooks/useBlockDistanceRequest.test.ts index 4076899d3c98..2e82ff3d055c 100644 --- a/tests/unit/hooks/useBlockDistanceRequest.test.ts +++ b/tests/unit/hooks/useBlockDistanceRequest.test.ts @@ -300,4 +300,28 @@ describe('useBlockDistanceRequest', () => { expect(result.current()).toBe(false); expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); + + it('does not block editing an existing manual distance request', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_forced`, { + id: 'policy_forced', + name: 'Forced workspace', + commuterExclusions: { + method: 'fixedDistance', + fixedDistance: 1, + fixedDistanceUnit: 'mi', + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => + useBlockDistanceRequest({ + policyID: 'policy_forced', + isManualDistanceRequest: true, + isEditingExistingDistanceRequest: true, + }), + ); + + expect(result.current()).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/hooks/useConfirmationCtaText.test.tsx b/tests/unit/hooks/useConfirmationCtaText.test.tsx index 927fe215c522..a1f2d0036441 100644 --- a/tests/unit/hooks/useConfirmationCtaText.test.tsx +++ b/tests/unit/hooks/useConfirmationCtaText.test.tsx @@ -17,7 +17,6 @@ type Params = Parameters[0]; const baseParams: Params = { expensesNumber: 1, isTypeInvoice: false, - isTypeTrackExpense: false, isTypeSplit: false, isTypeRequest: false, iouAmount: 100, @@ -27,7 +26,6 @@ const baseParams: Params = { receiptPath: '', isDistanceRequestWithPendingRoute: false, isPerDiemRequest: false, - isNewManualExpenseFlowEnabled: false, }; function Wrapper({children}: {children: React.ReactNode}) { @@ -58,18 +56,14 @@ describe('useConfirmationCtaText', () => { }); it('uses createExpense copy for track expense with zero amount', () => { - const {result} = renderHook(() => useConfirmationCtaText({...baseParams, isTypeTrackExpense: true, iouType: CONST.IOU.TYPE.TRACK, iouAmount: 0}), {wrapper: Wrapper}); + const {result} = renderHook(() => useConfirmationCtaText({...baseParams, iouType: CONST.IOU.TYPE.TRACK, iouAmount: 0}), {wrapper: Wrapper}); expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); }); - it('uses createExpense copy when new manual expense flow is enabled', () => { - const {result} = renderHook(() => useConfirmationCtaText({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); - expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); - }); - - it('includes formatted amount in createExpenseWithAmount copy', () => { + it('uses createExpense copy without the amount for a submit with a non-zero amount', () => { const {result} = renderHook(() => useConfirmationCtaText({...baseParams, formattedAmount: '$42.00'}), {wrapper: Wrapper}); - expect(result.current.at(0)?.text).toContain('$42.00'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); + expect(result.current.at(0)?.text).not.toContain('$42.00'); }); it('uses next copy for invoice without invoicing details', () => { @@ -94,19 +88,19 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text).toContain('$50.00'); }); - it('includes formatted amount for track expense with non-zero amount', () => { + it('uses createExpense copy without the amount for track expense with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ ...baseParams, - isTypeTrackExpense: true, iouType: CONST.IOU.TYPE.TRACK, iouAmount: 100, formattedAmount: '$1.23', }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text).toContain('$1.23'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); + expect(result.current.at(0)?.text).not.toContain('$1.23'); }); it('uses createExpense for distance request with pending route', () => { @@ -123,7 +117,7 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); }); - it('uses createExpenseWithAmount for per-diem request with non-zero amount', () => { + it('uses createExpense copy without the amount for per-diem request with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -134,10 +128,11 @@ describe('useConfirmationCtaText', () => { }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text).toContain('$2.00'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); + expect(result.current.at(0)?.text).not.toContain('$2.00'); }); - it('uses splitAmount with formatted amount for split with non-zero amount when manual flow is disabled', () => { + it('uses splitExpense copy for split with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -145,11 +140,11 @@ describe('useConfirmationCtaText', () => { isTypeSplit: true, iouAmount: 500, formattedAmount: '$5.00', - isNewManualExpenseFlowEnabled: false, }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text).toContain('$5.00'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('split'); + expect(result.current.at(0)?.text).not.toContain('$5.00'); }); it('uses createExpense for default zero-amount fallback', () => { diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 09f271c7f414..dc39352a9ae4 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -101,7 +101,6 @@ const baseParams = { isMovingTransactionFromTrackExpense: false, isTimeRequest: false, routeError: undefined, - isNewManualExpenseFlowEnabled: false, isReadOnly: false, shouldShowDate: true, isTaxAmountEmpty: false, @@ -310,13 +309,12 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('returns fieldRequired for manual expense when amount is not set in new manual expense flow with a policy expense chat participant', () => { + it('returns fieldRequired for manual expense when amount is not set with a policy expense chat participant', () => { const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { - isNewManualExpenseFlowEnabled: true, iouAmount: 0, }, {isAmountSet: false}, @@ -326,11 +324,10 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); - it('does not return fieldRequired for scan expense when amount is not set in new manual expense flow', () => { + it('does not return fieldRequired for scan expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, - isNewManualExpenseFlowEnabled: true, transaction: createTransactionBase({ amount: 1000, iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, @@ -341,11 +338,10 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('does not return fieldRequired for per diem expense when amount is not set in new manual expense flow', () => { + it('does not return fieldRequired for per diem expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, - isNewManualExpenseFlowEnabled: true, isPerDiemRequest: true, transaction: createTransactionBase({ amount: 5000, @@ -367,9 +363,8 @@ describe('useConfirmationValidation', () => { expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({errorKey: null}); }); - describe('amount validation — new manual expense flow (isAmountSet)', () => { - const newManualFlowParams = { - isNewManualExpenseFlowEnabled: true, + describe('amount validation — manual expense (isAmountSet)', () => { + const zeroAmountParams = { iouAmount: 0, }; @@ -380,7 +375,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouType, }, {isAmountSet: false}, @@ -396,7 +391,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouType: CONST.IOU.TYPE.PAY, }, {isAmountSet: false}, @@ -410,7 +405,7 @@ describe('useConfirmationValidation', () => { it('returns errorKey: null when manual amount is explicitly set to zero for submit', () => { const {result} = renderHook(() => useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.SUBMIT}, {amount: 0, isAmountSet: true}), + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.SUBMIT}, {amount: 0, isAmountSet: true}), ), ); expect(result.current.validate()).toEqual({errorKey: null}); @@ -419,7 +414,7 @@ describe('useConfirmationValidation', () => { it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { const {result} = renderHook(() => useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), ), ); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); @@ -435,7 +430,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( P2P_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouType, }, {isAmountSet: false}, @@ -452,7 +447,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( P2P_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouAmount: 2500, }, {amount: 2500, isAmountSet: true}, @@ -463,15 +458,13 @@ describe('useConfirmationValidation', () => { }); it('returns invalidAmount when manual amount is explicitly set to zero', () => { - const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, newManualFlowParams, {amount: 0, isAmountSet: true}))); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, zeroAmountParams, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant(P2P_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), - ), + useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); @@ -479,35 +472,19 @@ describe('useConfirmationValidation', () => { describe('self-DM participant', () => { it('returns fieldRequired for unset manual amount', () => { - const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, newManualFlowParams, {isAmountSet: false}))); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, zeroAmountParams, {isAmountSet: false}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); it('returns errorKey: null when manual amount is explicitly set to zero', () => { - const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, newManualFlowParams, {amount: 0, isAmountSet: true}))); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, zeroAmountParams, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: null}); }); }); - - it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { - const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant( - P2P_PARTICIPANT, - { - isNewManualExpenseFlowEnabled: false, - iouAmount: 0, - }, - {isAmountSet: false}, - ), - ), - ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); - }); }); describe('amount validation — P2P zero amount guard', () => { - it('returns invalidAmount for P2P manual submit with zero amount when flow is disabled', () => { + it('returns invalidAmount for P2P manual submit with zero amount', () => { const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {iouAmount: 0}, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); @@ -574,15 +551,10 @@ describe('useConfirmationValidation', () => { }); describe('amount validation — programmatic request types (scan, distance, time, per diem)', () => { - const newManualFlowParams = { - ...baseParams, - isNewManualExpenseFlowEnabled: true, - }; - it('does not return fieldRequired for scan expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, transaction: createTransactionBase({ amount: 1000, iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, @@ -598,7 +570,7 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired for distance expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, iouAmount: 5000, isDistanceRequest: true, transaction: createTransactionBase({ @@ -616,7 +588,7 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired for time expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, iouAmount: 3600, isTimeRequest: true, transaction: createTransactionBase({ @@ -634,7 +606,7 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired for per diem expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, isPerDiemRequest: true, transaction: createTransactionBase({ amount: 5000, @@ -750,7 +722,6 @@ describe('useConfirmationValidation', () => { P2P_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, - isNewManualExpenseFlowEnabled: true, iouAmount: 0, selectedParticipants: splitParticipants, }, @@ -768,7 +739,6 @@ describe('useConfirmationValidation', () => { POLICY_EXPENSE_CHAT_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, - isNewManualExpenseFlowEnabled: true, iouAmount: 0, }, {isAmountSet: false}, @@ -786,7 +756,6 @@ describe('useConfirmationValidation', () => { POLICY_EXPENSE_CHAT_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, - isNewManualExpenseFlowEnabled: true, iouAmount: 0, selectedParticipants: splitParticipants, }, @@ -818,23 +787,16 @@ describe('useConfirmationValidation', () => { }); }); - describe('date validation — inline required date in new manual expense flow', () => { - const newManualFlowParams = { - ...baseParams, - isNewManualExpenseFlowEnabled: true, - }; - + describe('date validation — inline required date', () => { it('returns fieldRequired for manual expense when the date is removed', () => { - const {result} = renderHook(() => - useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, newManualFlowParams, {created: '', isAmountSet: true})), - ); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, baseParams, {created: '', isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); it('returns fieldRequired for distance expense when the date is removed', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, isDistanceRequest: true, transaction: createTransactionBase({ amount: 1000, @@ -852,7 +814,7 @@ describe('useConfirmationValidation', () => { it('returns fieldRequired for time expense when the date is removed', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, isTimeRequest: true, transaction: createTransactionBase({ amount: 1000, @@ -870,7 +832,7 @@ describe('useConfirmationValidation', () => { it('returns fieldRequired for invoice when the date is removed', () => { const {result} = renderHook(() => useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {created: '', isAmountSet: true}), + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, iouType: CONST.IOU.TYPE.INVOICE}, {created: '', isAmountSet: true}), ), ); expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); @@ -878,44 +840,29 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired when the date is present', () => { const {result} = renderHook(() => - useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, newManualFlowParams, {created: '2025-01-15', isAmountSet: true})), + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, baseParams, {created: '2025-01-15', isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: null}); }); it('does not return fieldRequired when the fields are read-only (date populated server-side)', () => { const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, isReadOnly: true}, {created: '', isAmountSet: true}), - ), + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, isReadOnly: true}, {created: '', isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: null}); }); it('does not return fieldRequired when the date field is not shown (pure scan flow)', () => { const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, shouldShowDate: false}, {created: '', isAmountSet: true}), - ), - ); - expect(result.current.validate()).toEqual({errorKey: null}); - }); - - it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { - const {result} = renderHook(() => - useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {isNewManualExpenseFlowEnabled: false}, {created: '', isAmountSet: true})), + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, shouldShowDate: false}, {created: '', isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: null}); }); }); - describe('tax validation — inline tax amount in new manual expense flow', () => { + describe('tax validation — inline tax amount', () => { function createTaxValidationParams(overrides: ValidationParamsOverrides = {}): UseConfirmationValidationParams { - return createValidationParamsForParticipant( - POLICY_EXPENSE_CHAT_PARTICIPANT, - {isNewManualExpenseFlowEnabled: true, shouldShowTax: true, ...overrides}, - {amount: 100, isAmountSet: true}, - ); + return createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {shouldShowTax: true, ...overrides}, {amount: 100, isAmountSet: true}); } it('returns invalidAmount when the inline tax amount is left empty', () => { @@ -932,7 +879,6 @@ describe('useConfirmationValidation', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, - isNewManualExpenseFlowEnabled: true, shouldShowTax: true, isTaxAmountEmpty: true, isDistanceRequest: true, @@ -953,10 +899,5 @@ describe('useConfirmationValidation', () => { const {result} = renderHook(() => useConfirmationValidation(createTaxValidationParams({shouldShowTax: false, isTaxAmountEmpty: true}))); expect(result.current.validate()).toEqual({errorKey: null}); }); - - it('does not block when the new manual expense flow beta is disabled', () => { - const {result} = renderHook(() => useConfirmationValidation(createTaxValidationParams({isNewManualExpenseFlowEnabled: false, isTaxAmountEmpty: true}))); - expect(result.current.validate()).toEqual({errorKey: null}); - }); }); }); diff --git a/tests/unit/hooks/useDefaultParticipants.test.ts b/tests/unit/hooks/useDefaultParticipants.test.ts index d08db5015173..7c15ff787357 100644 --- a/tests/unit/hooks/useDefaultParticipants.test.ts +++ b/tests/unit/hooks/useDefaultParticipants.test.ts @@ -50,19 +50,19 @@ jest.mock('@hooks/useSelfDMReport', () => ({ const globalCreateTransaction: Transaction = {...createRandomTransaction(1), isFromGlobalCreate: true}; -function renderDefaultParticipantsHook(iouType: IOUType, transaction: Transaction = globalCreateTransaction, isNewManualExpenseFlowEnabled = true) { - return renderHook(() => useDefaultParticipants({sourceReport: undefined, transaction, iouType, isNewManualExpenseFlowEnabled})); +function renderDefaultParticipantsHook(iouType: IOUType, transaction: Transaction = globalCreateTransaction) { + return renderHook(() => useDefaultParticipants({sourceReport: undefined, transaction, iouType})); } // The hook reads the billing NVPs through `useOnyx`, so the result is only settled once those subscriptions have. -async function renderDefaultParticipantsResult(iouType: IOUType, transaction: Transaction = globalCreateTransaction, isNewManualExpenseFlowEnabled = true) { - const {result} = renderDefaultParticipantsHook(iouType, transaction, isNewManualExpenseFlowEnabled); +async function renderDefaultParticipantsResult(iouType: IOUType, transaction: Transaction = globalCreateTransaction) { + const {result} = renderDefaultParticipantsHook(iouType, transaction); await act(waitForBatchedUpdates); return result.current; } -async function renderDefaultParticipants(iouType: IOUType, transaction: Transaction = globalCreateTransaction, isNewManualExpenseFlowEnabled = true) { - return (await renderDefaultParticipantsResult(iouType, transaction, isNewManualExpenseFlowEnabled)).participants; +async function renderDefaultParticipants(iouType: IOUType, transaction: Transaction = globalCreateTransaction) { + return (await renderDefaultParticipantsResult(iouType, transaction)).participants; } describe('useDefaultParticipants', () => { @@ -121,10 +121,4 @@ describe('useDefaultParticipants', () => { expect(participants).toEqual([]); }); - - it('should not seed anything when the new manual expense flow beta is disabled', async () => { - const participants = await renderDefaultParticipants(CONST.IOU.TYPE.TRACK, globalCreateTransaction, false); - - expect(participants).toEqual([]); - }); }); diff --git a/tests/unit/hooks/useDistanceRequestState.test.ts b/tests/unit/hooks/useDistanceRequestState.test.ts index aec6fa6ff031..be6036b65d20 100644 --- a/tests/unit/hooks/useDistanceRequestState.test.ts +++ b/tests/unit/hooks/useDistanceRequestState.test.ts @@ -2,6 +2,8 @@ import {renderHook} from '@testing-library/react-native'; import useDistanceRequestState from '@components/MoneyRequestConfirmationList/hooks/useDistanceRequestState'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; + import type * as OnyxTypes from '@src/types/onyx'; import createMock from '../../utils/createMock'; @@ -52,6 +54,22 @@ describe('useDistanceRequestState', () => { expect(result.current.distanceRequestAmount).toBe(500); // 10 * 0.5 * 100 }); + it('does not back-calculate a rate from a quantity that is not written yet', () => { + const getRateSpy = jest.spyOn(DistanceRequestUtils, 'getRate').mockReturnValue({unit: 'mi', currency: 'USD'}); + const {result} = renderHook(() => + useDistanceRequestState({ + ...baseParams, + isMovingTransactionFromTrackExpense: true, + iouAmount: 210917, + transaction: createMock({transactionID: 'txn1', comment: {customUnit: {quantity: 0, routeDistanceMeters: 10}}}), + }), + ); + + expect(result.current.rate).toBeUndefined(); + expect(Number.isFinite(result.current.distanceRequestAmount)).toBe(true); + getRateSpy.mockRestore(); + }); + it('recalculates only when the reimbursable distance changes', () => { const transaction = baseParams.transaction; if (!transaction) { diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index 770eba7aefc0..b6ecf24e6711 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import {act, renderHook} from '@testing-library/react-native'; +import * as IOUUtils from '@libs/IOUUtils'; 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 useExpenseSubmission from '@pages/iou/request/step/confirmation/useExpenseSubmission'; @@ -244,6 +247,42 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); describe('requestMoney path', () => { + it('uses the transaction report ID for a brand-new P2P recipient optimistic chat', async () => { + // Given a new P2P recipient whose transaction already reserved a report ID + const optimisticP2PReportID = 'reused-p2p-report-1'; + const transaction = buildTransaction({reportID: optimisticP2PReportID}); + const getChatByParticipantsSpy = jest.spyOn(ReportUtils, 'getChatByParticipants').mockReturnValue(undefined); + const getReusableP2PReportIDSpy = jest.spyOn(IOUUtils, 'getReusableP2PReportID').mockReturnValue(optimisticP2PReportID); + + try { + const {result} = renderHook(() => + useExpenseSubmission( + buildParams({ + transaction, + transactions: [transaction], + report: undefined, + reportID: optimisticP2PReportID, + }), + ), + ); + await waitForBatchedUpdatesWithAct(); + + // When the request is created before a persisted chat can be resolved + await act(async () => { + result.current.createTransaction(false, false); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the reserved ID is forwarded so optimistic transaction and chat data align + expect(getChatByParticipantsSpy).toHaveBeenCalled(); + expect(getReusableP2PReportIDSpy).toHaveBeenCalledWith(expect.objectContaining({accountID: 42}), optimisticP2PReportID); + expect(mockRequestMoneyAction).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: optimisticP2PReportID})); + } finally { + getChatByParticipantsSpy.mockRestore(); + getReusableP2PReportIDSpy.mockRestore(); + } + }); + it('calls cleanupAfterExpenseCreate and skips cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=false (orchestrator pre-navigated)', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams())); await waitForBatchedUpdatesWithAct(); @@ -419,6 +458,34 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(transactionParams).not.toHaveProperty('modifiedMerchant'); } }); + + it('uses the transaction report ID for a brand-new P2P recipient even when a page-level report is still set', async () => { + // Given a distance expense whose brand-new P2P recipient already reserved an optimistic report ID, + // while the page-level report still points at the flow's origin report + const optimisticP2PReportID = 'reused-p2p-distance-1'; + const distanceTransaction = buildTransaction({reportID: optimisticP2PReportID, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL}); + + const {result} = renderHook(() => + useExpenseSubmission( + buildParams({ + transaction: distanceTransaction, + transactions: [distanceTransaction], + requestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isDistanceRequest: true, + isManualDistanceRequest: true, + }), + ), + ); + await waitForBatchedUpdatesWithAct(); + + // When the distance request is submitted + await act(async () => { + result.current.createTransaction(false, true); + }); + + // Then the reserved ID is forwarded, so the chat is built at the ID the screen subscribes to + expect(mockCreateDistanceRequestAction).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: optimisticP2PReportID})); + }); }); describe('trackExpense path', () => { @@ -492,6 +559,44 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { // The self-DM is forced as the chat target (route report is cleared) so the action defaults to the self-DM. expect(mockTrackExpenseAction).toHaveBeenCalledWith(expect.objectContaining({report: undefined})); }); + + // A self-DM destination clears the route report, so trackExpense resolves the chat to the self-DM. Reporting + // the route report's draft state would make getTrackExpenseInformation build a workspace whose expense chat + // overwrites the self-DM's report, so the flag has to follow the chat that is actually used. + it('reports isDraftChatReport=false for a self-DM destination even when the route report is a draft', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${REPORT_ID}`, {reportID: REPORT_ID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report); + + const {result} = renderHook(() => + useExpenseSubmission( + buildParams({ + iouType: CONST.IOU.TYPE.CREATE, + participants: [{accountID: CURRENT_USER_ACCOUNT_ID, login: 'me@test.com', selected: true}], + }), + ), + ); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + result.current.createTransaction(false, true); + }); + await waitForBatchedUpdatesWithAct(); + + expect(mockTrackExpenseAction).toHaveBeenCalledWith(expect.objectContaining({report: undefined, isDraftChatReport: false})); + }); + + it('reports isDraftChatReport=true when the draft route report is the chat the expense is tracked against', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${REPORT_ID}`, {reportID: REPORT_ID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report); + + const {result} = renderHook(() => useExpenseSubmission(buildParams({iouType: CONST.IOU.TYPE.TRACK}))); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + result.current.createTransaction(false, true); + }); + await waitForBatchedUpdatesWithAct(); + + expect(mockTrackExpenseAction).toHaveBeenCalledWith(expect.objectContaining({isDraftChatReport: true})); + }); }); describe('per diem path', () => { diff --git a/tests/unit/hooks/useExportAgainModalTest.ts b/tests/unit/hooks/useExportAgainModalTest.ts new file mode 100644 index 000000000000..ed885e0faf6d --- /dev/null +++ b/tests/unit/hooks/useExportAgainModalTest.ts @@ -0,0 +1,98 @@ +import {renderHook} from '@testing-library/react-native'; + +import useExportAgainModal from '@hooks/useExportAgainModal'; + +import {exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; + +import CONST from '@src/CONST'; + +const REPORT_ID = 'report1'; +const POLICY_ID = 'policy1'; + +// `lastSync.isConnected` marks the connection verified, which the real "Export to integration" path requires. +const mockPolicy = { + id: POLICY_ID, + connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {lastSync: {isConnected: true}}}, +}; + +jest.mock('@libs/actions/Report', () => ({ + exportToIntegration: jest.fn(), + markAsManuallyExported: jest.fn(), +})); + +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +// A QBO-connected policy and an already-exported report, which is the state that used to trigger the +// "export again" confirmation for BOTH export options. +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string) => { + if (key === `report_${REPORT_ID}`) { + return [{reportID: REPORT_ID, policyID: POLICY_ID, reportName: 'Approved report', isExportedToIntegration: true}]; + } + if (key === `policy_${POLICY_ID}`) { + return [mockPolicy]; + } + return [undefined]; + }, +})); + +describe('useExportAgainModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShowConfirmModal.mockResolvedValue({action: 'CONFIRM'}); + }); + + it('marks an already-exported report as manually exported without showing the QBO export-again modal', async () => { + /** + * Given: a single already-exported report on a QuickBooks Online workspace. + * + * When: the user picks "Mark as exported". + * + * Then: MarkAsExported runs straight away. No confirmation is shown, because that action only writes a + * per-report exported marker and never (re-)exports anything to QuickBooks Online. + */ + const {result} = renderHook(() => useExportAgainModal(REPORT_ID, POLICY_ID)); + + result.current.triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.QBO, expect.objectContaining({id: POLICY_ID})); + expect(exportToIntegration).not.toHaveBeenCalled(); + }); + + it('still shows the export-again confirmation before re-exporting an already-exported report to the integration', async () => { + /** + * Given: the same already-exported report. + * + * When: the user picks the real "Export to QuickBooks Online" action. + * + * Then: the existing export-again confirmation is shown first and the report is exported only after it + * is confirmed — this path is deliberately unchanged. + */ + const {result} = renderHook(() => useExportAgainModal(REPORT_ID, POLICY_ID)); + + result.current.triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); + + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'workspace.exportAgainModal.title', + prompt: 'workspace.exportAgainModal.description', + }), + ); + + // Flush the modal promise chain, then confirm the export ran. + await Promise.resolve(); + expect(exportToIntegration).toHaveBeenCalledWith(REPORT_ID, CONST.POLICY.CONNECTIONS.NAME.QBO, expect.objectContaining({id: POLICY_ID})); + expect(markAsManuallyExported).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useExportedToFilterOptions.test.ts b/tests/unit/hooks/useExportedToFilterOptions.test.ts index 3c0ffb2297ab..b282606bb60c 100644 --- a/tests/unit/hooks/useExportedToFilterOptions.test.ts +++ b/tests/unit/hooks/useExportedToFilterOptions.test.ts @@ -1,10 +1,12 @@ import {renderHook} from '@testing-library/react-native'; -import useExportedToFilterOptions from '@hooks/useExportedToFilterOptions'; +import useExportedToFilterOptions, {exportedToPoliciesSelector} from '@hooks/useExportedToFilterOptions'; + +import {isAdminOfCardEnabledPolicy} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ExportTemplate} from '@src/types/onyx'; +import type {ExportTemplate, Policy} from '@src/types/onyx'; import type {ConnectionName} from '@src/types/onyx/Policy'; import Onyx from 'react-native-onyx'; @@ -24,6 +26,11 @@ function buildPolicyWithConnection(policyID: string, connectionName: ConnectionN return {id: policyID, connections: {[connectionName]: {lastSync: {isConnected: true}}}} as const; } +/** Narrows the policy argument recorded by the getExportTemplates mock, whose arguments are typed as unknown. */ +function isPolicy(value: unknown): value is Policy { + return typeof value === 'object' && value !== null && 'id' in value; +} + describe('useExportedToFilterOptions', () => { const expenseLevelLabel = CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT; const quickBooksDisplayName = 'QuickBooks Online'; @@ -131,4 +138,26 @@ describe('useExportedToFilterOptions', () => { expect(result.current.connectedIntegrationNames).toEqual(new Set([CONST.POLICY.CONNECTIONS.NAME.XERO])); }); + + it('keeps a card enabled admin policy eligible for the reconciliation template after the selector trims it', () => { + const policy = createMock({id: '1', role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}); + + const trimmedPolicy = exportedToPoliciesSelector({[`${ONYXKEYS.COLLECTION.POLICY}1`]: policy})?.[`${ONYXKEYS.COLLECTION.POLICY}1`]; + + expect(isAdminOfCardEnabledPolicy(trimmedPolicy)).toBe(true); + }); + + it('passes a card enabled admin policy to getExportTemplates with its card product flags intact', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, {id: '1', role: CONST.POLICY.ROLE.CARD_ADMIN, areExpensifyCardsEnabled: true}); + + renderHook(() => useExportedToFilterOptions()); + + const policiesPassedToGetExportTemplates = mockGetExportTemplates.mock.calls.flatMap((call: unknown[]) => { + const policy = call.at(4); + return isPolicy(policy) ? [policy] : []; + }); + + expect(policiesPassedToGetExportTemplates).toHaveLength(1); + expect(isAdminOfCardEnabledPolicy(policiesPassedToGetExportTemplates.at(0))).toBe(true); + }); }); diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index afe3e85a7656..764ec1eef4ed 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -45,8 +45,9 @@ const baseParams: Params = { routeError: undefined, isTypeSplit: false, shouldShowReadOnlySplits: false, - isNewManualExpenseFlowEnabled: false, isDistanceRequest: false, + shouldShowDate: false, + isReadOnly: false, }; // A manual draft the user never typed a merchant into: `initMoneyRequest` seeds it with the "Expense" placeholder. @@ -129,38 +130,80 @@ describe('useFormErrorManagement', () => { expect(result.current.errorMessage).toBeUndefined(); }); - it('errorMessage suppresses required/invalid amount errors in the new manual expense flow (surfaced inline)', () => { - const {result: required} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); + it('errorMessage suppresses required/invalid amount errors (surfaced inline)', () => { + const {result: required} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => required.current.setFormError('common.error.fieldRequired')); expect(required.current.errorMessage).toBeUndefined(); - const {result: invalid} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); + const {result: invalid} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => invalid.current.setFormError('common.error.invalidAmount')); expect(invalid.current.errorMessage).toBeUndefined(); }); - it('errorMessage still shows required/invalid amount errors when the new manual expense flow is disabled', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: false}), {wrapper: Wrapper}); + it('errorMessage still shows the invalid amount error for a distance request (no inline surface)', () => { + const {result} = renderHook(() => useFormErrorManagement({...baseParams, isDistanceRequest: true}), {wrapper: Wrapper}); act(() => result.current.setFormError('common.error.invalidAmount')); expect(result.current.errorMessage).toBeDefined(); }); - it('errorMessage still shows the invalid amount error for a distance request in the new manual expense flow (no inline surface)', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true, isDistanceRequest: true}), {wrapper: Wrapper}); - act(() => result.current.setFormError('common.error.invalidAmount')); - expect(result.current.errorMessage).toBeDefined(); + const splitParams: Params = {...baseParams, isTypeSplit: true, shouldShowReadOnlySplits: false}; + + it('suppresses the duplicate footer invalid amount error on an editable split (#96565)', () => { + jest.useFakeTimers(); + try { + const {result} = renderHook(() => useFormErrorManagement(splitParams), {wrapper: Wrapper}); + act(() => result.current.setFormError('common.error.invalidAmount')); + act(() => jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY + 1)); + + // The debounce has settled, so an undefined message is real suppression rather than lag. + expect(result.current.debouncedFormError).toBe('common.error.invalidAmount'); + expect(result.current.errorMessage).toBeUndefined(); + } finally { + jest.useRealTimers(); + } }); - it('errorMessage suppresses the invalid merchant error in the new manual expense flow (surfaced inline)', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); - act(() => result.current.setFormError('iou.error.invalidMerchant')); - expect(result.current.errorMessage).toBeUndefined(); + it('keeps a corrected split error hidden while the debounce is still holding the old value (#96565)', () => { + jest.useFakeTimers(); + try { + const {result} = renderHook(() => useFormErrorManagement(splitParams), {wrapper: Wrapper}); + + // Given an editable split that failed confirmation with an invalid amount + act(() => result.current.setFormError('common.error.invalidAmount')); + act(() => jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY + 1)); + expect(result.current.errorMessage).toBeUndefined(); + + // When the user types a valid amount, clearing the live error while the debounced one still lags + act(() => result.current.setFormError('')); + expect(result.current.formError).toBe(''); + expect(result.current.debouncedFormError).toBe('common.error.invalidAmount'); + + // Then the footer error must not pop back in for the length of the debounce + expect(result.current.errorMessage).toBeUndefined(); + act(() => jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY + 1)); + expect(result.current.errorMessage).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + it('still shows footer errors that have no inline surface on an editable split', () => { + jest.useFakeTimers(); + try { + const {result} = renderHook(() => useFormErrorManagement(splitParams), {wrapper: Wrapper}); + act(() => result.current.setFormError('iou.error.noParticipantSelected')); + act(() => jest.advanceTimersByTime(CONST.TIMING.USE_DEBOUNCED_STATE_DELAY + 1)); + + expect(result.current.errorMessage).toBeDefined(); + } finally { + jest.useRealTimers(); + } }); - it('errorMessage still shows the invalid merchant error when the new manual expense flow is disabled', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: false}), {wrapper: Wrapper}); + it('errorMessage suppresses the invalid merchant error (surfaced inline)', () => { + const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => result.current.setFormError('iou.error.invalidMerchant')); - expect(result.current.errorMessage).toBeDefined(); + expect(result.current.errorMessage).toBeUndefined(); }); it('treats the placeholder merchant of an untouched draft as empty, so it is only invalid while a merchant is required', () => { @@ -189,8 +232,7 @@ describe('useFormErrorManagement', () => { it('clears the invalid merchant error once the recipient changes from a workspace chat to a user (#96593)', () => { // Given an untouched manual draft (still carrying the placeholder merchant) headed for a workspace chat const {result, rerender} = renderHook( - ({isPolicyExpenseChat}: {isPolicyExpenseChat: boolean}) => - useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isNewManualExpenseFlowEnabled: true, isPolicyExpenseChat}), + ({isPolicyExpenseChat}: {isPolicyExpenseChat: boolean}) => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat}), {wrapper: Wrapper, initialProps: {isPolicyExpenseChat: true}}, ); @@ -202,4 +244,69 @@ describe('useFormErrorManagement', () => { rerender({isPolicyExpenseChat: false}); expect(result.current.formError).toBe(''); }); + + const manualRequiredParams = ({ + isAmountSet, + created, + shouldShowDate = true, + isReadOnly = false, + }: { + isAmountSet: boolean; + created: string; + shouldShowDate?: boolean; + isReadOnly?: boolean; + }): Params => ({ + ...baseParams, + shouldShowDate, + isReadOnly, + transaction: createMock({ + transactionID: 'txn1', + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + isAmountSet, + created, + comment: {}, + }), + }); + + it('clears the required error once the amount is set when the date field is hidden, so a hidden date cannot strand it', () => { + // Given a confirmation that doesn't render the date field (a scan flow before its fields show), with no date yet + const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { + wrapper: Wrapper, + initialProps: manualRequiredParams({isAmountSet: false, created: '', shouldShowDate: false}), + }); + act(() => result.current.setFormError('common.error.fieldRequired')); + + // When the amount is filled, the empty date must not keep the error alive since it has no inline surface + rerender(manualRequiredParams({isAmountSet: true, created: '', shouldShowDate: false})); + expect(result.current.formError).toBe(''); + }); + + it('clears the required error once the amount is set on a read-only confirmation, where the date is populated server-side', () => { + const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { + wrapper: Wrapper, + initialProps: manualRequiredParams({isAmountSet: false, created: '', isReadOnly: true}), + }); + act(() => result.current.setFormError('common.error.fieldRequired')); + + rerender(manualRequiredParams({isAmountSet: true, created: '', isReadOnly: true})); + expect(result.current.formError).toBe(''); + }); + + it('keeps the shared required error until both the amount and the date are filled (#96568)', () => { + // Given a manual expense confirmed with both the amount and the date empty + const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { + wrapper: Wrapper, + initialProps: manualRequiredParams({isAmountSet: false, created: ''}), + }); + act(() => result.current.setFormError('common.error.fieldRequired')); + expect(result.current.formError).toBe('common.error.fieldRequired'); + + // When only the amount is filled, the shared error must survive so the date keeps showing it inline + rerender(manualRequiredParams({isAmountSet: true, created: ''})); + expect(result.current.formError).toBe('common.error.fieldRequired'); + + // Then filling the date too clears it, so confirmation is no longer blocked + rerender(manualRequiredParams({isAmountSet: true, created: '2026-07-29'})); + expect(result.current.formError).toBe(''); + }); }); diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index ad576682ffc6..1b129e6ec4ad 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -41,6 +41,8 @@ const {startMoneyRequest} = jest.requireMock<{startMoneyRequest: jest.Mock}>('@l const POLICY_ID = '1'; +const CURRENT_USER_ACCOUNT_ID = 5; + // Trial dates relative to today so the 60-day Getting Started window check // (isWithinGettingStartedPeriod) doesn't drift out of bounds as wall time // passes. The previously-hardcoded values passed at landing time but started @@ -107,6 +109,62 @@ async function setupTrackPersonalScenario(overrides: {policy?: Partial; await waitForBatchedUpdates(); } +/** + * Builds a DB-style timestamp (UTC, no timezone suffix) the given number of days in the past, matching what + * `DateUtils.getDBTime` writes to `policy.created`. + */ +function daysAgoDBTime(days: number): string { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', ''); +} + +/** + * The Submit intent runs on a free `submit` workspace, so unlike the other intents there is no free trial and + * NVP_FIRST_DAY_FREE_TRIAL is unset. The 60-day window is anchored on the Submit workspace's own creation time instead. + */ +async function setupSubmitScenario( + overrides: { + policy?: Partial; + firstPolicyCreatedDate?: string; + firstDayTrial?: string; + intent?: typeof CONST.ONBOARDING_CHOICES.EMPLOYER | typeof CONST.ONBOARDING_CHOICES.SUBMIT; + intentSource?: 'introSelected' | 'onboardingPurpose'; + } = {}, +) { + // Submit workspaces use a flat role model: `getRoleForCallerOnNewPolicy` gives even the creator the `editor` role, so a + // realistic fixture must be `editor` + owned by the current user. Building it as an admin would hide the fact that + // `isPolicyAdmin` can never gate this intent. + // New workspaces enable Categories by default, so keep the categories step visible unless a test opts out. + const policy = buildPolicy({ + type: CONST.POLICY.TYPE.SUBMIT, + role: CONST.POLICY.ROLE.EDITOR, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + areCategoriesEnabled: true, + // The 60-day window is anchored on this, so a realistic fixture must carry a creation time. + created: daysAgoDBTime(7), + ...overrides.policy, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID}); + + const intent = overrides.intent ?? CONST.ONBOARDING_CHOICES.EMPLOYER; + if (overrides.intentSource === 'onboardingPurpose') { + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, intent); + } else { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: intent}); + } + + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + + const createdDate = overrides.firstPolicyCreatedDate ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_FIRST_POLICY_CREATED_DATE, createdDate); + + if (overrides.firstDayTrial) { + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, overrides.firstDayTrial); + } + + await waitForBatchedUpdates(); +} + async function setupManageTeamScenario(overrides: {policy?: Partial; accounting?: OnboardingAccounting; firstDayTrial?: string; lastDayTrial?: string} = {}) { // New workspaces enable Categories by default, so keep the categories step visible unless a test opts out. const policy = buildPolicy({areCategoriesEnabled: true, ...overrides.policy}); @@ -159,7 +217,7 @@ describe('useGettingStartedItems', () => { }); it('should return no items when ONBOARDING_PURPOSE_SELECTED is set to a non-manage-team value and NVP_INTRO_SELECTED is not loaded', async () => { - await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.LOOKING_AROUND); await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); const policy = buildPolicy(); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); @@ -1852,6 +1910,217 @@ describe('useGettingStartedItems', () => { }); }); + describe('Submit intent', () => { + const customCategories: PolicyCategories = { + 'Custom Category': { + name: 'Custom Category', + enabled: true, + unencodedName: 'Custom Category', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + previousCategoryName: undefined, + }, + }; + + describe('visibility rules', () => { + it('should return items for the EMPLOYER intent on a free submit workspace with no free trial', async () => { + await setupSubmitScenario(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); + }); + + it('should return items for the SUBMIT intent', async () => { + await setupSubmitScenario({intent: CONST.ONBOARDING_CHOICES.SUBMIT}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); + }); + + it('should fall back to ONBOARDING_PURPOSE_SELECTED when NVP_INTRO_SELECTED is not available', async () => { + await setupSubmitScenario({intentSource: 'onboardingPurpose'}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); + }); + + // Submit workspaces make every member an editor, including the creator, so the role alone cannot tell the two + // apart. Ownership is what separates a self-created workspace from one the user was invited to. + it('should be visible for the editor who owns the auto-created Submit workspace', async () => { + await setupSubmitScenario({policy: {role: CONST.POLICY.ROLE.EDITOR, ownerAccountID: CURRENT_USER_ACCOUNT_ID}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); + }); + + it('should be hidden when the user was invited to someone else’s Submit workspace', async () => { + await setupSubmitScenario({policy: {role: CONST.POLICY.ROLE.EDITOR, ownerAccountID: CURRENT_USER_ACCOUNT_ID + 1}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should be hidden when the workspace has no creation time', async () => { + await setupSubmitScenario({policy: {created: undefined}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should render from the optimistic workspace creation time while the user is offline', async () => { + // A user who onboards offline has an optimistically created workspace but no NVPs, because only the server writes those. + await setupSubmitScenario({firstPolicyCreatedDate: '', policy: {created: daysAgoDBTime(7)}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.map((item) => item.key)).toEqual(['customizeExpenseCategories', 'linkPersonalCard']); + }); + + it('should be hidden after the 60-day Getting Started window', async () => { + await setupSubmitScenario({policy: {created: daysAgoDBTime(61)}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should be hidden when the active workspace is a paid group policy rather than a Submit workspace', async () => { + // useAutoCreateSubmitWorkspace skips creating a Submit workspace when the user already owns an editable + // group workspace, so this intent can land on a Team or Corporate workspace these steps do not describe. + await setupSubmitScenario({policy: {type: CONST.POLICY.TYPE.TEAM}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + // The account-level NVP points at the first workspace the account ever created, which can be one the user has + // since deleted. A Submit workspace created today must get its own full window, not the older workspace's. + it('should use the new workspace creation time even when the first policy creation NVP is long expired', async () => { + const sixtyOneDaysAgo = new Date(Date.now() - 61 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await setupSubmitScenario({firstPolicyCreatedDate: sixtyOneDaysAgo, policy: {created: daysAgoDBTime(1)}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.map((item) => item.key)).toEqual(['customizeExpenseCategories', 'linkPersonalCard']); + }); + + it('should ignore a stale free trial start date left over from an earlier paid workspace', async () => { + const sixtyOneDaysAgo = new Date(Date.now() - 61 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await setupSubmitScenario({firstDayTrial: sixtyOneDaysAgo, policy: {created: daysAgoDBTime(1)}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + }); + + // There is no "Create a workspace" step for this intent, so a user with no Submit workspace gets nothing rather + // than a step that creates a paid workspace and makes the section vanish. + it('should be hidden on a personal (non group) policy instead of showing a create-workspace step', async () => { + await setupSubmitScenario({policy: {type: CONST.POLICY.TYPE.PERSONAL}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should be hidden when there is no active workspace', async () => { + await setupSubmitScenario(); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, null); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + }); + + describe('items and check states', () => { + it('should return the two to-dos in order, without a createWorkspace step', async () => { + await setupSubmitScenario(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.items.map((item) => item.key)).toEqual(['customizeExpenseCategories', 'linkPersonalCard']); + }); + + it('should omit customizeExpenseCategories when the Categories feature is disabled', async () => { + await setupSubmitScenario({policy: {areCategoriesEnabled: false}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.items.map((item) => item.key)).toEqual(['linkPersonalCard']); + }); + + it('should resolve customizeExpenseCategories to the categories route', async () => { + await setupSubmitScenario(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.items.find((item) => item.key === 'customizeExpenseCategories')?.route).toBe(ROUTES.WORKSPACE_CATEGORIES.getRoute(POLICY_ID)); + }); + + it('should mark customizeExpenseCategories incomplete with only default categories and complete with a custom one', async () => { + await setupSubmitScenario(); + + const {result: defaultResult} = renderHook(() => useGettingStartedItems()); + expect(defaultResult.current.items.find((item) => item.key === 'customizeExpenseCategories')?.isComplete).toBe(false); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, customCategories); + await waitForBatchedUpdates(); + + const {result: customResult} = renderHook(() => useGettingStartedItems()); + expect(customResult.current.items.find((item) => item.key === 'customizeExpenseCategories')?.isComplete).toBe(true); + }); + + it('should resolve linkPersonalCard to the wallet route and reflect linked-card state', async () => { + await setupSubmitScenario(); + + const {result: noCards} = renderHook(() => useGettingStartedItems()); + const linkCardItem = noCards.current.items.find((item) => item.key === 'linkPersonalCard'); + expect(linkCardItem?.route).toBe(ROUTES.SETTINGS_WALLET); + expect(linkCardItem?.isComplete).toBe(false); + + await Onyx.merge(ONYXKEYS.CARD_LIST, {testCard: {cardID: 1}}); + await waitForBatchedUpdates(); + + const {result: withCard} = renderHook(() => useGettingStartedItems()); + expect(withCard.current.items.find((item) => item.key === 'linkPersonalCard')?.isComplete).toBe(true); + }); + }); + + it('should hide the section once every to-do is complete', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, customCategories); + await Onyx.merge(ONYXKEYS.CARD_LIST, {testCard: {cardID: 1}}); + await setupSubmitScenario(); + + const {result} = renderHook(() => useGettingStartedItems()); + + await waitFor(() => expect(result.current.shouldShowSection).toBe(false)); + expect(result.current.items).toEqual([]); + }); + }); + describe('hides the section once all to-dos are complete', () => { const customCategory: PolicyCategories = { 'Custom Category': { diff --git a/tests/unit/hooks/useHRSyncResultsPage.test.ts b/tests/unit/hooks/useHRSyncResultsPage.test.ts new file mode 100644 index 000000000000..8a9bcdf9c8f8 --- /dev/null +++ b/tests/unit/hooks/useHRSyncResultsPage.test.ts @@ -0,0 +1,116 @@ +import {renderHook} from '@testing-library/react-native'; + +import useHRSyncResultsPage from '@hooks/useHRSyncResultsPage'; + +import type HrSyncResult from '@libs/API/HrSyncResult'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import type {ConnectionName, PolicyConnectionSyncProgress, PolicyConnectionSyncStage} from '@src/types/onyx/Policy'; + +const RESULTS_ROUTE = 'workspaces/1A2B3C/hr-sync-results'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {navigate: jest.fn()}, +})); + +// The real helper reads the active navigation state, which a hook test has no reason to build. +jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute', () => ({ + __esModule: true, + default: jest.fn(() => RESULTS_ROUTE), +})); + +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: ({callback}: {callback: () => void}) => { + callback(); + return {cancel: jest.fn()}; + }, + }, +})); + +const mockNavigate = jest.mocked(Navigation.navigate); +const RESULT = {addedEmployeesCount: 2, removedEmployeesCount: 1, skippedEmployees: [{id: '7', name: 'Al Ex', reason: 'No email address.'}]}; +const RUNNING = CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.GUSTO_SYNC_TITLE; +const JOB_DONE = CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE; + +/** Build one sync progress entry. Each backend update carries a fresh timestamp, therefore the caller supplies it. */ +function syncProgress( + stageInProgress: PolicyConnectionSyncStage, + timestamp: string, + result?: HrSyncResult, + connectionName: ConnectionName = CONST.POLICY.CONNECTIONS.NAME.GUSTO, +): PolicyConnectionSyncProgress { + return { + connectionName, + stageInProgress, + timestamp, + ...(result ? {result} : {}), + }; +} + +function renderWith(initialProps: PolicyConnectionSyncProgress) { + return renderHook((progress: PolicyConnectionSyncProgress) => useHRSyncResultsPage(progress, true), {initialProps}); +} + +describe('useHRSyncResultsPage', () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + it('opens the results screen when the result arrives in a later update than the JOB_DONE stage', () => { + const {rerender} = renderWith(syncProgress(RUNNING, '2026-08-26 10:00:00.000')); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:05.000')); + expect(mockNavigate).not.toHaveBeenCalled(); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:06.000', RESULT)); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(RESULTS_ROUTE); + }); + + it('opens the results screen when the result arrives with the JOB_DONE stage', () => { + const {rerender} = renderWith(syncProgress(RUNNING, '2026-08-26 10:00:00.000')); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:05.000', RESULT)); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + + it('opens the results screen one time when a result-less JOB_DONE update follows the result', () => { + const {rerender} = renderWith(syncProgress(RUNNING, '2026-08-26 10:00:00.000')); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:05.000', RESULT)); + + // Onyx merges the later update, therefore the result stays on the entry. + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:06.000', RESULT)); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + + it('opens nothing for a sync that finished before the hook mounted', () => { + const {rerender} = renderWith(syncProgress(JOB_DONE, '2026-08-26 10:00:05.000', RESULT)); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:06.000', RESULT)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('opens the results screen for each sync the user runs', () => { + const {rerender} = renderWith(syncProgress(RUNNING, '2026-08-26 10:00:00.000')); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:05.000', RESULT)); + expect(mockNavigate).toHaveBeenCalledTimes(1); + + rerender(syncProgress(RUNNING, '2026-08-26 10:05:00.000', RESULT)); + rerender(syncProgress(JOB_DONE, '2026-08-26 10:05:05.000', RESULT)); + expect(mockNavigate).toHaveBeenCalledTimes(2); + }); + + it('opens nothing for a non-HR connection', () => { + const xero = CONST.POLICY.CONNECTIONS.NAME.XERO; + const {rerender} = renderWith(syncProgress(CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.XERO_SYNC_STEP, '2026-08-26 10:00:00.000', undefined, xero)); + + rerender(syncProgress(JOB_DONE, '2026-08-26 10:00:05.000', RESULT, xero)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useIsSupportalSession.test.ts b/tests/unit/hooks/useIsSupportalSession.test.ts new file mode 100644 index 000000000000..81c5b1926b6d --- /dev/null +++ b/tests/unit/hooks/useIsSupportalSession.test.ts @@ -0,0 +1,56 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useIsSupportalSession from '@hooks/useIsSupportalSession'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +describe('useIsSupportalSession', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + }); + + it('fails open (returns false) while SESSION is still loading', () => { + const {result} = renderHook(() => useIsSupportalSession()); + + expect(result.current).toBe(false); + }); + + it('returns false for a regular session', async () => { + await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'token', authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); + + const {result} = renderHook(() => useIsSupportalSession()); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + }); + + it('returns true once a support authTokenType lands, which the module-level util cannot re-render on', async () => { + const {result} = renderHook(() => useIsSupportalSession()); + + expect(result.current).toBe(false); + + await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'token', authTokenType: CONST.AUTH_TOKEN_TYPES.SUPPORT}); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('returns true when a support auth token is used on a regular session', async () => { + await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'token', isSupportAuthTokenUsed: true}); + + const {result} = renderHook(() => useIsSupportalSession()); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); +}); diff --git a/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts b/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts index 18447a9eb923..67f88ed9fd72 100644 --- a/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts +++ b/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts @@ -16,6 +16,11 @@ jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ getMoneyRequestParticipantOptions: jest.fn(), })); +const mockConvertToDisplayString = jest.fn(() => '$0.00'); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({convertToDisplayString: mockConvertToDisplayString}), +})); + const translate = jest.fn().mockReturnValue('translated'); describe('useMoneyRequestParticipantsPolicyTags', () => { @@ -56,7 +61,20 @@ describe('useMoneyRequestParticipantsPolicyTags', () => { await waitFor(() => expect(result.current.participants).toEqual(mockParticipants)); - expect(getMoneyRequestParticipantOptions).toHaveBeenCalledWith(999, report, policy, personalDetails, 'concierge1', false, reportAttributesDerived, reportDraft, translate, undefined); + expect(getMoneyRequestParticipantOptions).toHaveBeenCalledWith({ + currentUserAccountID: 999, + report, + policy, + personalDetails, + conciergeReportID: 'concierge1', + privateIsArchived: false, + rules: undefined, + reportAttributesDerived, + reportDraft, + translate, + convertToDisplayString: mockConvertToDisplayString, + dateFnsLocale: undefined, + }); }); it('derives participantsPolicyTags from Onyx policy tags keyed by each participant policyID', async () => { diff --git a/tests/unit/hooks/usePrimaryContactMethod.test.ts b/tests/unit/hooks/usePrimaryContactMethod.test.ts index e7b82701cd2e..9b9480a74ddd 100644 --- a/tests/unit/hooks/usePrimaryContactMethod.test.ts +++ b/tests/unit/hooks/usePrimaryContactMethod.test.ts @@ -51,6 +51,30 @@ describe('usePrimaryContactMethod', () => { expect(result.current).toBe('session-only@expensify.com'); }); + it('should fall back to session email when primaryLogin is an empty string', async () => { + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: ''}); + await Onyx.merge(ONYXKEYS.SESSION, {email: 'session-only@expensify.com'}); + await waitForBatchedUpdates(); + }); + + const {result} = renderHook(() => usePrimaryContactMethod()); + + expect(result.current).toBe('session-only@expensify.com'); + }); + + it('should return empty string when primaryLogin is empty and there is no session email', async () => { + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: ''}); + await Onyx.merge(ONYXKEYS.SESSION, {}); + await waitForBatchedUpdates(); + }); + + const {result} = renderHook(() => usePrimaryContactMethod()); + + expect(result.current).toBe(''); + }); + it('should return empty string when neither primaryLogin nor session email exist', async () => { await act(async () => { await Onyx.merge(ONYXKEYS.ACCOUNT, {}); diff --git a/tests/unit/hooks/useReceiptScanDrop.test.ts b/tests/unit/hooks/useReceiptScanDrop.test.ts index 3b3f8be37503..20f3509f739b 100644 --- a/tests/unit/hooks/useReceiptScanDrop.test.ts +++ b/tests/unit/hooks/useReceiptScanDrop.test.ts @@ -1,30 +1,74 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; +import useFilesValidation from '@hooks/useFilesValidation'; import useReceiptScanDrop from '@hooks/useReceiptScanDrop'; +import {navigateToParticipantPage} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import {initMoneyRequest, setMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest'; +import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; +import createMock from '../../utils/createMock'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; +jest.mock('@hooks/useFilesValidation'); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@libs/IOUUtils', () => ({navigateToParticipantPage: jest.fn()})); +jest.mock('@libs/Navigation/Navigation'); +jest.mock('@userActions/IOU/MoneyRequest', () => ({initMoneyRequest: jest.fn(), setMoneyRequestParticipantsFromReport: jest.fn()})); +jest.mock('@userActions/IOU/Receipt', () => ({setMoneyRequestReceipt: jest.fn()})); +jest.mock('@userActions/TransactionEdit', () => ({buildOptimisticTransactionAndCreateDraft: jest.fn()})); +let mockOnFilesValidated: Parameters[0] = jest.fn(); describe('useReceiptScanDrop', () => { - afterEach(async () => { + beforeEach(async () => { await Onyx.clear(); + jest.clearAllMocks(); + jest.mocked(useFilesValidation).mockImplementation((onFilesValidated) => { + mockOnFilesValidated = onFilesValidated; + return {validateFiles: jest.fn(), PDFValidationComponent: undefined}; + }); }); - it('should disable drag for anonymous users', async () => { await Onyx.merge(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); const {result} = renderHook(() => useReceiptScanDrop()); await waitForBatchedUpdatesWithAct(); expect(result.current.isDragDisabled).toBe(true); }); - it('should enable drag for logged-in users', async () => { await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'test-token'}); const {result} = renderHook(() => useReceiptScanDrop()); await waitForBatchedUpdatesWithAct(); expect(result.current.isDragDisabled).toBe(false); }); + it('keeps one and multiple valid receipts ordered on their intended transactions', async () => { + const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second'); + renderHook(() => useReceiptScanDrop()); + await waitForBatchedUpdatesWithAct(); + const [firstFile, secondFile] = [new File(['first'], 'first.png', {type: 'image/png'}), new File(['second'], 'second.png', {type: 'image/png'})]; + jest.mocked(initMoneyRequest).mockReturnValue(createMock>>({})); + mockOnFilesValidated([firstFile], []); + expect(jest.mocked(setMoneyRequestReceipt)).toHaveBeenLastCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, 'blob:first', 'first.png', true, 'image/png'); + expect(jest.mocked(navigateToParticipantPage)).toHaveBeenCalledWith(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, expect.any(String)); + jest.clearAllMocks(); + await act(() => Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'policy')); + await act(() => Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}policy`, {id: 'policy', type: CONST.POLICY.TYPE.TEAM})); + await waitForBatchedUpdatesWithAct(); + jest.mocked(initMoneyRequest).mockReturnValue(createMock>>({transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID})); + jest.mocked(buildOptimisticTransactionAndCreateDraft).mockReturnValue(createMock>({transactionID: 'later'})); + mockOnFilesValidated([firstFile, secondFile], []); + await waitForBatchedUpdatesWithAct(); + expect(createObjectURLSpy.mock.calls.slice(-2).map(([file]) => file)).toEqual([firstFile, secondFile]); + expect(jest.mocked(setMoneyRequestReceipt).mock.calls.map(([transactionID]) => transactionID)).toEqual([CONST.IOU.OPTIMISTIC_TRANSACTION_ID, 'later']); + expect(jest.mocked(buildOptimisticTransactionAndCreateDraft)).toHaveBeenCalledTimes(1); + expect(jest.mocked(setMoneyRequestParticipantsFromReport).mock.calls.map(([transactionID]) => transactionID)).toEqual([CONST.IOU.OPTIMISTIC_TRANSACTION_ID, 'later']); + expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledTimes(1); + expect(jest.mocked(navigateToParticipantPage)).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts b/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts index 3d3d24735a6d..4e9d87f5410c 100644 --- a/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts @@ -6,6 +6,8 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; import {deleteMoneyRequest} from '@libs/actions/IOU/DeleteMoneyRequest'; +import {deleteAppReport} from '@libs/actions/Report'; +import type * as SearchActions from '@libs/actions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,6 +46,8 @@ jest.mock('@libs/actions/Report', () => ({ })); jest.mock('@libs/actions/Search', () => ({ + getReportFromSearchSnapshot: jest.requireActual('@libs/actions/Search').getReportFromSearchSnapshot, + getReportActionsFromSearchSnapshot: jest.requireActual('@libs/actions/Search').getReportActionsFromSearchSnapshot, getExportTemplates: jest.fn(() => ({customTemplates: [], defaultTemplates: []})), exportSearchItemsToCSV: jest.fn(), queueExportSearchItemsToCSV: jest.fn(), @@ -292,6 +296,7 @@ function makeSelectedTransaction(overrides: Partial { // No deleteMoneyRequest call — no action was found. expect(deleteMoneyRequest).not.toHaveBeenCalled(); }); + + it('passes the report and reportActions from the search snapshot to deleteAppReport when the report is not in Onyx', async () => { + const REPORT_ID = 'report-1'; + const REPORT_ACTION_ID = 'report-action-1'; + + // Given: a whole-report row selected for bulk delete, where the report and its + // report actions exist in the search snapshot but not in the Onyx. + const snapshotAction = createMock({ + reportActionID: REPORT_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + actorAccountID: CURRENT_USER_ACCOUNT_ID, + created: '2026-01-01 10:00:00', + person: [], + shouldShow: true, + }); + const searchResults = createMock({ + search: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + offset: 0, + hasMoreResults: false, + hasResults: true, + isLoading: false, + count: 1, + total: 100, + currency: 'USD', + }, + data: {}, + }); + const searchData: SearchResultDataType = searchResults.data; + searchData[`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`] = { + reportID: REPORT_ID, + reportName: 'Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + searchData[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`] = {[REPORT_ACTION_ID]: snapshotAction}; + mockCurrentSearchResults = searchResults; + mockSelectedTransactions = { + [REPORT_ID]: makeSelectedTransaction({reportID: REPORT_ID}), + }; + mockShouldShowDeleteOption = true; + mockShowConfirmModal.mockResolvedValue({action: 'CONFIRM'}); + + const {result} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE)).toBeDefined(); + }); + + // When: the user confirms bulk delete. + await act(async () => { + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE)?.onSelected?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Then: deleteAppReport still receives the report and reportActions resolved + // from the snapshot (snapshot-first fallback), instead of undefined. + await waitFor(() => { + expect(deleteAppReport).toHaveBeenCalledTimes(1); + expect(deleteAppReport).toHaveBeenCalledWith( + expect.objectContaining({ + report: expect.objectContaining({reportID: REPORT_ID}), + reportActions: expect.objectContaining({ + [REPORT_ACTION_ID]: expect.objectContaining({reportActionID: REPORT_ACTION_ID}), + }), + }), + ); + }); + }); }); diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts index 37a87017a5a1..faf44a9fc3f7 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts @@ -270,6 +270,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -299,6 +300,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -336,6 +338,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -378,6 +381,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -394,6 +398,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '2', policyID: 'policy1', amount: 200, + displayAmount: 200, currency: 'USD', isFromOneTransactionReport: false, }, @@ -429,6 +434,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -445,6 +451,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '2', policyID: 'policy1', amount: 200, + displayAmount: 200, currency: 'USD', isFromOneTransactionReport: false, }, diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts index aa269f37c8cf..9d8a9fc1896b 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts @@ -189,6 +189,7 @@ function makeSelectedTransaction(overrides: Partial { expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID, REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, expect.anything(), undefined); }); - it('routes "Mark as exported" through the same flow: partial modal first, then export-again, then marks the subset', async () => { + it('routes "Mark as exported" through the partial modal only, skipping export-again, then marks the subset', async () => { /** * Given: a multi-integration selection (report1 → NetSuite already exported, report2 → QBO). * * When: the user clicks NetSuite's "Mark as exported". * - * Then: it goes through the identical shared flow as export — partial-export modal first, then the - * export-again modal — and only report1 is marked as exported to NetSuite. + * Then: only the partial-export modal is shown — the export-again modal is skipped because marking + * never (re-)exports to the integration — and only report1 is marked as exported to NetSuite. */ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { id: POLICY_ID_2, @@ -1129,12 +1130,78 @@ describe('useSearchBulkActions - export options', () => { expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, expect.anything()); }); - expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); - expect(mockShowConfirmModal).toHaveBeenNthCalledWith(1, expect.objectContaining({title: 'workspace.exportPartialModal.title'})); - expect(mockShowConfirmModal).toHaveBeenNthCalledWith(2, expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportPartialModal.title'})); + expect(mockShowConfirmModal).not.toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + expect(exportToIntegrationOnSearch).not.toHaveBeenCalled(); + }); + + it('marks already-exported reports without showing the export-again modal', async () => { + /** + * Given: a single-integration selection where every selected report has already been exported + * (the backend set `isExportedToIntegration`). + * + * When: the user clicks "Mark as exported". + * + * Then: the reports are marked straight away with no confirmation at all. The export-again copy warns + * that reports are about to be exported again to the integration, which never happens here: + * MarkAsExported only logs a per-report exported action. + */ + mockCurrentSearchResults = makeSearchResults([makeExportedSnapshotReport(), makeExportedSnapshotReport(REPORT_ID_2, POLICY_ID)]); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === 'workspace.common.markAsExported')).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === 'workspace.common.markAsExported') + ?.onSelected?.(); + + await waitFor(() => { + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID, REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, expect.anything()); + }); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); expect(exportToIntegrationOnSearch).not.toHaveBeenCalled(); }); + it('marks reports the backend flagged with a pending export field without showing the export-again modal', async () => { + /** + * Given: a selection whose already-exported state is represented by `pendingFields.export` rather than + * `isExportedToIntegration` — the other shape the backend uses. + * + * When: the user clicks "Mark as exported". + * + * Then: the reports are still marked straight away with no export-again warning. + */ + mockCurrentSearchResults = makeSearchResults([{...makeSnapshotReport(), isExportedToIntegration: false, pendingFields: {export: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === 'workspace.common.markAsExported')).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === 'workspace.common.markAsExported') + ?.onSelected?.(); + + await waitFor(() => { + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, expect.anything()); + }); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + it('shows templates when reports are selected through their report groups', async () => { mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); mockSelectedReports = [makeSelectedReport()]; @@ -1491,4 +1558,86 @@ describe('useSearchBulkActions - export options', () => { expect(getIncludeMultipleTaxExportArgument()).toBe(false); }); }); + + describe('Reconciliation - All Expenses eligibility', () => { + /** The includeReconciliationAllExpenses argument getExportTemplates was last called with */ + function getIncludeReconciliationAllExpensesArgument() { + return mockGetExportTemplates.mock.calls.at(-1)?.at(8); + } + + it('offers the template when the user is a workspace admin of the selected workspace and it has company cards enabled', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getIncludeReconciliationAllExpensesArgument()).toBe(true); + }); + }); + + it('offers the template when the user is a card admin of the selected workspace and it has the Expensify Card enabled', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {role: CONST.POLICY.ROLE.CARD_ADMIN, areExpensifyCardsEnabled: true}); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getIncludeReconciliationAllExpensesArgument()).toBe(true); + }); + }); + + it('hides the template when the user is a member, not admin, of every workspace', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {role: CONST.POLICY.ROLE.USER, areCompanyCardsEnabled: true}); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(mockGetExportTemplates).toHaveBeenCalled(); + }); + expect(getIncludeReconciliationAllExpensesArgument()).toBe(false); + }); + + it('hides the template when the admin workspaces have no card product enabled', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: false, areExpensifyCardsEnabled: false}); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(mockGetExportTemplates).toHaveBeenCalled(); + }); + expect(getIncludeReconciliationAllExpensesArgument()).toBe(false); + }); + + it('offers the template when the user is a card-enabled workspace admin of any workspace, even if the selected rows belong to a workspace they are only a member of', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, {id: POLICY_ID_2, role: CONST.POLICY.ROLE.USER, areCompanyCardsEnabled: true}); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()]); + mockSelectedReports = [makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; + mockSelectedTransactions = { + tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}), + }; + + renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getIncludeReconciliationAllExpensesArgument()).toBe(true); + }); + }); + }); }); diff --git a/tests/unit/hooks/useSearchBulkActionsPayTest.ts b/tests/unit/hooks/useSearchBulkActionsPayTest.ts index ff7f8384a053..6c653100612f 100644 --- a/tests/unit/hooks/useSearchBulkActionsPayTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsPayTest.ts @@ -7,7 +7,7 @@ import useSearchBulkActions from '@hooks/useSearchBulkActions'; import type {SearchHeaderOptionValue} from '@hooks/useSearchBulkActions'; import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; -import {getLastPolicyPaymentMethod} from '@libs/actions/Search'; +import {getLastPolicyPaymentMethod, queueBulkPayReports} from '@libs/actions/Search'; import type * as SearchActions from '@libs/actions/Search'; import CONST from '@src/CONST'; @@ -39,6 +39,7 @@ jest.mock('@libs/actions/Search', () => { exportSearchItemsToCSV: jest.fn(), queueExportSearchItemsToCSV: jest.fn(), queueExportSearchWithTemplate: jest.fn(), + queueBulkPayReports: jest.fn(), getSearchApproveOnyxData: jest.fn(() => ({})), getSearchPayOnyxData: jest.fn(() => ({})), bulkDeleteReports: jest.fn(), @@ -239,12 +240,28 @@ function makeSelectedTransaction(overrides: Partial = {}): SelectedReports { + return { + reportID: '1', + policyID: 'policy1', + chatReportID: undefined, + total: 100, + action: CONST.SEARCH.ACTION_TYPES.PAY, + canPay: true, + canApprove: false, + canSubmit: false, + canChangeApprover: false, + ...overrides, + }; +} + function getPayOptionFromResult(options: Array>): DropdownOption | undefined { return options.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); } @@ -321,6 +338,85 @@ describe('useSearchBulkActions - Pay option', () => { expect(payMoneyRequest).not.toHaveBeenCalled(); }); + it('queues a server-side bulk payment instead of paying per report when all matching items are selected', async () => { + // Given "Select all" is checked, so the selection can span more reports than are loaded on the current page + mockAreAllMatchingItemsSelected = true; + // Mark as paid is the only option offered in this mode + mockBulkPayButtonOptions = [{text: 'Mark as paid', key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}]; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + + // When the user selects the Pay option + const payOption = getPayOptionFromResult(result.current.headerButtonsOptions); + await act(async () => { + await payOption?.onSelected?.(); + }); + + // Then the payment is handed to the backend via the search query (which covers every page), not looped per loaded report + expect(queueBulkPayReports).toHaveBeenCalledTimes(1); + expect(queueBulkPayReports).toHaveBeenCalledWith(expect.any(String)); + expect(payMoneyRequest).not.toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalled(); + }); + + it('keeps the Pay option under Select all when getPayOption rejects the loaded page', async () => { + // Given "Select all" with at least one payable report loaded, but getPayOption rejecting the selection. + // getPayOption compares getReportType() across the payable subset, which reads live Onyx and returns + // undefined for reports the viewer never opened, so it says "no" for reasons that don't apply to the + // full query the backend will re-resolve. + mockAreAllMatchingItemsSelected = true; + mockShouldEnableBulkPayOption = false; + mockBulkPayButtonOptions = [{text: 'Mark as paid', key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}]; + mockSelectedReports = [makeSelectedReport({canPay: true}), makeSelectedReport({reportID: '2', canPay: false})]; + + // When the bulk actions hook computes the header dropdown options + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + // Then Pay is still offered, because eligibility for a select-all is decided server-side from the query + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + }); + + it('keeps the Pay option under Select all when a loaded transaction is held', async () => { + // Given "Select all" where one expense on the loaded page is held. The hold says nothing about the + // reports on the pages that were never loaded, so it must not drop Pay for the whole query. + mockAreAllMatchingItemsSelected = true; + mockBulkPayButtonOptions = [{text: 'Mark as paid', key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}]; + mockSelectedTransactions = {tx1: makeSelectedTransaction({isHeld: true})}; + mockSelectedReports = [makeSelectedReport({canPay: true})]; + + // When the bulk actions hook computes the header dropdown options + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + // Then Pay is still offered + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + }); + + it('hides the Pay option under Select all when no loaded report is payable', async () => { + // Given "Select all" where every report on the loaded page is view-only. That is the one honest signal + // the loaded page gives us that the query has nothing to pay. + mockAreAllMatchingItemsSelected = true; + mockBulkPayButtonOptions = [{text: 'Mark as paid', key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}]; + mockSelectedReports = [makeSelectedReport({canPay: false}), makeSelectedReport({reportID: '2', canPay: false})]; + + // When the bulk actions hook computes the header dropdown options + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions).toBeDefined(); + }); + + // Then Pay is hidden + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeUndefined(); + }); + it('hides the Pay option when bulk pay is not enabled', async () => { // Given a selection for which bulk pay is not enabled (getPayOption rejected it) mockShouldEnableBulkPayOption = false; @@ -335,6 +431,40 @@ describe('useSearchBulkActions - Pay option', () => { // Then the Pay option should be hidden because offering it would let the user attempt a payment that cannot succeed expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeUndefined(); }); + + it('keeps the Pay option when a held expense sits in a report that is not being paid', async () => { + // Given "Select all on this page" over a mixed page: report 1 is payable, report 2 is view-only and holds a + // held expense. Bulk pay only settles report 1, so report 2's hold must not decide the whole selection. + mockSelectedReports = [makeSelectedReport({reportID: '1', canPay: true}), makeSelectedReport({reportID: '2', canPay: false})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction({reportID: '1'}), + tx2: makeSelectedTransaction({reportID: '2', isHeld: true, action: CONST.SEARCH.ACTION_TYPES.VIEW}), + }; + + // When the bulk actions hook computes the header dropdown options + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + // Then Pay is still offered, because the hold is scoped to the reports that will actually be paid + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + }); + + it('hides the Pay option when a held expense sits in a report that is being paid', async () => { + // Given a selection whose only payable report contains a held expense + mockSelectedReports = [makeSelectedReport({reportID: '1', canPay: true})]; + mockSelectedTransactions = {tx1: makeSelectedTransaction({reportID: '1', isHeld: true})}; + + // When the bulk actions hook computes the header dropdown options + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions).toBeDefined(); + }); + + // Then Pay is hidden, because the hold blocks the payment the user would be making + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeUndefined(); + }); }); describe('useSearchBulkActions - bulk pay chat report fallback', () => { @@ -432,6 +562,43 @@ describe('useSearchBulkActions - bulk pay chat report fallback', () => { ); }); + it('pays only the payable reports when the selection also contains one the viewer cannot settle', async () => { + // Given a selection of two reports where only the first one can be paid, e.g. the second is still awaiting approval + mockSelectedReports = [ + { + reportID: '1', + policyID: 'policy1', + chatReportID: '2', + total: 100, + action: CONST.SEARCH.ACTION_TYPES.PAY, + canPay: true, + canApprove: false, + canSubmit: false, + canChangeApprover: false, + }, + { + reportID: '3', + policyID: 'policy1', + chatReportID: '4', + total: 100, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + canPay: false, + canApprove: false, + canSubmit: false, + canChangeApprover: false, + }, + ]; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}3`, {reportID: '3', type: CONST.REPORT.TYPE.EXPENSE, chatReportID: '4', policyID: 'policy1'}); + + // When the user selects bulk Pay + await selectBulkPay(); + + // Then only the payable report is paid: the ineligible one is left out of the run entirely rather than + // being paid alongside it or aborting the whole bulk payment + expect(payMoneyRequest).toHaveBeenCalledTimes(1); + expect(payMoneyRequest).toHaveBeenCalledWith(expect.objectContaining({iouReport: expect.objectContaining({reportID: '1'})})); + }); + it('skips a report when the chat is not loaded and no chatReportID is available', async () => { // Given a payable selected expense report whose chat report is not loaded and which has no chatReportID to build a fallback from mockSelectedReports = [ diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index bf7e20e6b0d6..90530a8d34cf 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -231,6 +231,7 @@ function makeSelectedTransaction(overrides: Partial, []>(); +const mockUseSearchQueryContext = jest.fn, []>(); +const mockMapFiltersFormToLabelValueList = jest.fn(); + +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ + __esModule: true, + default: () => ({setFilterQueryParams: mockSetFilterQueryParams, updateFilterQueryParams: mockUpdateFilterQueryParams}), +})); + +jest.mock('@libs/SearchUIUtils', () => ({ + mapFiltersFormToLabelValueList: (...args: unknown[]) => mockMapFiltersFormToLabelValueList(...args), +})); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchResultsContext: () => mockUseSearchResultsContext(), + useSearchQueryContext: () => mockUseSearchQueryContext(), +})); + +jest.mock('@libs/actions/Search'); +jest.mock('@libs/Navigation/Navigation'); + +const queryJSON: SearchQueryJSON = { + hash: 0, + recentSearchHash: 0, + similarSearchHash: 0, + groupBy: undefined, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + flatFilters: [], + inputQuery: '', + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, right: ''}, + columns: undefined, + limit: undefined, + rawFilterList: undefined, +}; + +function mockSearchResultsContext(overrides: Record = {}) { + mockUseSearchResultsContext.mockReturnValue({shouldShowFiltersBarLoading: false, currentSearchResults: undefined, ...overrides}); +} + +function mockSearchQueryContext(overrides: Record = {}) { + mockUseSearchQueryContext.mockReturnValue({ + currentDefaultSearchQueryFilterKeys: [], + currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + ...overrides, + }); +} + +function buildQueryJSON(flatFilters: SearchQueryJSON['flatFilters']): SearchQueryJSON { + return {...queryJSON, flatFilters}; +} + +const merchantFilters: SearchQueryJSON['flatFilters'] = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'Uber'}]}]; +const categoryFilters: SearchQueryJSON['flatFilters'] = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'Travel'}]}]; + +describe('useSearchFiltersBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearchResultsContext(); + mockSearchQueryContext(); + mockMapFiltersFormToLabelValueList.mockReturnValue([]); + }); + + describe('shouldShowResetFilters', () => { + it('is true when the default query filters differ from the current query filters', () => { + mockSearchQueryContext({ + currentDefaultSearchQueryJSON: buildQueryJSON(merchantFilters), + currentSearchQueryJSON: buildQueryJSON(categoryFilters), + }); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when the default query filters equal the current query filters', () => { + mockSearchQueryContext({ + currentDefaultSearchQueryJSON: buildQueryJSON(merchantFilters), + currentSearchQueryJSON: buildQueryJSON(merchantFilters), + }); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('falls back to having filters when there is no default query JSON', () => { + mockSearchQueryContext(); + mockMapFiltersFormToLabelValueList.mockReturnValue([{key: 'merchant'}]); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when there is no default query JSON and no filters', () => { + mockSearchQueryContext(); + mockMapFiltersFormToLabelValueList.mockReturnValue([]); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + }); + + describe('resetFilters', () => { + it('navigates to the query the filters reset to', () => { + const currentSearchQueryJSON = buildQueryJSON(categoryFilters); + const currentDefaultSearchQueryJSON = buildQueryJSON(merchantFilters); + mockSearchQueryContext({currentSearchQueryJSON, currentDefaultSearchQueryJSON}); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); + expect(setSearchContext).toHaveBeenCalledWith(false); + }); + + it('navigates to the query the filters reset to when there is no default query', () => { + const currentSearchQueryJSON = buildQueryJSON(categoryFilters); + mockSearchQueryContext({currentSearchQueryJSON}); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, undefined), rawQuery: undefined}); + expect(setSearchContext).toHaveBeenCalledWith(false); + }); + + it('does nothing when there is no current query', () => { + mockSearchQueryContext(); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + result.current.resetFilters(); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(setSearchContext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/hooks/useSearchPageSetup.test.ts b/tests/unit/hooks/useSearchPageSetup.test.ts index c91dd60a41f1..4ecbb0baba5e 100644 --- a/tests/unit/hooks/useSearchPageSetup.test.ts +++ b/tests/unit/hooks/useSearchPageSetup.test.ts @@ -16,6 +16,7 @@ jest.mock('@components/Search/SearchContext', () => ({ useSearchQueryContext: () => ({currentSearchKey: 'expenses'}), useSearchResultsContext: () => ({currentSearchResults: mockCurrentSearchResults, shouldUseLiveData: false}), useSearchSelectionActions: () => ({clearSelectedTransactions: jest.fn()}), + useSearchSelectionContext: () => ({areAllMatchingItemsSelected: false}), })); jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); diff --git a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts index 9aeb9b1e596e..4cc4a4d2cf51 100644 --- a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts +++ b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts @@ -22,19 +22,19 @@ describe('useSearchShouldCalculateTotals', () => { }); it('returns false when disabled', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, false)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, false)); expect(result.current).toBe(false); }); it('returns true for eligible suggested searches', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, true)); expect(result.current).toBe(true); }); it('returns false for non-eligible searches', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, true)); expect(result.current).toBe(false); }); @@ -48,7 +48,7 @@ describe('useSearchShouldCalculateTotals', () => { }, }; - const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 456, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals('savedSearch_456', true)); expect(result.current).toBe(true); }); @@ -62,19 +62,19 @@ describe('useSearchShouldCalculateTotals', () => { }, }; - const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 789, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals('savedSearch_123', true)); expect(result.current).toBe(false); }); it('returns true for an ad-hoc search when all matching items are selected', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, true, true)); expect(result.current).toBe(true); }); it('returns false for an all-matching selection when the caller has already obtained totals for pagination', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, false, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, false, true)); expect(result.current).toBe(false); }); diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index 243c7c361edd..59dc0453ea04 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -724,7 +724,7 @@ describe('useSelectedTransactionsActions', () => { unholdOption?.onSelected?.(); - expect(unholdRequest).toHaveBeenCalledWith(transactionID, 'child123', undefined, false, CURRENT_USER_LOGIN, CURRENT_USER_ACCOUNT_ID, undefined, false, undefined); + expect(unholdRequest).toHaveBeenCalledWith(transactionID, 'child123', undefined, false, CURRENT_USER_LOGIN, CURRENT_USER_ACCOUNT_ID, undefined, false, undefined, {}); expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); }); @@ -778,7 +778,7 @@ describe('useSelectedTransactionsActions', () => { unholdOption?.onSelected?.(); - expect(unholdRequest).toHaveBeenCalledWith(transactionID, 'child123', undefined, true, CURRENT_USER_LOGIN, CURRENT_USER_ACCOUNT_ID, undefined, false, undefined); + expect(unholdRequest).toHaveBeenCalledWith(transactionID, 'child123', undefined, true, CURRENT_USER_LOGIN, CURRENT_USER_ACCOUNT_ID, undefined, false, undefined, {}); expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); }); diff --git a/tests/unit/hooks/useSelectionModeReportActions.test.ts b/tests/unit/hooks/useSelectionModeReportActions.test.ts index bc73f37c8115..26cb2fbae71b 100644 --- a/tests/unit/hooks/useSelectionModeReportActions.test.ts +++ b/tests/unit/hooks/useSelectionModeReportActions.test.ts @@ -135,6 +135,9 @@ function mockLifecycleActionsReturn(overrides?: {shouldBlockSubmit?: boolean; is handleSubmitReport: mockLifecycleHandleSubmitReport, shouldBlockSubmit: overrides?.shouldBlockSubmit ?? false, isBlockSubmitDueToPreventSelfApproval: overrides?.isBlockSubmitDueToPreventSelfApproval ?? false, + approveSubMenuItems: undefined, + approveSubMenuHeaderText: '', + shouldShowApproveSubMenu: false, }; } diff --git a/tests/unit/hooks/useTimeSensitiveCards.test.ts b/tests/unit/hooks/useTimeSensitiveCards.test.ts index d7c568ea451d..6e0110c32aa3 100644 --- a/tests/unit/hooks/useTimeSensitiveCards.test.ts +++ b/tests/unit/hooks/useTimeSensitiveCards.test.ts @@ -37,6 +37,7 @@ describe('useTimeSensitiveCards', () => { expect(result.current.shouldShowAddShippingAddress).toBe(false); expect(result.current.shouldShowActivateCard).toBe(false); expect(result.current.shouldShowReviewCardFraud).toBe(false); + expect(result.current.hasActiveExpensifyCard).toBe(false); }); it('should return empty arrays when no cards need action', async () => { @@ -52,6 +53,7 @@ describe('useTimeSensitiveCards', () => { expect(result.current.cardsNeedingActivation).toEqual([]); expect(result.current.shouldShowAddShippingAddress).toBe(false); expect(result.current.shouldShowActivateCard).toBe(false); + expect(result.current.hasActiveExpensifyCard).toBe(true); }); it('should identify cards needing shipping address and set shouldShowAddShippingAddress to true', async () => { @@ -463,4 +465,93 @@ describe('useTimeSensitiveCards', () => { expect(result.current.shouldShowActivateCard).toBe(false); expect(result.current.shouldShowReviewCardFraud).toBe(false); }); + + it('should identify cards pending digital wallet approval and set shouldShowConfirmDigitalWalletAddition to true', async () => { + const cardPendingWalletApproval: Card = { + ...createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}), + nameValuePairs: createMock({ + pendingDigitalWalletApproval: {walletProvider: CONST.EXPENSIFY_CARD.WALLET_PROVIDER.APPLE_PAY, cardLastFourDigits: '1234'}, + }), + }; + const cardList: CardList = {'1': cardPendingWalletApproval}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsPendingDigitalWalletApproval).toHaveLength(1); + expect(result.current.cardsPendingDigitalWalletApproval.at(0)?.cardID).toBe(1); + expect(result.current.shouldShowConfirmDigitalWalletAddition).toBe(true); + }); + + it('should not surface a digital wallet to-do for a card without a pending approval', async () => { + const openCard = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + const cardList: CardList = {'1': openCard}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsPendingDigitalWalletApproval).toHaveLength(0); + expect(result.current.shouldShowConfirmDigitalWalletAddition).toBe(false); + }); + + it('should surface digital wallet approvals for virtual cards, which are the likeliest to be added to a wallet', async () => { + const virtualCardPendingWalletApproval: Card = { + ...createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}), + nameValuePairs: createMock({ + isVirtual: true, + pendingDigitalWalletApproval: {walletProvider: CONST.EXPENSIFY_CARD.WALLET_PROVIDER.ANDROID_PAY, cardLastFourDigits: '1234'}, + }), + }; + const cardList: CardList = {'1': virtualCardPendingWalletApproval}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsPendingDigitalWalletApproval).toHaveLength(1); + expect(result.current.shouldShowConfirmDigitalWalletAddition).toBe(true); + }); + + it('should surface digital wallet approvals for cards with a custom $0 limit, since approving a wallet token is not spending', async () => { + const zeroLimitCardPendingWalletApproval: Card = { + ...createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}), + nameValuePairs: createMock({ + hasCustomUnapprovedExpenseLimit: true, + unapprovedExpenseLimit: 0, + pendingDigitalWalletApproval: {walletProvider: CONST.EXPENSIFY_CARD.WALLET_PROVIDER.APPLE_PAY, cardLastFourDigits: '1234'}, + }), + }; + const cardList: CardList = {'1': zeroLimitCardPendingWalletApproval}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsPendingDigitalWalletApproval).toHaveLength(1); + expect(result.current.shouldShowConfirmDigitalWalletAddition).toBe(true); + }); + + it('should not surface digital wallet approvals for cards that are no longer in an active state', async () => { + const closedCardPendingWalletApproval: Card = { + ...createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.CLOSED}), + nameValuePairs: createMock({ + pendingDigitalWalletApproval: {walletProvider: CONST.EXPENSIFY_CARD.WALLET_PROVIDER.APPLE_PAY, cardLastFourDigits: '1234'}, + }), + }; + const cardList: CardList = {'1': closedCardPendingWalletApproval}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsPendingDigitalWalletApproval).toHaveLength(0); + expect(result.current.shouldShowConfirmDigitalWalletAddition).toBe(false); + }); }); diff --git a/tests/unit/hooks/useUpcomingTravelReservations.test.ts b/tests/unit/hooks/useUpcomingTravelReservations.test.ts index f8f6a1eff0ae..86ae37d5947d 100644 --- a/tests/unit/hooks/useUpcomingTravelReservations.test.ts +++ b/tests/unit/hooks/useUpcomingTravelReservations.test.ts @@ -4,7 +4,7 @@ import useUpcomingTravelReservations from '@pages/home/UpcomingTravelSection/use import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; +import type {Report, ReportNameValuePairs} from '@src/types/onyx'; import type {Pnr, PnrData, PnrTraveler} from '@src/types/onyx/TripData'; import Onyx from 'react-native-onyx'; @@ -411,8 +411,10 @@ function makeRailPnr(pnrId: string, departISO: string, arriveISO: string): Pnr { } const TEST_ACCOUNT_ID = 12345; +const TEST_USER_EMAIL = 'test@example.com'; +const OTHER_USER_EMAIL = 'other@example.com'; -function makeTripRoomReport(reportID: string, pnrs: Pnr[], ownerAccountID: number = TEST_ACCOUNT_ID): Report { +function makeTripRoomReport(reportID: string, ownerAccountID: number = TEST_ACCOUNT_ID): Report { return { reportID, ownerAccountID, @@ -420,11 +422,38 @@ function makeTripRoomReport(reportID: string, pnrs: Pnr[], ownerAccountID: numbe chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, reportName: `Trip ${reportID}`, policyID: 'policy1', + } as Report; +} + +function makeTraveler(email: string): PnrData['travelers'][number] { + return { + travelerPersonalInfo: {loyaltyInfos: []}, + user: {email, addresses: [], identityDocs: [], paymentInfos: [], phoneNumbers: []}, + userBusinessInfo: {phoneNumbers: [], designatedApproverInfos: [], designatedApproverUserIds: []}, + userOrgId: {}, + persona: '', + isActive: true, + tier: '', + adhocUserInfo: {}, + externalId: '', + }; +} + +function makeTripRoomReportNameValuePairs(reportID: string, pnrs: Pnr[], travelerEmail = TEST_USER_EMAIL): ReportNameValuePairs { + return { tripData: { tripID: `trip-${reportID}`, - payload: {pnrs}, + payload: { + pnrs: pnrs.map((pnr) => ({ + ...pnr, + data: { + ...pnr.data, + travelers: [makeTraveler(travelerEmail)], + }, + })), + }, }, - } as Report; + }; } describe('useUpcomingTravelReservations', () => { @@ -434,7 +463,7 @@ describe('useUpcomingTravelReservations', () => { beforeEach(async () => { await Onyx.clear(); - await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID}); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID, email: TEST_USER_EMAIL}); await waitForBatchedUpdates(); }); @@ -452,9 +481,10 @@ describe('useUpcomingTravelReservations', () => { it('should return empty array when all reservations are in the past', async () => { const pastFlight = makeAirPnr('PNR_PAST', daysFromNow(-3), daysFromNow(-3, 15)); - const tripRoom = makeTripRoomReport('100', [pastFlight]); + const tripRoom = makeTripRoomReport('100'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}100`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}100`, makeTripRoomReportNameValuePairs('100', [pastFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -466,9 +496,10 @@ describe('useUpcomingTravelReservations', () => { it('should return empty array when all reservations are more than 7 days away', async () => { const farFlight = makeAirPnr('PNR_FAR', daysFromNow(10), daysFromNow(10, 15)); - const tripRoom = makeTripRoomReport('101', [farFlight]); + const tripRoom = makeTripRoomReport('101'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}101`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}101`, makeTripRoomReportNameValuePairs('101', [farFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -480,9 +511,10 @@ describe('useUpcomingTravelReservations', () => { it('should return reservation departing within 7 days', async () => { const upcomingFlight = makeAirPnr('PNR_UPCOMING', daysFromNow(3), daysFromNow(3, 15)); - const tripRoom = makeTripRoomReport('102', [upcomingFlight]); + const tripRoom = makeTripRoomReport('102'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}102`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}102`, makeTripRoomReportNameValuePairs('102', [upcomingFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -498,9 +530,10 @@ describe('useUpcomingTravelReservations', () => { it('should exclude reservation that departed yesterday', async () => { const pastFlight = makeAirPnr('PNR_JUST_PASSED', daysFromNow(-1, 22), daysFromNow(-1, 23)); const upcomingFlight = makeAirPnr('PNR_LATER_TODAY', daysFromNow(1), daysFromNow(1, 15)); - const tripRoom = makeTripRoomReport('105', [pastFlight, upcomingFlight]); + const tripRoom = makeTripRoomReport('105'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}105`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}105`, makeTripRoomReportNameValuePairs('105', [pastFlight, upcomingFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -517,11 +550,13 @@ describe('useUpcomingTravelReservations', () => { const flight2 = makeAirPnr('PNR_DAY1', daysFromNow(1), daysFromNow(1, 15)); const flight3 = makeAirPnr('PNR_DAY3', daysFromNow(3), daysFromNow(3, 15)); - const tripRoom1 = makeTripRoomReport('200', [flight1]); - const tripRoom2 = makeTripRoomReport('201', [flight2, flight3]); + const tripRoom1 = makeTripRoomReport('200'); + const tripRoom2 = makeTripRoomReport('201'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}200`, tripRoom1); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}201`, tripRoom2); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}200`, makeTripRoomReportNameValuePairs('200', [flight1, flight3])); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}201`, makeTripRoomReportNameValuePairs('201', [flight2])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -539,9 +574,10 @@ describe('useUpcomingTravelReservations', () => { const hotel = makeHotelPnr('PNR_HOTEL_MIX', daysFromNow(4), daysFromNow(6)); const rail = makeRailPnr('PNR_RAIL_MIX', daysFromNow(1), daysFromNow(1, 16)); - const tripRoom = makeTripRoomReport('400', [flight, hotel, rail]); + const tripRoom = makeTripRoomReport('400'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}400`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}400`, makeTripRoomReportNameValuePairs('400', [flight, hotel, rail])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -564,11 +600,13 @@ describe('useUpcomingTravelReservations', () => { const flight1 = makeAirPnr('PNR_TRIP1', daysFromNow(4), daysFromNow(4, 15)); const flight2 = makeAirPnr('PNR_TRIP2', daysFromNow(2), daysFromNow(2, 15)); - const tripRoom1 = makeTripRoomReport('500', [flight1]); - const tripRoom2 = makeTripRoomReport('501', [flight2]); + const tripRoom1 = makeTripRoomReport('500'); + const tripRoom2 = makeTripRoomReport('501'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}500`, tripRoom1); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}501`, tripRoom2); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}500`, makeTripRoomReportNameValuePairs('500', [flight1])); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}501`, makeTripRoomReportNameValuePairs('501', [flight2])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -584,9 +622,10 @@ describe('useUpcomingTravelReservations', () => { it('should exclude trips owned by other users', async () => { const flight = makeAirPnr('PNR_OTHER', daysFromNow(2), daysFromNow(2, 15)); - const tripRoom = makeTripRoomReport('600', [flight], 99999); + const tripRoom = makeTripRoomReport('600', 99999); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}600`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}600`, makeTripRoomReportNameValuePairs('600', [flight], OTHER_USER_EMAIL)); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -600,11 +639,13 @@ describe('useUpcomingTravelReservations', () => { const ownFlight = makeAirPnr('PNR_OWN', daysFromNow(3), daysFromNow(3, 15)); const otherFlight = makeAirPnr('PNR_OTHER_MIX', daysFromNow(2), daysFromNow(2, 15)); - const ownTripRoom = makeTripRoomReport('700', [ownFlight], TEST_ACCOUNT_ID); - const otherTripRoom = makeTripRoomReport('701', [otherFlight], 88888); + const ownTripRoom = makeTripRoomReport('700', TEST_ACCOUNT_ID); + const otherTripRoom = makeTripRoomReport('701', 88888); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}700`, ownTripRoom); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}701`, otherTripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}700`, makeTripRoomReportNameValuePairs('700', [ownFlight])); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}701`, makeTripRoomReportNameValuePairs('701', [otherFlight], OTHER_USER_EMAIL)); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -616,7 +657,7 @@ describe('useUpcomingTravelReservations', () => { expect(result.current.at(0)?.reportID).toBe('700'); }); - it('should exclude trips with undefined ownerAccountID', async () => { + it('should exclude trips without the current user as a traveler', async () => { const flight = makeAirPnr('PNR_NO_OWNER', daysFromNow(2), daysFromNow(2, 15)); const tripRoom = { reportID: '800', @@ -624,13 +665,10 @@ describe('useUpcomingTravelReservations', () => { chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, reportName: 'Trip 800', policyID: 'policy1', - tripData: { - tripID: 'trip-800', - payload: {pnrs: [flight]}, - }, } as Report; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}800`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}800`, makeTripRoomReportNameValuePairs('800', [flight], OTHER_USER_EMAIL)); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -645,13 +683,16 @@ describe('useUpcomingTravelReservations', () => { const flight2 = makeAirPnr('PNR_OTHER_B', daysFromNow(3), daysFromNow(3, 15)); const flight3 = makeAirPnr('PNR_OTHER_C', daysFromNow(5), daysFromNow(5, 15)); - const tripRoom1 = makeTripRoomReport('900', [flight1], 11111); - const tripRoom2 = makeTripRoomReport('901', [flight2], 22222); - const tripRoom3 = makeTripRoomReport('902', [flight3], 33333); + const tripRoom1 = makeTripRoomReport('900', 11111); + const tripRoom2 = makeTripRoomReport('901', 22222); + const tripRoom3 = makeTripRoomReport('902', 33333); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}900`, tripRoom1); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}901`, tripRoom2); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}902`, tripRoom3); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}900`, makeTripRoomReportNameValuePairs('900', [flight1], OTHER_USER_EMAIL)); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}901`, makeTripRoomReportNameValuePairs('901', [flight2], OTHER_USER_EMAIL)); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}902`, makeTripRoomReportNameValuePairs('902', [flight3], OTHER_USER_EMAIL)); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -664,9 +705,10 @@ describe('useUpcomingTravelReservations', () => { it('should skip reservations with invalid start date and keep valid ones', async () => { const invalidFlight = makeAirPnr('PNR_INVALID', 'not-a-date', 'not-a-date'); const validFlight = makeAirPnr('PNR_VALID', daysFromNow(2), daysFromNow(2, 15)); - const tripRoom = makeTripRoomReport('1000', [invalidFlight, validFlight]); + const tripRoom = makeTripRoomReport('1000'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1000`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1000`, makeTripRoomReportNameValuePairs('1000', [invalidFlight, validFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -679,9 +721,10 @@ describe('useUpcomingTravelReservations', () => { it('should return empty array when all reservations have invalid start dates', async () => { const invalidFlight = makeAirPnr('PNR_INVALID_ALL', '', ''); - const tripRoom = makeTripRoomReport('1001', [invalidFlight]); + const tripRoom = makeTripRoomReport('1001'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1001`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1001`, makeTripRoomReportNameValuePairs('1001', [invalidFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -693,9 +736,10 @@ describe('useUpcomingTravelReservations', () => { it('should include reservation at the exact 7-day boundary', async () => { const boundaryFlight = makeAirPnr('PNR_BOUNDARY', daysFromNow(CONST.UPCOMING_TRAVEL_WINDOW_DAYS, 0), daysFromNow(CONST.UPCOMING_TRAVEL_WINDOW_DAYS, 3)); - const tripRoom = makeTripRoomReport('1002', [boundaryFlight]); + const tripRoom = makeTripRoomReport('1002'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1002`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1002`, makeTripRoomReportNameValuePairs('1002', [boundaryFlight])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -706,7 +750,7 @@ describe('useUpcomingTravelReservations', () => { expect(result.current.at(0)?.reservation.reservationID).toBe('PNR_BOUNDARY'); }); - it('should return empty array for trip room without tripData', async () => { + it('should return empty array for trip room without report name-value-pair tripData', async () => { const tripRoom = { reportID: '1003', ownerAccountID: TEST_ACCOUNT_ID, @@ -727,9 +771,10 @@ describe('useUpcomingTravelReservations', () => { }); it('should return empty array for trip room with empty pnrs array', async () => { - const tripRoom = makeTripRoomReport('1004', []); + const tripRoom = makeTripRoomReport('1004'); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1004`, tripRoom); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1004`, makeTripRoomReportNameValuePairs('1004', [])); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); @@ -748,13 +793,10 @@ describe('useUpcomingTravelReservations', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, reportName: 'Policy Room', policyID: 'policy1', - tripData: { - tripID: 'trip-1005', - payload: {pnrs: [flight]}, - }, } as Report; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1005`, nonTripReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1005`, {tripData: {tripID: 'trip-1005', payload: {pnrs: [flight]}}}); await waitForBatchedUpdates(); const {result} = renderHook(() => useUpcomingTravelReservations()); diff --git a/tests/unit/hooks/useUpdateFilterQuery.test.ts b/tests/unit/hooks/useUpdateFilterQuery.test.ts new file mode 100644 index 000000000000..4fd28127cdb8 --- /dev/null +++ b/tests/unit/hooks/useUpdateFilterQuery.test.ts @@ -0,0 +1,88 @@ +import {renderHook} from '@testing-library/react-native'; + +import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; +import type {SearchQueryJSON} from '@components/Search/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; + +const onyxData: Record = {}; + +const mockUseOnyx = jest.fn((key: string) => [onyxData[key]]); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string) => mockUseOnyx(key), +})); + +const mockResetSearchKey = jest.fn(); +const mockUseSearchQueryContext = jest.fn<{currentSearchHash: number}, []>(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryActions: () => ({resetSearchKey: mockResetSearchKey}), + useSearchQueryContext: () => mockUseSearchQueryContext(), +})); + +jest.mock('@libs/Navigation/Navigation'); + +const queryJSON: SearchQueryJSON = { + hash: 0, + recentSearchHash: 0, + similarSearchHash: 0, + groupBy: undefined, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + flatFilters: [], + inputQuery: '', + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, right: ''}, + columns: undefined, + limit: undefined, + rawFilterList: undefined, +}; + +describe('useUpdateFilterQuery', () => { + beforeEach(() => { + onyxData[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM] = {type: CONST.SEARCH.DATA_TYPES.EXPENSE} satisfies Partial; + mockResetSearchKey.mockClear(); + mockUseSearchQueryContext.mockReturnValue({currentSearchHash: -1}); + }); + + describe('setFilterQueryParams', () => { + it('resets the search key when the type changes', () => { + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.INVOICE}); + + expect(mockResetSearchKey).toHaveBeenCalledTimes(1); + expect(mockResetSearchKey).toHaveBeenCalledWith(expect.objectContaining({type: CONST.SEARCH.DATA_TYPES.INVOICE})); + }); + + it('does not reset the search key when the new query is the current query', () => { + mockUseSearchQueryContext.mockReturnValue({currentSearchHash: queryJSON.hash}); + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + + expect(mockResetSearchKey).not.toHaveBeenCalled(); + }); + + it('does not reset the search key when the type is unchanged', () => { + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE, merchant: 'Amazon'}); + + expect(mockResetSearchKey).not.toHaveBeenCalled(); + }); + + it('does not reset the search key when no type is provided', () => { + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({merchant: 'Amazon'}); + + expect(mockResetSearchKey).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts index cb99d3b27550..a8a666e597fe 100644 --- a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts +++ b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts @@ -17,6 +17,8 @@ import { updateMoneyRequestMerchant, updateMoneyRequestTag, } from '@userActions/IOU/UpdateMoneyRequest'; +import {createTransactionThreadReport} from '@userActions/Report'; +import type * as ReportActions from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,6 +38,13 @@ jest.mock('@userActions/IOU/UpdateMoneyRequest', () => ({ updateMoneyRequestTag: jest.fn(), })); +jest.mock('@userActions/Report', () => ({ + ...jest.requireActual('@userActions/Report'), + createTransactionThreadReport: jest.fn(), +})); + +const mockCreateTransactionThreadReport = jest.mocked(createTransactionThreadReport); + describe('TransactionInlineEdit', () => { describe('getTransactionEditPermissions', () => { // Use unreported transaction by default to bypass most permission checks @@ -90,6 +99,7 @@ describe('TransactionInlineEdit', () => { parentReport: baseParentReport, policy: basePolicy, parentReportActions: undefined, + rules: undefined, }; const policyCategories: PolicyCategories = { @@ -627,6 +637,7 @@ describe('TransactionInlineEdit', () => { reportPolicyTags: undefined, policyRecentlyUsedCategories: undefined, policyRecentlyUsedTags: undefined, + conciergeChat: undefined, isSelfTourViewed: true, hasCompletedGuidedSetupFlow: true, personalDetailsList: undefined, @@ -634,6 +645,14 @@ describe('TransactionInlineEdit', () => { isTrackIntentUser: false, getCurrencyDecimals: () => 2, getCurrencySymbol: () => '$', + transactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`]: snapshotTransaction}, + transactionViolations: {}, + betas: [], + isASAPSubmitBetaEnabled: false, + introSelected: undefined, + currentUserAccountID: CONST.DEFAULT_NUMBER_ID, + currentUserEmail: '', + rules: undefined, }; } @@ -661,5 +680,47 @@ describe('TransactionInlineEdit', () => { expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({value: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT})); }); + + describe('transaction thread creation', () => { + const CONCIERGE_CHAT: Report = {reportID: 'concierge-inline-edit-1'}; + const CREATED_THREAD: Report = {reportID: 'inline-edit-thread-1'}; + + /** An IOU action with no childReportID, so no transaction thread can be resolved from it. */ + const iouParentReportAction = { + reportActionID: '999', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2026-08-12', + } as ReportAction; + + it('creates the missing transaction thread with the conciergeChat threaded through', () => { + 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'); + + // The threaded conciergeChat reaches createTransactionThreadReport instead of the deprecated module-level lookup... + expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith( + expect.objectContaining({conciergeChat: CONCIERGE_CHAT, iouReportAction: iouParentReportAction, transaction: snapshotTransaction}), + ); + // ...and the created thread is what the edit call receives. + expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({transactionThreadReport: CREATED_THREAD})); + }); + + 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'); + + 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'); + + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({transactionThreadReport: undefined})); + }); + }); }); }); diff --git a/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts b/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts index d721323cd502..f0be2c749156 100644 --- a/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts +++ b/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts @@ -25,7 +25,16 @@ jest.mock('@libs/actions/Report', () => { const mockCreateTransactionThreadReport = jest.mocked(createTransactionThreadReport); const mockSetOptimisticTransactionThread = jest.mocked(setOptimisticTransactionThread); -const CONTEXT = {introSelected: undefined, betas: undefined, currentUserEmail: 'me@test.com', currentUserAccountID: 1, personalDetails: undefined, conciergeChat: undefined}; +const CONTEXT = { + introSelected: undefined, + betas: undefined, + currentUserEmail: 'me@test.com', + currentUserAccountID: 1, + personalDetails: undefined, + conciergeChat: undefined, + isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, +}; function buildTransaction(transactionID: string, reportID: string): Transaction { return {transactionID, reportID, amount: 0, created: '', currency: 'USD', merchant: '', comment: {}}; diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index 75d79aebebfb..e660121a8018 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -1,9 +1,11 @@ import type PrepareRequestPayload from '@libs/prepareRequestPayload/types'; -const mockCheckFileExists = jest.fn, [string | undefined]>(); +type MockFileCheckResult = {exists: boolean; error?: {message: string; code?: string}}; +const mockCheckFileExists = jest.fn, [string | undefined]>(); jest.mock('@libs/fileDownload/checkFileExists', () => ({ __esModule: true, - default: mockCheckFileExists, + default: (path: string | undefined) => mockCheckFileExists(path).then((result) => result.exists), + checkFileExistsWithReason: mockCheckFileExists, })); jest.mock('@libs/fileDownload/FileUtils', () => ({ @@ -43,7 +45,7 @@ describe('prepareRequestPayload (native)', () => { }); it('should include receipt in FormData when the file exists', async () => { - mockCheckFileExists.mockResolvedValue(true); + mockCheckFileExists.mockResolvedValue({exists: true}); const receipt = { source: 'file:///var/mobile/Documents/Receipts-Upload/receipt.jpg', @@ -59,7 +61,7 @@ describe('prepareRequestPayload (native)', () => { }); it('should log a joinable [Receipt] dropped line and omit receipt from FormData when file does not exist', async () => { - mockCheckFileExists.mockResolvedValue(false); + mockCheckFileExists.mockResolvedValue({exists: false, error: {message: 'ENOENT: no such file', code: 'ENOENT'}}); const receipt = { source: 'file:///var/mobile/Library/Caches/ImageManipulator/receipt.jpg', @@ -80,11 +82,13 @@ describe('prepareRequestPayload (native)', () => { command: 'RequestMoney', source: 'file:///var/mobile/Library/Caches/ImageManipulator/receipt.jpg', fileName: 'receipt.jpg', + // The errno separates a deleted file from one that is there but unreadable + statError: {message: 'ENOENT: no such file', code: 'ENOENT'}, }); }); it('should recover a queued receipt whose stored path names a stale container, by re-rooting the filename', async () => { - mockCheckFileExists.mockResolvedValue(true); + mockCheckFileExists.mockResolvedValue({exists: true}); const receipt = { // Written before an app upgrade. The device no longer has this container. @@ -103,7 +107,7 @@ describe('prepareRequestPayload (native)', () => { }); it('should still report a genuinely missing file as dropped', async () => { - mockCheckFileExists.mockResolvedValue(false); + mockCheckFileExists.mockResolvedValue({exists: false, error: {message: 'ENOENT: no such file', code: 'ENOENT'}}); const receipt = { source: 'file:///Containers/Data/Application/CURRENT/Documents/Receipts-Upload/gone.jpg', diff --git a/tests/unit/navigateToDomainRouteWithSidebarSyncTest.ts b/tests/unit/navigateToDomainRouteWithSidebarSyncTest.ts new file mode 100644 index 000000000000..85793d2987a6 --- /dev/null +++ b/tests/unit/navigateToDomainRouteWithSidebarSyncTest.ts @@ -0,0 +1,180 @@ +import navigateToDomainRouteWithSidebarSync from '@libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import createMock from '../utils/createMock'; + +// eslint-disable-next-line @typescript-eslint/unbound-method -- jest.fn() mock doesn't rely on `this` binding +const mockGetRootState = jest.mocked(navigationRef.getRootState); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + setParams: jest.fn(), + }, +})); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + isReady: jest.fn(), + getRootState: jest.fn(), + }, +})); + +function mockDomainNavigationState(domainAccountID?: number) { + jest.mocked(navigationRef).isReady.mockReturnValue(true); + jest.mocked(navigationRef).getRootState.mockReturnValue( + createMock>({ + routes: [ + { + key: 'tab-navigator', + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + key: 'tab-state', + routes: [ + { + key: 'workspace-navigator', + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: { + key: 'workspace-state', + routes: [ + { + key: 'domain-split', + name: NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR, + state: { + key: 'domain-split-state', + routes: [ + { + key: 'domain-sidebar', + name: SCREENS.DOMAIN.INITIAL, + params: {domainAccountID}, + }, + { + key: 'domain-members', + name: SCREENS.DOMAIN.MEMBERS, + params: {domainAccountID}, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }), + ); +} + +describe('navigateToDomainRouteWithSidebarSync', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates the persistent sidebar before navigating to another Domain', () => { + mockDomainNavigationState(100); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(200); + + navigateToDomainRouteWithSidebarSync(targetRoute, 200, false); + + expect(Navigation.setParams).toHaveBeenCalledWith({domainAccountID: 200}, 'domain-sidebar', 'domain-split-state'); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + const setParamsOrder = jest.mocked(Navigation.setParams).mock.invocationCallOrder.at(0) ?? 0; + const navigateOrder = jest.mocked(Navigation.navigate).mock.invocationCallOrder.at(0) ?? 0; + expect(setParamsOrder).toBeLessThan(navigateOrder); + }); + + it('does not update the sidebar when navigating within the same Domain', () => { + mockDomainNavigationState(100); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(100); + + navigateToDomainRouteWithSidebarSync(targetRoute, 100, false); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); + + it('navigates directly on narrow layouts', () => { + mockDomainNavigationState(100); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(200); + + navigateToDomainRouteWithSidebarSync(targetRoute, 200, true); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); + + it('navigates directly when there is no active Domain sidebar', () => { + jest.mocked(navigationRef).isReady.mockReturnValue(true); + jest.mocked(navigationRef).getRootState.mockReturnValue( + createMock>({ + routes: [], + }), + ); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(200); + + navigateToDomainRouteWithSidebarSync(targetRoute, 200, false); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); + + it('navigates directly when navigation is not ready', () => { + jest.mocked(navigationRef).isReady.mockReturnValue(false); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(200); + + navigateToDomainRouteWithSidebarSync(targetRoute, 200, false); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + expect(mockGetRootState).not.toHaveBeenCalled(); + }); + + it('synchronizes a sidebar with a missing Domain ID', () => { + mockDomainNavigationState(); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(200); + + navigateToDomainRouteWithSidebarSync(targetRoute, 200, false); + + expect(Navigation.setParams).toHaveBeenCalledWith({domainAccountID: 200}, 'domain-sidebar', 'domain-split-state'); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); + + it('finds a root-level Domain split navigator', () => { + jest.mocked(navigationRef).isReady.mockReturnValue(true); + jest.mocked(navigationRef).getRootState.mockReturnValue( + createMock>({ + routes: [ + { + key: 'domain-split', + name: NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR, + state: { + key: 'domain-split-state', + routes: [ + { + key: 'domain-sidebar', + name: SCREENS.DOMAIN.INITIAL, + params: {domainAccountID: 100}, + }, + ], + }, + }, + ], + }), + ); + const targetRoute = ROUTES.DOMAIN_SAML.getRoute(200); + + navigateToDomainRouteWithSidebarSync(targetRoute, 200, false); + + expect(Navigation.setParams).toHaveBeenCalledWith({domainAccountID: 200}, 'domain-sidebar', 'domain-split-state'); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); +}); diff --git a/tests/unit/navigateToWorkspaceSettingsRouteTest.ts b/tests/unit/navigateToWorkspaceSettingsRouteTest.ts new file mode 100644 index 000000000000..7bc64c29fe7f --- /dev/null +++ b/tests/unit/navigateToWorkspaceSettingsRouteTest.ts @@ -0,0 +1,222 @@ +import navigateToWorkspaceSettingsRoute from '@libs/Navigation/helpers/navigateToWorkspaceSettingsRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import createMock from '../utils/createMock'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + setParams: jest.fn(), + }, +})); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + isReady: jest.fn(), + getRootState: jest.fn(), + }, +})); + +function mockWorkspaceNavigationState(policyID: string, activeScreenName: keyof WorkspaceSplitNavigatorParamList = SCREENS.WORKSPACE.WORKFLOWS) { + jest.mocked(navigationRef).isReady.mockReturnValue(true); + jest.mocked(navigationRef).getRootState.mockReturnValue( + createMock>({ + key: 'root-state', + index: 0, + routeNames: [NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR], + type: 'stack', + stale: false, + routes: [ + { + key: 'workspace-split', + name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + state: { + key: 'split-state', + index: 1, + routeNames: [SCREENS.WORKSPACE.INITIAL, activeScreenName], + type: 'stack', + stale: false, + routes: [ + { + name: SCREENS.WORKSPACE.INITIAL, + key: 'workspace-sidebar', + params: {policyID}, + }, + { + name: activeScreenName, + key: 'workspace-central', + params: {policyID}, + }, + ], + }, + }, + ], + }), + ); +} + +function mockTabWorkspaceNavigationState(policyID: string, activeScreenName: keyof WorkspaceSplitNavigatorParamList = SCREENS.WORKSPACE.WORKFLOWS) { + jest.mocked(navigationRef).isReady.mockReturnValue(true); + jest.mocked(navigationRef).getRootState.mockReturnValue( + createMock>({ + key: 'root-state', + index: 0, + routeNames: [NAVIGATORS.TAB_NAVIGATOR], + type: 'stack', + stale: false, + routes: [ + { + key: 'tab-navigator', + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + key: 'tab-state', + index: 0, + routeNames: [NAVIGATORS.WORKSPACE_NAVIGATOR], + type: 'tab', + stale: false, + routes: [ + { + key: 'workspace-navigator', + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: { + key: 'workspace-state', + index: 0, + routeNames: [NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR], + type: 'stack', + stale: false, + routes: [ + { + key: 'workspace-split', + name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + state: { + key: 'split-state', + index: 1, + routeNames: [SCREENS.WORKSPACE.INITIAL, activeScreenName], + type: 'stack', + stale: false, + routes: [ + { + name: SCREENS.WORKSPACE.INITIAL, + key: 'workspace-sidebar', + params: {policyID}, + }, + { + name: activeScreenName, + key: 'workspace-central', + params: {policyID}, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }), + ); +} + +describe('navigateToWorkspaceSettingsRoute', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates directly on narrow layouts', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-b'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', true, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute, {shouldSkipInitialSplitNavigatorSidebar: true}); + expect(Navigation.setParams).not.toHaveBeenCalled(); + }); + + it('does not skip the sidebar on wide layouts', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-b'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', false, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + expect(Navigation.navigate).not.toHaveBeenCalledWith(targetRoute, {shouldSkipInitialSplitNavigatorSidebar: true}); + }); + + it('navigates directly when the current page is not a Workspace setting', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-b'); + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', false, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + expect(Navigation.setParams).not.toHaveBeenCalled(); + }); + + it('navigates directly when the target page is not a Workspace setting', () => { + const targetRoute = ROUTES.HOME; + mockWorkspaceNavigationState('workspace-a'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', false, SCREENS.HOME); + + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + expect(Navigation.setParams).not.toHaveBeenCalled(); + }); + + it('compares Workspace targets without query parameters while preserving them for navigation', () => { + const targetRoute = ROUTES.WORKSPACE_WORKFLOWS.getRoute('workspace-a', CONST.TAB.WORKFLOWS.APPROVALS); + mockWorkspaceNavigationState('workspace-a'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-a', false, SCREENS.WORKSPACE.WORKFLOWS); + + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + expect(Navigation.setParams).not.toHaveBeenCalled(); + }); + + it('updates the sidebar policy before navigating to a different Workspace page', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-b'); + mockWorkspaceNavigationState('workspace-a'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', false, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.setParams).toHaveBeenCalledWith({policyID: 'workspace-b'}, 'workspace-sidebar', 'split-state'); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + expect(jest.mocked(Navigation.setParams).mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(Navigation.navigate).mock.invocationCallOrder.at(0) ?? 0); + }); + + it('updates the sidebar policy inside the Workspaces tab navigator', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-b'); + mockTabWorkspaceNavigationState('workspace-a'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', false, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.setParams).toHaveBeenCalledWith({policyID: 'workspace-b'}, 'workspace-sidebar', 'split-state'); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); + + it('updates the sidebar when switching to the same page in another Workspace', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-b'); + mockWorkspaceNavigationState('workspace-a', SCREENS.WORKSPACE.MEMBERS); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-b', false, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.setParams).toHaveBeenCalledWith({policyID: 'workspace-b'}, 'workspace-sidebar', 'split-state'); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); + + it('keeps the existing sidebar policy when changing pages in the same Workspace', () => { + const targetRoute = ROUTES.WORKSPACE_MEMBERS.getRoute('workspace-a'); + mockWorkspaceNavigationState('workspace-a'); + + navigateToWorkspaceSettingsRoute(targetRoute, 'workspace-a', false, SCREENS.WORKSPACE.MEMBERS); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalledWith(targetRoute); + }); +}); diff --git a/tests/unit/onyxDerivedComputeSpanTest.ts b/tests/unit/onyxDerivedComputeSpanTest.ts new file mode 100644 index 000000000000..b111c7e5e041 --- /dev/null +++ b/tests/unit/onyxDerivedComputeSpanTest.ts @@ -0,0 +1,130 @@ +import {cancelAllSpans, startSpan} from '@libs/telemetry/activeSpans'; + +import initOnyxDerivedValues from '@userActions/OnyxDerived'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; + +import type {StartSpanOptions} from '@sentry/core'; + +import * as Sentry from '@sentry/react-native'; +import Onyx from 'react-native-onyx'; + +import createRandomTransaction from '../utils/collections/transaction'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@sentry/react-native', () => { + const startInactiveSpanCalls: unknown[] = []; + return { + startInactiveSpan: (options: {op?: string}) => { + startInactiveSpanCalls.push(options); + return { + op: options?.op, + attributes: {} as Record, + setAttribute(key: string, value: unknown) { + this.attributes[key] = value; + }, + setAttributes(attrs: Record) { + Object.assign(this.attributes, attrs); + }, + setStatus() {}, + end() {}, + }; + }, + spanToJSON: (span: {attributes: Record}) => ({data: span.attributes}), + captureMessage: () => {}, + captureException: () => {}, + addBreadcrumb: () => {}, + getClient: () => undefined, + getActiveSpan: () => undefined, + startInactiveSpanCalls, + }; +}); + +const DERIVED_KEY = ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS; + +function getStartInactiveSpanCalls() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (Sentry as unknown as {startInactiveSpanCalls: StartSpanOptions[]}).startInactiveSpanCalls; +} + +function getComputeCallForDerivedKey(derivedKey: string) { + return getStartInactiveSpanCalls().find((options) => options.op === CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE && options.attributes?.derivedKey === derivedKey); +} + +function startSendSpan(reportActionID: string) { + return startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${reportActionID}`, { + name: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, + }); +} + +async function mergeTransactionToRecomputeDerivedKey(transactionID: string) { + const transaction: Transaction = {...createRandomTransaction(1), transactionID, reportID: 'r1', amount: 100}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); +} + +describe('OnyxDerived compute span parenting', () => { + beforeAll(async () => { + jest.spyOn(console, 'debug').mockImplementation(() => {}); + Onyx.init({keys: ONYXKEYS}); + initOnyxDerivedValues(); + await waitForBatchedUpdates(); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + cancelAllSpans(); + getStartInactiveSpanCalls().length = 0; + }); + + it('nests a recompute under the only send span in flight and stamps the firing dependency', async () => { + const sendSpan = startSendSpan('a1'); + getStartInactiveSpanCalls().length = 0; + + await mergeTransactionToRecomputeDerivedKey('A'); + + const call = getComputeCallForDerivedKey(DERIVED_KEY); + expect(call).toBeDefined(); + expect(call?.parentSpan).toBe(sendSpan); + expect(call?.onlyIfParent).toBe(true); + expect(call?.attributes?.triggeredKeys).toBe(ONYXKEYS.COLLECTION.TRANSACTION); + expect(call?.attributes?.[CONST.TELEMETRY.ATTRIBUTE_IS_STARTUP]).toBe(false); + }); + + it('picks no parent when several sends are in flight', async () => { + startSendSpan('a1'); + startSendSpan('a2'); + getStartInactiveSpanCalls().length = 0; + + await mergeTransactionToRecomputeDerivedKey('B'); + + const call = getComputeCallForDerivedKey(DERIVED_KEY); + expect(call).toBeDefined(); + expect(call?.parentSpan).toBeUndefined(); + expect(call?.onlyIfParent).toBe(true); + }); + + it('prefers the startup span over a send span', async () => { + const startupSpan = startSpan(CONST.TELEMETRY.SPAN_APP_STARTUP, {name: CONST.TELEMETRY.SPAN_APP_STARTUP}); + startSendSpan('a1'); + getStartInactiveSpanCalls().length = 0; + + await mergeTransactionToRecomputeDerivedKey('C'); + + const call = getComputeCallForDerivedKey(DERIVED_KEY); + expect(call?.parentSpan).toBe(startupSpan); + expect(call?.attributes?.[CONST.TELEMETRY.ATTRIBUTE_IS_STARTUP]).toBe(true); + }); + + it('picks no parent when nothing is in flight', async () => { + await mergeTransactionToRecomputeDerivedKey('D'); + + const call = getComputeCallForDerivedKey(DERIVED_KEY); + expect(call).toBeDefined(); + expect(call?.parentSpan).toBeUndefined(); + }); +}); diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index 42b1fd012ffa..c9ac23465c51 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- Jest factory mocks use CommonJS require() which returns untyped modules; typing each mock precisely is not practical here */ import {render, screen, within} from '@testing-library/react-native'; +import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import HomePage from '@pages/home/HomePage'; @@ -9,12 +10,14 @@ import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import Onyx from 'react-native-onyx'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: true}))); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: (key: string) => key, @@ -57,15 +60,29 @@ jest.mock('@components/Navigation/QuickCreationActionsBar', () => { } return MockQuickCreationActionsBar; }); +// Rendered as a host View so `shouldShowLoadingBar` can be read back off the element's props. jest.mock('@components/Navigation/TopBar', () => { - function MockTopBar() { - return null; + const ReactModule = require('react'); + const {View: RNView} = require('react-native'); + function MockTopBar({shouldShowLoadingBar}: {shouldShowLoadingBar?: boolean}) { + return ReactModule.createElement(RNView, {testID: 'topBar', shouldShowLoadingBar}); } return MockTopBar; }); + +jest.mock('@gorhom/portal', () => { + const ReactModule = require('react'); + const {View: RNView} = require('react-native'); + return { + ...jest.requireActual('@gorhom/portal'), + PortalHost: ({name}: {name: string}) => ReactModule.createElement(RNView, {testID: `portal-host-${name}`}), + }; +}); + jest.mock('@components/ReceiptScanDropZone', () => { - function MockReceiptScanDropZone() { - return null; + // The drop zone wraps the page content, so the mock has to keep rendering its children. + function MockReceiptScanDropZone({children}: {children: React.ReactNode}) { + return children; } return MockReceiptScanDropZone; }); @@ -90,6 +107,7 @@ jest.mock('@pages/home/InsightsSection', () => mockSection('InsightsSection')); jest.mock('@pages/home/DiscoverSection', () => mockSection('DiscoverSection')); const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); +const mockUseNetwork = jest.mocked(useNetwork); function buildLayout(shouldUseNarrowLayout: boolean): ReturnType { return { @@ -115,11 +133,17 @@ function setWideLayout() { mockUseResponsiveLayout.mockReturnValue(buildLayout(false)); } +function setIsOffline(isOffline: boolean) { + mockUseNetwork.mockReturnValue({isOffline}); +} + const renderHomePage = () => render( - - - , + + + + + , ); function renderedSectionOrder() { @@ -134,10 +158,84 @@ describe('HomePage', () => { beforeEach(async () => { jest.clearAllMocks(); setNarrowLayout(); + setIsOffline(false); await Onyx.clear(); await waitForBatchedUpdates(); }); + describe('suggestion portal host', () => { + it.each([ + ['narrow', setNarrowLayout], + ['wide', setWideLayout], + ])('renders the suggestions host on %s layout', async (_label, setLayout) => { + // Given a layout + setLayout(); + await waitForBatchedUpdates(); + + // When the Home page renders + renderHomePage(); + + // Then the suggestions portal host is on the page + expect(screen.getByTestId('portal-host-suggestions')).toBeOnTheScreen(); + }); + }); + + // Offline, OpenApp/OpenReport never send, so IS_LOADING_APP / IS_LOADING_REPORT_DATA can stay true forever and the + // top loading bar used to hang on Home. The bar must stay hidden while offline no matter what the flags say. + describe('loading bar visibility', () => { + function renderedShouldShowLoadingBar() { + return screen.getByTestId('topBar').props.shouldShowLoadingBar; + } + + it('hides the loading bar while offline when report data is still loading', async () => { + // Given report data stuck loading while the user is offline + setIsOffline(true); + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: true, + }); + await waitForBatchedUpdates(); + + // When the Home page renders + renderHomePage(); + + // Then the top bar is told not to show the loading bar + expect(renderedShouldShowLoadingBar()).toBe(false); + }); + + it('shows the loading bar when report data is loading and the user is online', async () => { + // Given the same loading flag, but online + setIsOffline(false); + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: true, + }); + await waitForBatchedUpdates(); + + // When the Home page renders + renderHomePage(); + + // Then the loading bar is still shown, so the offline case above is the guard and not a dead assertion + expect(renderedShouldShowLoadingBar()).toBe(true); + }); + + it('hides the loading bar while offline when the app is still loading', async () => { + // Given the app stuck loading while the user is offline + setIsOffline(true); + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: true, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + }); + await waitForBatchedUpdates(); + + // When the Home page renders + renderHomePage(); + + // Then the top bar is told not to show the loading bar + expect(renderedShouldShowLoadingBar()).toBe(false); + }); + }); + // For you sits above Getting started on narrow layouts, regardless of the onboarding intent. describe('mobile ordering', () => { it.each([ diff --git a/tests/unit/pages/ReimbursementAccountPagePendingRedirectTest.tsx b/tests/unit/pages/ReimbursementAccountPagePendingRedirectTest.tsx new file mode 100644 index 000000000000..46d2f845baac --- /dev/null +++ b/tests/unit/pages/ReimbursementAccountPagePendingRedirectTest.tsx @@ -0,0 +1,486 @@ +import {act, render} 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 type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import ReimbursementAccountPage from '@pages/ReimbursementAccount/ReimbursementAccountPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Policy, ReimbursementAccount} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as ReimbursementAccountTestUtils from '../../utils/ReimbursementAccountTestUtils'; + +import createMock from '../../utils/createMock'; +import getOnyxValue from '../../utils/getOnyxValue'; +import {BACK_TO, buildAchData, OTHER_POLICY_ID, PENDING_ACCOUNT, POLICY_ID} from '../../utils/ReimbursementAccountTestUtils'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +// Mutable so individual tests can simulate the validation step covering this page and the user coming back to it. +let mockIsFocused = true; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: () => mockIsFocused, + usePreventRemove: jest.fn(), + }; +}); + +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({didScreenTransitionEnd: true}), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: jest.requireActual('../../utils/ReimbursementAccountTestUtils').createNavigationMock(), +})); + +// Stub the terminal screens so the assertions are about which branch the page picked, not about their internals. +let mockLoaderBackPress: (() => void) | undefined; +const mockEntryPoint = jest.fn(() => null); +const mockLoadingIndicator = jest.fn((props: {onBackButtonPress: () => void}) => { + mockLoaderBackPress = props.onBackButtonPress; + return null; +}); + +jest.mock('@pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint', () => ({ + __esModule: true, + default: () => mockEntryPoint(), +})); + +jest.mock('@components/ReimbursementAccountLoadingIndicator', () => ({ + __esModule: true, + default: (props: {onBackButtonPress: () => void}) => mockLoadingIndicator(props), +})); + +const USD_POLICY: Policy = { + id: POLICY_ID, + name: 'Test workspace', + outputCurrency: CONST.CURRENCY.USD, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + owner: 'admin@example.com', +}; + +const EUR_POLICY: Policy = {...USD_POLICY, outputCurrency: CONST.CURRENCY.EUR}; + +// A plain member. The USER role carries no WORKFLOWS_PAYMENTS write access, which is what canMemberWrite checks. +const MEMBER_POLICY: Policy = {...USD_POLICY, role: CONST.POLICY.ROLE.USER}; + +const PENDING_DELETE_POLICY: Policy = {...USD_POLICY, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + +// A policy that has loaded without publishing a currency. `Policy` declares outputCurrency as required, so the mock +// helper is what lets this fixture describe the partially-loaded shape Onyx can actually hold. +const NO_CURRENCY_POLICY = createMock({...USD_POLICY, outputCurrency: undefined}); + +type RouteParams = ReimbursementAccountNavigatorParamList[typeof SCREENS.REIMBURSEMENT_ACCOUNT_ROOT]; +type PageProps = PlatformStackScreenProps; + +const buildRoute = (params: RouteParams): PageProps['route'] => ({ + key: 'reimbursement-account-root', + name: SCREENS.REIMBURSEMENT_ACCOUNT_ROOT, + params, +}); + +// The page does not read the navigation prop. This inert double only satisfies the navigator-provided prop. +const navigation = createMock({}); + +// The policy reaches the page through the real withPolicy HOC, which reads it from Onyx by the route's policyID. +const seedOnyx = async (account: ReimbursementAccount, policy: Policy | null = USD_POLICY) => { + await act(async () => { + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, account); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await Onyx.set(ONYXKEYS.HAS_LOADED_APP, true); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdatesWithAct(); + }); +}; + +const pageElement = (params: RouteParams) => ( + + + +); + +const renderPage = async (params: RouteParams = {policyID: POLICY_ID}) => { + const rendered = render(pageElement(params)); + await waitForBatchedUpdatesWithAct(); + return rendered; +}; + +const validationRoute = (backTo?: string) => + backTo ? `bank-account/new/us/validation?policyID=${POLICY_ID}&backTo=${encodeURIComponent(backTo)}` : `bank-account/new/us/validation?policyID=${POLICY_ID}`; + +/** + * Asserts the page neither navigated into the validation step nor parked itself on the redirect loader, which is the + * other way a wrongly-derived redirect condition shows up: the page stops painting the entry point and never leaves. + */ +const expectNoPendingRedirect = () => { + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + expect(mockEntryPoint).toHaveBeenCalled(); +}; + +const pressLoaderBackButton = () => { + expect(mockLoadingIndicator).toHaveBeenCalled(); + mockLoaderBackPress?.(); +}; + +const getReimbursementAccount = () => getOnyxValue(ONYXKEYS.REIMBURSEMENT_ACCOUNT); +const getReimbursementAccountDraft = () => getOnyxValue(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + +describe('ReimbursementAccountPage pending USD redirect', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + global.fetch = getGlobalFetchMock(); + }); + + beforeEach(() => { + mockIsFocused = true; + mockLoaderBackPress = undefined; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + describe('when the account is pending', () => { + it('redirects to the validation step instead of painting the Continue setup entry point', async () => { + // Given a USD bank account for this policy that is pending validation + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened for that policy + await renderPage(); + + // Then it pushes the validation step and never renders the entry point + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute()); + expect(mockEntryPoint).not.toHaveBeenCalled(); + expect(mockLoadingIndicator).toHaveBeenCalled(); + }); + + it('carries backTo through to the validation route', async () => { + // Given a pending account reached from a screen that passed backTo + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened with that backTo + await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + // Then the redirect preserves it so the validation step can return there + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute(BACK_TO)); + }); + + it('redirects only once even when Onyx pushes another update for the same account', async () => { + // Given a pending account that has already redirected + await seedOnyx(PENDING_ACCOUNT); + await renderPage(); + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + + // When another update lands for the same account + await act(async () => { + await Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {bankAccountID: 5678}}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the redirect is not dispatched a second time + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + }); + + it('keeps the account data in Onyx when it unmounts because it redirected', async () => { + // Given a pending account that redirected into the validation step + await seedOnyx(PENDING_ACCOUNT); + const {unmount} = await renderPage(); + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute()); + + // When this page unmounts behind the validation step + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the data ConnectBankAccount reads survives, instead of being reset to the blank-RHP default + expect(await getReimbursementAccount()).toEqual(PENDING_ACCOUNT); + }); + + it('still clears the draft on that unmount, so the next visit does not prefill the last attempt', async () => { + // Given a pending account whose micro-deposit amounts were saved to the draft + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {amount1: '1.11', amount2: '2.22', amount3: '3.33'}); + await waitForBatchedUpdatesWithAct(); + }); + await seedOnyx(PENDING_ACCOUNT); + const {unmount} = await renderPage(); + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute()); + + // When this page unmounts behind the validation step + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then only the account survives: the draft is wiped, so the stale amounts cannot be resubmitted + expect(await getReimbursementAccount()).toEqual(PENDING_ACCOUNT); + const draft = await getReimbursementAccountDraft(); + expect(draft?.amount1).toBeUndefined(); + expect(draft?.amount2).toBeUndefined(); + expect(draft?.amount3).toBeUndefined(); + }); + + it('clears the account once the user has left the flow from the loader', async () => { + // Given a pending account that redirected, which the user then backs out of + await seedOnyx(PENDING_ACCOUNT); + const {unmount} = await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute(BACK_TO)); + pressLoaderBackButton(); + + // When this page unmounts after that exit + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then nothing is left to read the preserved data, so the usual wipe applies again + expect(await getReimbursementAccount()).toEqual(CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); + }); + }); + + describe('loader back button', () => { + it('returns to backTo when one was passed', async () => { + // Given a pending account opened with a backTo + await seedOnyx(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + // When the user presses back on the loader + pressLoaderBackButton(); + + // Then it leaves the flow to where the user came from + expect(Navigation.goBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('dismisses the modal when there is no backTo', async () => { + // Given a pending account opened without a backTo + await seedOnyx(PENDING_ACCOUNT); + await renderPage(); + + // When the user presses back on the loader + pressLoaderBackButton(); + + // Then it leaves the flow rather than stepping back into a page that redirects again + expect(Navigation.dismissModal).toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(Navigation.closeRHPFlow).not.toHaveBeenCalled(); + }); + }); + + describe('returning to the page after the redirect', () => { + it('leaves the flow when the user navigates back onto it', async () => { + // Given a pending account that redirected into the validation step + await seedOnyx(PENDING_ACCOUNT); + const {rerender} = await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + const rerenderPage = async () => { + rerender(pageElement({policyID: POLICY_ID, backTo: BACK_TO})); + await waitForBatchedUpdatesWithAct(); + }; + + // When the validation step covers this page and the user then goes back onto it + mockIsFocused = false; + await rerenderPage(); + mockIsFocused = true; + await rerenderPage(); + + // Then it leaves the flow instead of sitting on the loader forever + expect(Navigation.goBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('does not leave the flow while the redirect is still in flight', async () => { + // Given a pending account whose redirect has dispatched but has not covered this page yet + await seedOnyx(PENDING_ACCOUNT); + const {rerender} = await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + // When the page re-renders while still focused + rerender(pageElement({policyID: POLICY_ID, backTo: BACK_TO})); + await waitForBatchedUpdatesWithAct(); + + // Then it does not race the navigation it is meant to follow + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + }); + + describe('cases that must not redirect', () => { + it.each([[CONST.BANK_ACCOUNT.STATE.OPEN], [CONST.BANK_ACCOUNT.STATE.VERIFYING], [CONST.BANK_ACCOUNT.STATE.SETUP], [CONST.BANK_ACCOUNT.STATE.LOCKED]])( + 'leaves a %s account on the normal flow', + async (state) => { + // Given a USD bank account for this policy that is not pending + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({state})}); + + // When the page is opened + await renderPage(); + + // Then no redirect happens and the normal entry point is painted + expectNoPendingRedirect(); + }, + ); + + it('does not redirect a non-USD workspace', async () => { + // Given a pending account on a workspace that does not pay out in USD + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({currency: CONST.CURRENCY.EUR})}, EUR_POLICY); + + // When the page is opened + await renderPage({policyID: POLICY_ID}); + + // Then the USD validation step is not opened + expectNoPendingRedirect(); + }); + + it('does not redirect a non-USD account when the policy has not loaded yet', async () => { + // Given a pending non-USD account whose policy is not in Onyx, so policyCurrency falls back to achData + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({currency: CONST.CURRENCY.EUR})}, null); + + // When the page is opened + await renderPage(); + + // Then the fallback currency keeps the USD validation step closed + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + // The redirect fires from an effect, which runs even on the renders that return the not-found view, and the + // validation step has no authorization guard of its own. So these three are the difference between the + // not-authorized screen and the micro-deposit form. + it('does not redirect a member who cannot manage the workspace bank account', async () => { + // Given a matching pending account cached for a workspace the user only has member access to + await seedOnyx(PENDING_ACCOUNT, MEMBER_POLICY); + + // When the page is opened + await renderPage(); + + // Then the redirect does not carry the user past the not-authorized screen + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('does not redirect when the workspace is pending deletion', async () => { + // Given a matching pending account cached for a workspace the user has just deleted + await seedOnyx(PENDING_ACCOUNT, PENDING_DELETE_POLICY); + + // When the page is opened + await renderPage(); + + // Then the redirect does not reopen the flow for a workspace that is going away + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('waits for the policy instead of redirecting on a cached account alone', async () => { + // Given a pending USD account whose policy is not in Onyx, so nothing can grant access yet + await seedOnyx(PENDING_ACCOUNT, null); + + // When the page is opened for that policy + await renderPage(); + + // Then it holds, rather than authorizing the validation step from the persisted account + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('does not redirect when no currency can be resolved at all', async () => { + // Given a pending account carrying no currency, on a policy that has not published one either + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({currency: undefined})}, NO_CURRENCY_POLICY); + + // When the page is opened + await renderPage(); + + // Then the redirect stays closed rather than reading an absent currency as USD. This is the one case the + // fix does not cover: getBankAccountConnectionStatus does treat absent as USD, so the Workflows row can + // still offer Confirm and land the user on the entry point. + expectNoPendingRedirect(); + }); + + it('does not redirect while changing the bank account', async () => { + // Given a pending account on a page opened to replace that account + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened with isChangingBankAccount + await renderPage({policyID: POLICY_ID, isChangingBankAccount: true}); + + // Then the replacement flow is left alone + expectNoPendingRedirect(); + }); + + it('does not redirect when the pending account belongs to another policy', async () => { + // Given persisted data describing a different policy's pending account + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({policyID: OTHER_POLICY_ID})}); + + // When this policy's page is opened and its own fetch settles + await renderPage(); + await act(async () => { + await Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isLoading: false}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the stale account does not drag this policy into validation + expectNoPendingRedirect(); + }); + + it('does not redirect for an entry point that passes no policyID', async () => { + // Given the Wallet entry point, which passes only a bankAccountID + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened that way + await renderPage({bankAccountID: '1234'}); + + // Then there is nothing to match the persisted account against, so nothing is redirected + expectNoPendingRedirect(); + }); + + it('does not redirect before the account data has loaded', async () => { + // Given a pending account whose data is still being fetched + await seedOnyx({...PENDING_ACCOUNT, isLoading: true}); + + // When the page is opened + await renderPage(); + + // Then it waits instead of redirecting on half-loaded data + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('still clears the account on unmount when it did not redirect', async () => { + // Given an open account, which never redirects + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({state: CONST.BANK_ACCOUNT.STATE.OPEN})}); + const {unmount} = await renderPage(); + + // When the page unmounts + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the existing cleanup still resets the account + expect(await getReimbursementAccount()).toEqual(CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); + }); + }); +}); diff --git a/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx b/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx index f8bf474b7cdb..aac3db77174d 100644 --- a/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx +++ b/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx @@ -85,7 +85,7 @@ jest.mock('@components/Text', () => { return MockText; }); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { const {TouchableOpacity, Text} = jest.requireActual('react-native'); function MockButton({children, onPress}: {children: React.ReactNode; onPress?: () => void}) { return ( diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx new file mode 100644 index 000000000000..6f4ccde26778 --- /dev/null +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -0,0 +1,76 @@ +import {render} from '@testing-library/react-native'; + +import useFilterFeedValue from '@components/Search/hooks/useFilterFeedValue'; +import useFilterTaxRateValue from '@components/Search/hooks/useFilterTaxRateValue'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import * as SearchUIUtils from '@libs/SearchUIUtils'; + +import SearchSavePage from '@pages/Search/SearchSavePage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {Card, CardList} from '@src/types/onyx'; + +import React from 'react'; + +import createMock from '../../../utils/createMock'; +import {translateLocal} from '../../../utils/TestHelper'; + +jest.mock('@components/Form/FormProvider', () => jest.fn((props: React.PropsWithChildren) => props.children)); +jest.mock('@components/Form/InputWrapper', () => jest.fn(() => null)); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn((props: React.PropsWithChildren) => props.children)); +jest.mock('@components/Search/hooks/useFilterFeedValue'); +jest.mock('@components/Search/hooks/useFilterTaxRateValue'); +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: jest.fn(() => ({currentSearchQueryJSON: undefined, currentDefaultSearchQueryFilterKeys: new Set()})), + useSearchQueryActions: jest.fn(() => ({setCurrentSearchKey: jest.fn()})), +})); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@hooks/useAutoFocusInput', () => jest.fn(() => ({inputCallbackRef: jest.fn()}))); +jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: jest.fn(() => ({convertToDisplayStringWithoutCurrency: jest.fn()}))})); +jest.mock('@hooks/useLocalize'); +jest.mock('@hooks/useOnyx'); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({}))); +const cards = createMock({}); +cards[12] = createMock({cardID: 12, bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, state: CONST.EXPENSIFY_CARD.STATE.OPEN, nameValuePairs: {cardTitle: 'Selected Alpha'}}); +cards[23] = createMock({cardID: 23, bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, state: CONST.EXPENSIFY_CARD.STATE.OPEN, nameValuePairs: {cardTitle: 'Selected Beta'}}); +cards[123] = createMock({cardID: 123, bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, state: CONST.EXPENSIFY_CARD.STATE.OPEN, nameValuePairs: {cardTitle: 'Unselected Overlap'}}); +let form: Partial; +jest.mocked(useLocalize).mockReturnValue(createMock>({translate: translateLocal, localeCompare: (a, b) => a.localeCompare(b)})); +jest.mocked(useFilterFeedValue).mockImplementation((value) => `feed:${value?.join('|')}`); +jest.mocked(useFilterTaxRateValue).mockImplementation((value) => `tax:${value.join('|')}`); +jest.mocked(useOnyx).mockImplementation((key) => { + switch (key) { + case ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM: + return [form, {status: 'loaded'}]; + case ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST: + return [cards, {status: 'loaded'}]; + default: + return [undefined, {status: 'loaded'}]; + } +}); +beforeEach(() => jest.clearAllMocks()); +it.each([[['12']], [['12', '23']], [['123']]])('renders canonical card selection %j exactly', (cardID) => { + form = {cardID, feed: ['feed-a', 'feed-b'], taxRate: ['tax-a', 'tax-b'], merchant: 'Coffee Shop'}; + const output = JSON.stringify(render().toJSON()); + 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); +}); +it('renders scalar feed and tax values without calling array display hooks', () => { + jest.spyOn(SearchUIUtils, 'mapFiltersFormToLabelValueList').mockReturnValueOnce([ + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, label: 'Feed', value: 'feed-scalar'}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, label: 'Tax', value: 'tax-scalar'}, + ]); + const output = JSON.stringify(render().toJSON()); + 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]); +}); diff --git a/tests/unit/pages/USDVerifiedBankAccountFlowPageBackTest.tsx b/tests/unit/pages/USDVerifiedBankAccountFlowPageBackTest.tsx new file mode 100644 index 000000000000..0d125e083180 --- /dev/null +++ b/tests/unit/pages/USDVerifiedBankAccountFlowPageBackTest.tsx @@ -0,0 +1,180 @@ +import {act, render} 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 type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import USDVerifiedBankAccountFlowPage from '@pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {ReimbursementAccount} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as ReimbursementAccountTestUtils from '../../utils/ReimbursementAccountTestUtils'; + +import createMock from '../../utils/createMock'; +import {BACK_TO, buildAchData, PENDING_ACCOUNT, POLICY_ID} from '../../utils/ReimbursementAccountTestUtils'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: () => true, + usePreventRemove: jest.fn(), + }; +}); + +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: jest.requireActual('../../utils/ReimbursementAccountTestUtils').createNavigationMock(), +})); + +// Stub the step screens: the test only needs the back callback each one is handed, not its UI. +let mockStepBackPress: (() => void) | undefined; +const mockConnectBankAccount = jest.fn((props: {onBackButtonPress: () => void}) => { + mockStepBackPress = props.onBackButtonPress; + return null; +}); +const mockCompleteVerification = jest.fn((props: {onBackButtonPress: () => void}) => { + mockStepBackPress = props.onBackButtonPress; + return null; +}); + +jest.mock('@pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount', () => ({ + __esModule: true, + default: (props: {onBackButtonPress: () => void}) => mockConnectBankAccount(props), +})); + +jest.mock('@pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification', () => ({ + __esModule: true, + default: (props: {onBackButtonPress: () => void}) => mockCompleteVerification(props), +})); + +// The remaining steps pull in native modules (Plaid, Onfido) that cannot load under Jest, and this test never renders +// them. Only the back-navigation targets they contribute to the step order matter. +jest.mock('@pages/ReimbursementAccount/USD/BankInfo/BankInfo', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/Requestor/RequestorStep', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/KYBDocuments', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/Country', () => ({__esModule: true, default: () => null})); + +type RouteParams = ReimbursementAccountNavigatorParamList[typeof SCREENS.REIMBURSEMENT_ACCOUNT_USD]; +type PageProps = PlatformStackScreenProps; + +// The page does not read the navigation prop. This inert double only satisfies the navigator-provided prop. +const navigation = createMock({}); + +const renderPage = async (params: RouteParams) => { + const rendered = render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + return rendered; +}; + +const seedAccount = async (account: ReimbursementAccount) => { + await act(async () => { + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, account); + await waitForBatchedUpdatesWithAct(); + }); +}; + +/** Fires the back press of the step that the flow page decided to render, after confirming it is the expected one. */ +const pressStepBackButton = (expectedStep: jest.Mock) => { + expect(expectedStep).toHaveBeenCalled(); + mockStepBackPress?.(); +}; + +describe('USDVerifiedBankAccountFlowPage back press', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + mockStepBackPress = undefined; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + describe('on the validation step of a pending account', () => { + it('leaves the flow to backTo instead of returning to the page that redirects back here', async () => { + // Given a pending account sitting on the validation step, opened with a backTo + await seedAccount(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION, backTo: BACK_TO}); + + // When the user presses back + pressStepBackButton(mockConnectBankAccount); + + // Then it goes where the user came from, not to the setup entry point + expect(Navigation.goBack).toHaveBeenCalledWith(BACK_TO); + expect(Navigation.goBack).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new')); + }); + + it('dismisses the modal when there is no backTo', async () => { + // Given the same step reached without a backTo + await seedAccount(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION}); + + // When the user presses back + pressStepBackButton(mockConnectBankAccount); + + // Then it leaves the flow rather than popping onto a page that would redirect straight back + expect(Navigation.dismissModal).toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + }); + + describe('cases that keep stepping back through the flow', () => { + it('steps back to the previous page when the account is not pending', async () => { + // Given the validation step for an account that is still verifying + await seedAccount({...PENDING_ACCOUNT, achData: buildAchData({state: CONST.BANK_ACCOUNT.STATE.VERIFYING})}); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION, backTo: BACK_TO}); + + // When the user presses back + pressStepBackButton(mockConnectBankAccount); + + // Then the existing step-by-step behaviour is unchanged: back over the skipped KYB step to complete verification + expect(Navigation.goBack).toHaveBeenCalledWith(expect.stringContaining(`bank-account/new/us/${CONST.BANK_ACCOUNT.PAGE_NAMES.ACH_CONTRACT}`)); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('steps back to the previous page on a non-validation step of a pending account', async () => { + // Given a pending account on an earlier step of the flow + await seedAccount(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.ACH_CONTRACT, backTo: BACK_TO}); + + // When the user presses back + pressStepBackButton(mockCompleteVerification); + + // Then the pending shortcut does not apply and the flow steps back as before + expect(Navigation.goBack).toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/')); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/pages/USDVerifiedBankAccountFlowPageTest.tsx b/tests/unit/pages/USDVerifiedBankAccountFlowPageTest.tsx new file mode 100644 index 000000000000..938bcff02d94 --- /dev/null +++ b/tests/unit/pages/USDVerifiedBankAccountFlowPageTest.tsx @@ -0,0 +1,48 @@ +import {render} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import BankInfo from '@pages/ReimbursementAccount/USD/BankInfo/BankInfo'; +import Country from '@pages/ReimbursementAccount/USD/Country'; +import USDVerifiedBankAccountFlowPage from '@pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import createMock from '../../utils/createMock'; + +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({flex1: {}, appBG: {}}))); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()})); +jest.mock('@pages/ReimbursementAccount/USD/BankInfo/BankInfo', () => jest.fn(() => null)); +jest.mock('@pages/ReimbursementAccount/USD/Country', () => jest.fn(() => null)); +const [mockBankInfo, mockCountry] = [jest.mocked(BankInfo), jest.mocked(Country)]; +type PageProps = PlatformStackScreenProps; +function renderPage(params: PageProps['route']['params']) { + const props = {route: createMock({params}), navigation: createMock({})} satisfies PageProps; + return render(); +} +it('preserves policy-less, valid-policy, and Country-to-Plaid routing behavior', () => { + renderPage({page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT}); + renderPage({policyID: '', page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT}); + renderPage({policyID: 'policy-1', page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT}); + const [{policyID: absent}, {policyID: empty}, props] = mockBankInfo.mock.calls.map(([callProps]) => callProps); + if (!props) { + throw new Error('Expected the selected BankInfo child to render'); + } + expect([absent, empty, props.policyID, typeof props.onSubmit, typeof props.onBackButtonPress]).toEqual([undefined, '', 'policy-1', 'function', 'function']); + renderPage({policyID: 'policy-1'}); + const countryProps = mockCountry.mock.calls.at(0)?.at(0); + if (!countryProps?.onSubmit) { + throw new Error('Expected the default Country child with a submit callback'); + } + expect([countryProps.stepNames, countryProps.policyID]).toEqual([CONST.BANK_ACCOUNT.STEP_NAMES, 'policy-1']); + countryProps.onSubmit(); + expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledWith( + ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID: 'policy-1', page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT, subPage: CONST.BANK_ACCOUNT.BANK_INFO_STEP.SUB_PAGE_NAMES.PLAID}), + ); +}); diff --git a/tests/unit/pages/home/FreeTrialSectionTest.tsx b/tests/unit/pages/home/FreeTrialSectionTest.tsx index 19825e418cc9..f81b35b8a8e1 100644 --- a/tests/unit/pages/home/FreeTrialSectionTest.tsx +++ b/tests/unit/pages/home/FreeTrialSectionTest.tsx @@ -57,13 +57,6 @@ jest.mock('@hooks/useLocalize', () => }, })), ); -jest.mock('@components/Button', () => { - function MockButton({text}: {text: string}) { - return text; - } - - return MockButton; -}); jest.mock('@components/Icon', () => { function MockIcon() { return null; diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx index 2b6c078040ea..8874a502717e 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx @@ -3,9 +3,9 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import {openPersonalBankAccountSetupView} from '@src/libs/actions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; +import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import useTimeSensitiveAddBankAccount from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddBankAccount'; import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; -import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -68,9 +68,13 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: ' jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); -// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). function TimeSensitiveSection() { - return ; + return ( + + ); } const renderTimeSensitiveSection = () => diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index c5156d4fce24..17caaf6ad528 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -6,7 +6,7 @@ import {navigateToConciergeChat} from '@libs/actions/Report'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -71,9 +71,13 @@ const POLICY_ID = 'policy_1'; const POLICY_NAME = 'My Workspace'; const CONCIERGE_REPORT_ID = 'concierge_report_1'; -// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). function TimeSensitiveSection() { - return ; + return ( + + ); } const renderTimeSensitiveSection = () => diff --git a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx index 80ab7fe496e4..4a1b7e47ec38 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx @@ -3,8 +3,8 @@ import {render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; -import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -62,7 +62,12 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: ' jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); function TimeSensitiveSection() { - return ; + return ( + + ); } const renderTimeSensitiveSection = () => @@ -109,7 +114,6 @@ describe('TimeSensitiveSection - ValidateAccount', () => { renderTimeSensitiveSection(); - expect(screen.getByText('homePage.timeSensitiveSection.title')).toBeTruthy(); expect(screen.getByText('homePage.timeSensitiveSection.addPaymentCard.title')).toBeTruthy(); expect(screen.queryByText('homePage.timeSensitiveSection.validateAccount.title')).toBeNull(); }); @@ -134,7 +138,6 @@ describe('TimeSensitiveSection - ValidateAccount', () => { renderTimeSensitiveSection(); - expect(screen.getByText('homePage.timeSensitiveSection.title')).toBeTruthy(); expect(screen.getByText('homePage.timeSensitiveSection.addPaymentCard.title')).toBeTruthy(); expect(screen.queryByText('homePage.timeSensitiveSection.validateAccount.title')).toBeNull(); }); diff --git a/tests/unit/pages/inbox/report/HomeAddressRequiredContent.test.tsx b/tests/unit/pages/inbox/report/HomeAddressRequiredContent.test.tsx index 1ae88399e8c3..f5d28dd7895a 100644 --- a/tests/unit/pages/inbox/report/HomeAddressRequiredContent.test.tsx +++ b/tests/unit/pages/inbox/report/HomeAddressRequiredContent.test.tsx @@ -36,7 +36,7 @@ jest.mock('@components/RenderHTML', () => { return MockRenderHTML; }); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { function MockButton({children}: {children: React.ReactNode}) { return mockReact.createElement('mock-button', null, children); } diff --git a/tests/unit/pages/settings/AgentsPageTest.tsx b/tests/unit/pages/settings/AgentsPageTest.tsx index 77fc918fcbf6..6a3b7c6a5a9c 100644 --- a/tests/unit/pages/settings/AgentsPageTest.tsx +++ b/tests/unit/pages/settings/AgentsPageTest.tsx @@ -3,7 +3,6 @@ import {render, screen} from '@testing-library/react-native'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import AgentsPage from '@pages/settings/Agents/AgentsPage'; @@ -19,8 +18,6 @@ jest.mock('@userActions/Agent', () => ({ openAgentsPage: jest.fn(), })); -jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); - jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: (key: string) => key, @@ -77,8 +74,6 @@ jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}]) jest.mock('@components/OnyxListItemProvider', () => ({ usePersonalDetails: jest.fn(() => ({})), useSession: jest.fn(() => ({})), - usePolicyCategories: jest.fn(() => ({})), - usePolicyTags: jest.fn(() => ({})), useAllReportsTransactionsAndViolations: jest.fn(() => ({})), })); @@ -141,14 +136,6 @@ jest.mock('@components/RenderHTML', () => { return MockRenderHTML; }); -jest.mock('@pages/ErrorPage/NotFoundPage', () => { - function MockNotFoundPage() { - return 'NotFoundPage'; - } - return MockNotFoundPage; -}); - -const mockUsePermissions = jest.mocked(usePermissions); const mockOpenAgentsPage = jest.mocked(openAgentsPage); const mockUseOnyx = jest.mocked(useOnyx); const mockUsePersonalDetails = jest.mocked(usePersonalDetails); @@ -156,41 +143,23 @@ const mockUsePersonalDetails = jest.mocked(usePersonalDetails); describe('AgentsPage', () => { beforeEach(() => { jest.clearAllMocks(); - mockUsePermissions.mockReturnValue({isBetaEnabled: () => true}); mockUseOnyx.mockReturnValue([undefined, {status: 'loaded'}]); mockUsePersonalDetails.mockReturnValue({}); }); - it('renders page content when customAgent beta is enabled', () => { + it('renders page content', () => { const {toJSON} = render(); const output = JSON.stringify(toJSON()); - expect(output).not.toContain('NotFoundPage'); expect(output).toContain('agentsPage.title'); }); - it('renders NotFoundPage when customAgent beta is disabled', () => { - mockUsePermissions.mockReturnValue({isBetaEnabled: () => false}); - - const {toJSON} = render(); - - expect(JSON.stringify(toJSON())).toContain('NotFoundPage'); - }); - it('calls openAgentsPage on mount', () => { render(); expect(mockOpenAgentsPage).toHaveBeenCalledTimes(1); }); - it('does not call openAgentsPage when beta is disabled', () => { - mockUsePermissions.mockReturnValue({isBetaEnabled: () => false}); - - render(); - - expect(mockOpenAgentsPage).not.toHaveBeenCalled(); - }); - it('shows empty state when no agents exist', () => { mockUseOnyx.mockReturnValue([undefined, {status: 'loaded'}]); diff --git a/tests/unit/pages/settings/AgentsTableRowTest.tsx b/tests/unit/pages/settings/AgentsTableRowTest.tsx index d715a19c46cb..00367f5c1d21 100644 --- a/tests/unit/pages/settings/AgentsTableRowTest.tsx +++ b/tests/unit/pages/settings/AgentsTableRowTest.tsx @@ -112,7 +112,7 @@ const mockRecordButtonInnerStyles = jest.fn((accessibilityLabel: string, innerSt mockButtonInnerStyles[accessibilityLabel] = innerStyles; }); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { const {TouchableOpacity, Text} = jest.requireActual('react-native'); function MockButtonIcon() { diff --git a/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx b/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx index 2d64e48a2d1c..33b467d02405 100644 --- a/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx +++ b/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx @@ -76,7 +76,7 @@ jest.mock('@components/DelegateNoAccessWrapper', () => { return MockDelegateNoAccessWrapper; }); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { function MockButton() { return null; } diff --git a/tests/unit/pages/settings/NewAgentPageTest.tsx b/tests/unit/pages/settings/NewAgentPageTest.tsx index 39a87210c2e7..792cafe25edf 100644 --- a/tests/unit/pages/settings/NewAgentPageTest.tsx +++ b/tests/unit/pages/settings/NewAgentPageTest.tsx @@ -62,7 +62,7 @@ jest.mock('@components/BlockingViews/BlockingView', () => { return ({title, subtitle}: {title: string; subtitle?: string}) => ReactModule.createElement(ReactModule.Fragment, null, ReactModule.createElement(Text, null, title), subtitle ? ReactModule.createElement(Text, null, subtitle) : null); }); -jest.mock('@components/ButtonComposed', () => { +jest.mock('@components/Button', () => { const ReactModule = jest.requireActual('react'); const {Pressable, Text} = jest.requireActual<{ Pressable: React.ComponentType<{accessibilityRole?: string; onPress?: () => void; children?: React.ReactNode}>; diff --git a/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx new file mode 100644 index 000000000000..5282ebb121da --- /dev/null +++ b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx @@ -0,0 +1,60 @@ +import {render} from '@testing-library/react-native'; + +import ColumnsSettingsList from '@components/ColumnsSettingsList'; + +import useOnyx from '@hooks/useOnyx'; + +import {setReportDetailsColumns} from '@libs/actions/ReportLayout'; +import Navigation from '@libs/Navigation/Navigation'; + +import ReportDetailsColumnsPage from '@pages/settings/Report/ReportDetailsColumnsPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {useRoute} from '@react-navigation/native'; +import React from 'react'; + +jest.mock('@components/ColumnsSettingsList', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({accountID: 1}))); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@libs/actions/ReportLayout', () => ({setReportDetailsColumns: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@react-navigation/native', () => ({...jest.requireActual('@react-navigation/native'), useRoute: jest.fn()})); +let mockSavedColumns: string[] | undefined; +function renderColumns(savedColumns: string[] | undefined) { + mockSavedColumns = savedColumns; + render(); + const props = jest.mocked(ColumnsSettingsList).mock.calls.at(-1)?.at(0); + if (!props) { + throw new Error('Expected the rendered columns-list boundary'); + } + return props; +} +jest.mocked(useRoute).mockReturnValue({key: 'columns', name: 'columns', params: {reportID: 'report-1'}}); +jest.mocked(useOnyx).mockImplementation((key) => { + switch (key) { + case ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS: + return [mockSavedColumns, {status: 'loaded'}]; + case ONYXKEYS.COLLECTION.TRANSACTION: + return [[], {status: 'loaded'}]; + default: + return [undefined, {status: 'loaded'}]; + } +}); +it('filters unsupported saved columns, defaults invalid-only storage, and preserves save behavior', () => { + const mixedProps = renderColumns(['invalid', CONST.SEARCH.TABLE_COLUMNS.MERCHANT, 'foreign', CONST.SEARCH.TABLE_COLUMNS.DATE]); + expect(mixedProps.currentColumns).toEqual([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.DATE]); + const defaultProps = renderColumns(['invalid', 'foreign']); + expect([defaultProps.currentColumns, defaultProps.currentColumns.includes(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT)]).toEqual([defaultProps.defaultSelectedColumns, true]); + const savedColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; + const props = renderColumns(savedColumns); + expect([props.currentColumns, jest.mocked(setReportDetailsColumns).mock.calls.length]).toEqual([mockSavedColumns, 0]); + props.onSave(savedColumns); + props.onSave([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]); + expect(jest.mocked(setReportDetailsColumns)).toHaveBeenCalledWith([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT], mockSavedColumns); + expect(jest.mocked(Navigation.goBack)).toHaveBeenCalledTimes(2); +}); diff --git a/tests/unit/pages/workspace/accounting/certinia/CertiniaPrerequisitesStepTest.tsx b/tests/unit/pages/workspace/accounting/certinia/CertiniaPrerequisitesStepTest.tsx new file mode 100644 index 000000000000..4db8ae15a765 --- /dev/null +++ b/tests/unit/pages/workspace/accounting/certinia/CertiniaPrerequisitesStepTest.tsx @@ -0,0 +1,70 @@ +import {render} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import type {ScrollViewProps} from '@components/ScrollView'; +import TextLink from '@components/TextLink'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; + +import CertiniaPrerequisitesStep from '@pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import createMock from '../../../../../utils/createMock'; +import {translateLocal} from '../../../../../utils/TestHelper'; + +jest.mock('@components/Button', () => ({ + __esModule: true, + default: Object.assign( + jest.fn(() => null), + {KeyboardShortcut: jest.fn(() => null), Text: jest.fn(() => null)}, + ), +})); +jest.mock('@components/FixedFooter', () => jest.fn((props: React.PropsWithChildren) => props.children)); +jest.mock('@components/ScrollView', () => jest.fn((props: ScrollViewProps) => props.children)); +jest.mock('@components/TextLink', () => jest.fn(() => null)); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@hooks/useLocalize', () => jest.fn()); +jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({}))); +const mockLocalize = createMock>({translate: translateLocal}); +const [mockTranslate, mockOnNext, mockOnConnect] = [jest.spyOn(mockLocalize, 'translate'), jest.fn(), jest.fn()]; +const baseProps = {isEditing: false, onNext: mockOnNext, onMove: jest.fn(), onConnect: mockOnConnect, isSandbox: true}; +function renderStep(currentPageName?: string) { + render(React.createElement(CertiniaPrerequisitesStep, {...baseProps, currentPageName})); + const buttonProps = jest.mocked(Button).mock.calls.at(-1)?.[0]; + if (!buttonProps) { + throw new Error('Expected the rendered prerequisite action'); + } + return buttonProps; +} +jest.mocked(useLocalize).mockReturnValue(mockLocalize); +jest.mocked(useNetwork).mockReturnValue({isOffline: false}); +beforeEach(() => jest.clearAllMocks()); +it.each([undefined, 'foreign', CONST.CERTINIA_PREREQUISITES.PAGE_NAME.INSTALL_BUNDLE])('uses the intentional complete install-bundle fallback for %s', (pageName) => { + const button = renderStep(pageName); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundle'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundleDescription'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundleConfirm'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundlePSALink', {version: CONST.CERTINIA_PSA_BUNDLE_VERSION}); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundleFFALink', {version: CONST.CERTINIA_FFA_BUNDLE_VERSION}); + expect(jest.mocked(TextLink).mock.calls.map(([props]) => props.href)).toEqual([CONST.CERTINIA_PSA_BUNDLE_INSTALL_URL.SANDBOX, CONST.CERTINIA_FFA_BUNDLE_INSTALL_URL.SANDBOX]); + expect(button.onPress).toBe(mockOnNext); +}); +it('preserves setup-contact and OAuth content, actions, and offline state', () => { + const setupButton = renderStep(CONST.CERTINIA_PREREQUISITES.PAGE_NAME.SETUP_CONTACTS); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContacts'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsBullet1'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsBullet2'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsBullet3'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsConfirm'); + expect(setupButton.onPress).toBe(mockOnNext); + jest.mocked(useNetwork).mockReturnValue({isOffline: true}); + const oauthButton = renderStep(CONST.CERTINIA_PREREQUISITES.PAGE_NAME.OAUTH); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.oauthDescription'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.connectButton'); + expect([oauthButton.onPress, oauthButton.isDisabled]).toEqual([mockOnConnect, true]); +}); diff --git a/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts b/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts new file mode 100644 index 000000000000..eb6770c1fe30 --- /dev/null +++ b/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts @@ -0,0 +1,69 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import {getAccountingIntegrationData} from '@pages/workspace/accounting/utils'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {QBOConnectionConfig} from '@src/types/onyx/Policy'; + +type NonReimbursableDestination = QBOConnectionConfig['nonReimbursableExpensesExportDestination']; + +const POLICY_ID = 'policy123'; + +const EXISTING_CONNECTIONS = {sageIntacct: false, qbd: false, certinia: false, rillet: false, dualEntry: false, campfire: false}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const mockTranslate = ((key: string) => key) as unknown as LocaleContextProps['translate']; + +function buildPolicy(nonReimbursableExpensesExportDestination: NonReimbursableDestination, autoCreateVendor: boolean): Policy { + const policy = { + id: POLICY_ID, + connections: { + quickbooksOnline: { + config: { + nonReimbursableExpensesExportDestination, + autoCreateVendor, + }, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return policy as unknown as Policy; +} + +function getIntegrationData(nonReimbursableExpensesExportDestination: NonReimbursableDestination, autoCreateVendor = false) { + return getAccountingIntegrationData( + CONST.POLICY.CONNECTIONS.NAME.QBO, + POLICY_ID, + mockTranslate, + EXISTING_CONNECTIONS, + buildPolicy(nonReimbursableExpensesExportDestination, autoCreateVendor), + ); +} + +describe('QBO default vendor export subscriptions', () => { + it('subscribes to the bill default vendor on the Vendor Bill path even when autoCreateVendor is off', () => { + const subscribedExportSettings = getIntegrationData(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL)?.subscribedExportSettings ?? []; + + expect(subscribedExportSettings).toContain(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR); + expect(subscribedExportSettings).not.toContain(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR); + }); + + it('no longer surfaces autoCreateVendor on the export path — it is an Advanced setting', () => { + const integrationData = getIntegrationData(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, true); + + expect(integrationData?.subscribedExportSettings ?? []).not.toContain(CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR); + expect(integrationData?.subscribedAdvancedSettings ?? []).toContain(CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR); + }); + + it.each([CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD])( + 'subscribes to the credit card default vendor on the %s path', + (destination) => { + const subscribedExportSettings = getIntegrationData(destination)?.subscribedExportSettings ?? []; + + expect(subscribedExportSettings).toContain(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR); + expect(subscribedExportSettings).not.toContain(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR); + }, + ); +}); diff --git a/tests/unit/preMountDraftReportTest.ts b/tests/unit/preMountDraftReportTest.ts new file mode 100644 index 000000000000..d33baccaefea --- /dev/null +++ b/tests/unit/preMountDraftReportTest.ts @@ -0,0 +1,92 @@ +import {clearPreMountedDraftReport, clearPreMountedDraftReportMarker, preMountDraftReport} from '@libs/actions/Report/PreMountedDraftReport'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('preMountDraftReport lifecycle', () => { + beforeEach(() => Onyx.clear()); + + it('writes the pre-mount marker and the speculative report together', async () => { + // Given a draft selected as a pre-mount destination + const reportID = '123'; + const draftReport = {reportID} as Report; + + // When the draft is pre-mounted for speculative rendering + await preMountDraftReport(reportID, draftReport); + + // Then the report and recovery marker coexist so startup can detect interruption + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBe(true); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual(draftReport); + }); + + it('clearPreMountedDraftReportMarker (confirm path) clears only the marker, leaving the now-real report intact', async () => { + // Given a pre-mounted draft that completed submission successfully + const reportID = '123'; + const draftReport = {reportID} as Report; + await preMountDraftReport(reportID, draftReport); + + // When the confirmed pre-mount is finalized + await clearPreMountedDraftReportMarker(reportID); + + // Then only recovery state is cleared because the report is now real + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual(draftReport); + }); + + it('clearPreMountedDraftReport (cancel path) removes both the speculative report and the marker', async () => { + // Given a pre-mounted draft whose submission is canceled + const reportID = '123'; + const draftReport = {reportID} as Report; + await preMountDraftReport(reportID, draftReport); + + // When the speculative pre-mount is rolled back + await clearPreMountedDraftReport(reportID); + + // Then neither temporary record remains because no real report was created + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + }); + + it('clearPreMountedDraftReportMarker is a no-op on the report row when no pre-mount ever happened', async () => { + // Given a report ID without any pre-mount lifecycle state + const reportID = 'never-pre-mounted'; + + // When confirmation cleanup is called defensively + await clearPreMountedDraftReportMarker(reportID); + await waitForBatchedUpdates(); + + // Then no report row appears because marker cleanup must not create data + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + }); + + it('clearPreMountedDraftReport is safe when nothing was ever pre-mounted (does not create phantom rows)', async () => { + // Given a report ID without speculative pre-mount data + const reportID = 'never-pre-mounted'; + + // When cancellation cleanup is called defensively + await clearPreMountedDraftReport(reportID); + + // Then no phantom state is introduced because there is nothing to roll back + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + }); + + it('pre-mounting the same reportID twice overwrites the previous draft snapshot rather than merging it', async () => { + // Given a report ID whose draft snapshot changes before submission + const reportID = '123'; + await preMountDraftReport(reportID, {reportID, reportName: 'first'} as Report); + + // When the same destination is pre-mounted again with the latest snapshot + await preMountDraftReport(reportID, {reportID, reportName: 'second'} as Report); + + // Then stale fields are replaced so speculative rendering matches the latest draft + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBe(true); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual({reportID, reportName: 'second'}); + }); +}); diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index a898e705e07f..ea3fdf927fa6 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -283,6 +283,8 @@ describe('reportAttributes compute — policy change code flow', () => { undefined, // conciergeReportID undefined, // introSelected undefined, // reportMetadata + undefined, // currencyList + undefined, // rules undefined, // network ]; return args; diff --git a/tests/unit/sendMessagePhaseOnAddCommentTest.ts b/tests/unit/sendMessagePhaseOnAddCommentTest.ts new file mode 100644 index 000000000000..7c561a6610c7 --- /dev/null +++ b/tests/unit/sendMessagePhaseOnAddCommentTest.ts @@ -0,0 +1,64 @@ +import {startSendMessagePhase} from '@libs/telemetry/sendMessageSpans'; + +import CONST from '@src/CONST'; +import * as Report from '@src/libs/actions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import {createRandomReport} from '../utils/collections/reports'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/telemetry/sendMessageSpans', () => ({ + startSendMessagePhase: jest.fn(), + markSendMessageCommitted: jest.fn(), + endSendMessagePhases: jest.fn(), + cancelSendMessagePhases: jest.fn(), +})); + +const startSendMessagePhaseMock = jest.mocked(startSendMessagePhase); + +const REPORT_ID = '1'; +const REPORT_ACTION_ID = '7777'; +const CURRENT_USER_ACCOUNT_ID = 1; + +function addComment(reportActionID?: string) { + Report.addComment({ + report: {...createRandomReport(1), reportID: REPORT_ID}, + text: 'hello', + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + ancestors: [], + timezoneParam: CONST.DEFAULT_TIME_ZONE, + notifyReportID: REPORT_ID, + delegateAccountID: undefined, + conciergeReportID: undefined, + reportActionID, + }); + return waitForBatchedUpdates(); +} + +describe('addComment send-message phase span', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + startSendMessagePhaseMock.mockClear(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it("opens the Propagate phase for the caller's report action id", async () => { + await addComment(REPORT_ACTION_ID); + + expect(startSendMessagePhaseMock).toHaveBeenCalledWith(REPORT_ACTION_ID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); + }); + + it('does not open a phase without a report action id', async () => { + await addComment(); + + expect(startSendMessagePhaseMock).toHaveBeenCalledWith(undefined, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); + }); +}); diff --git a/tests/unit/setupSentryTest.ts b/tests/unit/setupSentryTest.ts index 86c261be177e..216c402181b8 100644 --- a/tests/unit/setupSentryTest.ts +++ b/tests/unit/setupSentryTest.ts @@ -43,4 +43,31 @@ describe('setupSentry', () => { it('keeps a thrown Error carrying the same text, which unlike a bare rejection has a stack to act on', () => { expect(isIgnored('No data found for key reportActions_123')).toBe(false); }); + + it('drops the ConstraintError the Convert Experiments script emits when it re-inserts an existing key', () => { + // Given the message Sentry builds from a DOMException, as `type: value` + const message = 'ConstraintError: Key already exists in the object store.'; + + // When the registered patterns are matched against it + // Then it is ignored, because only `add()` produces it and Onyx never calls `add()` + expect(isIgnored(message)).toBe(true); + }); + + it('keeps the other IndexedDB failures Onyx reports, so a real storage problem still surfaces', () => { + // Given a storage error Onyx does raise on its own write path + const message = 'AbortError: IDB write transaction aborted without an error'; + + // When the registered patterns are matched against it + // Then it still reports + expect(isIgnored(message)).toBe(false); + }); + + it('keeps a ConstraintError with different wording, which does not come from that third-party insert', () => { + // Given a ConstraintError about a unique index rather than a duplicate key + const message = "ConstraintError: Unable to add key to index 'byName': at least one key does not satisfy the uniqueness requirements."; + + // When the registered patterns are matched against it + // Then it still reports + expect(isIgnored(message)).toBe(false); + }); }); diff --git a/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts b/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts index 7d5f99ea95a0..2aea6dcba9b0 100644 --- a/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts +++ b/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts @@ -3,6 +3,7 @@ import {renderHook} from '@testing-library/react-native'; import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; import {cancelAllSpans, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {startSendMessagePhase} from '@libs/telemetry/sendMessageSpans'; import CONST from '@src/CONST'; @@ -12,6 +13,7 @@ type SpanStartListener = (span: unknown) => void; jest.mock('@sentry/react-native', () => { const spanStartListeners = new Set(); + const endOrder: string[] = []; const client = { on: (hook: string, callback: SpanStartListener) => { if (hook !== 'spanStart') { @@ -34,7 +36,9 @@ jest.mock('@sentry/react-native', () => { Object.assign(this.attributes, attrs); }, setStatus() {}, - end() {}, + end() { + endOrder.push(this.op ?? ''); + }, }; // The real SDK emits spanStart synchronously during span creation, before startInactiveSpan returns. for (const listener of spanStartListeners) { @@ -43,9 +47,15 @@ jest.mock('@sentry/react-native', () => { return span; }, spanToJSON: (span: {op?: string; attributes: Record}) => ({op: span.op, data: span.attributes}), + endOrder, }; }); +function getEndOrder() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (Sentry as unknown as {endOrder: string[]}).endOrder; +} + /** Start a send-message span the way the composer does, for a given report. Returns the (typed-as-Sentry) span. */ function sendMessageWhileLoading(reportID: string) { const spanID = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${Math.random()}`; @@ -64,6 +74,7 @@ function flushMicrotasks() { afterEach(() => { cancelAllSpans(); + getEndOrder().length = 0; }); describe('useCancelSendMessageSpanOnSkeleton', () => { @@ -124,6 +135,34 @@ describe('useCancelSendMessageSpanOnSkeleton', () => { expect(getSpan(spanID)).toBeDefined(); }); + it('cancels a phase opened before the deferred cancel runs, and cancels it before its parent', async () => { + renderHook(() => useCancelSendMessageSpanOnSkeleton('reportPhases', CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); + + const reportActionID = `${Math.random()}`; + const parentSpanID = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${reportActionID}`; + startSpan(parentSpanID, { + name: 'send-message-visible', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, + attributes: {[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: 'reportPhases'}, + }); + startSendMessagePhase(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); + + const phaseSpanID = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_${reportActionID}`; + const phaseSpan = getSpan(phaseSpanID); + if (!phaseSpan) { + throw new Error('Expected the phase span to be started while the parent was still registered'); + } + + await flushMicrotasks(); + + expect(Sentry.spanToJSON(phaseSpan).data[CONST.TELEMETRY.ATTRIBUTE_CANCELED]).toBe(true); + expect(getSpan(phaseSpanID)).toBeUndefined(); + expect(getSpan(parentSpanID)).toBeUndefined(); + + // Sentry drops a child still running when its parent ends. + expect(getEndOrder()).toEqual([CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE, CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE]); + }); + it('does nothing without a reportID', async () => { renderHook(() => useCancelSendMessageSpanOnSkeleton(undefined, CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); diff --git a/tests/unit/useCreateNavigationSuggestionsTest.ts b/tests/unit/useCreateNavigationSuggestionsTest.ts index ae2aff207660..39bb27024e10 100644 --- a/tests/unit/useCreateNavigationSuggestionsTest.ts +++ b/tests/unit/useCreateNavigationSuggestionsTest.ts @@ -33,14 +33,15 @@ const mockUseCreateReport = jest.fn<{createReport: typeof mockCreateReport; isVi isVisible: mockCreateReportIsVisible, })); const mockUseOnyx = jest.fn(); -const mockIsBetaEnabled = jest.fn(() => true); +// Enabling preventSpotnanaTravel blocks travel, so keep it off by default and let the cases that need it opt in +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); -const mockIsPermissionsBetaEnabled = jest.fn(() => false); const mockIsOnSearchMoneyRequestReportPage = jest.fn(() => false); const mockGetCurrencyDecimals = jest.fn(); let mockIsRestrictedPolicyCreation = false; @@ -162,13 +163,6 @@ jest.mock('@libs/openTravelDotLink', () => ({ openTravelDotLink: jest.fn(), })); -jest.mock('@libs/Permissions', () => ({ - __esModule: true, - default: { - isBetaEnabled: () => mockIsPermissionsBetaEnabled(), - }, -})); - jest.mock('@libs/PolicyUtils', () => ({ canSendInvoice: (...args: unknown[]) => mockCanSendInvoice(...args), getDefaultChatEnabledPolicy: (policies: unknown[]) => mockGetDefaultChatEnabledPolicy(policies), @@ -225,7 +219,7 @@ describe('useCreateNavigationSuggestions', () => { mockShouldShowPolicy.mockReturnValue(true); mockHasAcceptedTravelTerms.mockReturnValue(false); mockIsPaidGroupPolicy.mockReturnValue(false); - mockIsPermissionsBetaEnabled.mockReturnValue(false); + mockIsBetaEnabled.mockImplementation(isBetaEnabledByDefault); mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([]); mockIsOnSearchMoneyRequestReportPage.mockReturnValue(false); mockIsRestrictedPolicyCreation = false; @@ -372,7 +366,7 @@ describe('useCreateNavigationSuggestions', () => { mockOnyxValues.set(`${ONYXKEYS.COLLECTION.POLICY}${submitPolicy.id}`, {...submitPolicy, isTravelEnabled: true}); mockOnyxValues.set(ONYXKEYS.ACCOUNT, {primaryLogin}); mockOnyxValues.set(ONYXKEYS.SESSION, {...session, email: sessionEmail}); - mockIsPermissionsBetaEnabled.mockReturnValue(isBlocked); + mockIsBetaEnabled.mockImplementation((beta: string) => (beta === CONST.BETAS.PREVENT_SPOTNANA_TRAVEL ? isBlocked : true)); mockIsPaidGroupPolicy.mockReturnValue(isPaid); mockHasAcceptedTravelTerms.mockReturnValue(hasAcceptedTerms); const {result} = renderHook(() => useCreateNavigationSuggestions()); @@ -429,7 +423,7 @@ describe('useCreateNavigationSuggestions', () => { const onCreateReport = mockUseCreateReport.mock.calls.at(0)?.at(0)?.onCreateReport; act(() => onCreateReport?.(true)); - expect(createNewReport).toHaveBeenCalledWith(expect.anything(), false, true, submitPolicy, [], false, mockGetCurrencyDecimals, false, true); + 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}); expect(Navigation.navigate).toHaveBeenNthCalledWith(2, 'report/created-report', {forceReplace: false}); diff --git a/tests/unit/useCurrencyListTest.tsx b/tests/unit/useCurrencyListTest.tsx new file mode 100644 index 000000000000..4fa26b1216f2 --- /dev/null +++ b/tests/unit/useCurrencyListTest.tsx @@ -0,0 +1,129 @@ +import {renderHook} 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 {useCurrencyListActions} from '@hooks/useCurrencyList'; + +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 waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import currencyList from './currencyList.json'; + +function Wrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +async function renderCurrencyListActions() { + const hook = renderHook(() => useCurrencyListActions(), {wrapper: Wrapper}); + await waitForBatchedUpdates(); + return hook; +} + +describe('useCurrencyListActions', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.NVP_PREFERRED_LOCALE]: CONST.LOCALES.DEFAULT, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + return waitForBatchedUpdates(); + }); + + beforeEach(async () => { + await IntlStore.load(CONST.LOCALES.DEFAULT); + await Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT); + await Onyx.merge(ONYXKEYS.CURRENCY_LIST, currencyList); + await waitForBatchedUpdates(); + }); + + describe('getCurrencyDecimals', () => { + test('Currency decimals smaller than or equal 2', async () => { + const {result} = await renderCurrencyListActions(); + expect(result.current.getCurrencyDecimals('JPY')).toBe(0); + expect(result.current.getCurrencyDecimals('USD')).toBe(2); + expect(result.current.getCurrencyDecimals('RSD')).toBe(2); + }); + + test('Currency decimals larger than 2 should return 2', async () => { + const {result} = await renderCurrencyListActions(); + + // Actual: 3 + expect(result.current.getCurrencyDecimals('LYD')).toBe(2); + + // Actual: 4 + expect(result.current.getCurrencyDecimals('UYW')).toBe(2); + }); + }); + + describe('convertToDisplayString', () => { + test.each([ + [CONST.CURRENCY.USD, 25, '$0.25'], + [CONST.CURRENCY.USD, 2500, '$25.00'], + [CONST.CURRENCY.USD, 150, '$1.50'], + [CONST.CURRENCY.USD, 250000, '$2,500.00'], + ['JPY', 2500, '¥25'], + ['JPY', 250000, '¥2,500'], + ['JPY', 2500.5, '¥25'], + ['RSD', 100, 'RSD\xa01.00'], + ['RSD', 145, 'RSD\xa01.45'], + ['BHD', 12345, 'BHD\xa0123.45'], + ['BHD', 1, 'BHD\xa00.01'], + ])('Correctly displays %s', async (currency, amount, expectedResult) => { + const {result} = await renderCurrencyListActions(); + expect(result.current.convertToDisplayString(amount, currency)).toBe(expectedResult); + }); + + test.each([ + ['EUR', 25, '0,25\xa0€'], + ['EUR', 2500, '25,00\xa0€'], + ['EUR', 250000, '2500,00\xa0€'], + ['EUR', 250000000, '2.500.000,00\xa0€'], + ])('Correctly displays %s in ES locale', async (currency, amount, expectedResult) => { + await IntlStore.load(CONST.LOCALES.ES); + await Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES); + await waitForBatchedUpdates(); + + const {result} = await renderCurrencyListActions(); + expect(result.current.convertToDisplayString(amount, currency)).toBe(expectedResult); + }); + }); + + describe('convertToDisplayString with malformed currency', () => { + test.each(['', 'XX', 'USDD', '???'])('does not throw and falls back to USD formatting for %p', async (input) => { + const {result} = await renderCurrencyListActions(); + expect(() => result.current.convertToDisplayString(2500, input)).not.toThrow(); + expect(result.current.convertToDisplayString(2500, input)).toBe('$25.00'); + }); + + test('normalizes case-only variations to the intended currency instead of USD', async () => { + const {result} = await renderCurrencyListActions(); + expect(result.current.convertToDisplayString(2500, 'eur')).toBe(result.current.convertToDisplayString(2500, 'EUR')); + }); + + test('handles undefined currency', async () => { + const {result} = await renderCurrencyListActions(); + expect(result.current.convertToDisplayString(2500, undefined)).toBe('$25.00'); + }); + }); + + describe('convertToDisplayStringWithoutCurrency with malformed currency', () => { + test.each(['', 'XX', 'USDD', '???'])('does not throw and produces a numeric output for %p', async (input) => { + const {result} = await renderCurrencyListActions(); + expect(() => result.current.convertToDisplayStringWithoutCurrency(2500, input)).not.toThrow(); + // Output should not contain a currency symbol but should contain the numeric portion. + const output = result.current.convertToDisplayStringWithoutCurrency(2500, input); + expect(output).not.toMatch(/\$/); + expect(output).toContain('25'); + }); + }); +}); diff --git a/tests/unit/useNavigateToTransactionThreadTest.tsx b/tests/unit/useNavigateToTransactionThreadTest.tsx new file mode 100644 index 000000000000..3fd53cf9126e --- /dev/null +++ b/tests/unit/useNavigateToTransactionThreadTest.tsx @@ -0,0 +1,83 @@ +import {renderHook} from '@testing-library/react-native'; + +import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomReportAction from '../utils/collections/reportActions'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => ({}), +})); + +jest.mock('@components/WideRHPContextProvider', () => ({ + useWideRHPActions: () => ({markReportRHPWidth: jest.fn(), unmarkReportRHPWidth: jest.fn()}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({email: 'a@b.com', accountID: 1}), +})); + +const IOU_REPORT_ID = 'iou1'; +const REPORT_ROUTE = ROUTES.REPORT_WITH_ID.getRoute(IOU_REPORT_ID); + +function buildIOUAction(index: number, transactionID: string, childReportID: string) { + return { + ...createRandomReportAction(index), + reportActionID: `action${index}`, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID, + originalMessage: {IOUTransactionID: transactionID, IOUReportID: IOU_REPORT_ID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'}, + }; +} + +function buildReportActions(): ReportAction[] { + return [buildIOUAction(1, 't1', 'threadA'), buildIOUAction(2, 't2', 'threadB')]; +} + +describe('useNavigateToTransactionThread', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('opens only one expense when two rows are pressed inside the deferred window', async () => { + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); + // Both presses are made while the report is still the active route, and the first navigation has not + // been dispatched yet — the shape that used to push two expense screens. + const getActiveRouteSpy = jest.spyOn(Navigation, 'getActiveRoute').mockReturnValue(REPORT_ROUTE); + + const {result} = renderHook(() => useNavigateToTransactionThread()); + const navigateToTransaction = result.current; + + const params = { + reportActions: buildReportActions(), + report: {reportID: IOU_REPORT_ID} as Report, + transaction: undefined, + siblingTransactionIDs: ['t1', 't2'], + backTo: undefined, + }; + navigateToTransaction({...params, transactionID: 't1'}); + navigateToTransaction({...params, transactionID: 't2'}); + + // The first continuation navigates; by the time the second runs the route has moved off the report. + getActiveRouteSpy.mockImplementation(() => (navigateSpy.mock.calls.length === 0 ? REPORT_ROUTE : ROUTES.SEARCH_REPORT.getRoute({reportID: 'threadA'}))); + await waitForBatchedUpdates(); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/useParticipantSubmissionTest.ts b/tests/unit/useParticipantSubmissionTest.ts index d95f495118e4..54f80453c7c4 100644 --- a/tests/unit/useParticipantSubmissionTest.ts +++ b/tests/unit/useParticipantSubmissionTest.ts @@ -8,10 +8,17 @@ import Navigation from '@libs/Navigation/Navigation'; // eslint-disable-next-line no-restricted-imports -- findSelfDMReportID is not a billing/paid-only helper; the rule only warns because the namespace also exposes isPaidGroupPolicy*, which this test never uses. import * as ReportUtils from '@libs/ReportUtils'; +import {setCustomUnitRateID} from '@userActions/IOU/MoneyRequest'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Transaction} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; -import createRandomTransaction from '../utils/collections/transaction'; +import type {OnyxCollection} from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../utils/collections/transaction'; // The confirmation page reaches the RHP by replacing the recipient picker, so goToNextStep must pass an explicit // backTo for it to be able to navigate back to the picker. For the SUBMIT "Send to someone"/"Submit to a friend" flows @@ -35,6 +42,10 @@ jest.mock('@src/utils/keyboard', () => ({ }, })); +// `mock`-prefixed so the jest.mock factories below may close over them. +let mockPolicies: OnyxCollection = {}; +let mockDraftTransactions: Transaction[] = []; + // Stub out the hook's data dependencies so the test can render it in isolation and focus purely on the backTo logic. // Every Onyx read is stubbed to undefined here; goToNextStep's backTo path does not depend on Onyx data, but a future // dependency added to that path would need a matching stub rather than silently reading undefined. @@ -42,12 +53,16 @@ jest.mock('@hooks/useBlockDistanceRequest', () => ({__esModule: true, default: ( jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: () => ({getCurrencyDecimals: () => 2})})); jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 1, email: 'test@example.com', localCurrencyCode: 'USD'})})); jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key})})); -jest.mock('@hooks/useMappedPolicies', () => ({__esModule: true, default: () => [{}]})); +jest.mock('@hooks/useMappedPolicies', () => ({__esModule: true, default: () => [mockPolicies]})); jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => [undefined]})); -jest.mock('@hooks/useOptimisticDraftTransactions', () => ({__esModule: true, default: () => [[]]})); +jest.mock('@hooks/useOptimisticDraftTransactions', () => ({__esModule: true, default: () => [mockDraftTransactions]})); jest.mock('@hooks/usePersonalPolicy', () => ({__esModule: true, default: () => undefined})); jest.mock('@hooks/usePolicyForMovingExpenses', () => ({__esModule: true, default: () => ({policyForMovingExpenses: undefined})})); jest.mock('@hooks/useTransactionsByID', () => ({__esModule: true, default: () => [[]]})); +jest.mock('@userActions/IOU/MoneyRequest', () => ({ + ...jest.requireActual>('@userActions/IOU/MoneyRequest'), + setCustomUnitRateID: jest.fn(), +})); const mockGoBack = jest.mocked(Navigation.goBack); // Keep the real ReportUtils and spy only on findSelfDMReportID: goToNextStep anchors the reconstructed SUBMIT backTo on the @@ -183,3 +198,145 @@ describe('useParticipantSubmission goToNextStep backTo', () => { expect(mockGoBack).toHaveBeenCalledWith(expect.not.stringContaining('backTo='), {compareParams: false}); }); }); + +const WORKSPACE_RATE_ID = 'RATE_DESTINATION'; +const OTHER_WORKSPACE_RATE_ID = 'RATE_SOURCE'; +const DESTINATION_POLICY_ID = 'WS_DESTINATION'; + +const DESTINATION_CHAT: Participant = {reportID: 'R3', policyID: DESTINATION_POLICY_ID, isPolicyExpenseChat: true}; + +function buildDestinationPolicy(): Policy { + return { + ...createRandomPolicy(1), + id: DESTINATION_POLICY_ID, + customUnits: { + distance: { + customUnitID: 'distance', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + [WORKSPACE_RATE_ID]: { + customUnitRateID: WORKSPACE_RATE_ID, + name: 'Default Rate', + rate: 70, + currency: CONST.CURRENCY.USD, + enabled: true, + }, + }, + }, + }, + }; +} + +function buildTrackedDistanceDraft(customUnitRateID: string | undefined): Transaction { + return { + ...createRandomDistanceRequestTransaction(2), + transactionID: 'T1', + amount: 210917, + currency: CONST.CURRENCY.USD, + created: '2026-08-14', + // quantity is 0 while the map route is still being generated. + comment: {customUnit: {customUnitRateID, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, quantity: 0}}, + }; +} + +describe('useParticipantSubmission addParticipant distance rate', () => { + beforeEach(() => { + jest.mocked(setCustomUnitRateID).mockClear(); + mockPolicies = {[`${ONYXKEYS.COLLECTION.POLICY}${DESTINATION_POLICY_ID}`]: buildDestinationPolicy()}; + }); + + afterEach(() => { + mockPolicies = {}; + mockDraftTransactions = []; + }); + + it("selects the destination workspace's rate when the tracked expense carries another workspace's rate", () => { + mockDraftTransactions = [buildTrackedDistanceDraft(OTHER_WORKSPACE_RATE_ID)]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).toHaveBeenCalledTimes(1); + expect(jest.mocked(setCustomUnitRateID).mock.calls.at(0)?.at(1)).toBe(WORKSPACE_RATE_ID); + }); + + it("selects the destination workspace's rate when the tracked expense draft has no rate ID yet", () => { + mockDraftTransactions = [buildTrackedDistanceDraft(undefined)]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).toHaveBeenCalledTimes(1); + expect(jest.mocked(setCustomUnitRateID).mock.calls.at(0)?.at(1)).toBe(WORKSPACE_RATE_ID); + }); + + it.each([ + ['per diem', CONST.IOU.REQUEST_TYPE.PER_DIEM, 'PER_DIEM_RATE'], + ['time', CONST.IOU.REQUEST_TYPE.TIME, undefined], + ])('leaves a tracked %s expense rate alone', (_name, iouRequestType, rateID) => { + mockDraftTransactions = [{...buildTrackedDistanceDraft(rateID), iouRequestType}]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).not.toHaveBeenCalled(); + }); + + it('keeps the p2p rate so the confirmation step can ask the user to pick a workspace rate', () => { + mockDraftTransactions = [buildTrackedDistanceDraft(CONST.CUSTOM_UNITS.FAKE_P2P_ID)]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).not.toHaveBeenCalled(); + }); + + it('leaves the rate alone when the destination workspace has no rates loaded yet', () => { + mockPolicies = {[`${ONYXKEYS.COLLECTION.POLICY}${DESTINATION_POLICY_ID}`]: {...createRandomPolicy(2), id: DESTINATION_POLICY_ID}}; + mockDraftTransactions = [buildTrackedDistanceDraft(OTHER_WORKSPACE_RATE_ID)]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).not.toHaveBeenCalled(); + }); + + it('leaves the rate alone when every rate on the destination workspace is disabled', () => { + const policy = buildDestinationPolicy(); + const distanceUnit = policy.customUnits?.distance; + if (distanceUnit?.rates[WORKSPACE_RATE_ID]) { + distanceUnit.rates[WORKSPACE_RATE_ID].enabled = false; + } + mockPolicies = {[`${ONYXKEYS.COLLECTION.POLICY}${DESTINATION_POLICY_ID}`]: policy}; + mockDraftTransactions = [buildTrackedDistanceDraft(OTHER_WORKSPACE_RATE_ID)]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).not.toHaveBeenCalled(); + }); + + it('leaves a rate the destination workspace already owns alone', () => { + mockDraftTransactions = [buildTrackedDistanceDraft(WORKSPACE_RATE_ID)]; + const {result} = renderSubmission(); + + act(() => { + result.current.addParticipant([DESTINATION_CHAT]); + }); + + expect(setCustomUnitRateID).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/usePermissionsTest.tsx b/tests/unit/usePermissionsTest.tsx index 86826805892e..3678bfd543a8 100644 --- a/tests/unit/usePermissionsTest.tsx +++ b/tests/unit/usePermissionsTest.tsx @@ -2,6 +2,8 @@ import {renderHook} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import Permissions from '@libs/Permissions'; + import CONST from '@src/CONST'; import usePermissions from '@src/hooks/usePermissions'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -135,4 +137,55 @@ describe('usePermissions', () => { // Then: The beta check should return false since neither beta is enabled expect(result.current.isBetaEnabled(exclusionBeta)).toBe(false); }); + + it('should let local beta overrides take precedence over the server-provided betas', async () => { + // Given: An account with one beta enabled and another disabled + Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.DEFAULT_ROOMS]); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => usePermissions(), {wrapper: Wrapper}); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(true); + expect(result.current.isBetaEnabled(CONST.BETAS.PER_DIEM)).toBe(false); + + // When: Overrides force-disable the enabled beta and force-enable the disabled one + Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: false, [CONST.BETAS.PER_DIEM]: true}); + await waitForBatchedUpdatesWithAct(); + + // Then: The overrides win over the server state, and untouched betas keep their server state + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(false); + expect(result.current.isBetaEnabled(CONST.BETAS.PER_DIEM)).toBe(true); + expect(result.current.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL)).toBe(false); + + expect(Permissions.isBetaEnabled(CONST.BETAS.PER_DIEM, [])).toBe(false); + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [CONST.BETAS.DEFAULT_ROOMS])).toBe(true); + + // When: The overrides are cleared + Onyx.set(ONYXKEYS.BETA_OVERRIDES, null); + await waitForBatchedUpdatesWithAct(); + + // Then: Everything falls back to the server state + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(true); + expect(result.current.isBetaEnabled(CONST.BETAS.PER_DIEM)).toBe(false); + }); + + it('should force-disable a beta granted by the "all" beta when overridden off', async () => { + // Given: An account on the 'all' beta + Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.ALL]); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => usePermissions(), {wrapper: Wrapper}); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(true); + + // When: A single beta is overridden off + Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: false}); + await waitForBatchedUpdatesWithAct(); + + // Then: That beta is disabled while the others granted by 'all' stay enabled + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(false); + expect(result.current.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL)).toBe(true); + }); }); diff --git a/tests/unit/usePersonalDetailSearchSelectorTest.tsx b/tests/unit/usePersonalDetailSearchSelectorTest.tsx index 37974fc712dc..a50ba4258fda 100644 --- a/tests/unit/usePersonalDetailSearchSelectorTest.tsx +++ b/tests/unit/usePersonalDetailSearchSelectorTest.tsx @@ -495,6 +495,76 @@ describe('usePersonalDetailSearchSelector selectedNonExistingOptions', () => { }); }); +describe('usePersonalDetailSearchSelector search term trimming', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet(MOCK_ONYX_STATE); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterAll(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + /** Renders the hook with the invite option enabled and types searchTerm into it, flushing the debounce. */ + const renderWithSearchTerm = async (searchTerm: string) => { + jest.useFakeTimers(); + const {result} = renderHook(() => + usePersonalDetailSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + includeUserToInvite: true, + includeRecentReports: false, + }), + ); + await waitForBatchedUpdatesWithAct(); + + act(() => { + result.current.setSearchTerm(searchTerm); + }); + // Advance past the debounce delay (300ms) + await act(async () => { + jest.advanceTimersByTime(400); + }); + await waitForBatchedUpdatesWithAct(); + jest.useRealTimers(); + + return result; + }; + + it('still shows the invite option when the email has a leading space', async () => { + const result = await renderWithSearchTerm(' invitee@gmail.com'); + + expect(result.current.availableOptions.userToInvite?.login).toBe('invitee@gmail.com'); + }); + + it('still shows the invite option when the email has a trailing space', async () => { + const result = await renderWithSearchTerm('invitee@gmail.com '); + + expect(result.current.availableOptions.userToInvite?.login).toBe('invitee@gmail.com'); + }); + + it('still shows the invite option when the phone number is space padded', async () => { + const result = await renderWithSearchTerm(' +1 (234) 567-8901 '); + + expect(result.current.availableOptions.userToInvite?.login).toBe('+12345678901'); + }); + + it('does not show an invite option for a search term that is only whitespace', async () => { + const result = await renderWithSearchTerm(' '); + + expect(result.current.availableOptions.userToInvite).toBeNull(); + }); +}); + describe('usePersonalDetailSearchSelector includeLoginsOnly', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); diff --git a/tests/unit/usePolicyIndicatorChecksTest.ts b/tests/unit/usePolicyIndicatorChecksTest.ts index 1bf80e62a5dd..7f939bd6a6dd 100644 --- a/tests/unit/usePolicyIndicatorChecksTest.ts +++ b/tests/unit/usePolicyIndicatorChecksTest.ts @@ -5,7 +5,7 @@ import usePolicyIndicatorChecks from '@hooks/usePolicyIndicatorChecks'; import CONST from '@src/CONST'; import initOnyxDerivedValues from '@src/libs/actions/OnyxDerived'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; +import type {Domain, Policy} from '@src/types/onyx'; import type {CustomUnit} from '@src/types/onyx/Policy'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; @@ -332,6 +332,20 @@ describe('usePolicyIndicatorChecks', () => { }); describe('domain error statuses', () => { + const requesterAccountID = 777; + const domainKey = `${ONYXKEYS.COLLECTION.DOMAIN}domain1` as const; + const domainErrorsKey = `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}domain1` as const; + + const createDomainFixture = (domainAdminRequesters: Domain['domain_adminRequesters']): Domain => ({ + validated: true, + accountID: 1, + email: 'domain.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: domainAdminRequesters, + }); + beforeEach(async () => { await Onyx.clear(); await waitForBatchedUpdatesWithAct(); @@ -348,7 +362,128 @@ describe('usePolicyIndicatorChecks', () => { const {result} = renderHook(() => usePolicyIndicatorChecks()); await waitForBatchedUpdatesWithAct(); - expect(result.current.domainStatus).toBe(CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS); + expect(result.current.domainErrorStatus).toBe(CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS); + }); + + it('returns HAS_DOMAIN_ERRORS for an adminship request error while that request is still pending', async () => { + await act(async () => { + await Onyx.multiSet({ + [domainKey]: createDomainFixture({[requesterAccountID]: 'read'}), + [domainErrorsKey]: {adminshipRequesterErrors: {[requesterAccountID]: {errors: {timestamp: 'Approve error'}}}}, + } satisfies OnyxMultiSetInput); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => usePolicyIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.domainErrorStatus).toBe(CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS); + }); + + it('does not return HAS_DOMAIN_ERRORS once the request is gone, since no row is left to dismiss the error from', async () => { + await act(async () => { + await Onyx.multiSet({ + [domainKey]: createDomainFixture({}), + [domainErrorsKey]: {adminshipRequesterErrors: {[requesterAccountID]: {errors: {timestamp: 'Approve error'}}}}, + } satisfies OnyxMultiSetInput); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => usePolicyIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.domainErrorStatus).toBeUndefined(); + }); + }); + + describe('domain info statuses', () => { + const domainAdminAccountID = 555; + const requesterAccountID = 777; + const domainAccountID = 1; + const domainKey = `${ONYXKEYS.COLLECTION.DOMAIN}domain1` as const; + const domainPendingActionsKey = `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}domain1` as const; + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it('returns HAS_PENDING_DOMAIN_ADMIN_REQUESTS when the current user is a domain admin with pending requests', async () => { + const domainWithAdmin: Domain = { + validated: true, + accountID: domainAccountID, + email: 'domain.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + Reflect.set(domainWithAdmin, `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${domainAdminAccountID}`, domainAdminAccountID); + + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: domainAdminAccountID, email: userID}, + [domainKey]: domainWithAdmin, + } satisfies OnyxMultiSetInput); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => usePolicyIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.domainInfoStatus).toBe(CONST.INDICATOR_STATUS.HAS_PENDING_DOMAIN_ADMIN_REQUESTS); + }); + + it('does not return an indicator when the last pending request has been denied but the decline has not landed yet', async () => { + const domainWithAdmin: Domain = { + validated: true, + accountID: domainAccountID, + email: 'domain.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + Reflect.set(domainWithAdmin, `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${domainAdminAccountID}`, domainAdminAccountID); + + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: domainAdminAccountID, email: userID}, + [domainKey]: domainWithAdmin, + [domainPendingActionsKey]: { + adminshipRequester: {[requesterAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}, + }, + } satisfies OnyxMultiSetInput); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => usePolicyIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.domainInfoStatus).toBeUndefined(); + }); + + it('does not return an indicator when the current user is only a requester, not a domain admin', async () => { + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: requesterAccountID, email: otherUserID}, + [domainKey]: { + validated: true, + accountID: domainAccountID, + email: 'domain.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }, + } satisfies OnyxMultiSetInput); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => usePolicyIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.domainInfoStatus).toBeUndefined(); }); }); @@ -380,7 +515,7 @@ describe('usePolicyIndicatorChecks', () => { expect(result.current.policyErrorStatus).toBeUndefined(); expect(result.current.policyInfoStatus).toBeUndefined(); - expect(result.current.domainStatus).toBeUndefined(); + expect(result.current.domainErrorStatus).toBeUndefined(); expect(result.current.indicatorPolicyID).toBeUndefined(); }); }); @@ -397,7 +532,7 @@ describe('usePolicyIndicatorChecks', () => { expect(result.current.policyErrorStatus).toBeUndefined(); expect(result.current.policyInfoStatus).toBeUndefined(); - expect(result.current.domainStatus).toBeUndefined(); + expect(result.current.domainErrorStatus).toBeUndefined(); expect(result.current.indicatorPolicyID).toBeUndefined(); }); }); diff --git a/tests/unit/useReleaseOptionListCachesTest.ts b/tests/unit/useReleaseOptionListCachesTest.ts new file mode 100644 index 000000000000..ab91e9226f50 --- /dev/null +++ b/tests/unit/useReleaseOptionListCachesTest.ts @@ -0,0 +1,88 @@ +import {renderHook} from '@testing-library/react-native'; + +import useReleaseOptionListCaches from '@hooks/useReleaseOptionListCaches'; + +/** Called when the built option lists are released. */ +const mockReleaseOptions = jest.fn(); + +/** Called when the filtered and selected option lists are released. */ +const mockReleaseSearchSelector = jest.fn(); + +/** Whether Search is the tab currently on top, read by the hook on every navigation state change. */ +let mockIsSearchTopmostRoute = true; + +/** The listener the hook subscribes to the root navigation with. */ +let mockNavigationListener: (() => void) | undefined; +const mockUnsubscribe = jest.fn(); + +jest.mock('@hooks/usePersonalDetailOptions', () => ({ + __esModule: true, + clearPersonalDetailOptionsCache: () => { + mockReleaseOptions(); + }, +})); + +jest.mock('@hooks/usePersonalDetailSearchSelector/base', () => ({ + __esModule: true, + clearPersonalDetailSearchSelectorCaches: () => { + mockReleaseSearchSelector(); + }, +})); + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => ({ + __esModule: true, + default: () => mockIsSearchTopmostRoute, +})); + +jest.mock('@libs/Navigation/helpers/subscribeToRootNavigation', () => ({ + __esModule: true, + default: (listener: () => void) => { + mockNavigationListener = listener; + return mockUnsubscribe; + }, +})); + +/** Moves to another tab and reports the navigation state change the hook listens for. */ +function leaveSearch() { + mockIsSearchTopmostRoute = false; + mockNavigationListener?.(); +} + +/** Reports a navigation state change that leaves Search on top, e.g. a right hand pane opening over it. */ +function navigateWithinSearch() { + mockNavigationListener?.(); +} + +function renderReleaseHook() { + return renderHook(() => useReleaseOptionListCaches()); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockIsSearchTopmostRoute = true; + mockNavigationListener = undefined; +}); + +describe('useReleaseOptionListCaches', () => { + it('releases the cached lists when another tab takes over from Search', () => { + const {unmount} = renderReleaseHook(); + + leaveSearch(); + + // Both caches, because releasing only one of them leaves the other holding the lists it was built from. + expect(mockReleaseOptions).toHaveBeenCalled(); + expect(mockReleaseSearchSelector).toHaveBeenCalled(); + + unmount(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('keeps the cached lists while Search is still the tab on top', () => { + renderReleaseHook(); + + navigateWithinSearch(); + + expect(mockReleaseOptions).not.toHaveBeenCalled(); + expect(mockReleaseSearchSelector).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index f95775bd5e36..87e435b002a5 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -90,6 +90,8 @@ function buildParams(overrides: Partial = {}): HookParams { reportID: '1', introSelected: undefined, conciergeChat: undefined, + isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, betas: [], isOffline: false, reportScrollManager, @@ -129,6 +131,18 @@ describe('useReportActionsNewActionLiveTail', () => { expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({conciergeChat})); }); + it('threads the onboarding status through to the catch-up openReport call', () => { + renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({isSelfTourViewed: true, hasCompletedGuidedSetupFlow: false})}); + + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + // The onboarding flags are threaded straight through so guided-setup optimistic data is derived from real Onyx + // data supplied by the caller instead of the deprecated module-level fallback. + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({isSelfTourViewed: true, hasCompletedGuidedSetupFlow: false})); + }); + it('clears the report screen param after loading the live tail without changing the focused route', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams()}); diff --git a/tests/unit/useSearchPageSetupTest.tsx b/tests/unit/useSearchPageSetupTest.tsx index b8e430baecef..f875de6c77e7 100644 --- a/tests/unit/useSearchPageSetupTest.tsx +++ b/tests/unit/useSearchPageSetupTest.tsx @@ -50,6 +50,7 @@ jest.mock('@components/Search/SearchContext', () => ({ useSearchResultsContext: () => ({shouldUseLiveData: false, currentSearchResults: mockSearchResults}), useSearchQueryContext: () => ({currentSearchKey: mockSearchKey}), useSearchSelectionActions: () => ({clearSelectedTransactions: jest.fn()}), + useSearchSelectionContext: () => ({areAllMatchingItemsSelected: false}), })); const QUERY = 'type:expense sortBy:date sortOrder:desc'; diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx index f204de5a881c..77c231ab658a 100644 --- a/tests/unit/useSearchSelectorTest.tsx +++ b/tests/unit/useSearchSelectorTest.tsx @@ -52,6 +52,8 @@ jest.mock('@hooks/useFilteredOptions', () => ({ jest.mock('@components/OnyxListItemProvider', () => ({ usePersonalDetails: () => ({}), + useCardList: () => ({}), + useWorkspaceCardList: () => ({}), })); jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({ @@ -685,3 +687,82 @@ describe('useSearchSelector phone contact de-duplication', () => { expect(getPersonalDetailsPassedToGetValidOptions().map((option) => option.login)).toEqual(['alice@expensify.com', 'carol@gmail.com']); }); }); + +describe('useSearchSelector search term trimming', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockFilteredPersonalDetails.current = []; + mockGetValidOptions.mockReturnValue({options: EMPTY_OPTIONS, hasMore: false}); + mockGetSearchOptions.mockReturnValue({options: EMPTY_OPTIONS, hasMore: false}); + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet( + createMock({ + [ONYXKEYS.SESSION]: {accountID: MOCK_ACCOUNT_ID, email: MOCK_EMAIL}, + [ONYXKEYS.BETAS]: [], + [ONYXKEYS.COUNTRY_CODE]: CONST.DEFAULT_COUNTRY_CODE, + }), + ); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterAll(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + /** Renders the hook for the given search context and types searchTerm into it, flushing the debounce. */ + async function renderWithSearchTerm(searchContext: typeof CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL | typeof CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH, searchTerm: string) { + jest.useFakeTimers(); + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext, + includeUserToInvite: true, + }), + ); + await waitForBatchedUpdatesWithAct(); + + act(() => { + result.current.setSearchTerm(searchTerm); + }); + // Advance past the debounce delay (300ms) + await act(async () => { + jest.advanceTimersByTime(400); + }); + await waitForBatchedUpdatesWithAct(); + jest.useRealTimers(); + } + + it('trims a leading space out of the email search string passed to getValidOptions', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, ' user@example.com'); + + const config = mockGetValidOptions.mock.calls.at(-1)?.[7]; + expect(config?.searchString).toBe('user@example.com'); + expect(config?.searchInputValue).toBe('user@example.com'); + }); + + it('trims a trailing space out of the email search string passed to getValidOptions', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, 'user@example.com '); + + expect(mockGetValidOptions.mock.calls.at(-1)?.[7]?.searchString).toBe('user@example.com'); + }); + + it('trims the search query passed to getSearchOptions', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH, ' user@example.com '); + + expect(mockGetSearchOptions.mock.calls.at(-1)?.[0]?.searchQuery).toBe('user@example.com'); + }); + + it('still resolves a padded phone number to its e164 form', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, ' +1 (234) 567-8901 '); + + expect(mockGetValidOptions.mock.calls.at(-1)?.[7]?.searchString).toBe('+12345678901'); + }); +}); diff --git a/tests/unit/useSelectionListShortcutsTest.ts b/tests/unit/useSelectionListShortcutsTest.ts new file mode 100644 index 000000000000..e7278ffcd99c --- /dev/null +++ b/tests/unit/useSelectionListShortcutsTest.ts @@ -0,0 +1,86 @@ +import {renderHook} from '@testing-library/react-native'; + +import useSelectionListShortcuts from '@components/SelectionList/hooks/useSelectionListShortcuts'; +import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; + +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; + +import CONST from '@src/CONST'; + +jest.mock('@hooks/useKeyboardShortcut', () => jest.fn()); + +const mockUseKeyboardShortcut = jest.mocked(useKeyboardShortcut); + +type Registration = {isActive?: boolean}; + +/** Returns the config of the most recent registration for the given shortcut, so tests can assert whether it is armed. */ +function getRegistration(shortcut: (typeof CONST.KEYBOARD_SHORTCUTS)['ENTER'] | (typeof CONST.KEYBOARD_SHORTCUTS)['CTRL_ENTER']): Registration | undefined { + return mockUseKeyboardShortcut.mock.calls.findLast(([registeredShortcut]) => registeredShortcut === shortcut)?.[2] as Registration | undefined; +} + +type ShortcutParams = Parameters>[0]; + +function renderShortcuts(overrides: Partial = {}) { + const params: ShortcutParams = { + selectFocusedItem: jest.fn(), + getFocusedOption: () => undefined, + confirmButtonOptions: undefined, + isActive: true, + focusedIndex: 0, + disableKeyboardShortcuts: false, + shouldStopPropagation: false, + shouldBubble: false, + ...overrides, + }; + return renderHook(() => useSelectionListShortcuts(params)); +} + +describe('useSelectionListShortcuts', () => { + beforeEach(() => { + mockUseKeyboardShortcut.mockClear(); + }); + + it('arms the plain Enter shortcut when a real focused index is passed', () => { + renderShortcuts({focusedIndex: 0}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(true); + }); + + it('disarms the plain Enter shortcut when the focused index is gated to -1', () => { + renderShortcuts({focusedIndex: -1}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(false); + }); + + it('leaves Ctrl+Enter armed when the focused index is gated to -1', () => { + const confirmButtonOptions: ConfirmButtonOptions = {onConfirm: jest.fn()}; + renderShortcuts({focusedIndex: -1, confirmButtonOptions}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.isActive).toBe(true); + }); + + it('still passes the real focused option to onConfirm when the focused index is gated to -1', () => { + const onConfirm = jest.fn(); + const focusedOption: ListItem = {text: 'Item 0', keyForList: '0'}; + renderShortcuts({focusedIndex: -1, confirmButtonOptions: {onConfirm}, getFocusedOption: () => focusedOption}); + + const ctrlEnterCallback = mockUseKeyboardShortcut.mock.calls.findLast(([shortcut]) => shortcut === CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.[1]; + ctrlEnterCallback?.(); + + expect(onConfirm).toHaveBeenCalledWith(undefined, focusedOption); + }); + + it('disarms both shortcuts when keyboard shortcuts are disabled', () => { + renderShortcuts({disableKeyboardShortcuts: true}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(false); + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.isActive).toBe(false); + }); + + it('disarms both shortcuts when the list is not active', () => { + renderShortcuts({isActive: false}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(false); + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.isActive).toBe(false); + }); +}); diff --git a/tests/unit/useSendMessageSpanMarksTest.ts b/tests/unit/useSendMessageSpanMarksTest.ts new file mode 100644 index 000000000000..21f98b659cab --- /dev/null +++ b/tests/unit/useSendMessageSpanMarksTest.ts @@ -0,0 +1,182 @@ +import {renderHook} from '@testing-library/react-native'; + +import {cancelAllSpans, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {cancelAllSendMessageSpans, cancelSendMessagePhases, startSendMessagePhase} from '@libs/telemetry/sendMessageSpans'; +import useSendMessageSpanMarks from '@libs/telemetry/useSendMessageSpanMarks'; + +import CONST from '@src/CONST'; + +import * as Sentry from '@sentry/react-native'; + +jest.mock('@sentry/react-native', () => { + const endOrder: string[] = []; + return { + startInactiveSpan: (options: {op?: string}) => ({ + op: options?.op, + attributes: {} as Record, + setAttribute(key: string, value: unknown) { + this.attributes[key] = value; + }, + setAttributes(attrs: Record) { + Object.assign(this.attributes, attrs); + }, + setStatus() {}, + end() { + endOrder.push(this.op ?? ''); + }, + }), + spanToJSON: (span: {attributes: Record}) => ({data: span.attributes}), + endOrder, + }; +}); + +function getEndOrder() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (Sentry as unknown as {endOrder: string[]}).endOrder; +} + +function startSendSpan(reportActionID: string) { + return startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${reportActionID}`, { + name: 'send-message-visible', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, + }); +} + +function buildForeignSpanIDThatSlicesToReportActionID(reportActionID: string) { + return `${'OtherSpanFamily_'.padEnd(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE.length + 1, 'x')}${reportActionID}`; +} + +function startSendWithOpenPropagate(reportActionID: string) { + startSendSpan(reportActionID); + startSendMessagePhase(reportActionID, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); +} + +afterEach(() => { + cancelAllSpans(); + getEndOrder().length = 0; +}); + +describe('useSendMessageSpanMarks', () => { + it('closes Propagate and opens PostCommit while mounting', () => { + startSendWithOpenPropagate('1'); + + renderHook(() => useSendMessageSpanMarks('1')); + + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_1`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT}_1`)).toBeDefined(); + }); + + it('ends the open phase before the parent when the returned handler runs', () => { + startSendWithOpenPropagate('2'); + + const {result} = renderHook(() => useSendMessageSpanMarks('2')); + result.current(); + + expect(getEndOrder()).toEqual([CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT, CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_2`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT}_2`)).toBeUndefined(); + }); + + it('leaves other sends alone', () => { + startSendWithOpenPropagate('3'); + + const {result} = renderHook(() => useSendMessageSpanMarks('4')); + result.current(); + + expect(getEndOrder()).toEqual([]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_3`)).toBeDefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_3`)).toBeDefined(); + }); +}); + +describe('startSendMessagePhase', () => { + it('does not start a phase when the parent send span is not active', () => { + startSendMessagePhase('10', CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); + + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_10`)).toBeUndefined(); + }); + + it('does not start a phase against a parent whose id was built from an undefined reportActionID', () => { + startSendSpan('undefined'); + + startSendMessagePhase(undefined, CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE); + + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_undefined`)).toBeUndefined(); + }); +}); + +describe('cancelSendMessagePhases', () => { + it('is a no-op for an undefined parent span id', () => { + startSendWithOpenPropagate('20'); + + expect(() => cancelSendMessagePhases(undefined)).not.toThrow(); + + expect(getEndOrder()).toEqual([]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_20`)).toBeDefined(); + }); + + it('is a no-op for a span id from another family', () => { + startSendWithOpenPropagate('21'); + + cancelSendMessagePhases(buildForeignSpanIDThatSlicesToReportActionID('21')); + cancelSendMessagePhases('SomeOtherSpan_21'); + + expect(getEndOrder()).toEqual([]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_21`)).toBeDefined(); + }); + + it('cancels the open phases but leaves the parent to its own caller', () => { + startSendWithOpenPropagate('22'); + + cancelSendMessagePhases(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_22`); + + expect(getEndOrder()).toEqual([CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_22`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_22`)).toBeDefined(); + }); +}); + +describe('cancelAllSendMessageSpans', () => { + it('sweeps an open phase before its parent', () => { + startSendWithOpenPropagate('30'); + + cancelAllSendMessageSpans(); + + expect(getEndOrder()).toEqual([CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE, CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_30`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_30`)).toBeUndefined(); + }); + + it('sweeps a PostCommit phase left open by a row that never laid out', () => { + startSendWithOpenPropagate('31'); + renderHook(() => useSendMessageSpanMarks('31')); + getEndOrder().length = 0; + + cancelAllSendMessageSpans(); + + expect(getEndOrder()).toEqual([CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT, CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE]); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.POST_COMMIT}_31`)).toBeUndefined(); + }); + + it('sweeps every send in flight', () => { + startSendWithOpenPropagate('32'); + startSendWithOpenPropagate('33'); + + cancelAllSendMessageSpans(); + + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_32`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_PHASE.PROPAGATE}_33`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_32`)).toBeUndefined(); + expect(getSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_33`)).toBeUndefined(); + }); + + it('leaves spans outside the send-message family alone', () => { + startSendWithOpenPropagate('34'); + startSpan('ManualOpenReport_34', {name: 'open-report', op: 'ManualOpenReport'}); + + cancelAllSendMessageSpans(); + + expect(getEndOrder()).not.toContain('ManualOpenReport'); + expect(getSpan('ManualOpenReport_34')).toBeDefined(); + }); +}); diff --git a/tests/unit/useShareSavedSearchTest.ts b/tests/unit/useShareSavedSearchTest.ts index 7990614f8bac..79fec7997ca6 100644 --- a/tests/unit/useShareSavedSearchTest.ts +++ b/tests/unit/useShareSavedSearchTest.ts @@ -14,7 +14,7 @@ jest.mock('@hooks/useEnvironment', () => jest.fn(() => ({environmentURL: 'https: const mockClipboardSetString = jest.mocked(Clipboard.setString); -const ITEM_HASH = 12345; +const ITEM_ID = '12345'; const ITEM_QUERY = 'type:expense status:all'; describe('useShareSavedSearch', () => { @@ -27,77 +27,77 @@ describe('useShareSavedSearch', () => { jest.useRealTimers(); }); - it('copies the correct URL to clipboard and sets copiedHash', () => { + it('copies the correct URL to clipboard and sets copiedID', () => { const {result} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); const expectedURL = `https://new.expensify.com/${ROUTES.SEARCH_ROOT.getRoute({query: ITEM_QUERY})}`; expect(mockClipboardSetString).toHaveBeenCalledWith(expectedURL); - expect(result.current.copiedHash).toBe(ITEM_HASH); + expect(result.current.copiedID).toBe(ITEM_ID); }); - it('resets copiedHash to null after 1800ms', () => { + it('resets copiedID to null after 1800ms', () => { const {result} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); - expect(result.current.copiedHash).toBe(ITEM_HASH); + expect(result.current.copiedID).toBe(ITEM_ID); act(() => { jest.advanceTimersByTime(1800); }); - expect(result.current.copiedHash).toBeNull(); + expect(result.current.copiedID).toBeNull(); }); - it('does not reset copiedHash before 1800ms elapses', () => { + it('does not reset copiedID before 1800ms elapses', () => { const {result} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); act(() => { jest.advanceTimersByTime(1799); }); - expect(result.current.copiedHash).toBe(ITEM_HASH); + expect(result.current.copiedID).toBe(ITEM_ID); }); it('resets timer when handleShare is called again before timeout', () => { const {result} = renderHook(() => useShareSavedSearch()); - const secondHash = 99999; + const secondID = '99999'; act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); act(() => { jest.advanceTimersByTime(1000); - result.current.handleShare(secondHash, ITEM_QUERY); + result.current.handleShare(secondID, ITEM_QUERY); }); // First hash should be replaced by second - expect(result.current.copiedHash).toBe(secondHash); + expect(result.current.copiedID).toBe(secondID); // Advancing 1800ms from second call — should reset act(() => { jest.advanceTimersByTime(1800); }); - expect(result.current.copiedHash).toBeNull(); + expect(result.current.copiedID).toBeNull(); }); it('clears timer on unmount without calling setState', () => { const {result, unmount} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); // Unmount before timer fires diff --git a/tests/unit/useUnreadMarkerTest.ts b/tests/unit/useUnreadMarkerTest.ts index 9eecbdf44d47..0ba1a1cb9ed5 100644 --- a/tests/unit/useUnreadMarkerTest.ts +++ b/tests/unit/useUnreadMarkerTest.ts @@ -2,6 +2,7 @@ import {act, renderHook} from '@testing-library/react-native'; import useUnreadMarker from '@hooks/useUnreadMarker'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -123,6 +124,117 @@ describe('useUnreadMarker', () => { expect(result.current.unreadMarkerReportActionIndex).toBe(-1); }); + it('does not push the read watermark on a bulk history reveal without a session boundary (marker appears on the next render)', () => { + const greeting = makeAction(CONST.CONCIERGE_GREETING_ACTION_ID, {created: LAST_READ_TIME}); + const createdAction = makeAction('created', {created: '2023-01-01 08:00:00.000', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); + const welcomeActions = [greeting, createdAction]; + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + }), + {initialProps: welcomeActions}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const bumpedGreeting = makeAction(CONST.CONCIERGE_GREETING_ACTION_ID, {created: '2023-01-01 12:00:00.000'}); + rerender([bumpedGreeting, createdAction]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const unreadMessage = makeAction('unread', {created: '2023-01-01 11:00:00.000'}); + const readMessage = makeAction('read', {created: '2023-01-01 09:00:00.000'}); + const fullHistory = [unreadMessage, readMessage, createdAction]; + rerender(fullHistory); + + rerender(fullHistory); + expect(result.current.unreadMarkerReportActionID).toBe('unread'); + expect(result.current.unreadMarkerReportActionIndex).toBe(0); + }); + + it('shows the marker immediately on the reveal render when a session boundary is provided ("Show history")', () => { + const sessionStartTime = '2023-01-01 11:30:00.000'; + const greeting = makeAction(CONST.CONCIERGE_GREETING_ACTION_ID, {created: LAST_READ_TIME}); + const createdAction = makeAction('created', {created: '2023-01-01 08:00:00.000', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + newMessageBoundaryTime: sessionStartTime, + }), + {initialProps: [greeting, createdAction]}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const unreadMessage = makeAction('unread', {created: '2023-01-01 11:00:00.000'}); + const readMessage = makeAction('read', {created: '2023-01-01 09:00:00.000'}); + rerender([unreadMessage, readMessage, createdAction]); + + expect(result.current.unreadMarkerReportActionID).toBe('unread'); + expect(result.current.unreadMarkerReportActionIndex).toBe(0); + }); + + it('still auto-reads a live message received while caught up when a session boundary is provided', () => { + const sessionStartTime = '2023-01-01 10:30:00.000'; + const oldMessage = makeAction('old', {created: '2023-01-01 09:00:00.000'}); + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + newMessageBoundaryTime: sessionStartTime, + }), + {initialProps: [oldMessage]}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const incoming = makeAction('incoming', {created: '2023-01-01 11:00:00.000'}); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + }); + + it('still pushes the watermark past a new message received while caught up', () => { + const oldMessage = makeAction('old', {created: '2023-01-01 09:00:00.000'}); + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + }), + {initialProps: [oldMessage]}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const incoming = makeAction('incoming', {created: '2023-01-01 11:00:00.000'}); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + }); + it('seeds the marker from the switched-to report lastReadTime (one mount per report)', () => { // Setup: report 'A' was last read at 10:00 and 'B' at 12:00; one action from another user lands // at 11:00 — after A's read time (unread on A) but before B's read time (already read on B). diff --git a/tests/unit/useWorkspacesTabIndicatorStatusTest.ts b/tests/unit/useWorkspacesTabIndicatorStatusTest.ts index 17edbf3874e0..48a80325dfa1 100644 --- a/tests/unit/useWorkspacesTabIndicatorStatusTest.ts +++ b/tests/unit/useWorkspacesTabIndicatorStatusTest.ts @@ -8,6 +8,7 @@ import {defaultTheme} from '@styles/theme'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxValues} from '@src/ONYXKEYS'; +import type {Domain} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {Connections} from '@src/types/onyx/Policy'; @@ -319,6 +320,90 @@ describe('useWorkspacesTabIndicatorStatus', () => { }); }); + describe('domain admin pending requests (info)', () => { + const domainAdminAccountID = 555; + const requesterAccountID = 777; + const domainKey = `${ONYXKEYS.COLLECTION.DOMAIN}domain1` as const; + + beforeAll(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + + const domainWithAdmin: Domain = { + validated: true, + accountID: 1, + email: 'domain.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + Reflect.set(domainWithAdmin, `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${domainAdminAccountID}`, domainAdminAccountID); + + await act(async () => { + await Onyx.multiSet( + createMock({ + [ONYXKEYS.SESSION]: {accountID: domainAdminAccountID, email: userID}, + [domainKey]: domainWithAdmin, + }), + ); + await waitForBatchedUpdatesWithAct(); + }); + }); + + it('returns HAS_PENDING_DOMAIN_ADMIN_REQUESTS status with success color', async () => { + const {result} = renderHook(() => useWorkspacesTabIndicatorStatus()); + await waitForBatchedUpdatesWithAct(); + const {status, indicatorColor} = result.current; + + expect(status).toBe(CONST.INDICATOR_STATUS.HAS_PENDING_DOMAIN_ADMIN_REQUESTS); + expect(indicatorColor).toBe(defaultTheme.success); + }); + }); + + describe('domain errors take priority over domain admin pending requests', () => { + const domainAdminAccountID = 555; + const requesterAccountID = 777; + const domainKey = `${ONYXKEYS.COLLECTION.DOMAIN}domain1` as const; + const domainErrorsKey = `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}domain1` as const; + + beforeAll(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + + const domainWithAdmin: Domain = { + validated: true, + accountID: 1, + email: 'domain.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_defaultSecurityGroupID: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[requesterAccountID]: 'read'}, + }; + Reflect.set(domainWithAdmin, `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${domainAdminAccountID}`, domainAdminAccountID); + + await act(async () => { + await Onyx.multiSet( + createMock({ + [ONYXKEYS.SESSION]: {accountID: domainAdminAccountID, email: userID}, + [domainKey]: domainWithAdmin, + [domainErrorsKey]: {errors: {domainError: 'Domain error'}}, + }), + ); + await waitForBatchedUpdatesWithAct(); + }); + }); + + it('shows red domain error instead of the green pending-requests indicator', async () => { + const {result} = renderHook(() => useWorkspacesTabIndicatorStatus()); + await waitForBatchedUpdatesWithAct(); + const {status, indicatorColor} = result.current; + + expect(status).toBe(CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS); + expect(indicatorColor).toBe(defaultTheme.danger); + }); + }); + describe('missing data', () => { beforeAll(async () => { // Clear all Onyx data first to ensure clean state diff --git a/tests/utils/ExpensifyCardStatementTestUtils.ts b/tests/utils/ExpensifyCardStatementTestUtils.ts index 7ca7c019c69e..783449ae6a41 100644 --- a/tests/utils/ExpensifyCardStatementTestUtils.ts +++ b/tests/utils/ExpensifyCardStatementTestUtils.ts @@ -19,6 +19,7 @@ function makeSelectedTransaction(overrides: Partial = {}) { + return createMock({ + policyID: POLICY_ID, + state: CONST.BANK_ACCOUNT.STATE.PENDING, + currentStep: CONST.BANK_ACCOUNT.STEP.VALIDATION, + bankAccountID: 1234, + currency: CONST.CURRENCY.USD, + country: CONST.COUNTRY.US, + ...overrides, + }); +} + +const PENDING_ACCOUNT: ReimbursementAccount = { + achData: buildAchData(), + isLoading: false, + shouldShowResetModal: false, +}; + +/** + * The Navigation double both reimbursement-account suites assert against. `jest.mock` factories are hoisted above + * imports, so each suite calls this from inside its own factory via `jest.requireActual` rather than importing it. + */ +function createNavigationMock() { + return { + navigate: jest.fn(), + goBack: jest.fn(), + dismissModal: jest.fn(), + closeRHPFlow: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + isTopmostRouteModalScreen: jest.fn(() => false), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback?.()), + setParams: jest.fn(), + }; +} + +export {BACK_TO, buildAchData, createNavigationMock, OTHER_POLICY_ID, PENDING_ACCOUNT, POLICY_ID}; diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index 11595b496632..675143fec395 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -4,7 +4,8 @@ import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types'; import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {formatPhoneNumberWithCountryCode} from '@libs/LocalePhoneNumber'; import {translate} from '@libs/Localize'; -import {format as formatNumber} from '@libs/NumberFormatUtils'; +import registerMiddlewares from '@libs/Middleware/register'; +import {format as formatNumber, formatToParts} from '@libs/NumberFormatUtils'; import Pusher from '@libs/Pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; @@ -33,6 +34,10 @@ import {isObject} from './typeGuards'; import waitForBatchedUpdates from './waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; +// Every test that imports this module can make an API call without first calling setupApp(), so middlewares +// must be registered here too. Idempotent, so this doesn't conflict with the appSetup() call inside setupApp(). +registerMiddlewares(); + type MockFetch = jest.Mock, Parameters> & { pause: () => void; fail: () => void; @@ -93,7 +98,6 @@ function setupApp(initialUrl = `https://new.expensify.com/${ROUTES.INBOX}`) { Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }); }); } @@ -505,6 +509,26 @@ function convertToDisplayString(amountInCents: number | undefined, currencyCode: }); } +/** + * A local version of useCurrencyListActions().convertToDisplayStringWithoutCurrency for tests that call lib + * functions directly and need to inject the symbol-less formatter without the full app context. + */ +function convertToDisplayStringWithoutCurrency(amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = getCurrencyDecimalsLocal(sanitizedCurrency); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); + return formatToParts(CONST.LOCALES.EN, convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + minimumFractionDigits: decimals, + maximumFractionDigits: 2, + }) + .filter((x) => x.type !== 'currency') + .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) + .map((x) => x.value) + .join(''); +} + function getNavigateToChatHintRegex(): RegExp { const hintTextPrefix = translateLocal('accessibilityHints.navigatesToChat'); return new RegExp(hintTextPrefix, 'i'); @@ -536,6 +560,7 @@ export { anyString, translateLocal, convertToDisplayString, + convertToDisplayStringWithoutCurrency, getCurrencyDecimalsLocal, getCurrencySymbolLocal, assertFormDataMatchesObject, diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index 24855874145b..d1110cf397f4 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -186,6 +186,10 @@ function TestTabNavigator() { name={NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR} component={TestSearchFullscreenNavigator} /> + ['navigation']; + +type MockScreenNavigation = { + /** Stub to hand to the screen's `navigation` prop. Only the APIs the confirmation step actually calls are backed. */ + navigation: ConfirmationScreenNavigation; + + /** Replays the refocus the screen gets when the user returns to it from another RHP. */ + emitScreenFocus: () => void; + + /** Drops every recorded listener, so a test file can reset between renders. */ + resetScreenFocusListeners: () => void; +}; + +/** + * Builds the `navigation` stub the money request confirmation tests hand to the screen. + * + * These tests render the page outside a navigator, so the real navigation prop isn't available. Recording the 'focus' + * subscriptions lets a test replay the refocus that happens on returning from another RHP. The cast lives here rather + * than at every render site, so the stub can grow a new API in one place when the page starts calling one. + */ +function createMockScreenNavigation(): MockScreenNavigation { + const focusListeners: Array<() => void> = []; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only navigation stub. The single assertion lives here so no render site needs one. + const navigation = { + addListener: jest.fn((event: string, callback: () => void) => { + if (event !== 'focus') { + return () => {}; + } + focusListeners.push(callback); + return () => { + const index = focusListeners.indexOf(callback); + if (index === -1) { + return; + } + focusListeners.splice(index, 1); + }; + }), + setParams: jest.fn(), + } as unknown as ConfirmationScreenNavigation; + + return { + navigation, + emitScreenFocus: () => { + for (const callback of [...focusListeners]) { + callback(); + } + }, + resetScreenFocusListeners: () => { + focusListeners.length = 0; + }, + }; +} + +export default createMockScreenNavigation; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 000000000000..ff95333aff5c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./tsconfig.app.ts7.tsbuildinfo", + "types": ["react-native", "react-native-web"], + "lib": ["DOM", "ESNext"] + }, + "include": [ + "src", + "assets", + "modules", + ".storybook/**/*", + // Not app directories, but the app program reaches into them: `src/App.tsx` imports + // `wdyr`, `src/libs/ApiUtils` imports `config/proxyConfig`, `.storybook` imports the + // Rsbuild config, and the Storybook stories import the shared report fixtures. They were + // already being type-checked here via those imports; listing them makes that visible. + "wdyr.ts", + "config/proxyConfig.ts", + "config/rsbuild", + "__mocks__/reportData" + ], + "exclude": ["**/node_modules/*", "**/dist/*", "src/**/__mocks__/**"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index eca65ec97f85..2ef26e24d0fa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,6 +4,10 @@ "compilerOptions": { "isolatedModules": true, "strict": true, + // Every project under the root solution is type-check only. `expo/tsconfig.base` happens to + // set this too, but it is restated here because `tsc --build` rejects `--noEmit` as a command + // line flag: the whole typecheck gate now depends on this option coming from a config file. + "noEmit": true, "incremental": true, "paths": { "@assets/*": ["./assets/*"], diff --git a/tsconfig.bun.json b/tsconfig.bun.json index 7e5d2ae4b42c..74aa46978cb1 100644 --- a/tsconfig.bun.json +++ b/tsconfig.bun.json @@ -1,9 +1,13 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.base.json", "compilerOptions": { + "tsBuildInfoFile": "./tsconfig.bun.ts7.tsbuildinfo", "types": ["bun"], "lib": ["ESNext"] }, - "include": ["scripts", "tests/tooling", "server/libs", "server/plugins", "server/stubs", "evals"], + // `prompts` and `.github/libs` are not Bun directories, but `scripts`, `evals` and + // `tests/tooling` import them, so this program has to list them. + "include": ["scripts", "tests/tooling", "server/libs", "server/plugins", "server/stubs", "evals", "prompts", ".github/libs"], "exclude": ["**/node_modules/*", "**/dist/*", "server/victory-chart-renderer/**"] } diff --git a/tsconfig.jest.json b/tsconfig.jest.json index 328c2c6162d4..4078e94fa8cf 100644 --- a/tsconfig.jest.json +++ b/tsconfig.jest.json @@ -1,6 +1,9 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "include": ["src", "assets", "tests", "jest", "prompts", "__mocks__", "modules"], + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./tsconfig.jest.ts7.tsbuildinfo" + }, + "include": ["src", "assets", "modules", "tests", "jest", "__mocks__"], "exclude": ["**/node_modules/*", "**/dist/*", "tests/tooling"] } diff --git a/tsconfig.json b/tsconfig.json index 49f4efed1e41..992f4ceb291b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,21 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.base.json", + + // This is a solution-style config: it owns no files of its own, it just names every project in + // the repo. Two things read it: + // + // 1. Editors. A language service resolves a file to the *closest* `tsconfig.json`, so every + // file in the repo lands here first. Because this config has no files, the editor walks + // `references` to find the project that does own the file, and type-checks it with that + // project's options. Without the references a file outside the app program (a Jest test, a + // Bun script) falls into an inferred project, which has no `paths`, which is why `@libs` + // and friends stopped resolving in `tests/` when the Jest project was split out. + // 2. `npm run typecheck`, which type-checks exactly the projects listed here. Adding a project + // to `references` is all it takes to put it in CI. + // + // `paths` is duplicated here from `tsconfig.base.json` because tools that read this file + // directly do not resolve its `extends` chain. "compilerOptions": { - "types": ["react-native", "react-native-web"], - "lib": ["DOM", "ESNext"], - // Babel reads this config directly and does not resolve its `extends` chain. "paths": { "@assets/*": ["./assets/*"], "@components/*": ["./src/components/*"], @@ -23,21 +34,14 @@ "tests/*": ["./tests/*"] } }, - "include": ["src", "website", "docs", "assets", "prompts", ".storybook/**/*", ".claude/**/*", "modules", "**/*.nitro/*.ts", "**/*.nitro/*.tsx"], - "exclude": [ - "**/node_modules/*", - "**/dist/*", - ".github/actions/**/index.js", - "**/docs/*", - ".claude/worktrees/**", - "tests/tooling", - "src/**/__mocks__/**", - "server/libs", - "server/plugins", - "server/stubs", - "evals", - ".github", - "web/proxy.ts", - "config" + "files": [], + // Order matters for editors: a file that appears in more than one project (all of `src` is in + // both the app and Jest programs) resolves to the first project here that lists it. + "references": [ + {"path": "./tsconfig.app.json"}, + {"path": "./tsconfig.jest.json"}, + {"path": "./tsconfig.bun.json"}, + {"path": "./tsconfig.node.json"}, + {"path": "./server/victory-chart-renderer"} ] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 21d512a9facd..7896af84ad2a 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,9 +1,14 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.base.json", "compilerOptions": { + "tsBuildInfoFile": "./tsconfig.node.ts7.tsbuildinfo", "types": ["node"], "lib": ["ESNext"] }, + // `prompts` is deliberately not listed even though the Proposal Police action imports it: the Bun + // program owns those files, and listing them in two referenced projects would give editors two + // candidate owners for one file. The action's imports still pull them into this program. "include": [".github/**/*", "web/proxy.ts", "config/**/*", "config/**/*.d.mts"], "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js"] }