diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts index dd7bae51190b..25ac16ad11fb 100644 --- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts +++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts @@ -84,8 +84,9 @@ async function run() { } core.setOutput('MOBILE_EXPENSIFY_PR_LIST', mobileExpensifyPRList); } catch (error) { - console.error((error as Error).message); - core.setFailed(error as Error); + const message = error instanceof Error ? error.message : String(error); + console.error(message); + core.setFailed(error instanceof Error ? error : message); } } diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index c6a083ec32cf..9fee4c2e6414 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -26716,8 +26716,9 @@ async function run() { } setOutput("MOBILE_EXPENSIFY_PR_LIST", mobileExpensifyPRList); } catch (error2) { - console.error(error2.message); - setFailed(error2); + const message = error2 instanceof Error ? error2.message : String(error2); + console.error(message); + setFailed(error2 instanceof Error ? error2 : message); } } if (import.meta.main) { diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 3633c87fb9cb..9aa04f3f7089 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -127,8 +127,8 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-adhoc-web-deploy-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} aws-region: us-east-1 - name: Deploy to S3 diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index de8b5ff08840..8bdba2f7e5f3 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -156,8 +156,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 7cdb09c2d8a1..c79ca9aec984 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -237,8 +237,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Prepare provisioning profiles JSON diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eeb9592a36f2..ea6d403ec235 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -201,6 +201,8 @@ jobs: androidBuild: name: Build Android HybridApp needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildAndroid.yml with: @@ -429,6 +431,8 @@ jobs: iosBuild: name: Build iOS HybridApp needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildIOS.yml with: @@ -703,6 +707,8 @@ jobs: name: Deploy Web to S3 needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' }} + # Preserve the default token scopes while adding OIDC access + permissions: write-all runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -735,8 +741,8 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-${{ env.DEPLOY_ENV }}-web-deploy-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} aws-region: us-east-1 - name: Deploy to S3 diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 7462d1498d15..e24ff55b08b6 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -47,6 +47,8 @@ jobs: build: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access + permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-2vcpu-ubuntu-2404' }} strategy: fail-fast: false @@ -82,8 +84,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 482f8a1d32f6..f246dcf43b0d 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -47,6 +47,8 @@ jobs: build: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access + permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }} strategy: fail-fast: false @@ -94,8 +96,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - iOS diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index c58bffcfb075..a29336e8684b 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -63,6 +63,8 @@ jobs: buildApps: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: # Fall back to App main when nothing is resolved (test builds default to the latest NewDot). diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index b7f3d20e3d0b..286e2acfc44c 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -70,6 +70,8 @@ jobs: buildApps: needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: APP_REF: ${{ needs.prep.outputs.APP_REF }} diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml index f16b9f77ac5d..762f08d95427 100644 --- a/.github/workflows/verifyHybridApp.yml +++ b/.github/workflows/verifyHybridApp.yml @@ -75,6 +75,10 @@ jobs: verify_android: name: Verify Android HybridApp builds on main + permissions: + contents: read + id-token: write + pull-requests: write if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} cache-mode: read uses: ./.github/workflows/buildAndroid.yml @@ -85,6 +89,10 @@ jobs: verify_ios: name: Verify iOS HybridApp builds on main + permissions: + contents: read + id-token: write + pull-requests: write if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} cache-mode: read uses: ./.github/workflows/buildIOS.yml diff --git a/.storybook/mockPaths.ts b/.storybook/mockPaths.ts index 2d97ca447146..960802b0d25d 100644 --- a/.storybook/mockPaths.ts +++ b/.storybook/mockPaths.ts @@ -8,6 +8,9 @@ const dirname = path.dirname(filename); /* eslint-disable @typescript-eslint/naming-convention */ export default { '@react-native-community/netinfo': path.resolve(dirname, '../__mocks__/@react-native-community/netinfo.ts'), - '@react-navigation/native': path.resolve(dirname, '../__mocks__/@react-navigation/native'), + // The static mock, plus a story-owned route stack so a whole flow can mount + '@react-navigation/native': path.resolve(dirname, './mocks/react-navigation-native.ts'), + // Modal backdrops read the stack card animation, which no story mounts + '@react-navigation/stack': path.resolve(dirname, './mocks/react-navigation-stack.ts'), }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/.storybook/mocks/navigationStoryLoader.cjs b/.storybook/mocks/navigationStoryLoader.cjs new file mode 100644 index 000000000000..6e7bfe49cd2c --- /dev/null +++ b/.storybook/mocks/navigationStoryLoader.cjs @@ -0,0 +1,11 @@ +const path = require('path'); + +/** + * Rewrites the App's Navigation module for Storybook so navigate and goBack land on the story's route stack. + * A loader is used because `@libs` is a prefix alias that no alias entry for a single module can outrank. + */ +module.exports = function navigationStoryLoader(source) { + const routerPath = JSON.stringify(path.resolve(__dirname, './storyRouter.tsx')); + const renamed = source.replace(/export default \{/, 'const navigationForStorybook = {'); + return `import {withStoryRouter} from ${routerPath};\n${renamed}\nexport default withStoryRouter(navigationForStorybook);\n`; +}; diff --git a/.storybook/mocks/react-navigation-native.ts b/.storybook/mocks/react-navigation-native.ts new file mode 100644 index 000000000000..097840b9f798 --- /dev/null +++ b/.storybook/mocks/react-navigation-native.ts @@ -0,0 +1,86 @@ +import {useEffect} from 'react'; + +import {useStoryRouter} from './storyRouter'; + +const noop = () => {}; + +const staticRoute = {key: 'story', name: 'StoryFlow', params: {}}; +const staticNavigation = { + navigate: noop, + goBack: noop, + setParams: noop, + canGoBack: () => false, + getState: () => ({routes: []}), + isFocused: () => true, + addListener: () => noop, +}; + +/** Inside a StoryRouterProvider the route comes from the story's stack. Elsewhere no story mounts a navigator. */ +function useRoute() { + const router = useStoryRouter(); + return router ? {...staticRoute, params: router.current} : staticRoute; +} + +function useNavigation() { + const router = useStoryRouter(); + if (!router) { + return staticNavigation; + } + return {...staticNavigation, setParams: router.setParams, goBack: router.pop, canGoBack: () => true}; +} + +const useIsFocused = () => true; +const useTheme = () => ({}); +const useLocale = () => ({}); +const useNavigationState = () => undefined; +const usePreventRemove = noop; +const triggerTransitionEnd = noop; +const useFocusEffect = (callback: () => (() => void) | void) => { + useEffect(() => callback(), [callback]); +}; +function Link() { + return null; +} +function LinkingContext() { + return null; +} +function NavigationContainer() { + return null; +} +function ServerContainer() { + return null; +} +function ThemeProvider() { + return null; +} +const DarkTheme = {}; +const DefaultTheme = {}; +const useLinkBuilder = () => null; +const useLinkProps = () => null; +const useLinkTo = () => null; +const useScrollToTop = () => null; + +// Everything a story does not override comes from the navigation core +export * from '@react-navigation/core'; +export { + useRoute, + useNavigation, + useIsFocused, + useTheme, + useLocale, + useNavigationState, + usePreventRemove, + triggerTransitionEnd, + useFocusEffect, + Link, + LinkingContext, + NavigationContainer, + ServerContainer, + ThemeProvider, + DarkTheme, + DefaultTheme, + useLinkBuilder, + useLinkProps, + useLinkTo, + useScrollToTop, +}; diff --git a/.storybook/mocks/react-navigation-stack.ts b/.storybook/mocks/react-navigation-stack.ts new file mode 100644 index 000000000000..6ef3f6ec056f --- /dev/null +++ b/.storybook/mocks/react-navigation-stack.ts @@ -0,0 +1,27 @@ +// useCardAnimation returns React Native Animated values, not Reanimated ones +// eslint-disable-next-line no-restricted-imports +import {Animated} from 'react-native'; + +// The alias points the package name at this file, so the real module has to be reached by path, which has no declaration +// @ts-expect-error -- see above +// eslint-disable-next-line import/no-relative-packages +export * from '../../node_modules/@react-navigation/stack/lib/module/index.js'; + +const presented = new Animated.Value(1); +const idle = new Animated.Value(0); + +/** No story mounts a stack screen, so every modal is treated as fully presented */ +function useCardAnimation() { + return { + current: {progress: presented}, + next: undefined, + closing: idle, + swiping: idle, + inverted: presented, + index: 0, + layouts: {screen: {width: 0, height: 0}}, + insets: {top: 0, right: 0, bottom: 0, left: 0}, + }; +} + +export {useCardAnimation}; diff --git a/.storybook/mocks/storyRouter.tsx b/.storybook/mocks/storyRouter.tsx new file mode 100644 index 000000000000..41b38a883706 --- /dev/null +++ b/.storybook/mocks/storyRouter.tsx @@ -0,0 +1,106 @@ +import type {ReactNode} from 'react'; + +import React, {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react'; + +type StoryRouteParams = {subPage?: string; action?: 'edit'}; + +type StoryRouter = { + current: StoryRouteParams; + push: (params: StoryRouteParams) => void; + replace: (params: StoryRouteParams) => void; + pop: () => boolean; + setParams: (params: StoryRouteParams) => void; +}; + +type ActiveStoryRouter = {routePrefix: string; api: StoryRouter}; + +const StoryRouterContext = createContext(undefined); + +let activeStoryRouter: ActiveStoryRouter | undefined; + +/** A one-screen stack for stories: the App's navigation hooks and helpers read and write it while a story is mounted */ +function StoryRouterProvider({routePrefix, initialParams = {}, children}: {routePrefix: string; initialParams?: StoryRouteParams; children: ReactNode}) { + const [stack, setStack] = useState([initialParams]); + const stackRef = useRef(stack); + useEffect(() => { + stackRef.current = stack; + }, [stack]); + + const api = useMemo( + () => ({ + current: stack.at(-1) ?? {}, + push: (params) => setStack((previous) => [...previous, params]), + replace: (params) => setStack((previous) => [...previous.slice(0, -1), params]), + pop: () => { + if (stackRef.current.length <= 1) { + return false; + } + setStack((previous) => previous.slice(0, -1)); + return true; + }, + setParams: (params) => setStack((previous) => [...previous.slice(0, -1), {...previous.at(-1), ...params}]), + }), + [stack], + ); + + useEffect(() => { + activeStoryRouter = {routePrefix, api}; + return () => { + activeStoryRouter = undefined; + }; + }, [routePrefix, api]); + + return {children}; +} + +function useStoryRouter(): StoryRouter | undefined { + return useContext(StoryRouterContext); +} + +function getActiveStoryRouter(): ActiveStoryRouter | undefined { + return activeStoryRouter; +} + +/** Reads `subPage` and `action` back out of a route the story's route builder produced */ +function parseStoryRoute(routePrefix: string, route: string): StoryRouteParams { + const path = route.split('?').at(0) ?? ''; + const rest = path.startsWith(routePrefix) ? path.slice(routePrefix.length) : path; + const [subPage, action] = rest.split('/').filter(Boolean); + return {subPage, action: action === 'edit' ? 'edit' : undefined}; +} + +type NavigationLike = { + navigate: (route: string, options?: {forceReplace?: boolean}) => void; + goBack: (backToRoute?: string, options?: unknown) => void; +}; + +/** Wraps the App's Navigation object so that, while a story router is mounted, navigation lands on its stack */ +function withStoryRouter(navigation: T): T { + const navigate: NavigationLike['navigate'] = (route, options) => { + const router = getActiveStoryRouter(); + if (!router) { + navigation.navigate(route, options); + return; + } + const params = parseStoryRoute(router.routePrefix, route); + if (options?.forceReplace) { + router.api.replace(params); + return; + } + router.api.push(params); + }; + const goBack: NavigationLike['goBack'] = (backToRoute, options) => { + const router = getActiveStoryRouter(); + if (!router) { + navigation.goBack(backToRoute, options); + return; + } + if (!router.api.pop() && backToRoute) { + router.api.replace(parseStoryRoute(router.routePrefix, backToRoute)); + } + }; + return {...navigation, navigate, goBack}; +} + +export {StoryRouterProvider, useStoryRouter, getActiveStoryRouter, parseStoryRoute, withStoryRouter}; +export type {StoryRouteParams}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 8730a9acda24..da8a0da4930b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,3 +1,4 @@ +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; import EnvironmentProvider from '@components/EnvironmentContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; @@ -9,7 +10,9 @@ import colors from '@styles/theme/colors'; import ComposeProviders from '@src/components/ComposeProviders'; import HTMLEngineProvider from '@src/components/HTMLEngineProvider'; +import KeyboardProvider from '@src/components/KeyboardProvider'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; +import {ModalProvider} from '@src/components/Modal/Global/ModalContext'; import {KeyboardStateProvider} from '@src/components/withKeyboardState'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -22,12 +25,14 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import currencyList from '../tests/unit/currencyList.json'; import './fonts.css'; registerMiddlewares(); Onyx.init({ keys: ONYXKEYS, + initialKeyStates: {[ONYXKEYS.CURRENCY_LIST]: currencyList}, }); IntlStore.load(CONST.LOCALES.EN); @@ -38,9 +43,12 @@ const decorators = [ components={[ OnyxListItemProvider, LocaleContextProvider, + CurrencyListContextProvider, HTMLEngineProvider, SafeAreaProvider, PortalProvider, + KeyboardProvider, + ModalProvider, EnvironmentProvider, KeyboardStateProvider, SearchContextProvider, diff --git a/.storybook/rsbuild.config.ts b/.storybook/rsbuild.config.ts index 5f7bae2e98ad..4f7bdb02b54a 100644 --- a/.storybook/rsbuild.config.ts +++ b/.storybook/rsbuild.config.ts @@ -1,11 +1,15 @@ import type {RsbuildConfig} from '@rsbuild/core'; import {mergeRsbuildConfig} from '@rsbuild/core'; +import path from 'path'; +import {fileURLToPath} from 'url'; // Storybook 10 loads TS files directly and requires .ts extension for ESM imports // @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig import mockPaths from './mockPaths.ts'; +const dirname = path.dirname(fileURLToPath(import.meta.url)); + let envFile: string; switch (process.env.ENV) { case 'production': @@ -30,6 +34,15 @@ const rsbuildFinal = async (config: RsbuildConfig): Promise => { // they're already set in `shared.resolve.alias` and `mergeRsbuildConfig` keeps both. alias: mockPaths, }, + tools: { + rspack: (rspackConfig) => { + // `@libs` is a prefix alias no single-module alias can outrank, so the module is rewritten by a loader instead + rspackConfig.module?.rules?.push({ + test: /src\/libs\/Navigation\/Navigation\.ts$/, + use: [{loader: path.resolve(dirname, './mocks/navigationStoryLoader.cjs')}], + }); + }, + }, }); }; diff --git a/Mobile-Expensify b/Mobile-Expensify index 903b4645c4e9..552545018fe1 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 903b4645c4e95b2a637d496db39b940292b84804 +Subproject commit 552545018fe13d715bfe91638702566aa22f463d diff --git a/android/app/build.gradle b/android/app/build.gradle index 85d4aee072a0..adee017af7f6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050804 - versionName "9.5.8-4" + versionCode 1009051200 + versionName "9.5.12-0" // 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/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 968073a518b3..7be147cfcd32 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -14,7 +14,6 @@ "../../.github/actions/javascript/failureNotifier/failureNotifier.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/formatCodeCovComment/formatCodeCovComment.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getAndroidRolloutPercentage/getAndroidRolloutPercentage.ts" "rulesdir/no-default-id-values" 1 -"../../.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 @@ -84,7 +83,6 @@ "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 "../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/AutoUpdateTime.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 @@ -121,7 +119,6 @@ "../../src/components/Composer/implementation/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Composer/implementation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Composer/implementation/index.tsx" "react-hooks/set-state-in-effect" 2 -"../../src/components/ConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ConnectionLayout.tsx" "no-restricted-imports" 1 "../../src/components/ConnectToMergeFlow/index.ios.tsx" "no-restricted-imports" 1 @@ -154,9 +151,7 @@ "../../src/components/DragAndDrop/Provider/index.tsx" "react-hooks/refs" 6 "../../src/components/DraggableList/SortableItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/DropZone/DropZoneWrapper.tsx" "react-hooks/refs" 1 -"../../src/components/DynamicForm/components/DynamicFormFieldRow.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/components/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/EmojiPicker/CategoryShortcutButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "react-hooks/refs" 3 "../../src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -632,7 +627,6 @@ "../../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 @@ -663,7 +657,6 @@ "../../src/libs/actions/Link.ts" "no-restricted-syntax" 2 "../../src/libs/actions/MapboxToken.ts" "no-restricted-syntax" 2 "../../src/libs/actions/MergeAccounts.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 "../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/MergeTransaction.ts" "no-restricted-imports" 1 "../../src/libs/actions/MergeTransaction.ts" "no-restricted-syntax" 1 @@ -833,7 +826,6 @@ "../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -848,7 +840,7 @@ "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteAdaptedState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/Navigation/helpers/getOnboardingRouteFromScreen.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/getParamsFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/getPathFromState.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -897,7 +889,8 @@ "../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/PolicyUtils/connections.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/PolicyUtils/errors.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/PolicyUtils/rules.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/prepareRequestPayload/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -914,7 +907,7 @@ "../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 16 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 7 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 31 "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 13 "../../src/libs/sanitizeLogParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1249,14 +1242,14 @@ "../../src/pages/SetDefaultWorkspacePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 -"../../src/pages/settings/Agents/AddAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/EditAgentPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/Fields/EditNamePage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/NewAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicAddAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicEditAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicNewAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AppDownloadLinks.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Copilot/CopilotPage.tsx" "no-restricted-imports" 1 diff --git a/contributingGuides/DYNAMIC_FORMS.md b/contributingGuides/DYNAMIC_FORMS.md new file mode 100644 index 000000000000..6ffae2d5ea1c --- /dev/null +++ b/contributingGuides/DYNAMIC_FORMS.md @@ -0,0 +1,142 @@ +# Dynamic Forms + +Some forms are described by data instead of JSX: the fields a Wise bank account needs depend on the country and currency, and the server sends them as a schema. `DynamicForm` renders such a schema with the regular form components, so these forms look and behave like every other form in the App. + +## Usage + +There are two ways in. + +### A whole flow: `DynamicFormFlow` + +`DynamicFormFlow` turns a schema into a multi-page flow, followed by a confirmation page with every answer. + +```tsx +import DynamicFormFlow from "@components/DynamicForm"; + + ROUTES.SOME_FLOW.getRoute(pageName, action)} + onSubmit={(answers) => submitSomeFlow(answers)} + onBack={() => Navigation.goBack()} +/>; +``` + +- `schema` is either `{pages}`, the pages as the server groups them, or `{fields}`, a flat list the App splits into pages (see [Pages](#pages)). +- The current page is kept in the route, through `useSubPage`. Pass `shouldReplaceRoute` for flows on dynamic routes. +- A new visit resumes on the first page its draft leaves incomplete, or on the last page when nothing is missing. +- `hasConfirmation` decides whether a confirmation page follows the last page. It is on by default; without it the last page's Confirm button submits. +- `layout` decides the step indicator: `auto` shows it at three or more shown pages, `stepper` always, `pages` never. +- Answers are saved to the form draft as the user types, and again, cleaned, when they leave a page with Next. +- `onPageSubmit` receives each page and its drafted answers, list entries included, when the user leaves it with Next, for flows that save page by page. Sensitive answers are not passed. +- `onSubmit` receives the answers to every visible field, sensitive ones included. Answers left on fields that were hidden later are not sent. +- `isSubmitting` and `submitError` show the confirmation page's Confirm button loading and the error from sending the answers. +- `onRefreshRequirements` is called when a field marked `refreshOnChange` changes, so the screen can fetch the schema again. Typed fields call it when the user leaves the input, not on every keystroke. +- `currency` is the currency of amount fields that let the user pick none. `maxFileSize` is the largest size of one file, in bytes, for file fields that set none. + +### One page: `DynamicFormFields` + +Render `DynamicFormFields` inside a `FormProvider` and validate the same content with `getDynamicFieldErrors`: + +```tsx +import DynamicFormFields from "@components/DynamicForm/components/DynamicFormFields"; +import getContentFields from "@components/DynamicForm/utils/getContentFields"; +import getDynamicFieldErrors from "@components/DynamicForm/utils/getDynamicFieldErrors"; + + getDynamicFieldErrors(getContentFields(content), values, translate)} + onSubmit={onSubmit} +> + {({ inputValues }) => } +; +``` + +Pass `inputValues` from the render prop, so `showWhen` and `dependsOn` react to the user's answers as they type. When `content` is one page of a bigger form, pass the whole form as `allFields`, since a field can depend on an answer asked on another page. Outside `DynamicFormFlow`, list entries are edited in a modal. + +## Pages + +A page is a heading with its own `content`: `key`, `title` or `titleKey`, `description` or `descriptionKey`, and an optional `showWhen`. Its `content` holds headings and fields in screen order. An item without a `type` is a heading; a heading shows only while its `showWhen` matches and a visible field follows it before the next heading. + +When the schema is a flat `{fields}` list, the App builds the pages: + +- Addresses, files, multi-selects, lists, dependent selects and selects with more than 10 options get a page of their own. +- Other fields share a page, up to 4 in a row. +- A field with `showWhen` joins the page of its controller and does not count toward that limit. + +A page is shown while its own `showWhen` matches and it has a visible field; a page that is not shown is skipped, and its fields are neither asked nor submitted. A page without a title is headed by its only question: the field's `question` or `questionKey` when it has one, else its label. An untitled page with several questions has no headline and no section name on the confirmation page, since no one question names it. A label never names a confirmation section, since the label is already on the row below it. Page keys become route segments, so they are made URL-safe and unique, and `confirm` is reserved for the confirmation page. + +## The field format + +`DynamicFormField` in `src/types/onyx/DynamicFormField.ts` is the contract with the server. Each `type` has its own variant, so a property such as `rule` or `currencyKey` only exists on the types that use it. + +| Type | Input | Type-specific properties | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `text` | `TextInput` | `regex`, `minLength`, `maxLength`, `example`, `keyboard`, `multiline`, `rule: 'legalName' \| 'phone'` | +| `number` | `TextInput` with the numeric keyboard | `regex`, `minLength`, `maxLength`, `example` | +| `select` | `ValuePicker`, or `PushRowWithModal` above 10 options | `values`, `dependsOn`, `presentation: 'tabs'` | +| `radio` | `RadioButtons` | `values`, `dependsOn`, `presentation: 'tabs'` | +| `multiselect` | `PushRowWithModal` with checkboxes and a Save button | `values`, `dependsOn` | +| `date` | `DatePicker` | `rule: 'dateOfBirth'` | +| `boolean` | Yes and No radio buttons, where No is an answer. With `presentation: 'checkbox'`, `CheckboxWithLabel`, which must be ticked when required. | `presentation: 'checkbox'` | +| `country` | `CountryPicker` | | +| `countryMultiselect` | `PushRowWithModal` with every country | | +| `currency` | `CurrencyPicker` | | +| `address` | The standard address form. The street is the field's value; the other parts are stored under `.city`, `.zipCode` and so on. | `rule: 'zipCode'` | +| `file` | `UploadFile` for PDF, JPG and PNG files, taking one file unless `maxFiles` allows more | `maxFiles`, `maxFileSize` | +| `amount` | `AmountForm`. With `currencyKey`, a `CurrencyPicker` above it saves the currency under that key. | `currencyKey` | +| `percent` | `PercentageForm`, from 1 to 100 | | +| `list` | Rows with Add, Edit and Remove. Each entry is edited on its own page in the flow, or in a modal outside it. | `itemFields`, `itemLabel`, `addItemDescription`, `minItems`, `maxItems` | + +Every type also takes: + +- `key`: unique within the form, and the field's draft key. A field without one is left out and logged. +- `label` or `labelKey`, and the same pair for `description`. A `*Key` is our translation and wins over the server's wording. +- `question` or `questionKey`: the headline of an untitled page where this is the only question, such as "What's your account number?". Without one, that page is headed by the label, which a text input then repeats. +- `required`. An optional address the user has started filling in is checked like a required one, so it is never sent in part. +- `readonly`: shown as a row with its value, never edited or validated +- `sensitive`: kept out of the form draft, see below +- `showWhen`: shown only while another answer is one of the listed values. A list answer counts when any chosen value matches. A field whose controlling field is hidden stays hidden. +- `refreshOnChange`: see `onRefreshRequirements` above + +A choice or list field that is the only question on its page is drawn as the page itself, as an inline list, with its question above it. A page without its own title takes the question as its headline, so the label is not repeated below it. Fields it reveals, such as an "Other" description, do not count, so the layout stays put when they appear. On a page with several questions, every choice, tabs included, shows its question above it. + +`maxFileSize` comes from the field when the schema sets it, else from the `maxFileSize` the screen passes. With neither, files of any size are taken. + +The server can send a type this App version does not know. The schema is typed `DynamicFormSchemaField` for that reason: such a field is left out and logged once, so a newer schema never blocks the form. + +## Privacy and accessibility + +- Every field row, read-only rows included, every confirmation row and the list entry editor are masked in session recordings. The mask sits on the row's `View`, so it also covers inputs that do not pass a FullStory class on, such as `CountryPicker`. +- The confirmation before removing a list entry names the entry only in its masked prompt, since the modal title cannot be masked. +- Radio buttons, Yes/No questions and tabs are named after their question for screen readers, and the bold labels above addresses, files and lists are headings. + +## Sensitive answers + +`sensitive` answers, such as SSNs, never reach the form draft. `DynamicFormFlow` keeps them in memory, in the RAM-only `ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS`, for one visit: + +- They are cleared when a new visit opens without a page in the route, and when the user leaves from the first page. +- They are cleared once `isSubmitting` turns false without `submitError`, or right after `onSubmit` when the screen passes no `isSubmitting`. +- They are never passed to `onPageSubmit`, since a page save would persist them in the request queue. +- The confirmation page shows only their last four characters, as in `•••-••-6789` for an SSN. + +## List fields + +A `list` field holds repeated entries, such as a company's owners. Its value is an array of entries, each holding its answers keyed by item field key and an `id` made on the device. + +- In the flow each entry is edited on its own page, using the `dynamicFormListItemForm` form, which the page fills with the entry's answers when it opens and empties when it closes. The modal outside the flow does the same. These pages come after the confirmation page and Next and Back skip them, so only Add, Edit or an entry's row opens them. +- Saving an entry drafts the list right away. Leaving the list page with Next drops the sensitive answers of entries the user removed. +- Sensitive entry answers are kept in memory per entry and are sent back with their entry on submit. They are checked with their entry, so one lost with the memory, such as after a restart, is asked for again. +- A list marked `sensitive` is kept in memory whole and never drafted. +- On the confirmation page each entry is a row of its own, with a letter avatar, that opens the entry. An empty list is a plain row that opens its page. +- `minItems`, `maxItems` and the first entry with a problem show as the list's error. + +## Adding a field type + +1. Add a variant to `DynamicFormField`. +2. Typecheck then points at every place that has to handle it: the renderers in `renderers/`, the validators in `utils/getDynamicFieldErrors.ts`, the supported types in `utils/isSupportedField.ts` and the input map in `DynamicFormFieldsTest.tsx`. +3. Map the field onto an existing input component. If the component's props do not fit a form input, add a thin adapter in `adapters/`: it takes `value`, `onInputChange` and `errorText`, renders one existing component, reads no Onyx and navigates nowhere, and passes `forwardedFSClass` on so the answer stays masked. If no component fits, add the input outside `DynamicForm` first, so other forms can use it too. diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 0880ce415641..478b1245f47b 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -341,6 +341,8 @@ InputWrapper component will automatically provide the following props to any inp ## Dynamic Form Inputs +For forms whose fields are described by data rather than JSX, use the schema-driven renderer in [DYNAMIC_FORMS.md](DYNAMIC_FORMS.md). + It's possible to conditionally render inputs (or more complex components with multiple inputs) inside a form. For example, an IdentityForm might be nested as input for a Form component. In order for Form to track the nested values properly, each field must have a unique identifier. It's not safe to use an index because adding or removing fields from the child Form component will not update these internal keys. Therefore, we will need to define keys and dynamically access the correlating child form data for validation/submission. diff --git a/contributingGuides/philosophies/SECURITY.md b/contributingGuides/philosophies/SECURITY.md index b452f0106a0b..208307a519b9 100644 --- a/contributingGuides/philosophies/SECURITY.md +++ b/contributingGuides/philosophies/SECURITY.md @@ -10,6 +10,7 @@ Updated rules for managing members across all types of chats in New Expensify. - Admins MUST NOT be able to leave or be removed from expense chats - Members MUST NOT be able to leave or be removed from the #announce room - Admins MUST NOT be able to leave or be removed from #admins +- Auditors MUST NOT be able to leave or be removed from #admins - Domain members MUST NOT be able to leave or be removed from their domain chat - Report submitters MUST NOT be able to leave or be removed from their reports - Report managers MUST NOT be able to leave or be removed from their reports @@ -32,50 +33,44 @@ Updated rules for managing members across all types of chats in New Expensify. - DM always has two participants. None of the participant can leave or be removed from the DM. Also no additional member can be invited to the chat. ### Workspace Rooms -#### Workspace -| | Creator | Member(Employee/User) | Admin | Auditor? | -| :----------------: | :-----: | :-------------------: | :---: | :------: | -| **Invite** | ✅ | ❌ | ✅ | ❌ | -| **Remove** | ✅ | ❌ | ✅ | ❌ | -| **Leave** | ❌ | ✅ | ❌ | ✅ | -| **Can be removed** | ❌ | ✅ | ✅ | ✅ | - -- Creator can't leave or be removed from their own workspace -- Admins can't leave from the workspace -- Admins can remove other workspace admins, as well as workspace members, and invited guests -- Creator can remove other workspace admins, as well as workspace members, and invited guests -- Members and Auditors cannot invite or remove anyone from the workspace - #### Workspace #announce room -| | Member(Employee/User) | Admin | Auditor? | -| :----------------: | :-------------------: | :---: | :------: | -| **Invite** | ❌ | ❌ | ❌ | -| **Remove** | ❌ | ❌ | ❌ | -| **Leave** | ❌ | ❌ | ❌ | -| **Can be removed** | ❌ | ❌ | ❌ | +| | Member(Employee/User) | Admin | Auditor | +| :----------------: | :-------------------: | :---: | :-----: | +| **Invite** | ❌ | ❌ | ❌ | +| **Remove** | ❌ | ❌ | ❌ | +| **Leave** | ❌ | ❌ | ❌ | +| **Can be removed** | ❌ | ❌ | ❌ | - No one can leave or be removed from the #announce room +- Auditors are added to #announce when they join the workspace, so they can't leave or be removed from it either +- Only workspace admins can post in #announce. Every other role, auditors included, sees the room as read-only -#### Workspace #admin room -| | Admin | -| :----------------: | :---: | -| **Invite** | ❌ | -| **Remove** | ❌ | -| **Leave** | ❌ | -| **Can be removed** | ❌ | +#### Workspace #admins room +| | Admin | Auditor | +| :----------------: | :---: | :-----: | +| **Invite** | ❌ | ❌ | +| **Remove** | ❌ | ❌ | +| **Leave** | ❌ | ❌ | +| **Can be removed** | ❌ | ❌ | - Admins can't leave or be removed from #admins +- Auditors are added to #admins when they are given the Auditor role, and membership follows the role rather than manual invites +- Auditors can read and comment in #admins. Unlike #announce, the room has no admins-only write capability +- No one can leave #admins, auditors included +- Auditors lose #admins when their role changes to one that has no #admins access. That is a role change, not a removal from the room #### Workspace rooms -| | Creator | Member | Guest(outside of the workspace) | -| :----------------: | :-----: | :----: | :-----------------------------: | -| **Invite** | ✅ | ✅ | ✅ | -| **Remove** | ✅ | ✅ | ❌ | -| **Leave** | ✅ | ✅ | ✅ | -| **Can be removed** | ✅ | ✅ | ✅ | +| | Creator | Member | Auditor | Guest(outside of the workspace) | +| :----------------: | :-----: | :----: | :-----: | :-----------------------------: | +| **Invite** | ✅ | ✅ | ✅ | ✅ | +| **Remove** | ✅ | ✅ | ✅ | ❌ | +| **Leave** | ✅ | ✅ | ✅ | ✅ | +| **Can be removed** | ✅ | ✅ | ✅ | ✅ | - Everyone can be removed/can leave from the room including creator - Guests are not able to remove anyone from the room +- Auditors are added to the workspace rooms that are not private when they are given the Auditor role. Private rooms are skipped, so they join those by invitation only +- Auditors are able to invite and remove other participants in the rooms that their role shared with them #### Expense chats | | Admin | Member(default) | Member(invited) | @@ -90,6 +85,9 @@ Updated rules for managing members across all types of chats in New Expensify. - Invited members(invited by members) are not able to invite or remove from the expense chat - Invited members(invited by members) are able to leave the expense chat - Default members and admins are able to remove invited members +- Auditors are added to every workspace expense chat, so they can see the expenses on the workspace, and they are able to comment +- Auditors are not able to be removed from an expense chat +- Auditors are able to leave another member's expense chat, but not their own ### Domain chat | | Member | @@ -100,12 +98,26 @@ Updated rules for managing members across all types of chats in New Expensify. - Domain members can't leave or be removed from their domain chat -4. ### Reports - | | Submitter | Manager | - | :----------------: | :-------: | :-----: | - | **Remove** | ❌ | ❌ | - | **Leave** | ❌ | ❌ | - | **Can be removed** | ❌ | ❌ | +### Reports +| | Submitter | Manager | +| :----------------: | :-------: | :-----: | +| **Remove** | ❌ | ❌ | +| **Leave** | ❌ | ❌ | +| **Can be removed** | ❌ | ❌ | - Report submitters can't leave or be removed from their reports (eg, if they are the report.accountID) - Report managers can't leave or be removed from their reports (eg, if they are the report.managerID) + +## Workspace membership +| | Creator | Member(Employee/User) | Admin | Auditor | +| :----------------: | :-----: | :-------------------: | :---: | :-----: | +| **Invite** | ✅ | ❌ | ✅ | ❌ | +| **Remove** | ✅ | ❌ | ✅ | ❌ | +| **Leave** | ❌ | ✅ | ❌ | ✅ | +| **Can be removed** | ❌ | ✅ | ✅ | ✅ | + +- Creator can't leave or be removed from their own workspace +- Admins can't leave from the workspace +- Admins can remove other workspace admins, as well as workspace members, and invited guests +- Creator can remove other workspace admins, as well as workspace members, and invited guests +- Members and Auditors cannot invite or remove anyone from the workspace diff --git a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md index 19b1ed1d8b83..c3aa80b9b295 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md @@ -94,5 +94,5 @@ Only Domain Admins can access the Reconciliation tool. - Amount - Last four digits of the card number -**Note: Only posted transactions will be imported.** +**Note: Only posted transactions will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post.** diff --git a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index 39bd550f1bad..754345740a96 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,85 +1,90 @@ --- title: NS0068 Export Error in NetSuite Integration -description: Learn how to fix the NS0068 export error in NetSuite when the Created From field is not visible on the preferred transaction form. -keywords: NS0068, NetSuite Created From field not visible, permission to set value Created From, NetSuite transaction form visibility, Expensify Card journal entry error, Expensify NetSuite export error, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field not being visible on the preferred NetSuite form. Does not cover other NetSuite error codes. +description: Learn how to fix the NS0068 export error in NetSuite when Expensify can't set the Created From field (custbody_createdfrom_expensify) during export. +keywords: NS0068, NetSuite Created From field not visible, custbody_createdfrom_expensify, permission to set value Created From, Transaction Body Fields NetSuite, NetSuite transaction form visibility, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite export error, workspace admin +internalScope: Audience is workspace admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover other NetSuite error codes. --- + + # NS0068 Export Error in NetSuite Integration If you see the error: -NS0068: You do not have permission to set value for element — 'Created From'. +NS0068: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify). + +This means NetSuite is blocking Expensify from setting the **Created From** field during export. -This means the **Created From** field is not visible on the preferred transaction form in NetSuite. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite. --- -## Why the NS0068 Export Error Happens in NetSuite +## Why the NS0068 export error happens in NetSuite The NS0068 error occurs when: +- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries). - The **Created From** field is hidden on the preferred export form. -- The form does not allow the integration to populate the Created From field. -- Expensify Card transactions are exported as journal entries and the Journal Entry form does not show the Created From field. +- The role used by the Expensify connection can't edit the **Created From** field. +- The **Created From** field is missing because the **Expensify Connect bundle** is not installed or is out of date. +- Expensify Card transactions are exported as journal entries and the journal entry form does not show the Created From field. Expensify must be able to write to the Created From field during export. --- -## How to Fix the NS0068 Export Error +## How to check the Created From field settings in NetSuite -### If Exporting Vendor Bills or Standard Transactions +1. Log in to **NetSuite** as an administrator. +2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. +3. Open the field with the ID **custbody_createdfrom_expensify**. +4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries. +5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**. +6. Select **Save**. -1. Log in to **NetSuite** as an Administrator. -2. Go to **Customization**. -3. Select **Forms**. -4. Click **Transaction Forms**. -5. Click **Edit** next to the form marked as **Preferred** for the export type. -6. Locate the **Created From** field. -7. Ensure the field is marked as **Show**. -8. Save the form. +If you can't find **custbody_createdfrom_expensify**, install or update the Expensify Connect bundle in NetSuite before continuing. [Learn how to install the Expensify Connect bundle in NetSuite](/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite). + +--- -Then in Expensify: +## How to show the Created From field on vendor bill and expense report forms -1. Go to **Settings**. -2. Select **Workspaces**. -3. Select your Workspace. -4. Click **Accounting**. -5. Click **Sync**. -6. Retry exporting the report. +1. Log in to **NetSuite** as an administrator. +2. Go to **Customization > Forms > Transaction Forms**. +3. Select **Edit** next to the form marked as **Preferred** for the export type. If you set a custom form ID for this export type in New Expensify, edit that form instead. +4. Go to **Screen Fields > Main**. +5. Confirm the **Created From** field is marked as **Show**. +6. Select **Save**. --- -### If Exporting Expensify Card Transactions as Journal Entries +## How to show the Created From field on Expensify Card journal entry forms + +1. Log in to **NetSuite** as an administrator. +2. Confirm **custbody_createdfrom_expensify** applies to journal entries, using the steps in **How to check the Created From field settings in NetSuite** above. +3. Go to **Customization > Forms > Transaction Forms**. +4. Select **Edit** next to the preferred **Journal Entry** form. +5. Go to **Screen Fields > Main**. +6. Confirm the **Created From** field is marked as **Show**. +7. Select **Save**. + +--- -1. Log in to **NetSuite**. -2. Go to **Customization**. -3. Select **Forms**. -4. Click **Transaction Forms**. -5. Click **Edit** next to the preferred **Journal Entry** form. -6. Go to **Screen Fields**. -7. Open the **Main** tab. -8. Ensure the **Created From** field is marked as **Show**. -9. Save the form. +## How to sync NetSuite and retry the export -Then in Expensify: +After updating NetSuite: -1. Go to **Settings**. -2. Select **Workspaces**. -3. Select your Workspace. -4. Click **Accounting**. -5. Click **Sync**. -6. Retry exporting the report. +1. Go to **Settings > Workspaces > [workspace name] > Accounting**. +2. Select **Sync Now**. +3. Retry exporting the report. --- # FAQ -## Does NS0068 Mean My Integration Is Broken? +## Does NS0068 mean my integration is broken? -No. This error is caused by form visibility settings in NetSuite. +No. This error is caused by the Created From field settings, form visibility, or role access in NetSuite. -## Do I Need to Reconnect NetSuite? +## Do I need to reconnect NetSuite? -No. Updating the form settings and running **Sync** is typically sufficient. +No. Updating the field and form settings and selecting **Sync Now** is typically sufficient. If the field is missing, install or update the Expensify Connect bundle first. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..fd62cd6ec6db --- /dev/null +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -0,0 +1,70 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically indicates: + +- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +This issue must be resolved in Sage Intacct and then retried in Expensify. + +### Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +### Retry the Sync in Expensify + +1. Go to **Settings > Workspaces**. +2. Select your Workspace. +3. Click **Accounting**. +4. Click **Sync Now**. + +Confirm the sync completes successfully. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify. + +## Does INT133 Mean the Integration Is Disconnected? + +Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..fd62cd6ec6db --- /dev/null +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -0,0 +1,70 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically indicates: + +- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +This issue must be resolved in Sage Intacct and then retried in Expensify. + +### Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +### Retry the Sync in Expensify + +1. Go to **Settings > Workspaces**. +2. Select your Workspace. +3. Click **Accounting**. +4. Click **Sync Now**. + +Confirm the sync completes successfully. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify. + +## Does INT133 Mean the Integration Is Disconnected? + +Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again. diff --git a/docs/articles/expensify-classic/reports/Report-Approvals.md b/docs/articles/expensify-classic/reports/Report-Approvals.md index 1af13e89bf83..e6d41e32b1ab 100644 --- a/docs/articles/expensify-classic/reports/Report-Approvals.md +++ b/docs/articles/expensify-classic/reports/Report-Approvals.md @@ -263,7 +263,7 @@ Only **Domain Admins** have access. - Amount - The last four digits of the card -**Note:** Only posted credit card transactions will import. +**Note:** Only posted credit card transactions will import, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post. No. Concierge Receipt Audit is automatically enabled for all Control plan workspaces and cannot be turned off. @@ -282,7 +282,7 @@ Use the **Reconciliation tool** to find missing expenses: - Amount - Last four digits of the card number -**Note:** Only **posted transactions** will be imported. +**Note:** Only **posted transactions** will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post. ## Can admins delete expenses on behalf of members? diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md index 75edd7314e20..4bb9b13535a7 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md @@ -52,6 +52,18 @@ To accomplish this, include phrases like: --- +## How to write instructions that unapprove expense reports + +**Goal:** Undoes the approval of an Approved expense report so it can be reviewed and approved again. The report returns to Outstanding (submitted), or to Draft if the submitter approved it themselves. Requires workspace admin role, unless the agent is the approver who gave the final approval. On a workspace that uses Dynamic External Workflow approvals, only a workspace admin can unapprove. A report that has already been paid through Expensify cannot be unapproved. + +To accomplish this, include phrases like: +- Unapprove reports over $10,000 +- Unapprove this report so it can be reviewed again +- Undo the approval on reports that were approved with violations +- Unapprove reports that were approved while an expense was missing a receipt + +--- + ## How to write instructions that reject expense reports **Goal:** Sends an entire Outstanding (submitted) expense report back to the submitter with a user-facing rejection comment explaining why. @@ -177,7 +189,7 @@ To accomplish this, include phrases like: ## How to write instructions that delete expense reports -**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only reports that are still open or in draft can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability. +**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only draft reports can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability. To accomplish this, include phrases like: - Delete this report @@ -236,7 +248,7 @@ To accomplish this, include phrases like: To accomplish this, include phrases like: - Send each employee a reminder to submit their expenses at the end of the month - Message the submitter directly when their report is rejected -- Notify individual workspace members of a workspace change +- Notify individual workspace members of an expense policy change - DM each person with outstanding reports a personalized nudge - Send a private message to the submitter when a receipt is missing - Reach out to each team member individually with their spending summary @@ -268,9 +280,21 @@ To accomplish this, include phrases like: --- +## How to write instructions that export a report as a PDF + +**Goal:** Generates a PDF of an expense report and attaches it to the chat as a downloadable file. When the chat is itself an expense report, it exports that report. From any other chat, such as a Concierge DM, the requester can choose a report by report ID, by submitter, or by what was bought. It does not change the report, and anyone who can view the report can ask for one. + +To accomplish this, include phrases like: +- Send me a PDF of this report +- Download this expense report as a PDF +- Export Alice's latest report to PDF +- Send me a PDF of my most recent report + +--- + ## How to write instructions that manage workspace settings and members -**Goal:** Inspects workspace configuration, answers membership questions, and — for workspace admins — adds or removes members, lists and assigns company cards from a connected card feed, and modifies workspace settings such as categories and approval rules. +**Goal:** Inspects workspace configuration, answers membership questions, reads the Expensify Card limit and remaining spend for anyone allowed to see the Expensify Card, and — for workspace admins — adds or removes members, lists and assigns company cards from a connected card feed, and modifies workspace settings such as categories and approval rules. To accomplish this, include phrases like: - Add new employees to the workspace when they join @@ -280,6 +304,7 @@ To accomplish this, include phrases like: - What is the current approval mode for this workspace? - Copy the category list from one workspace to another - Assign all unassigned company cards to the matching members +- How much of our Expensify Card limit is left? --- @@ -297,4 +322,4 @@ To accomplish this, include phrases like: --- -*This reference covers all 22 AI agent capabilities.* +*This reference covers all 24 AI agent capabilities.* diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md index 35d0c3fdcd6c..4481d01a8a34 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md @@ -636,6 +636,12 @@ To react to this event, include phrases like: - When QuickBooks / NetSuite / Xero sync fails - When the accounting connection breaks +**A running count of how many of the workspace's reports are currently failing to export. There is one of these per workspace in its #admins room, kept up to date in place as reports start and stop failing, and addressed to the workspace's configured exporter.** + +To react to this event, include phrases like: +- When reports fail to export to accounting +- How many reports failed to export + **An accounting integration export failed for a specific report. This notification appears on the expense report itself, not in #admins.** To react to this event, include phrases like: @@ -920,4 +926,4 @@ To react to this event, include phrases like: --- -*This reference covers all 126 events AI agents can react to.* +*This reference covers all 127 events AI agents can react to.* diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md b/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md index 1fb8f2825430..d70ec3a64667 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md @@ -11,7 +11,7 @@ order: 2 # Learn About Billing Permissions -Every Workspace has a Workspace owner, sometimes called the billing owner. The Workspace owner is responsible for the Workspace's Expensify subscription and is the only person who can manage billing-related settings like plans and subscriptions. +Every Workspace has a Workspace owner, sometimes called the billing owner. The Workspace owner is responsible for the Workspace's Expensify subscription and is the only person who can manage billing-related settings like the subscription and payment method. Any Workspace Admin can change the Workspace's plan, which affects the Workspace owner's bill. To change the Workspace owner, see [Transfer Workspace Ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership). @@ -57,7 +57,7 @@ No. Each Workspace has one Workspace owner, although the same member can own mul ## Can Workspace Admins manage billing? -No. Workspace Admins can only manage workspace settings, but only the Workspace owner can manage billing and related settings. To learn more about the difference between Workspace Admin and Workspace owner, see [What member terms mean](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Terms-and-Definitions). +Partly. Workspace Admins can manage Workspace settings and change the Workspace's plan, but only the Workspace owner can manage the subscription, payment method, and other billing settings. To learn more about changing the plan, see [Change Your Workspace Plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan). To learn more about the difference between Workspace Admin and Workspace owner, see [What member terms mean](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Terms-and-Definitions). ## Who can view the Expensify billing receipts? diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md index 89d5a075c53c..f83efcdd6778 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md @@ -1,8 +1,8 @@ --- title: Change Your Workspace Plan description: Change your Workspace's Plan type to Collect or Control. -keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, workspace admin] -internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. +keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, Workspace Admin] +internalScope: Audience is Workspace Admins. Covers how to switch a Workspace between the Collect and Control plans, what happens in the #admins room after the plan changes, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. retrievalIntent: How to switch a Workspace plan between Collect and Control. contentType: task platform: new @@ -19,12 +19,23 @@ To learn which plan is right for your organization, see [Compare Collect and Con ## Who can change the workspace plan -Only the Workspace owner can change a workspace's plan. +Any Workspace Admin can change a Workspace's plan, including upgrading from Collect to Control and downgrading from Control to Collect. + +The Workspace owner pays for the Workspace, so plan changes made by any Workspace Admin affect the Workspace owner's bill. If you have an Annual subscription, changes to your plan may be restricted. To learn more, see [When You Can Change Your Plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management#when-you-can-change-your-plan). --- +## What happens after you change the Workspace plan + +When a Workspace Admin changes the plan, Expensify posts a message in the Workspace's **#admins** room. The message shows which Workspace Admin made the change, and the Workspace owner and all Workspace Admins can see it. + +- **Upgrade to Control:** The message says "[Admin name] upgraded this workspace to the Control plan." The Workspace Admin who upgraded also gets a private message in #admins with Control plan pricing. +- **Downgrade to Collect:** The message says "[Admin name] downgraded this workspace to the Collect plan." + +--- + ## How to switch to the Collect or Control plans 1. In the navigation tabs (on the left on web, at the bottom on mobile), go to **Workspaces > [workspace name]**. @@ -52,6 +63,14 @@ A free Submit workspace can be upgraded to Collect or Control. Once upgraded, it # FAQ +## Can a Workspace Admin change the Workspace plan? + +Yes. Any Workspace Admin can upgrade or downgrade the Workspace plan. Subscription settings, such as the subscription type and payment card, are still managed by the Workspace owner. + +## How will the Workspace owner know if a Workspace Admin changed the plan? + +Expensify posts every plan change in the Workspace's **#admins** room, which the Workspace owner and all Workspace Admins can see. The message shows which Workspace Admin upgraded or downgraded the Workspace. + ## Can I switch back to Control after downgrading to Collect? Yes. You can upgrade a workspace to Control at any time by returning to the **Plan type** setting. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md index 390ec0b500d1..cd7a34adcdb2 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md @@ -1,8 +1,8 @@ --- title: Learn About Plan and Subscription Management description: Learn who can change a Workspace's plan or subscription in New Expensify and the conditions that apply, including when you can and can't downgrade. -keywords: [New Expensify, plan management, subscription management, change plan, change subscription, downgrade plan, workspace Owner, Collect, Control, Annual, Pay-per-use] -internalScope: Audience is Workspace owners. Covers who can change a Workspace's plan or subscription and the conditions that apply. Does not cover plan features or how to change a subscription. +keywords: [New Expensify, plan management, subscription management, change plan, change subscription, downgrade plan, workspace Owner, Workspace Admin, Collect, Control, Annual, Pay-per-use] +internalScope: Audience is Workspace owners and Workspace Admins. Covers who can change a Workspace's plan or subscription and the conditions that apply. Does not cover plan features or how to change a subscription. retrievalIntent: When are Workspace plan or subscription changes allowed. contentType: topic platform: new @@ -11,7 +11,7 @@ order: 1 # Learn About Plan and Subscription Management -Every paid Workspace has a plan and a subscription, and together they determine how billing is calculated. The Workspace owner can manage them, but changes may be restricted depending on your current subscription type. +Every paid Workspace has a plan and a subscription, and together they determine how billing is calculated. Any Workspace Admin can change the plan, and the Workspace owner manages the subscription. Changes may be restricted depending on your current subscription type. If you're new to Expensify billing, start with [Learn How Billing and Subscriptions Work](/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work). @@ -19,9 +19,9 @@ If you're new to Expensify billing, start with [Learn How Billing and Subscripti ## Who can manage a plan or subscription -Only the Workspace owner can manage the Workspace's plan or subscription. Each workspace has exactly one owner, whose payment card is charged for the subscription. +Any Workspace Admin can change the Workspace's plan, including upgrading from Collect to Control and downgrading from Control to Collect. -Being a Workspace Admin doesn't include plan and subscription management permissions. Admins can manage members and workspace settings, but they can't change the Workspace's plan or subscription unless they first take over ownership. +Only the Workspace owner can manage the Workspace's subscription. Each Workspace has exactly one owner, whose payment card is charged for the subscription. Plan changes made by any Workspace Admin affect the Workspace owner's bill. To learn more, see [Learn About Billing Permissions](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions). @@ -57,7 +57,7 @@ Use these articles to complete common plan and subscription tasks: ## Can a Workspace Admin change the plan or subscription? -No. Only the Workspace owner can manage the Workspace's plan or subscription. If an admin needs to make these changes, they must first become the Workspace owner. [Learn how to transfer workspace ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership). +A Workspace Admin can change the plan, but not the subscription. Only the Workspace owner can manage the Workspace's subscription. If a Workspace Admin needs to change the subscription, they must first become the Workspace owner. [Learn how to transfer Workspace ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership). ## Why can't I switch from Control to Collect? diff --git a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md index 29403cce3bac..aa5d5e5eff58 100644 --- a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md @@ -41,6 +41,7 @@ If you haven’t set up a company card feed yet, learn how to set one up: ## What happens after you assign company cards - Posted transactions from the assigned card import automatically into the member's account as expenses. +- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection). - Expensify imports transactions from the **Transaction start date** onward. - The imported company card expenses are added to reports and submitted according to workspace rules. diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md index 6ba11d39b978..17f47f5b2997 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md @@ -24,12 +24,14 @@ Anyone with an Expensify account can connect up to two personal cards for free. 2. Tap **Add personal card**. 3. Select your country from the country selector. 4. If you selected **United States**, choose your bank from the bank selector. - - If your bank is listed, you'll be prompted to connect via your bank's secure login (oAuth). + - If your bank is listed, you'll be prompted to directly connect via your bank's secure login. - If your bank is not listed, select **Other** to connect via Plaid. 5. Follow the prompts to log into your bank and authorize the connection. Once your card is connected, it will appear under **Cards** in the **Wallet**, and transactions will begin importing automatically. +If your card is connected through Plaid, pending transactions also import. They're marked as pending and update automatically when they post. Cards connected directly through your bank's secure login import transactions after they post. + --- ## What to do if you see a company card warning diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md index fe63c007544a..e3002e7f5c76 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md @@ -1,7 +1,7 @@ --- title: Set up a Direct Connection for Company Cards description: Learn how to connect a company card account using a direct connection so its cards can be assigned to workspace members and their transactions imported into Expensify. -keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards] +keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards, pending company card transactions, Plaid pending transactions] internalScope: Audience is workspace admins and card admins. Covers connecting a company card account using a direct connection or Plaid. Does not cover assigning cards, managing cards, or troubleshooting connections. retrievalIntent: How to set up a direct connection to a bank or card provider for company cards. contentType: task @@ -48,6 +48,7 @@ To set up a direct connection, you must: - Cards with recent expenses appear as a list and can be assigned to workspace members. - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. +- If the card is connected through Plaid, pending transactions also import. They're marked as pending until they post. [Learn which connections import pending transactions](#do-pending-transactions-import-from-a-direct-connection). [Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards). @@ -92,6 +93,20 @@ If you're still unable to connect to your bank, you can [import company card tra Yes, direct connections can be shared across workspaces. [Learn how to share a company card connection across workspaces](/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces). +## Do pending transactions import from a direct connection? + +Pending transactions import only for cards connected through Plaid (the **Other** option in the bank list). Cards connected to any other bank in the list import transactions after they post, which typically takes 1–3 business days. + +When a pending transaction imports: + +- It's marked as pending until it posts. +- It updates automatically when the transaction posts. +- It's removed if the bank cancels the transaction or it doesn't post. + +A report can't be submitted or approved while every expense on it is pending. + +If you delete a pending expense, the transaction may import again after it posts. + ## Can I import debit card transactions? Yes, debit card transactions can be imported by following the same steps for setting up a direct connection for company cards. diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md index 8b429d92725c..d86370751e60 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md @@ -52,6 +52,7 @@ To share a company card connection, you must: - Individual cards appear as a list and can be assigned to workspace members. - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. +- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection). [Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards). diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index ae03aac56e27..6f19e7d253bb 100644 --- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,111 +1,114 @@ --- title: NS0068 Export Error in NetSuite Integration -description: Learn what the NS0068 export error means and how to make the Created From field visible on NetSuite transaction forms before exporting. -keywords: NS0068, NetSuite Created From field error, permission to set Created From NetSuite, transaction form visibility NetSuite, Expensify Card journal entry error, Expensify NetSuite integration, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field not being visible on NetSuite transaction forms. Does not cover role permission or token configuration issues. +description: Learn what the NS0068 export error means and how to configure the Created From field (custbody_createdfrom_expensify) in NetSuite so Expensify can set it during export. +keywords: NS0068, NetSuite Created From field error, custbody_createdfrom_expensify, permission to set Created From NetSuite, Transaction Body Fields NetSuite, transaction form visibility NetSuite, custom form ID NetSuite, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite integration, workspace admin +internalScope: Audience is workspace admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover token configuration issues. --- + + # NS0068 Export Error in NetSuite Integration If you see the error: -NS0068 Export Error: You do not have permission to set value for element — 'Created From'. +NS0068 Export Error: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify). -This means the **Created From** field is not visible on the preferred export form in NetSuite. +This means NetSuite is blocking Expensify from setting the **Created From** field during export. -The Workspace must be able to access and populate the Created From field during export. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite. --- -## Why the NS0068 Export Error Happens in NetSuite +## Why the NS0068 export error happens in NetSuite The NS0068 error typically occurs when: -- The **Created From** field is hidden on the NetSuite transaction form used for exports. -- The preferred transaction form does not allow the field to be displayed. -- NetSuite blocks the integration from setting the field value because it is not visible. - -If the field is hidden, NetSuite prevents the integration from setting its value and the export fails. +- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries). +- The **Created From** field is hidden on the transaction form Expensify uses for your export type. +- The role used by the Expensify connection can't edit the **Created From** field. +- The **Created From** field is missing from NetSuite because the **Expensify Connect bundle** is not installed or is out of date. -This is a transaction form visibility issue, not a role permission or token configuration issue. +If any of these apply, NetSuite prevents the integration from setting the field value and the export fails. --- -## How to Fix the NS0068 Export Error +## How to fix the NS0068 export error -Follow the steps below to make the Created From field visible. +Follow the steps below to check the field setup, form visibility, and role access in NetSuite. --- -## Make the Created From Field Visible in NetSuite +## How to check the Created From field settings in NetSuite 1. Log in to NetSuite as an administrator. -2. Go to **Customization > Forms > Transaction Forms**. -3. Locate the transaction form marked as **Preferred** for your export type. -4. Click **Edit**. -5. Locate the **Created From** field. -6. Ensure the field is marked as **Show**. -7. Click **Save**. +2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. +3. Open the field with the ID **custbody_createdfrom_expensify**. +4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries. +5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**. +6. Select **Save**. -Confirm the field is visible on the preferred form before proceeding. +If you can't find **custbody_createdfrom_expensify**, update or reinstall the Expensify Connect bundle before continuing. [Learn how to update the Expensify Connect bundle in NetSuite](/articles/new-expensify/connections/netsuite/Update-Expensify-Connect-Bundle-in-NetSuite). --- -## Sync the Workspace in Expensify +## How to make the Created From field visible on the export form + +Expensify exports with the transaction form marked as **Preferred** in NetSuite. If you set a custom form under **Workspaces > [workspace name] > Accounting > Advanced > Custom form ID**, Expensify uses that form instead. -After updating the form: +1. Go to **Customization > Forms > Transaction Forms**. +2. Locate the transaction form Expensify uses for your export type. +3. Select **Edit**. +4. Go to **Screen Fields > Main**. +5. Confirm the **Created From** field is marked as **Show**. +6. Select **Save**. -On web: +--- -1. Go to the navigation tabs on the left and select **Workspaces**. -2. Select your Workspace. -3. Click **Accounting**. -4. Click the three-dot menu next to the NetSuite connection. -5. Click **Sync Now**. +## How to sync the workspace in Expensify -On mobile: +After updating NetSuite: -1. Tap the navigation tabs on the bottom and select **Workspaces**. -2. Select your Workspace. -3. Tap **Accounting**. -4. Tap the three-dot menu next to the NetSuite connection. -5. Tap **Sync Now**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > Accounting**. +2. Select **More (⋮)** next to the NetSuite connection. +3. Select **Sync now**. --- -## Retry the Export +## How to retry the export 1. Open the report. 2. Retry exporting to NetSuite. -If the Created From field is visible on the preferred form, the export should complete successfully. +If the Created From field applies to your export type, is visible on the export form, and is editable by the Expensify role, the export should complete successfully. --- -# Additional Steps for Expensify Card Journal Entries +## How to fix the NS0068 export error for Expensify Card journal entries -If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is visible on the journal entry form. +If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is set up for journal entries. -1. Go to **Customization > Forms > Transaction Forms**. -2. Click **Edit** next to the preferred journal entry form. -3. Go to **Screen Fields > Main**. -4. Confirm **Created From** is marked as **Show**. -5. Click **Save**. +1. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields** and open **custbody_createdfrom_expensify**. +2. On the **Applies To** tab, confirm **Journal** is selected, then select **Save**. +3. Go to **Customization > Forms > Transaction Forms**. +4. Select **Edit** next to the preferred journal entry form. +5. Go to **Screen Fields > Main**. +6. Confirm **Created From** is marked as **Show**. +7. Select **Save**. -Then return to the Workspace and select **Sync Now** before retrying the export. +Then sync the workspace in Expensify before retrying the export. --- # FAQ -## Does the NS0068 Export Error Affect All Export Types? +## Does the NS0068 export error affect all export types? -It affects exports using a transaction form where the Created From field is hidden. +It affects any export type where the Created From field doesn't apply to the record, is hidden on the export form, or isn't editable by the Expensify role. -## Do I Need NetSuite Admin Access to Fix the NS0068 Export Error? +## Do I need NetSuite admin access to fix the NS0068 export error? -Yes. Updating transaction form field visibility requires NetSuite administrator permissions. +Yes. Updating custom field settings and transaction form visibility requires NetSuite administrator permissions. -## Do I Need to Reconnect the Integration? +## Do I need to reconnect the integration? -No. Making the Created From field visible and selecting **Sync Now** is typically sufficient. +No. Updating the Created From field settings and selecting **Sync now** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..321331f5b379 --- /dev/null +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -0,0 +1,75 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**. + +When the web services user is inactive, syncing and exports will not work. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically occurs when: + +- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +Follow the steps below to reactivate the web services user and restore the connection. + +--- + +## Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +## Retry the Sync + +After reactivating the web services user: + +1. Go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Sync Now**. +3. Confirm the sync completes successfully. + +If the web services user is active, the sync should complete without errors. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access to Fix This? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace. + +## Does This Error Affect Exports? + +Yes. Exports and other integration features will not work until the web services user is active again. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..321331f5b379 --- /dev/null +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -0,0 +1,75 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**. + +When the web services user is inactive, syncing and exports will not work. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically occurs when: + +- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +Follow the steps below to reactivate the web services user and restore the connection. + +--- + +## Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +## Retry the Sync + +After reactivating the web services user: + +1. Go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Sync Now**. +3. Confirm the sync completes successfully. + +If the web services user is active, the sync should complete without errors. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access to Fix This? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace. + +## Does This Error Affect Exports? + +Yes. Exports and other integration features will not work until the web services user is active again. diff --git a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md index 6564b3a6164b..ce1938db439d 100644 --- a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md +++ b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md @@ -5,7 +5,7 @@ keywords: [New Expensify, join workspace, set up profile, add expense, enable tw --- -Welcome to Expensify! If you've received an invite to join your company's workspace, this guide walks you through setting up your account and getting started. +Welcome to Expensify! Whether you've received an invite to join your company's workspace or need to instruct your admin to send you one, this guide walks you through setting up your account and getting started. --- @@ -19,6 +19,8 @@ Ask one of your company's Expensify workspace admins to send you an invite using Now, check your email inbox for the invite to join your company's workspace. If you don't see it, check your **Spam** and **Archive** folders. +If you're signing up without an invite, select **Join my company workspace** when asked **What do you want to do today?** After you add and verify your work email, Expensify shows the workspaces at your company that you can join. Click **Join now**, or **Ask to join** if the workspace needs admin approval. + --- # Step 1: Access Expensify diff --git a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md index eb0f3ddc1f7d..6af9bfc99e0d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md @@ -168,7 +168,7 @@ The outdated row is removed from the report. The expense itself is not deleted # FAQ ## Why can't I action a pending expense? -Expensify Card expenses show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. +Expensify Card expenses, and expenses from cards connected through Plaid, show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. ## Why can’t I see Bypass approvers? Only Workspace admins can bypass the prescribed approval workflow. If **Prevent Self-Approval** is enabled, an admin cannot bypass approvals to approve their own report. diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index cc0b8a686ee0..d7d4cb2a50f2 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -229,9 +229,9 @@ Unsubmitted reports will remain in your Open reports section. You can go back an The **Submit** button only appears once your report includes at least one valid expense. If the report has already been submitted, the button will no longer be visible. If your workspace has no approval workflow, the button will say **Mark as done** instead of **Submit**. -## Why can’t I submit a report with pending Expensify Card transactions? +## Why can’t I submit a report with pending card transactions? -If all transactions on your report are pending Expensify Card transactions, the report can't be submitted yet. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report. +If all transactions on a report are pending, the report can’t be submitted yet. This applies to Expensify Card transactions and to company or personal cards connected through Plaid. You can submit the report once at least one transaction posts. ## What happens to held expenses when I submit a report? diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md index e770c6e3be6c..1956bdc235a5 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 @@ -180,7 +180,6 @@ Use `group-by:` to group results by a specific dimension. | **Syntax** | **Description** | **Example** | |---|---|---| -| `group-by:report` | Group results by report | `type:expense group-by:report` | | `group-by:from` | Group results by submitter | `type:expense group-by:from` | | `group-by:card` | Group results by card | `type:expense group-by:card` | | `group-by:withdrawal-id` | Group results by withdrawal ID | `type:expense group-by:withdrawal-id` | diff --git a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md index 5dd2c097d3c4..c14c4fb2ee4d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md +++ b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md @@ -35,14 +35,14 @@ Expensify automatically merges a SmartScanned receipt with a matching card trans - The transaction dates match - The currencies match or fall within the allowed foreign exchange tolerance - Only one possible match exists -- The card transaction posts within seven days of the receipt being SmartScanned +- The card transaction posts within seven days of the receipt being SmartScanned. For cards connected through Plaid, the pending transaction can merge before it posts. **Expenses will not automatically merge if:** - The receipt was not SmartScanned or SmartScan did not complete -- Expense details were edited before the card transaction posted +- Expense details were edited before the card transaction posted (or, for cards connected through Plaid, before the pending transaction imported) - The amount, date, or currency differs beyond tolerance -- The card transaction posts after seven days +- The card transaction posts after seven days (or, for cards connected through Plaid, the pending transaction imports after seven days) - The foreign exchange difference exceeds five percent - The receipt is already matched to another expense - Both expenses are card transactions @@ -94,7 +94,7 @@ If both expenses are card transactions, they cannot be merged. Instead, [delete This happens when the receipt and card transaction did not merge into a single expense. This is usually due to differences in amount, date, currency, or timing. ## Why didn’t my receipt merge with my card transaction? -SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days, the expenses will not merge automatically. +SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days (for cards connected through Plaid, imported as pending within seven days), the expenses will not merge automatically. ## Can uploading the same receipt twice create duplicates? diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index eef91bd6fa0e..8d3097664a55 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -2,14 +2,14 @@ title: Delegate When Out of Office description: Set a vacation delegate to approve reports on your behalf while you're away. keywords: [New Expensify, delegate approvals, out-of-office approver, vacation, out of office, approval delegation, temporary delegate, vacation delegate, copilot vs delegate, copilot, shared access, proxy, behalf of] -internalScope: Audience is all members. Covers assigning and removing your own vacation delegate from your profile status. Does not cover Copilot access or Expensify Classic delegates. +internalScope: Audience is all members. Covers assigning, scheduling the end of, and removing your own vacation delegate from your profile. Does not cover Copilot access or Expensify Classic delegates. --- # Delegate When Out of Office If you're out of the office or on vacation, you can assign a vacation delegate to handle your approvals while you're away. -A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. +A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. Pick a **Clear after** date and time and the delegate is removed automatically at that time, so reports come back to you when you return. All delegate actions are tracked in the report history for full visibility. @@ -17,19 +17,20 @@ All delegate actions are tracked in the report history for full visibility. ## How to assign a vacation delegate -1. Go to **Account > Profile > Status**. -2. Under **Vacation delegate**, enter the email address or phone number of your delegate. -3. Click **Save**. +1. Go to **Account > Profile > Vacation delegate**. +2. Select the member who should approve on your behalf. +3. Under **Clear after (recommended)**, pick the last day the delegate should cover (optional). +4. Under **Time**, pick when the delegate clears on that day (defaults to the end of the day). +5. Click **Save**. -Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. +Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date and time it clears are shown under **Vacation delegate** on your profile. ## How to remove a vacation delegate -When you're ready to take back control of your reports: +If you set a **Clear after** date, the delegate is removed automatically. To remove it sooner: -1. Go to **Account > Profile > Status**. -2. Under **Vacation delegate**, clear the delegate's email or phone number. -3. Click **Save**. +1. Go to **Account > Profile > Vacation delegate**. +2. Click **Remove vacation delegate**. --- @@ -49,7 +50,7 @@ Your delegate can assign their own vacation delegate too. This ensures approvals ## If I clear my status, does that remove my vacation delegate? -No. Clearing your status won't affect your delegate. You'll need to manually remove them when you're back. +No. Clearing your status won't affect your delegate. Set a **Clear after** date on your vacation delegate, or remove them when you're back. ## What if my delegate isn’t in the same workspace? diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index 7179adb64976..2821511d1309 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -1,7 +1,7 @@ --- title: Managing Workspace Members in New Expensify description: Learn how to add, remove, filter, and manage user roles in a New Expensify workspace. -keywords: [New Expensify, workspace members, add user, remove user, user roles, workspace admin, card admin, people admin, payments admin, invite users, filter members, role filter] +keywords: [New Expensify, workspace members, add user, remove user, user roles, workspace admin, card admin, people admin, payments admin, invite users, filter members, role filter, member display name] --- @@ -100,6 +100,19 @@ On the web, you can also change a member’s role directly from the members list --- +# How to Set a Member’s Display Name + +Workspace admins can set a display name for a member who hasn’t added a name of their own. + +1. Go to **Workspaces > [Workspace Name] > Members**. +2. Click the member’s name. +3. In the right-hand panel, click **Profile**. +4. Click **Display name**, enter a first and last name, and click **Save**. + +The name applies in every workspace the member belongs to. After it’s saved, only the member can change it. + +--- + # Transfer Workspace Ownership To transfer ownership to another admin: diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e09fb2b24b29..fb952548093e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.8 + 9.5.12 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.8.4 + 9.5.12.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 22acf7c996d6..1400a9e0b310 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.8 + 9.5.12 CFBundleVersion - 9.5.8.4 + 9.5.12.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index b8b01434878f..f5d8b19ab43b 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.8 + 9.5.12 CFBundleVersion - 9.5.8.4 + 9.5.12.0 NSExtension NSExtensionAttributes diff --git a/jest/setup.ts b/jest/setup.ts index d7e3eec8321e..4589474f054c 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -350,6 +350,10 @@ jest.mock('@shopify/react-native-skia', () => ({ listFontFamilies: jest.fn(() => []), })); +// The native tab bar icons are drawn with Skia's imperative API, which the mock above leaves out, so the tabs get no icons. +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer', () => jest.fn(() => undefined)); +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage', () => jest.fn(() => undefined)); + jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), @@ -365,6 +369,8 @@ jest.mock('victory-native', () => ({ CartesianChart: jest.fn( ({children}: {children?: (args: Record) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null, ), + Pie: {Chart: jest.fn(() => null)}, + PolarChart: jest.fn(() => null), })); // Provide a default global fetch mock for tests that do not explicitly set it up diff --git a/package-lock.json b/package-lock.json index 7ab9deb59a10..a2bb430fc1df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.12-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.12-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -152,7 +152,7 @@ "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "scheduler": "0.27.0", - "victory-native": "^41.21.0", + "victory-native": "^42.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { @@ -166,9 +166,9 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@callstack/reassure-compare": "^1.0.0-rc.4", - "@callstack/repack": "5.3.0", - "@callstack/repack-plugin-expo-modules": "5.3.0", - "@callstack/repack-plugin-reanimated": "5.3.0", + "@callstack/repack": "5.4.1", + "@callstack/repack-plugin-expo-modules": "5.4.1", + "@callstack/repack-plugin-reanimated": "5.4.1", "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@fullstory/babel-plugin-annotate-react": "^2.4.0", "@fullstory/babel-plugin-react-native": "^1.7.1", @@ -4027,13 +4027,13 @@ } }, "node_modules/@callstack/repack": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack/-/repack-5.3.0.tgz", - "integrity": "sha512-/5pnG6bz+nkV8DkVZfRDpRSVMUU+znLEfKy49HQgxsJx5vxlxL3MV7Pn2+APetitB9NEGFP+GQApj5hesUKQCw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack/-/repack-5.4.1.tgz", + "integrity": "sha512-vlpXdlVLiu9mi2ca3TMBv1MydnTGX789EHTDQ7RMBZO43DHx6RDrWh5snzbWclhy3+HmvdUkKA4eyYduRoDWUg==", "dev": true, "license": "MIT", "dependencies": { - "@callstack/repack-dev-server": "5.3.0", + "@callstack/repack-dev-server": "5.4.1", "@discoveryjs/json-ext": "^0.5.7", "@rspack/plugin-react-refresh": "1.0.0", "babel-loader": "^9.2.1", @@ -4081,9 +4081,9 @@ } }, "node_modules/@callstack/repack-dev-server": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack-dev-server/-/repack-dev-server-5.3.0.tgz", - "integrity": "sha512-WW5onAddhO12wM59VCCpLQYn7NIPZc2HvM+mmcTvJsi8k3ggeW61pYsOnnX6GMNWmy/2Wu4AiecouQa4+zGuwA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack-dev-server/-/repack-dev-server-5.4.1.tgz", + "integrity": "sha512-aRXPBx1ZRWuO9m3JVI5RWXO9GqxTLRX1Z3Ca+T724CQqqj/VK7q91+uEc7ReqiqHKrCMYwTY2CM8lNpZDKggUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4183,22 +4183,22 @@ } }, "node_modules/@callstack/repack-plugin-expo-modules": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-expo-modules/-/repack-plugin-expo-modules-5.3.0.tgz", - "integrity": "sha512-4tnnb4ujLckWqYQOAzPIeOM9vWvVPdzS4SD+y5kjiFckVu0N6COzF29gl3EYZfJwKLU4tGkPqUqfvIa60HzQaQ==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-expo-modules/-/repack-plugin-expo-modules-5.4.1.tgz", + "integrity": "sha512-YCCQXbjdnI3euHZYcpCMSngwBVovNFNUfD3HOsntAzCEyClpU1DVg2kWdI9wM9ZsTUPRr/eH49L2KZ1xyyzzOA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" }, "peerDependencies": { - "@callstack/repack": "^5.3.0" + "@callstack/repack": "^5.4.1" } }, "node_modules/@callstack/repack-plugin-reanimated": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-reanimated/-/repack-plugin-reanimated-5.3.0.tgz", - "integrity": "sha512-TtuVhhAlvLdg+wrHf8k15Q5zNLiemOuFavsFiOQQhQP5vX299eEp2lw/1tr25vYsdoGvdc0Na0BsePRIF4ujGg==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-reanimated/-/repack-plugin-reanimated-5.4.1.tgz", + "integrity": "sha512-qwiiLlqb1xWf3K2Qv3yVO/ujnkaTOkGSg2gPq4+x8IcFqZRftiW3qlZYTRN913OXqB7e3ZajOepwDUUMWmZJCg==", "dev": true, "license": "MIT", "dependencies": { @@ -4209,7 +4209,7 @@ }, "peerDependencies": { "@babel/core": "^7.20", - "@callstack/repack": "^5.3.0" + "@callstack/repack": "^5.4.1" } }, "node_modules/@callstack/repack-plugin-reanimated/node_modules/semver": { @@ -8613,9 +8613,9 @@ "license": "MIT" }, "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", "dev": true, "funding": [ { @@ -8634,9 +8634,9 @@ } }, "node_modules/@fastify/sensible": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.5.tgz", - "integrity": "sha512-v4mGejwT6EJA2zd2rS6XwlyUSva2ILsqzG0aGkyrlS7l2jIpUgFACclSPN3kDijX//cf8+GutRLhJVKx3ae2Cw==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.6.tgz", + "integrity": "sha512-2OhR9prEDmjeVSrBUBXgP9e595WWrBGQxjo/JMweA7DgJ5gseotN0OhRWZLom1nnmFkLywkccdQc10ihFtT6fw==", "dev": true, "funding": [ { @@ -27204,9 +27204,9 @@ } }, "node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", "dev": true, "funding": [ { @@ -27262,9 +27262,9 @@ } }, "node_modules/fastify": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", - "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", "dev": true, "funding": [ { @@ -42208,9 +42208,9 @@ } }, "node_modules/victory-native": { - "version": "41.21.0", - "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-41.21.0.tgz", - "integrity": "sha512-qyldgowkkiAWzN765sODzp4REh2MxBoex+DF5LerIpssTXpxGYEF21bIrhAoQJLDXiZ2rrCj1Dye+MNVYJd7vw==", + "version": "42.0.1", + "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-42.0.1.tgz", + "integrity": "sha512-W3dNBMeRtZ8AQGBkV1PrYYKLFSstTy6uulmlJJFch4hvPX/R1xH1VJXzZopK6GGbcq/ejcr8WwntWwhqTo0lzA==", "license": "MIT", "dependencies": { "d3-scale": "^4.0.2", @@ -42220,11 +42220,11 @@ "react-fast-compare": "^3.2.2" }, "peerDependencies": { - "@shopify/react-native-skia": ">=1.2.3 <3.0.0", + "@shopify/react-native-skia": ">=2.6.0 <3.0.0", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", - "react-native-reanimated": ">=3.0.0" + "react-native-reanimated": ">=3.19.1" } }, "node_modules/vlq": { diff --git a/package.json b/package.json index 69ac415da967..5ebd25830977 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.12-0", "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.", @@ -228,7 +228,7 @@ "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "scheduler": "0.27.0", - "victory-native": "^41.21.0", + "victory-native": "^42.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { @@ -242,9 +242,9 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@callstack/reassure-compare": "^1.0.0-rc.4", - "@callstack/repack": "5.3.0", - "@callstack/repack-plugin-expo-modules": "5.3.0", - "@callstack/repack-plugin-reanimated": "5.3.0", + "@callstack/repack": "5.4.1", + "@callstack/repack-plugin-expo-modules": "5.4.1", + "@callstack/repack-plugin-reanimated": "5.4.1", "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@fullstory/babel-plugin-annotate-react": "^2.4.0", "@fullstory/babel-plugin-react-native": "^1.7.1", diff --git a/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch b/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch deleted file mode 100644 index a9ea9ca1aa0e..000000000000 --- a/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -index 0121929..7c2856b 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -@@ -17,6 +17,7 @@ class Compiler { - statsCache = {}; - resolvers = {}; - progressSenders = {}; -+ lastProgress = {}; - isCompilationInProgress = false; - // late-init - -@@ -24,7 +25,9 @@ class Compiler { - this.reporter = reporter; - this.rootDir = rootDir; - const handler = (platform, value) => { -- const percentage = Math.floor(value * 100); -+ const rawPercentage = Math.floor(value * 100); -+ const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0); -+ this.lastProgress[platform] = percentage; - this.progressSenders[platform]?.forEach(sendProgress => { - sendProgress({ - completed: percentage, -@@ -36,7 +39,7 @@ class Compiler { - message: [{ - progress: { - platform, -- value -+ value: percentage / 100 - } - }], - timestamp: Date.now(), -@@ -78,6 +81,7 @@ class Compiler { - setupCompiler() { - this.compiler.hooks.watchRun.tap('repack:watch', () => { - this.isCompilationInProgress = true; -+ this.lastProgress = {}; - this.platforms.forEach(platform => { - if (platform === 'android') { - void (0, _index2.runAdbReverse)({ -@@ -96,6 +100,7 @@ class Compiler { - }); - this.compiler.hooks.invalid.tap('repack:invalid', () => { - this.isCompilationInProgress = true; -+ this.lastProgress = {}; - this.platforms.forEach(platform => { - this.devServerContext.notifyBuildStart(platform); - this.devServerContext.broadcastToHmrClients({ diff --git a/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch b/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch deleted file mode 100644 index 9865f8152739..000000000000 --- a/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch +++ /dev/null @@ -1,358 +0,0 @@ -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts -index 26d726c..d8999a0 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts -+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts -@@ -13,8 +13,11 @@ export declare class Compiler { - statsCache: Record; - resolvers: Record void>>; - progressSenders: Record; -- isCompilationInProgress: boolean; -+ isCompilationInProgress: Record; - devServerContext: Server.DelegateContext; -+ private pendingCompilations; -+ private activePlatforms; -+ private isClosed; - constructor(configs: MultiRspackOptions, reporter: Reporter, rootDir: string); - get devServerOptions(): import("@rspack/core").DevServer; - get watchOptions(): import("@rspack/core").WatchOptions; -@@ -23,7 +26,10 @@ export declare class Compiler { - private removeProgressSender; - setDevServerContext(ctx: Server.DelegateContext): void; - private setupCompiler; -+ private setupChildCompilerHooks; -+ private activatePlatform; - start(): void; -+ close(callback?: (error?: Error | null) => void): void; - getAsset(filename: string, platform: string, sendProgress?: SendProgress): Promise; - getSource(filename: string, platform: string | undefined, sendProgress?: SendProgress): Promise; - getSourceMap(filename: string, platform: string | undefined): Promise; -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -index 7c2856b..cabce76 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -@@ -18,13 +18,20 @@ class Compiler { - resolvers = {}; - progressSenders = {}; - lastProgress = {}; -- isCompilationInProgress = false; -+ isCompilationInProgress = {}; - // late-init - -+ pendingCompilations = new Map(); -+ activePlatforms = new Set(); -+ isClosed = false; -+ - constructor(configs, reporter, rootDir) { - this.reporter = reporter; - this.rootDir = rootDir; - const handler = (platform, value) => { -+ // An unrequested platform is parked, so its progress must not reach the terminal or -+ // the device: it would drive a second progress bar beside the platform in use. -+ if (!this.activePlatforms.has(platform)) return; - const rawPercentage = Math.floor(value * 100); - const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0); - this.lastProgress[platform] = percentage; -@@ -79,43 +86,66 @@ class Compiler { - this.devServerContext = ctx; - } - setupCompiler() { -- this.compiler.hooks.watchRun.tap('repack:watch', () => { -- this.isCompilationInProgress = true; -- this.lastProgress = {}; -- this.platforms.forEach(platform => { -- if (platform === 'android') { -- void (0, _index2.runAdbReverse)({ -- port: this.devServerContext.options.port, -- logger: this.devServerContext.log -- }); -+ // rspack's MultiCompiler fires the aggregated `done` only when every child has reported -+ // (@rspack/core: `doneCompilers === this.compilers.length`). A parked child never reports, so -+ // that event stays pending and the server never settles. Upstream moves this bookkeeping onto -+ // the child compilers for the same reason. -+ for (const childCompiler of this.compiler.compilers) { -+ this.setupChildCompilerHooks(childCompiler); -+ } -+ } -+ setupChildCompilerHooks(childCompiler) { -+ const platform = childCompiler.options.name; -+ childCompiler.hooks.watchRun.tapAsync('repack:lazy-compilation', (_compiler, done) => { -+ if (this.activePlatforms.has(platform)) { -+ done(); -+ return; -+ } -+ this.pendingCompilations.set(platform, () => { -+ // Exclude time spent waiting for the platform to be requested and -+ // avoid replaying file changes that happened before that request. -+ if (childCompiler.watching) { -+ const startTime = Date.now(); -+ childCompiler.watching.startTime = startTime; -+ childCompiler.watching.lastWatcherStartTime = startTime; - } -- this.devServerContext.notifyBuildStart(platform); -- this.devServerContext.broadcastToHmrClients({ -- action: 'compiling', -- body: { -- name: platform -- } -- }); -+ done(); - }); - }); -- this.compiler.hooks.invalid.tap('repack:invalid', () => { -- this.isCompilationInProgress = true; -- this.lastProgress = {}; -- this.platforms.forEach(platform => { -- this.devServerContext.notifyBuildStart(platform); -- this.devServerContext.broadcastToHmrClients({ -- action: 'compiling', -- body: { -- name: platform -- } -+ childCompiler.hooks.watchRun.tap('repack:watch', () => { -+ if (!this.activePlatforms.has(platform)) return; -+ this.isCompilationInProgress[platform] = true; -+ this.lastProgress[platform] = 0; -+ if (platform === 'android') { -+ void (0, _index2.runAdbReverse)({ -+ port: this.devServerContext.options.port, -+ logger: this.devServerContext.log - }); -+ } -+ this.devServerContext.notifyBuildStart(platform); -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'compiling', -+ body: { -+ name: platform -+ } - }); - }); -- this.compiler.hooks.done.tap('repack:done', multiStats => { -- const stats = multiStats.toJson({ -+ childCompiler.hooks.invalid.tap('repack:invalid', () => { -+ if (!this.activePlatforms.has(platform)) return; -+ this.isCompilationInProgress[platform] = true; -+ this.lastProgress[platform] = 0; -+ this.devServerContext.notifyBuildStart(platform); -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'compiling', -+ body: { -+ name: platform -+ } -+ }); -+ }); -+ childCompiler.hooks.done.tap('repack:done', stats => { -+ const childStats = stats.toJson({ - all: false, - assets: true, -- children: true, - outputPath: true, - timings: true, - hash: true, -@@ -123,49 +153,46 @@ class Compiler { - warnings: true - }); - try { -- stats.children.map(childStats => { -- const platform = childStats.name; -- this.devServerContext.broadcastToHmrClients({ -- action: 'hash', -- body: { -- name: platform, -- hash: childStats.hash -- } -- }); -- this.statsCache[platform] = childStats; -- const assets = childStats.assets; -- this.assetsCache[platform] = assets.filter(asset => asset.type === 'asset').reduce((acc, { -- name, -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'hash', -+ body: { -+ name: platform, -+ hash: childStats.hash -+ } -+ }); -+ this.statsCache[platform] = childStats; -+ const assets = childStats.assets; -+ this.assetsCache[platform] = assets.filter(asset => asset.type === 'asset').reduce((acc, { -+ name, -+ info, -+ size -+ }) => { -+ const assetPath = _nodePath.default.join(childStats.outputPath, name); -+ const data = this.filesystem.readFileSync(assetPath); -+ const asset = { -+ data, - info, - size -- }) => { -- const assetPath = _nodePath.default.join(childStats.outputPath, name); -- const data = this.filesystem.readFileSync(assetPath); -- const asset = { -- data, -- info, -- size -- }; -- acc[(0, _index.adaptFilenameToPlatform)(name)] = asset; -- if (info.related?.sourceMap) { -- const sourceMapName = Array.isArray(info.related.sourceMap) ? info.related.sourceMap[0] : info.related.sourceMap; -- const sourceMapPath = _nodePath.default.join(childStats.outputPath, sourceMapName); -- const sourceMapData = this.filesystem.readFileSync(sourceMapPath); -- const sourceMapAsset = { -- data: sourceMapData, -- info: { -- hotModuleReplacement: info.hotModuleReplacement, -- size: sourceMapData.length -- }, -+ }; -+ acc[(0, _index.adaptFilenameToPlatform)(name)] = asset; -+ if (info.related?.sourceMap) { -+ const sourceMapName = Array.isArray(info.related.sourceMap) ? info.related.sourceMap[0] : info.related.sourceMap; -+ const sourceMapPath = _nodePath.default.join(childStats.outputPath, sourceMapName); -+ const sourceMapData = this.filesystem.readFileSync(sourceMapPath); -+ const sourceMapAsset = { -+ data: sourceMapData, -+ info: { -+ hotModuleReplacement: info.hotModuleReplacement, - size: sourceMapData.length -- }; -- acc[(0, _index.adaptFilenameToPlatform)(sourceMapName)] = sourceMapAsset; -- } -- return acc; -- }, -- // keep old assets -- this.assetsCache[platform] ?? {}); -- }); -+ }, -+ size: sourceMapData.length -+ }; -+ acc[(0, _index.adaptFilenameToPlatform)(sourceMapName)] = sourceMapAsset; -+ } -+ return acc; -+ }, -+ // keep old assets -+ this.assetsCache[platform] ?? {}); - } catch (error) { - this.reporter.process({ - type: 'error', -@@ -174,32 +201,41 @@ class Compiler { - message: ['An error occured while processing assets from compilation:', String(error)] - }); - } -- this.isCompilationInProgress = false; -- stats.children?.forEach(childStats => { -- const platform = childStats.name; -- const time = childStats.time; -- this.callPendingResolvers(platform); -- this.devServerContext.notifyBuildEnd(platform); -- this.devServerContext.broadcastToHmrClients({ -- action: 'ok', -- body: { -- name: platform -+ this.isCompilationInProgress[platform] = false; -+ this.callPendingResolvers(platform); -+ this.devServerContext.notifyBuildEnd(platform); -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'ok', -+ body: { -+ name: platform -+ } -+ }); -+ this.reporter.process({ -+ issuer: 'DevServer', -+ message: [{ -+ progress: { -+ platform, -+ time: childStats.time - } -- }); -- this.reporter.process({ -- issuer: 'DevServer', -- message: [{ -- progress: { -- platform, -- time -- } -- }], -- timestamp: Date.now(), -- type: 'progress' -- }); -+ }], -+ timestamp: Date.now(), -+ type: 'progress' - }); - }); - } -+ activatePlatform(platform) { -+ if (!this.platforms.includes(platform)) { -+ throw new _index.CLIError(`Unrecognized platform: ${platform}`); -+ } -+ if (this.activePlatforms.has(platform)) return; -+ this.activePlatforms.add(platform); -+ this.isCompilationInProgress[platform] = true; -+ const resumeCompilation = this.pendingCompilations.get(platform); -+ if (resumeCompilation) { -+ this.pendingCompilations.delete(platform); -+ resumeCompilation(); -+ } -+ } - start() { - this.compiler.watch(this.watchOptions, error => { - if (!error) return; -@@ -208,14 +244,33 @@ class Compiler { - }); - }); - } -+ close(callback = () => {}) { -+ this.isClosed = true; -+ const error = new Error('Compiler closed before compilation completed'); -+ this.platforms.forEach(platform => { -+ this.callPendingResolvers(platform, error); -+ }); -+ -+ // Resume pending compilations so the Watching instances can be closed -+ for (const resumeCompilation of this.pendingCompilations.values()) { -+ resumeCompilation(); -+ } -+ this.pendingCompilations.clear(); -+ this.compiler.close(callback); -+ } - async getAsset(filename, platform, sendProgress) { -+ if (this.isClosed) { -+ throw new Error('Compiler closed before compilation completed'); -+ } -+ this.activatePlatform(platform); -+ - // Return file from assetsCache if exists - const fileFromCache = this.assetsCache[platform]?.[filename]; - if (fileFromCache) { - return fileFromCache; - } - this.addProgressSender(platform, sendProgress); -- if (!this.isCompilationInProgress) { -+ if (!this.isCompilationInProgress[platform]) { - this.removeProgressSender(platform, sendProgress); - return Promise.reject(new Error(`File ${filename} for ${platform} not found in compilation assets (no compilation in progress)`)); - } -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/start.js b/node_modules/@callstack/repack/dist/commands/rspack/start.js -index cc57d82..6ae7930 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/start.js -+++ b/node_modules/@callstack/repack/dist/commands/rspack/start.js -@@ -191,7 +191,15 @@ async function start(_, cliConfig, args) { - return { - stop: async () => { - reporter.stop(); -- await stop(); -+ try { -+ // A platform nobody asked for is parked mid watch-run; resume it so the Watching -+ // instances can shut down instead of hanging (callstack/repack#1430). -+ await new Promise((resolve, reject) => { -+ compiler.close(error => (error ? reject(error) : resolve())); -+ }); -+ } finally { -+ await stop(); -+ } - } - }; - } -\ No newline at end of file diff --git a/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch b/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch new file mode 100644 index 000000000000..1e29ef76c8e9 --- /dev/null +++ b/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch @@ -0,0 +1,46 @@ +--- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js ++++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js +@@ -17,6 +17,7 @@ + statsCache = {}; + resolvers = {}; + progressSenders = {}; ++ lastProgress = {}; + isCompilationInProgress = {}; + // late-init + +@@ -28,7 +29,9 @@ + this.rootDir = rootDir; + const handler = (platform, value) => { + if (!this.activePlatforms.has(platform)) return; +- const percentage = Math.floor(value * 100); ++ const rawPercentage = Math.floor(value * 100); ++ const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0); ++ this.lastProgress[platform] = percentage; + this.progressSenders[platform]?.forEach(sendProgress => { + sendProgress({ + completed: percentage, +@@ -40,7 +43,7 @@ + message: [{ + progress: { + platform, +- value ++ value: percentage / 100 + } + }], + timestamp: Date.now(), +@@ -104,6 +107,7 @@ + childCompiler.hooks.watchRun.tap('repack:watch', () => { + if (!this.activePlatforms.has(platform)) return; + this.isCompilationInProgress[platform] = true; ++ this.lastProgress[platform] = 0; + if (platform === 'android') { + void (0, _index2.runAdbReverse)({ + port: this.devServerContext.options.port, +@@ -121,6 +125,7 @@ + childCompiler.hooks.invalid.tap('repack:invalid', () => { + if (!this.activePlatforms.has(platform)) return; + this.isCompilationInProgress[platform] = true; ++ this.lastProgress[platform] = 0; + this.devServerContext.notifyBuildStart(platform); + this.devServerContext.broadcastToHmrClients({ + action: 'compiling', diff --git a/patches/@callstack/repack/details.md b/patches/@callstack/repack/details.md index 66ab25dec30b..ad585c4c8c31 100644 --- a/patches/@callstack/repack/details.md +++ b/patches/@callstack/repack/details.md @@ -1,6 +1,6 @@ # `@callstack/repack` patches -### [@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch](@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch) +### [@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch](@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch) - Reason: @@ -12,32 +12,12 @@ and resets it on the `watchRun` and `invalid` hooks when a new compilation starts. ``` -- Upstream PR/issue: https://github.com/callstack/repack/issues/1459 - filed with a standalone reproduction. Delete this patch once a release clamps the forwarded percentage. +- Rewritten against 5.4.1 on the bump from 5.3.0. The 5.3.0 version of this patch targeted the pre-lazy-compilation `Compiler.js` and no longer applies: upstream released per-platform lazy compilation, so the progress handler now sits behind an `activePlatforms` guard and `isCompilationInProgress` is a per-platform record. Same clamp, new anchors. +- Upstream PR/issue: https://github.com/callstack/repack/issues/1459 - filed with a standalone reproduction; closed as will-not-fix, so this stays until upstream changes its mind. - E/App issue: None. Reported during review of the PR below. - PR Introducing Patch: https://github.com/Expensify/App/pull/99407 -### [@callstack+repack+5.3.0+002+lazy-platform-compilation.patch](@callstack+repack+5.3.0+002+lazy-platform-compilation.patch) +Removed on the bump to `@callstack/repack@5.4.1`: -- Reason: - - ``` - Back-port of upstream lazy platform compilation. On 5.3.0 the dev server starts every configured - platform compiling immediately, so a machine running one app also pays for a full compile of the - other platform (measured: iOS 188.3s and Android 190.7s, both compiled although only iOS was ever - requested). Narrowing the server to one platform does not fix it: that server cannot serve the - other platform's bundle at all, so a second app reusing the running server gets nothing. - - This patch keeps every platform configured and parks each child compiler at its first watch run - until something actually asks for that platform's bundle. A platform is activated by - `getAsset`, i.e. by a bundle request, and only activated platforms emit progress and build - signals, so one progress stream serves the platform in use. Bookkeeping moved from the - MultiCompiler hooks to the child compilers because rspack only fires the aggregated `done` once - every child has reported, and a parked child never reports. `close()` releases parked compilations - before the compiler closes, as upstream does. - ``` - -- Also updates `dist/commands/rspack/Compiler.d.ts`: its declarations describe the pre-patch shape (`isCompilationInProgress: boolean`) and omit the new members. -- Depends on `001`: both patches edit the progress handling in `dist/commands/rspack/Compiler.js`, and this one is authored against the post-`001` file. Keep them in numeric order. -- Upstream PR/issue: https://github.com/callstack/repack/pull/1430 - merged into `main`, not in any release yet; staged for 5.4.0 by https://github.com/callstack/repack/pull/1441. Delete this patch when a release containing it is pinned. -- E/App issue: None. Found while answering review on the PR below. -- PR Introducing Patch: https://github.com/Expensify/App/pull/99407 +- `002+lazy-platform-compilation` — upstream released the feature (`dist/commands/rspack/Compiler.js` now parks each child compiler until `getAsset` requests that platform). Was https://github.com/callstack/repack/pull/1430. +- `003+android-fonts-raw` and `004+android-keep-xml` — https://github.com/callstack/repack/issues/1472 fixed both: the assets loader emits `require()`'d font files to `res/raw` as well as `res/font` (`dist/loaders/assetsLoader/assetsLoader.js`), and the assets copy step writes `res/raw/keep.xml` with `tools:keep` for every copied resource (`dist/plugins/utils/AssetsCopyProcessor.js`), matching Metro. Verified against the published 5.4.1 tarball, and on device: Expensify/App#103243 shipped an AdHoc build on the 5.4.1 canary where fonts resolve from `raw` and all 765 `raw` assets plus 22 drawables survive `shrinkResources=true`. E/App issue: https://github.com/Expensify/App/issues/102619. diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index b4741c128c44..d09ae410b58d 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -156,9 +156,9 @@ Untransformed canvases keep the previous ratio, so nothing else changes. ``` -- Upstream PR/issue: +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/4084 was closed as not a Skia bug: the maintainer considers a canvas painted under a CSS transform an app concern, and said neither https://github.com/Shopify/react-native-skia/pull/4085 (this approach) nor https://github.com/Shopify/react-native-skia/pull/4105 (an opt-in pixelDensity prop) is the way to fix it. No upstream fix is planned, so this patch has to be carried across Skia upgrades unless the charts stop being fitted with a transform. - E/App issue: https://github.com/Expensify/App/issues/95221 -- PR introducing patch: +- PR introducing patch: https://github.com/Expensify/App/pull/101291 ### [@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch](@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index babf9982cb42..16fdf13a141b 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -67,3 +67,26 @@ - Upstream PR/issue: https://github.com/software-mansion/react-native-screens/issues/4156, https://github.com/software-mansion/react-native-screens/pull/4157 - E/App issue: https://github.com/Expensify/App/issues/102046 - PR introducing patch: https://github.com/Expensify/App/pull/103126 + +### [react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch) + +- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch animates the change once the bar is in a window, which lets UIKit run its own show/hide animation without animating the bar out on the first render. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below. +- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+003+android-tab-label-typography.patch](react-native-screens+4.28.0+003+android-tab-label-typography.patch) + +- Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. + - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. `updateFontStyles` sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. Material's `setItemTextAppearanceActiveBoldEnabled` is not used for this: it re-applies Material's 12sp text size to the large label alone and recomputes the selected item's offset from the difference between the two labels' sizes, so every selected tab would sit higher than the others. + - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets the letter spacing of both labels to 0. +- Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch) + +- Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. +- Upstream PR/issue: not reported, because dropping the tint for every icon fits only a bar whose icons all arrive pre-colored, as the App's do. The disabled and focused icon colors go with it, which the App does not use. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch new file mode 100644 index 000000000000..632508daac5b --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch @@ -0,0 +1,14 @@ +diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +@@ -244,7 +244,9 @@ + _tabBarHidden = newComponentProps.tabBarHidden; + #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + if (@available(iOS 18.0, *)) { +- [_controller setTabBarHidden:_tabBarHidden animated:NO]; ++ // Animated so the bar slides out with the screen that hid it instead of blinking away, except before the bar ++ // is on screen, where the first render would animate it out. ++ [_controller setTabBarHidden:_tabBarHidden animated:[[_controller tabBar] window] != nil]; + } else + #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + { diff --git a/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch b/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch new file mode 100644 index 000000000000..a532d6a859e5 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index 10cd91b..6f059bd 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance + import android.annotation.SuppressLint + import android.content.Context + import android.content.res.ColorStateList ++import android.graphics.Typeface + import android.graphics.drawable.StateListDrawable + import android.util.TypedValue + import android.view.MenuItem +@@ -161,13 +162,18 @@ internal class TabsAppearanceApplicator( + tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } + ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) + ++ // Both labels drop the 0.5sp letter tracking of Material's text appearance, which reads wider than the app's labels. + // Inactive + smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) + smallLabel.typeface = fontFamily ++ smallLabel.letterSpacing = 0f + + // Active ++ // Bold is set here rather than with `setItemTextAppearanceActiveBoldEnabled`, which re-applies Material's text size ++ // to this label alone, so Material lifts the selected item by the size difference between the two labels. + largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) +- largeLabel.typeface = fontFamily ++ largeLabel.setTypeface(fontFamily, Typeface.BOLD) ++ largeLabel.letterSpacing = 0f + } + } + diff --git a/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch new file mode 100644 index 000000000000..95dc7a725632 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch @@ -0,0 +1,33 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index 9238416..d7d2024 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -64,24 +64,10 @@ internal class TabsAppearanceApplicator( + bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) + + // Icon color +- val iconDisabledColor = +- tabBarAppearance?.disabled?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconFocusedColor = +- tabBarAppearance?.focused?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconSelectedColor = +- tabBarAppearance?.selected?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer) +- +- val iconNormalColor = +- tabBarAppearance?.normal?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor) +- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors) ++ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph and the user's ++ // avatar on the account tab. A tint list paints over all of it with a single color, so no tint is applied and ++ // the bitmap is drawn as supplied, which is how `tinted: false` already behaves on iOS. ++ bottomNavigationView.itemIconTintList = null + + // LabelVisibilityMode + // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch new file mode 100644 index 000000000000..ee75c7d7fdea --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index 1183c51..303635c 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -160,7 +160,7 @@ export function NativeBottomTabView({ + ios: PlatformColor('label'), + default: colors.text + }); +- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined; ++ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); + const onTransitionStart = ({ + closing, + route diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch new file mode 100644 index 000000000000..9dd8d44e6bd1 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch @@ -0,0 +1,151 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index e6ea55b..4d577fe 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac + import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native'; + import Color from 'color'; + import * as React from 'react'; +-import { Platform, PlatformColor } from 'react-native'; ++import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; + import { Tabs } from 'react-native-screens'; + import { Deferred } from "./Deferred.js"; + import { NativeScreen } from "./NativeScreen/NativeScreen.js"; +-import { jsx as _jsx } from "react/jsx-runtime"; ++import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; + const meta = { + type: 'native-tabs' + }; +@@ -32,9 +32,21 @@ export function NativeBottomTabView({ + fonts + } = useTheme(); + const focusedRouteKey = state.routes[state.index].key; ++ // A route with `tabBarItemHidden` gets no native tab item. While it is focused, it is drawn over the native host ++ // and the tab bar is hidden, and the host keeps the last focused visible tab selected underneath. ++ const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true; ++ const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]); ++ // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped. ++ const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route)); ++ const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ // Hidden routes stay mounted once visited, the same as the native tabs. ++ const visitedHiddenRouteKeysRef = React.useRef(new Set()); ++ if (isFocusedRouteHidden) { ++ visitedHiddenRouteKeysRef.current.add(focusedRouteKey); ++ } + const [nativeState, dispatch] = React.useReducer(reducer, { + confirmed: { +- routeKey: focusedRouteKey, ++ routeKey: nativeSelectedRouteKey, + provenance: 0 + } + }); +@@ -189,17 +201,17 @@ export function NativeBottomTabView({ + const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior; + const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none'; + const bottomAccessory = currentOptions.bottomAccessory; +- return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { +- children: /*#__PURE__*/_jsx(Tabs.Host, { ++ return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { ++ children: [/*#__PURE__*/_jsx(Tabs.Host, { + navStateRequest: { +- selectedScreenKey: focusedRouteKey, ++ selectedScreenKey: nativeSelectedRouteKey, + baseProvenance: nativeState.confirmed.provenance + }, + rejectStaleNavStateUpdates: true, + onTabSelected: onTabSelected, + onTabSelectionRejected: onTabSelectionRejected, + onTabSelectionPrevented: onTabSelectionPrevented, +- tabBarHidden: shouldHideTabBar, ++ tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, + colorScheme: dark ? 'dark' : 'light', + nativeContainerStyle: { + backgroundColor: colors.background +@@ -213,6 +225,9 @@ export function NativeBottomTabView({ + tabBarTintColor: activeTintColor + }, + children: state.routes.map((route, index) => { ++ if (isRouteHidden(route)) { ++ return null; ++ } + const { + options, + render, +@@ -224,6 +239,7 @@ export function NativeBottomTabView({ + title, + lazy = false, + tabBarLabel, ++ tabBarAccessibilityLabel, + tabBarSelectionEnabled, + tabBarBadgeStyle, + tabBarIcon, +@@ -290,6 +306,7 @@ export function NativeBottomTabView({ + preventNativeSelection: tabBarSelectionEnabled === false, + badgeValue: tabBarBadge?.toString(), + title: tabTitle, ++ tabBarItemAccessibilityLabel: tabBarAccessibilityLabel, + nativeContainerStyle: { + backgroundColor: colors.background + }, +@@ -369,9 +386,40 @@ export function NativeBottomTabView({ + }) + }, route.key); + }) +- }) ++ }, "native-tabs-host"), ...state.routes.map((route, index) => { ++ if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { ++ return null; ++ } ++ const { ++ options, ++ render, ++ navigation ++ } = descriptors[route.key]; ++ const isFocused = state.index === index; ++ return /*#__PURE__*/_jsx(View, { ++ style: [StyleSheet.absoluteFill, { ++ backgroundColor: colors.background ++ }, !isFocused && styles.hiddenRoute], ++ pointerEvents: isFocused ? 'auto' : 'none', ++ children: /*#__PURE__*/_jsx(ScreenWithHeader, { ++ isFocused: isFocused, ++ route: route, ++ navigation: navigation, ++ options: options, ++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { ++ value: meta, ++ children: render() ++ }) ++ }) ++ }, route.key); ++ })] + }); + } ++const styles = StyleSheet.create({ ++ hiddenRoute: { ++ display: 'none' ++ } ++}); + function ScreenWithHeader({ + isFocused, + route, +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +index ac25ed1..c05b04b 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts ++++ b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +@@ -78,6 +78,15 @@ type IconIOSSfSymbol = { + }; + export type NativeBottomTabIcon = IconIOSSfSymbol | IconImage; + export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { ++ /** ++ * Whether the tab gets no item in the native tab bar. While a hidden tab is focused, it is drawn over the ++ * native tabs and the tab bar is hidden. ++ */ ++ tabBarItemHidden?: boolean; ++ /** ++ * Accessibility label for the tab bar item, read by the screen reader. Useful when the tab has no visible label. ++ */ ++ tabBarAccessibilityLabel?: string; + /** + * Title text for the screen. + */ diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 542721926812..6a91d61be2f1 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -62,6 +62,22 @@ - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. - Note: Re-ported onto 7.11.2. `STATE_TRANSITIONING_OR_BELOW_TOP` is now a module-level constant upstream, so the patch no longer declares it. 7.11.2 (https://github.com/react-navigation/react-navigation/pull/13255) removed the `overflow: active ? undefined : 'hidden'` line from `CardA11yWrapper`, so the `zIndex` hunk was re-anchored on the line above it. The added lines are unchanged. With that `overflow: hidden` gone from `CardA11yWrapper`, the Safari case the `zIndex` part was added for may no longer happen. That has not been verified, so the `zIndex` part stays. +### [@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch) + +- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. +- Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A + +### [@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch) + +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. +- Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A + ### Removed patches - `@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch` was dropped: upstream raised the `createMemoryHistory.go()` fallback timeout from 100ms to 1000ms in 7.3.18 (https://github.com/react-navigation/react-navigation/pull/13217), with the same Firefox rationale as our patch. Original context: [#94571](https://github.com/Expensify/App/issues/94571) / [#95980](https://github.com/Expensify/App/pull/95980), upstream issue https://github.com/react-navigation/react-navigation/issues/11145. diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md index 218061dd9c1d..ad919a91a15c 100644 --- a/patches/victory-native/details.md +++ b/patches/victory-native/details.md @@ -1,6 +1,6 @@ # `victory-native` patches -### [victory-native+41.21.0+001+horizontal-bars.patch](victory-native+41.21.0+001+horizontal-bars.patch) +### [victory-native+42.0.1+001+horizontal-bars.patch](victory-native+42.0.1+001+horizontal-bars.patch) - Reason: @@ -12,30 +12,18 @@ - E/App issue: https://github.com/Expensify/App/issues/91883 - PR introducing patch: https://github.com/Expensify/App/pull/91659 -### [victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch](victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch) -- Reason: - - ``` - Fix bug https://github.com/FormidableLabs/victory-native-xl/issues/652 - ``` - -- Upstream PR/issue: https://github.com/FormidableLabs/victory-native-xl/pull/666 -- E/App issue: https://github.com/Expensify/App/issues/92114 -- PR introducing patch: https://github.com/Expensify/App/pull/92130 - -### [victory-native+41.21.0+003+canvas-props.patch](victory-native+41.21.0+003+canvas-props.patch) +### [victory-native+42.0.1+002+tickCount-4121.patch](victory-native+42.0.1+002+tickCount-4121.patch) - Reason: ``` - Forwards a `canvasProps` prop from CartesianChart/PolarChart to the underlying Skia , so the - expanded (full-screen) chart can opt into Skia's `__destroyWebGLContextAfterRender` static renderer on - web. That renders the chart into a plain 2D canvas bitmap and releases the WebGL context, which keeps - the chart visible through the modal close animation without the WebGL white flash and without holding - a live GPU context per expanded chart. + After updating to v42 the tickCount broke. + This was caused by this commit https://github.com/FormidableLabs/victory-native-xl/pull/664/changes/fcd8affe655878d0bf95ab87c087eab428d4f62d + which fixed some bug but caused us another bug. This small patch reverts it. This is a temporary solution not a real fix. + The fix should address both bugs. ``` - Upstream PR/issue: Not yet. -- E/App issue: https://github.com/Expensify/App/issues/92969 -- PR introducing patch: https://github.com/Expensify/App/pull/97698 +- E/App issue: https://github.com/Expensify/App/issues/91883 +- PR introducing patch: https://github.com/Expensify/App/pull/100186 diff --git a/patches/victory-native/victory-native+41.21.0+001+horizontal-bars.patch b/patches/victory-native/victory-native+41.21.0+001+horizontal-bars.patch deleted file mode 100644 index 064636ba22be..000000000000 --- a/patches/victory-native/victory-native+41.21.0+001+horizontal-bars.patch +++ /dev/null @@ -1,111 +0,0 @@ -diff --git a/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts b/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts -index b486f76..45788f0 100644 ---- a/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts -+++ b/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts -@@ -16,6 +16,7 @@ type BarGroupProps = { - groupWidth: number; - gapWidth: number; - }) => void; -+ isHorizontal?: boolean; - }; - export declare function BarGroup({ betweenGroupPadding, withinGroupPadding, chartBounds, roundedCorners, children, onBarSizeChange, barWidth: customBarWidth, barCount, }: BarGroupProps): React.DetailedReactHTMLElement, HTMLInputElement>[] | null; - export declare namespace BarGroup { -diff --git a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts -index 63b69bc..75641b1 100644 ---- a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts -+++ b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts -@@ -1,6 +1,6 @@ - import type { ChartBounds, PointsArray } from "../../types"; - import { type RoundedCorners } from "../../utils/createRoundedRectPath"; --export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number) => { -+export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number, isHorizontal?: boolean) => { - barWidth: number; - groupWidth: number; - gapWidth: number; -diff --git a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx -index f46fad7..ad75c04 100644 ---- a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx -+++ b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx -@@ -20,6 +20,7 @@ type BarGroupProps = { - groupWidth: number; - gapWidth: number; - }) => void; -+ isHorizontal?: boolean; - }; - - export function BarGroup({ -@@ -31,6 +32,7 @@ export function BarGroup({ - onBarSizeChange, - barWidth: customBarWidth, - barCount, -+ isHorizontal, - }: BarGroupProps) { - // Collect the bar props - const bars = [] as BarGroupBarProps[]; -@@ -49,6 +51,7 @@ export function BarGroup({ - roundedCorners, - customBarWidth, - barCount, -+ isHorizontal, - ); - - // Handle bar size change -diff --git a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts -index 4bfb6d4..9805bfe 100644 ---- a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts -+++ b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts -@@ -15,17 +15,19 @@ export const useBarGroupPaths = ( - roundedCorners?: RoundedCorners, - customBarWidth?: number, - barCount?: number, -+ isHorizontal?: boolean, - ) => { - const numGroups = points[0]?.length || 0; - -- const { yScale } = useCartesianChartContext(); -+ const { xScale, yScale } = useCartesianChartContext(); - - const groupWidth = React.useMemo(() => { -+ const boundSize = isHorizontal ? chartBounds.top - chartBounds.bottom : chartBounds.right - chartBounds.left; - return ( -- ((1 - betweenGroupPadding) * (chartBounds.right - chartBounds.left)) / -+ ((1 - betweenGroupPadding) * boundSize) / - Math.max(1, numGroups) - ); -- }, [betweenGroupPadding, chartBounds.left, chartBounds.right, numGroups]); -+ }, [betweenGroupPadding, chartBounds, numGroups, isHorizontal]); - - const barWidth = React.useMemo(() => { - if (customBarWidth) return customBarWidth; -@@ -46,19 +48,24 @@ export const useBarGroupPaths = ( - const offset = -groupWidth / 2 + i * (barWidth + gapWidth); - pointSet.forEach(({ x, y, yValue }) => { - if (typeof y !== "number") return; -- const barHeight = yScale(0) - y; -+ const barHeight = isHorizontal ? xScale(0) - x : yScale(0) - y; - if (roundedCorners) { - const nonUniformRoundedRect = createRoundedRectPath( -- x + offset, -- y, -- barWidth, -- barHeight, -- roundedCorners, -+ isHorizontal ? x : x + offset, -+ isHorizontal ? y + offset : y, -+ isHorizontal ? barHeight : barWidth, -+ isHorizontal ? barWidth : barHeight, -+ isHorizontal ? {topLeft: roundedCorners.bottomLeft, topRight: roundedCorners.topLeft, bottomLeft: roundedCorners.bottomRight, bottomRight: roundedCorners.topRight} : roundedCorners, - Number(yValue), - ); - p.addRRect(nonUniformRoundedRect); - } else { -- p.addRect(Skia.XYWHRect(x + offset, y, barWidth, barHeight)); -+ p.addRect(Skia.XYWHRect( -+ isHorizontal ? x : x + offset, -+ isHorizontal ? y + offset : y, -+ isHorizontal ? barHeight : barWidth, -+ isHorizontal ? barWidth : barHeight, -+ )); - } - }); - return p; diff --git a/patches/victory-native/victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch b/patches/victory-native/victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch deleted file mode 100644 index 827614abdf50..000000000000 --- a/patches/victory-native/victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch +++ /dev/null @@ -1,63 +0,0 @@ -diff --git a/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts b/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts -index 18425b7..5508130 100644 ---- a/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts -+++ b/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts -@@ -4,5 +4,5 @@ type SliceAngularInsetPathArgs = { - slice: PieSliceData; - angularInset: PieSliceAngularInsetData; - }; --export declare const useSliceAngularInsetPath: ({ angularInset, slice, }: SliceAngularInsetPathArgs) => readonly [import("@shopify/react-native-skia").SkPath, import("@shopify/react-native-skia").SkPaint]; -+export declare const useSliceAngularInsetPath: ({ angularInset, slice, }: SliceAngularInsetPathArgs) => readonly [import("@shopify/react-native-skia").SkPath]; - export {}; -diff --git a/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx b/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx -index bede090..0523900 100644 ---- a/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx -+++ b/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx -@@ -21,7 +21,7 @@ type PieSliceAngularInsetProps = { - export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { - const { angularInset, children, animate, ...rest } = props; - const { slice } = usePieSliceContext(); -- const [path, insetPaint] = useSliceAngularInsetPath({ slice, angularInset }); -+ const [path] = useSliceAngularInsetPath({ slice, angularInset }); - - // If the path is empty, don't render anything - if (path.toSVGString() === "M0 0L0 0M0 0L0 0") { -@@ -34,7 +34,7 @@ export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { - - const Component = animate ? AnimatedPath : Path; - return ( -- -+ - {children} - - ); -diff --git a/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts b/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts -index 022dc49..2da6c64 100644 ---- a/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts -+++ b/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts -@@ -15,7 +15,7 @@ export const useSliceAngularInsetPath = ({ - angularInset, - slice, - }: SliceAngularInsetPathArgs) => { -- const [path, paint] = useMemo(() => { -+ const [path] = useMemo(() => { - const { radius, center, innerRadius } = slice; - - const path = Skia.Path.Make(); -@@ -72,14 +72,8 @@ export const useSliceAngularInsetPath = ({ - path.moveTo(center.x, center.y); - path.lineTo(endPoint.x, endPoint.y); - } -- -- // Create Paint for inset -- const insetPaint = Skia.Paint(); -- insetPaint.setColor(Skia.Color(angularInset.angularStrokeColor)); -- insetPaint.setStyle(PaintStyle.Stroke); -- insetPaint.setStrokeWidth(angularInset.angularStrokeWidth); -- return [path, insetPaint] as const; -+ return [path] as const; - }, [slice, angularInset]); - -- return [path, paint] as const; -+ return [path] as const; - }; diff --git a/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch b/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch deleted file mode 100644 index 7692cce6596d..000000000000 --- a/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch +++ /dev/null @@ -1,248 +0,0 @@ -diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts -index 6802428..b30fdac 100644 ---- a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts -+++ b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts -@@ -8,6 +8,7 @@ import type { ChartPressState, ChartPressStateInit } from "./hooks/useChartPress - import { type ChartTransformState } from "./hooks/useChartTransformState"; - import { type PanTransformGestureConfig, type PinchTransformGestureConfig } from "./utils/transformGestures"; - import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; -+import { type ChartCanvasProps } from "../shared/ChartWrapper"; - export type CartesianActionsHandle = T extends ChartPressState ? S extends ChartPressStateInit ? { - handleTouch: (v: T, x: number, y: number) => void; - } : never : never; -@@ -67,6 +68,7 @@ type CartesianChartProps, XK extends key - x: InputFields[XK]; - y: Record; - }> | undefined>>; -+ canvasProps?: ChartCanvasProps; - } & ChartLayoutModeProps; - export declare function CartesianChart, XK extends keyof InputFields, YK extends keyof NumericalFields>({ transformState, children, ref, ...rest }: CartesianChartProps): React.JSX.Element; - export {}; -diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.js b/node_modules/victory-native/dist/cartesian/CartesianChart.js -index 839afbd..3b0837a 100644 ---- a/node_modules/victory-native/dist/cartesian/CartesianChart.js -+++ b/node_modules/victory-native/dist/cartesian/CartesianChart.js -@@ -73,7 +73,7 @@ function CartesianChart(_a) { - ); - } - exports.CartesianChart = CartesianChart; --function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, }) { -+function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, canvasProps, }) { - var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; - const { size, hasMeasuredLayoutSize, onLayout, isHeadless } = (0, useChartCanvasSize_1.useChartCanvasSize)({ explicitSize, headless }); - const chartBoundsRef = React.useRef(undefined); -@@ -498,5 +498,5 @@ function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, chil - height: primaryYScale.range()[1] - Math.min(primaryYScale.range()[0], 0), - }}/>); - } -- return (); -+ return (); - } -diff --git a/node_modules/victory-native/dist/polar/PolarChart.d.ts b/node_modules/victory-native/dist/polar/PolarChart.d.ts -index 6cf2971..5b862da 100644 ---- a/node_modules/victory-native/dist/polar/PolarChart.d.ts -+++ b/node_modules/victory-native/dist/polar/PolarChart.d.ts -@@ -4,6 +4,7 @@ import type { ColorFields, InputFields, NumericalFields, StringKeyOf } from "../ - import { type ChartTransformState } from "../cartesian/hooks/useChartTransformState"; - import { type ChartExplicitSize } from "../shared/ChartExplicitSize"; - import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; -+import { type ChartCanvasProps } from "../shared/ChartWrapper"; - type PolarChartBaseProps = { - onLayout: ({ nativeEvent: { layout } }: LayoutChangeEvent) => void; - hasMeasuredLayoutSize: boolean; -@@ -16,6 +17,7 @@ type PolarChartBaseProps = { - transformState?: ChartTransformState; - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -+ canvasProps?: ChartCanvasProps; - }; - type PolarChartProps, LabelKey extends StringKeyOf>, ValueKey extends StringKeyOf>, ColorKey extends StringKeyOf>> = { - data: RawData[]; -diff --git a/node_modules/victory-native/dist/polar/PolarChart.js b/node_modules/victory-native/dist/polar/PolarChart.js -index d6416d0..c990be2 100644 ---- a/node_modules/victory-native/dist/polar/PolarChart.js -+++ b/node_modules/victory-native/dist/polar/PolarChart.js -@@ -34,7 +34,7 @@ const GestureHandler_1 = require("../shared/GestureHandler"); - const ChartWrapper_1 = require("../shared/ChartWrapper"); - const useChartCanvasSize_1 = require("../shared/useChartCanvasSize"); - const PolarChartBase = (props) => { -- const { containerStyle, canvasStyle, children, onLayout, hasMeasuredLayoutSize, canvasSize, transformState, isHeadless, explicitSize, } = props; -+ const { containerStyle, canvasStyle, children, onLayout, hasMeasuredLayoutSize, canvasSize, transformState, isHeadless, explicitSize, canvasProps, } = props; - const { width, height } = canvasSize; - const Bridge = (0, its_fine_1.useContextBridge)(); - let composed = react_native_gesture_handler_1.Gesture.Race(); -@@ -44,7 +44,7 @@ const PolarChartBase = (props) => { - const chartContent = ( - {hasMeasuredLayoutSize && children} - ); -- return ( {content}} gestureOverlay={isHeadless ? undefined : ()}/>); -+ return ( {content}} gestureOverlay={isHeadless ? undefined : ()}/>); - }; - const PolarChart = (props) => { - const { data, labelKey, colorKey, valueKey } = props; -diff --git a/node_modules/victory-native/dist/shared/ChartWrapper.d.ts b/node_modules/victory-native/dist/shared/ChartWrapper.d.ts -index 0688ab2..a878191 100644 ---- a/node_modules/victory-native/dist/shared/ChartWrapper.d.ts -+++ b/node_modules/victory-native/dist/shared/ChartWrapper.d.ts -@@ -1,8 +1,9 @@ - import * as React from "react"; - import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; --import { type CanvasRef } from "@shopify/react-native-skia"; -+import { type CanvasProps, type CanvasRef } from "@shopify/react-native-skia"; - import { type ChartExplicitSize } from "./ChartExplicitSize"; - import { type ChartCanvasSize } from "./chartCanvasSizeUtils"; -+export type ChartCanvasProps = Omit; - type ChartWrapperProps = { - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -@@ -15,6 +16,7 @@ type ChartWrapperProps = { - containerStyle?: StyleProp; - canvasStyle?: StyleProp; - wrapCanvasContent?: (content: React.ReactNode) => React.ReactNode; -+ canvasProps?: ChartCanvasProps; - }; --export declare function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, }: ChartWrapperProps): React.JSX.Element; -+export declare function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, canvasProps, }: ChartWrapperProps): React.JSX.Element; - export {}; -diff --git a/node_modules/victory-native/dist/shared/ChartWrapper.js b/node_modules/victory-native/dist/shared/ChartWrapper.js -index 5aca3cc..8d9f9ee 100644 ---- a/node_modules/victory-native/dist/shared/ChartWrapper.js -+++ b/node_modules/victory-native/dist/shared/ChartWrapper.js -@@ -28,7 +28,7 @@ const React = __importStar(require("react")); - const react_native_1 = require("react-native"); - const react_native_skia_1 = require("@shopify/react-native-skia"); - const react_native_gesture_handler_1 = require("react-native-gesture-handler"); --function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, }) { -+function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, canvasProps, }) { - if (isHeadless) { - return {chartContent}; - } -@@ -42,7 +42,7 @@ function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSiz - : null, - containerStyle, - ]} onLayout={explicitSize ? undefined : onLayout}> -- - >; -+ canvasProps?: ChartCanvasProps; - } & ChartLayoutModeProps; - - export function CartesianChart< -@@ -188,6 +189,7 @@ function CartesianChartContent< - ref, - explicitSize, - headless, -+ canvasProps, - }: CartesianChartProps) { - const { size, hasMeasuredLayoutSize, onLayout, isHeadless } = - useChartCanvasSize({ explicitSize, headless }); -@@ -773,6 +775,7 @@ function CartesianChartContent< - canvasRef={canvasRef} - chartContent={chartContent} - gestureOverlay={gestureOverlay} -+ canvasProps={canvasProps} - /> - ); - } -diff --git a/node_modules/victory-native/src/polar/PolarChart.tsx b/node_modules/victory-native/src/polar/PolarChart.tsx -index 4229d7b..7665633 100644 ---- a/node_modules/victory-native/src/polar/PolarChart.tsx -+++ b/node_modules/victory-native/src/polar/PolarChart.tsx -@@ -22,7 +22,7 @@ import { - import { GestureHandler } from "../shared/GestureHandler"; - import { type ChartExplicitSize } from "../shared/ChartExplicitSize"; - import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; --import { ChartWrapper } from "../shared/ChartWrapper"; -+import { ChartWrapper, type ChartCanvasProps } from "../shared/ChartWrapper"; - import { useChartCanvasSize } from "../shared/useChartCanvasSize"; - - type PolarChartBaseProps = { -@@ -34,6 +34,7 @@ type PolarChartBaseProps = { - transformState?: ChartTransformState; - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -+ canvasProps?: ChartCanvasProps; - }; - - const PolarChartBase = ( -@@ -49,6 +50,7 @@ const PolarChartBase = ( - transformState, - isHeadless, - explicitSize, -+ canvasProps, - } = props; - const { width, height } = canvasSize; - const Bridge: ContextBridge = useContextBridge(); -@@ -77,6 +79,7 @@ const PolarChartBase = ( - canvasSize={canvasSize} - containerStyle={containerStyle} - canvasStyle={canvasStyle} -+ canvasProps={canvasProps} - chartContent={chartContent} - wrapCanvasContent={ - isHeadless ? undefined : (content) => {content} -diff --git a/node_modules/victory-native/src/shared/ChartWrapper.tsx b/node_modules/victory-native/src/shared/ChartWrapper.tsx -index ab149e9..b907381 100644 ---- a/node_modules/victory-native/src/shared/ChartWrapper.tsx -+++ b/node_modules/victory-native/src/shared/ChartWrapper.tsx -@@ -6,11 +6,16 @@ import { - type StyleProp, - type ViewStyle, - } from "react-native"; --import { Canvas, Group, type CanvasRef } from "@shopify/react-native-skia"; -+import { Canvas, Group, type CanvasProps, type CanvasRef } from "@shopify/react-native-skia"; - import { GestureHandlerRootView } from "react-native-gesture-handler"; - import { type ChartExplicitSize } from "./ChartExplicitSize"; - import { type ChartCanvasSize } from "./chartCanvasSizeUtils"; - -+export type ChartCanvasProps = Omit< -+ CanvasProps, -+ "children" | "ref" | "style" | "onLayout" -+>; -+ - type ChartWrapperProps = { - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -@@ -23,6 +28,7 @@ type ChartWrapperProps = { - containerStyle?: StyleProp; - canvasStyle?: StyleProp; - wrapCanvasContent?: (content: React.ReactNode) => React.ReactNode; -+ canvasProps?: ChartCanvasProps; - }; - - export function ChartWrapper({ -@@ -37,6 +43,7 @@ export function ChartWrapper({ - containerStyle, - canvasStyle, - wrapCanvasContent, -+ canvasProps, - }: ChartWrapperProps) { - if (isHeadless) { - return {chartContent}; -@@ -58,6 +65,7 @@ export function ChartWrapper({ - onLayout={explicitSize ? undefined : onLayout} - > - void; ++ isHorizontal?: boolean; + }; + export declare function BarGroup({ betweenGroupPadding, withinGroupPadding, chartBounds, roundedCorners, children, onBarSizeChange, barWidth: customBarWidth, barCount, }: BarGroupProps): React.JSX.Element | null; + export declare namespace BarGroup { +diff --git a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts +index 63b69bc..75641b1 100644 +--- a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts ++++ b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts +@@ -1,6 +1,6 @@ + import type { ChartBounds, PointsArray } from "../../types"; + import { type RoundedCorners } from "../../utils/createRoundedRectPath"; +-export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number) => { ++export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number, isHorizontal?: boolean) => { + barWidth: number; + groupWidth: number; + gapWidth: number; +diff --git a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx +index f188ddc..526c5a7 100644 +--- a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx ++++ b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx +@@ -20,6 +20,7 @@ type BarGroupProps = { + groupWidth: number; + gapWidth: number; + }) => void; ++ isHorizontal?: boolean; + }; + + export function BarGroup({ +@@ -31,6 +32,7 @@ export function BarGroup({ + onBarSizeChange, + barWidth: customBarWidth, + barCount, ++ isHorizontal, + }: BarGroupProps) { + // Collect the bar props + const bars = [] as BarGroupBarProps[]; +@@ -49,6 +51,7 @@ export function BarGroup({ + roundedCorners, + customBarWidth, + barCount, ++ isHorizontal, + ); + + // Handle bar size change +diff --git a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts +index fafab1a..07da62f 100644 +--- a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts ++++ b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts +@@ -17,10 +17,11 @@ export const useBarGroupPaths = ( + roundedCorners?: RoundedCorners, + customBarWidth?: number, + barCount?: number, ++ isHorizontal?: boolean, + ) => { + const numGroups = points[0]?.length || 0; + +- const { yScale } = useCartesianChartContext(); ++ const { xScale, yScale } = useCartesianChartContext(); + + const { barWidth, groupWidth, gapWidth } = getBarGroupDimensions({ + chartBounds, +@@ -30,9 +31,11 @@ export const useBarGroupPaths = ( + barsPerGroup: points.length, + customBarWidth, + barCount, ++ isHorizontal, + }); + + const paths = React.useMemo(() => { ++ const baselineX = xScale(0); + const baselineY = yScale(0); + + return points.map((pointSet, i) => { +@@ -40,11 +43,13 @@ export const useBarGroupPaths = ( + pointSet.forEach((point) => { + const rect = getVerticalBarGroupRect({ + point, ++ baselineX, + baselineY, + barWidth, + groupWidth, + gapWidth, + barIndex: i, ++ isHorizontal, + }); + if (!rect) return; + +@@ -54,7 +59,7 @@ export const useBarGroupPaths = ( + rect.y, + rect.width, + rect.height, +- roundedCorners, ++ isHorizontal ? {topLeft: roundedCorners.bottomLeft, topRight: roundedCorners.topLeft, bottomLeft: roundedCorners.bottomRight, bottomRight: roundedCorners.topRight} : roundedCorners, + Number(point.yValue), + ); + builder.addRRect(nonUniformRoundedRect); +@@ -66,7 +71,7 @@ export const useBarGroupPaths = ( + }); + return builder.build(); + }); +- }, [barWidth, gapWidth, groupWidth, points, roundedCorners, yScale]); ++ }, [barWidth, gapWidth, groupWidth, points, roundedCorners, xScale, yScale, isHorizontal]); + + return { barWidth, groupWidth, gapWidth, paths }; + }; +diff --git a/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts b/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts +index 8ff9f1f..1e6f160 100644 +--- a/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts ++++ b/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts +@@ -2,13 +2,14 @@ import type { ChartBounds } from "../../types"; + import { getBarGroupDimensionsForAxis } from "./getBarGroupDimensionsForAxis"; + + export type GetBarGroupDimensionsArgs = { +- chartBounds: Pick; ++ chartBounds: ChartBounds; + betweenGroupPadding: number; + withinGroupPadding: number; + groupCount: number; + barsPerGroup: number; + customBarWidth?: number; + barCount?: number; ++ isHorizontal?: boolean, + }; + + export const getBarGroupDimensions = ({ +@@ -19,10 +20,11 @@ export const getBarGroupDimensions = ({ + barsPerGroup, + customBarWidth, + barCount, ++ isHorizontal, + }: GetBarGroupDimensionsArgs) => { + return getBarGroupDimensionsForAxis({ +- axisStart: chartBounds.left, +- axisEnd: chartBounds.right, ++ axisStart: isHorizontal ? chartBounds.bottom : chartBounds.left, ++ axisEnd: isHorizontal ? chartBounds.top : chartBounds.right, + betweenGroupPadding, + withinGroupPadding, + groupCount, +diff --git a/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts b/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts +index 37b9467..52f363d 100644 +--- a/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts ++++ b/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts +@@ -6,18 +6,22 @@ type Point = PointsArray[number]; + + export const getVerticalBarGroupRect = ({ + point, ++ baselineX, + baselineY, + barWidth, + groupWidth, + gapWidth, + barIndex, ++ isHorizontal, + }: { + point: Point; ++ baselineX: number; + baselineY: number; + barWidth: number; + groupWidth: number; + gapWidth: number; + barIndex: number; ++ isHorizontal?: boolean; + }): VerticalBarRect | null => { + const { x, y } = point; + +@@ -25,8 +29,19 @@ export const getVerticalBarGroupRect = ({ + return null; + } + ++ const offset = getBarGroupOffset({ groupWidth, barWidth, gapWidth, barIndex }); ++ ++ if (isHorizontal) { ++ return { ++ x: x, ++ y: y + offset, ++ width: baselineX - x, ++ height: barWidth, ++ } ++ } ++ + return { +- x: x + getBarGroupOffset({ groupWidth, barWidth, gapWidth, barIndex }), ++ x: x + offset, + y, + width: barWidth, + height: baselineY - y, diff --git a/patches/victory-native/victory-native+42.0.1+002+tickCount-4121.patch b/patches/victory-native/victory-native+42.0.1+002+tickCount-4121.patch new file mode 100644 index 000000000000..2df5629281c1 --- /dev/null +++ b/patches/victory-native/victory-native+42.0.1+002+tickCount-4121.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/victory-native/src/utils/tickHelpers.ts b/node_modules/victory-native/src/utils/tickHelpers.ts +index 100fb89..ea9fdca 100644 +--- a/node_modules/victory-native/src/utils/tickHelpers.ts ++++ b/node_modules/victory-native/src/utils/tickHelpers.ts +@@ -17,7 +17,7 @@ export const downsampleTicks = (tickValues: number[], tickCount: number) => { + } + + if (tickCount <= 0) { +- return []; ++ return tickValues; + } + + if (!Array.isArray(tickValues) || tickValues.length <= tickCount) { diff --git a/rock.config.mjs b/rock.config.mjs index b6f00dbe3970..42b54180ee2d 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -22,8 +22,7 @@ export default { bucket: 'ad-hoc-expensify-cash', region: 'us-east-1', acl: 'public-read', - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + // Credentials come from the AWS SDK default chain so AWS_SESSION_TOKEN from OIDC sessions is used publicAccess: isPublicAccess, }), bundler: useMetro ? pluginMetro() : pluginRepack(), diff --git a/server/libs/bundleWithRnStubs.ts b/server/libs/bundleWithRnStubs.ts new file mode 100644 index 000000000000..448daf25f504 --- /dev/null +++ b/server/libs/bundleWithRnStubs.ts @@ -0,0 +1,33 @@ +import assertBuildSuccess from '@server/libs/assertBuildSuccess'; +import createRnStubPlugin from '@server/plugins/rnStubPlugin'; +import {basename, dirname, join, resolve} from 'node:path'; + +type BundleWithRnStubsOptions = { + packageRoot: string; + entrypoint: string; + outFile: string; +}; + +/** Every output is written beside outFile, because the bundle loads CanvasKit's wasm from beside itself. */ +async function bundleWithRnStubs({packageRoot, entrypoint, outFile}: BundleWithRnStubsOptions): Promise { + const buildResult = await Bun.build({ + entrypoints: [entrypoint], + target: 'bun', + packages: 'bundle', + conditions: ['react-native'], + tsconfig: join(packageRoot, 'tsconfig.json'), + plugins: [createRnStubPlugin(resolve(packageRoot, '../stubs'))], + }); + + assertBuildSuccess(buildResult, `Failed to bundle ${entrypoint}`); + + if (buildResult.outputs.length === 0) { + throw new Error(`Bundled output of ${entrypoint} is missing`); + } + + for (const output of buildResult.outputs) { + await Bun.write(output.kind === 'entry-point' ? outFile : join(dirname(outFile), basename(output.path)), output); + } +} + +export default bundleWithRnStubs; diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts index 28e91ab32ffd..f71b01ec13a8 100644 --- a/server/victory-chart-renderer/scripts/dev.ts +++ b/server/victory-chart-renderer/scripts/dev.ts @@ -2,52 +2,25 @@ * Development runner for the victory-chart-renderer CLI. * * victory-native is hoisted to the App repo root, so a bare `bun run src/cli.tsx` would - * resolve real react-native native sources that Bun cannot bundle. This script bundles - * src/cli.tsx with rnStubPlugin (redirecting RN imports to server/stubs), writes the - * bundle to .dev/cli.js, then executes it under the current Bun binary. + * resolve real react-native native sources that Bun cannot bundle. * * process.argv entries after this script are forwarded unchanged to the bundled CLI. * The child process cwd is the App repository root (two levels above this package). * * For a standalone executable instead of a Bun-run bundle, use build.ts. */ -import assertBuildSuccess from '@server/libs/assertBuildSuccess'; -import createRnStubPlugin from '@server/plugins/rnStubPlugin'; +import bundleWithRnStubs from '@server/libs/bundleWithRnStubs'; import {spawnSync} from 'node:child_process'; import {mkdirSync} from 'node:fs'; -import {basename, join, resolve} from 'node:path'; +import {join, resolve} from 'node:path'; const packageRoot = resolve(import.meta.dir, '..'); const repoRoot = resolve(packageRoot, '../..'); -const stubRoot = resolve(packageRoot, '../stubs'); -const tsconfigPath = join(packageRoot, 'tsconfig.json'); const outFile = resolve(packageRoot, '.dev/cli.js'); mkdirSync(join(packageRoot, '.dev'), {recursive: true}); -const buildResult = await Bun.build({ - entrypoints: [resolve(packageRoot, 'src/bootstrap.tsx')], - target: 'bun', - packages: 'bundle', - conditions: ['react-native'], - tsconfig: tsconfigPath, - plugins: [createRnStubPlugin(stubRoot)], -}); - -assertBuildSuccess(buildResult, 'Failed to bundle victory-chart-renderer CLI'); - -if (buildResult.outputs.length === 0) { - throw new Error('Bundled CLI output is missing'); -} - -const devDir = join(packageRoot, '.dev'); - -for (const output of buildResult.outputs) { - const outputName = basename(output.path); - const destName = output.kind === 'entry-point' ? 'cli.js' : outputName; - - await Bun.write(join(devDir, destName), output); -} +await bundleWithRnStubs({packageRoot, entrypoint: resolve(packageRoot, 'src/bootstrap.tsx'), outFile}); const runResult = spawnSync(process.execPath, [outFile, ...process.argv.slice(2)], { cwd: repoRoot, diff --git a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png index a934e07abadb..3d1eb1a17626 100644 Binary files a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png and b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png differ diff --git a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png index 7fbdc5cd98bf..088854b04d90 100644 Binary files a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png and b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png differ diff --git a/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts b/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts new file mode 100644 index 000000000000..7a889732edd3 --- /dev/null +++ b/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts @@ -0,0 +1,70 @@ +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; + +import type {ChartBounds} from 'victory-native'; + +import bundleWithRnStubs from '@server/libs/bundleWithRnStubs'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {packageRoot} from './testUtils'; + +type PlotBounds = Pick; + +type ProbeOutput = { + predicted: PlotBounds; + reported: PlotBounds; +}; + +const runDir = mkdtempSync(join(tmpdir(), 'vcr-plot-bounds-')); +const bundlePath = join(runDir, 'plotBoundsProbe.js'); + +function hasPlotBounds(value: unknown): value is PlotBounds { + return typeof value === 'object' && value !== null && 'left' in value && typeof value.left === 'number' && 'right' in value && typeof value.right === 'number'; +} + +function parseProbeOutput(raw: string): ProbeOutput { + const parsed: unknown = JSON.parse(raw); + + if (typeof parsed !== 'object' || parsed === null || !('predicted' in parsed) || !('reported' in parsed) || !hasPlotBounds(parsed.predicted) || !hasPlotBounds(parsed.reported)) { + throw new Error(`Plot bounds probe printed unexpected output:\n${raw}`); + } + + return {predicted: parsed.predicted, reported: parsed.reported}; +} + +let probeOutput: ProbeOutput; + +describe('cartesian plot bounds', () => { + beforeAll(async () => { + await bundleWithRnStubs({ + packageRoot, + entrypoint: fileURLToPath(import.meta.resolve('./probes/plotBoundsProbe.tsx')), + outFile: bundlePath, + }); + + const runResult = spawnSync(process.execPath, [bundlePath], { + cwd: resolve(packageRoot, '../..'), + encoding: 'utf8', + }); + + if (runResult.status !== 0) { + throw new Error(`Plot bounds probe failed:\n${runResult.stderr}\n${runResult.stdout}`); + } + + probeOutput = parseProbeOutput(runResult.stdout); + }, 120_000); + + afterAll(() => { + rmSync(runDir, {recursive: true, force: true}); + }); + + test('should predict the plot bounds victory-native reports', () => { + // Given a real chart laid out the way the vertical bar chart is, whose padding and axes the line chart shares, drawn headless by the probe + // When its reported plot bounds are compared with the ones predicted from its width + // Then they match, so a victory-native upgrade that moves the plot fails here instead of misplacing labels + expect(probeOutput.predicted).toEqual(probeOutput.reported); + }); +}); diff --git a/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx b/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx new file mode 100644 index 000000000000..f4fd7087213d --- /dev/null +++ b/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx @@ -0,0 +1,9 @@ +/* + * Runs as a bundled child process rather than inside the test process, because victory-native resolves + * react-native, which only loads through the stubs rnStubPlugin substitutes at bundle time. + */ +import initSkiaForCli from '../../src/initSkiaForCli'; + +// Skia must be initialized before the chart module and its transitive imports are evaluated. +await initSkiaForCli(); +await import('./plotBoundsProbeMain'); diff --git a/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx b/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx new file mode 100644 index 000000000000..7fc9af2e003c --- /dev/null +++ b/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx @@ -0,0 +1,63 @@ +import {VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; +import {getCartesianPlotBounds} from '@components/Charts/utils'; +import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; + +import type {ChartBounds} from 'victory-native'; + +import {drawOffscreen, makeOffscreenSurface} from '@shopify/react-native-skia/lib/module/headless'; +import {CartesianChart} from 'victory-native'; + +const CHART_WIDTH = 400; + +const Y_AXIS_LABEL_WIDTH = 37; + +const CHART_PADDING_RIGHT = Y_AXIS_LABEL_WIDTH + GLYPH_PADDING; + +const DATA = [ + {x: 0, y: 120}, + {x: 1, y: 80}, + {x: 2, y: 45}, +]; + +const captured: {bounds: ChartBounds | null} = {bounds: null}; + +const chartElement = ( + + {({chartBounds}) => { + // The bounds reach onChartBoundsChange from an effect, which the offscreen draw never flushes, so they + // are read here instead. Both come from the same value the chart computes for its own layout. + captured.bounds = chartBounds; + return null; + }} + +); + +using surface = makeOffscreenSurface(CHART_WIDTH, CHART_CONTENT_MIN_HEIGHT); +using image = await drawOffscreen(surface, chartElement); + +if (!image) { + throw new Error('Skia failed to draw the probe chart'); +} + +if (!captured.bounds) { + throw new Error('CartesianChart never reported its bounds'); +} + +const {left, right} = getCartesianPlotBounds(CHART_WIDTH, CHART_PADDING_RIGHT); + +console.log(JSON.stringify({predicted: {left, right}, reported: {left: captured.bounds.left, right: captured.bounds.right}})); diff --git a/src/App.tsx b/src/App.tsx index 8a1ed222ff7b..5bf33e75d410 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import '../wdyr'; import {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import {AvatarTooltipsProvider} from './components/Avatar/tooltips/AvatarTooltipContext'; +import {dismissPinnedChartTooltip} from './components/Charts/utils/pinnedChartTooltip'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import {CurrencyListContextProvider} from './components/CurrencyListContextProvider'; @@ -97,6 +98,7 @@ function App() { |\*|"|:|#|\?|\\|\|/g, - ENCODE_PERCENT_CHARACTER: /%(25)+/g, INVISIBLE_CHARACTERS_GROUPS: /[\p{C}\p{Z}]/gu, OTHER_INVISIBLE_CHARACTERS: /[\u3164\u115f\u1160\uffa0\u2800]/g, SHORT_MENTION_HTML: /(.*?)<\/mention-short>/g, @@ -6773,6 +6797,9 @@ const CONST = { // that should be removed from the copied contents in the getHTMLOfSelection() method SELECTION_SCRAPER_HIDDEN_ELEMENT: 'selection-scrapper-hidden-element', INNER_BOX_SHADOW_ELEMENT: 'inner-box-shadow-element', + COPYABLE_TEXT_ELEMENT: 'copyable-text', + COPYABLE_ROW_ELEMENT: 'copyable-row', + COPYABLE_ROW_CONTINUATION_ELEMENT: 'copyable-row-continuation', MODERATION: { MODERATOR_DECISION_PENDING: 'pending', MODERATOR_DECISION_PENDING_HIDE: 'pendingHide', @@ -9542,11 +9569,14 @@ const CONST = { /** Narrowest drag width. Low enough to hide most content, but wide enough to keep the column's edge reachable. */ MIN_WIDTH: 48, - /** Width of the invisible drag strip centred on a column's edge. */ - HANDLE_HIT_WIDTH: 12, - - /** Width of the line shown while a column's edge is dragged. */ + /** Width of the line shown while a column's edge is dragged, and of the grip shown while its heading is hovered. */ INDICATOR_WIDTH: 2, + + /** Share of the heading's height the grip spans, centred vertically. */ + GRIP_HEIGHT_RATIO: 0.8, + + /** How long, in ms, the grip takes to stretch into the line once its edge is pressed. */ + INDICATOR_MORPH_DURATION: 150, }, }, @@ -9839,6 +9869,11 @@ const CONST = { EXPORT_FILE: 'MoreMenu-ExportFile', DOWNLOAD_PDF: 'MoreMenu-DownloadPDF', DOWNLOAD_RECEIPTS: 'MoreMenu-DownloadReceipts', + SHARE: 'MoreMenu-Share', + SHARE_REPORT: 'MoreMenu-ShareReport', + SHARE_CODE: 'MoreMenu-ShareCode', + REFER_FRIEND: 'MoreMenu-ReferFriend', + COPY_URL: 'MoreMenu-CopyURL', PRINT: 'MoreMenu-Print', CLOSE_PDF_MODAL: 'MoreMenu-ClosePDFModal', SUBMIT: 'MoreMenu-Submit', @@ -9887,6 +9922,7 @@ const CONST = { HOURS_FIELD: 'RequestConfirmationList-HoursField', TIME_RATE_FIELD: 'RequestConfirmationList-TimeRateField', CATEGORY_FIELD: 'RequestConfirmationList-CategoryField', + ADD_CATEGORY_BUTTON: 'RequestConfirmationList-AddCategoryButton', DATE_FIELD: 'RequestConfirmationList-DateField', TAG_FIELD: 'RequestConfirmationList-TagField', TAX_RATE_FIELD: 'RequestConfirmationList-TaxRateField', @@ -9965,6 +10001,9 @@ const CONST = { GETTING_STARTED_ROW: 'HomePage-GettingStartedRow', GETTING_STARTED_FOOTER_HELP: 'HomePage-GettingStartedFooterHelp', }, + CATEGORY_PICKER_MODAL: { + BACK_BUTTON: 'CategoryPickerModal-BackButton', + }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', MONTH_PICKER: 'CalendarPicker-MonthPicker', @@ -10106,6 +10145,7 @@ const CONST = { INITIAL: { PROFILE: 'WorkspaceInitial-Profile', MEMBERS: 'WorkspaceInitial-Members', + AGENTS: 'WorkspaceInitial-Agents', ROOMS: 'WorkspaceInitial-Rooms', REPORTS: 'WorkspaceInitial-Reports', ACCOUNTING: 'WorkspaceInitial-Accounting', @@ -10135,6 +10175,10 @@ const CONST = { DESCRIPTION: 'WorkspaceOverview-Description', CURRENCY: 'WorkspaceOverview-Currency', ADDRESS: 'WorkspaceOverview-Address', + OFFICE_LOCATION: 'WorkspaceOverview-OfficeLocation', + COMPANY_ADDRESS_OFFICE_LOCATION: 'WorkspaceOverview-CompanyAddressOfficeLocation', + ADD_OFFICE_LOCATION: 'WorkspaceOverview-AddOfficeLocation', + DELETE_OFFICE_LOCATION: 'WorkspaceOverview-DeleteOfficeLocation', PLAN_TYPE: 'WorkspaceOverview-PlanType', SHARE: 'WorkspaceOverview-Share', CUSTOM_RULES: 'WorkspaceOverview-CustomRules', @@ -10350,6 +10394,9 @@ const CONST = { UPLOAD_FILE: { REMOVE_BUTTON: 'UploadFile-RemoveButton', }, + LIST_FIELD: { + REMOVE_BUTTON: 'ListField-RemoveButton', + }, BANK_ACCOUNT: { DATA_SECURE_LINK: 'BankAccount-DataSecureLink', }, @@ -10394,6 +10441,7 @@ const CONST = { DISPLAY_NAME: 'SettingsProfile-DisplayName', CONTACT_METHODS: 'SettingsProfile-ContactMethods', STATUS: 'SettingsProfile-Status', + VACATION_DELEGATE: 'SettingsProfile-VacationDelegate', PRONOUNS: 'SettingsProfile-Pronouns', TIMEZONE: 'SettingsProfile-Timezone', SHARE_CODE: 'SettingsProfile-ShareCode', @@ -10529,6 +10577,11 @@ const CONST = { }, AGENTS: { + OWNER_TYPE: { + PERSON: 'person', + WORKSPACE: 'workspace', + DOMAIN: 'domain', + }, BULK_ACTION_TYPES: { DELETE: 'delete', }, diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 704fa8b8ed02..4bc68f46ac72 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -141,6 +141,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { conciergeReportID, introSelected, guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, reportNameValuePairsRef.current, conciergePersonalDetailsRef.current, @@ -188,6 +189,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { conciergeReportID, introSelected, guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, reportNameValuePairsRef.current, conciergePersonalDetailsRef.current, diff --git a/src/DelegateAccessHandler.tsx b/src/DelegateAccessHandler.tsx index 22eb3734f3f2..697a05ed6c4f 100644 --- a/src/DelegateAccessHandler.tsx +++ b/src/DelegateAccessHandler.tsx @@ -3,14 +3,14 @@ import {useEffect, useRef} from 'react'; import CONST from './CONST'; import useIsLoadingAppRecovery from './hooks/useIsLoadingAppRecovery'; import useOnyx from './hooks/useOnyx'; -import {disconnect} from './libs/actions/Delegate'; +import {disconnect, resumeSelfRemovalAsDelegate} from './libs/actions/Delegate'; import Log from './libs/Log'; import ONYXKEYS from './ONYXKEYS'; -import {accountIDSelector, emailSelector} from './selectors/Session'; +import {accountIDSelector, emailSelector, isDelegateSessionSelector} from './selectors/Session'; /** * Component that does not render anything but isolates delegate-access–related Onyx subscriptions - * (account, stashedCredentials, stashedSession, hasLoadedApp, isLoadingApp) + * (account, stashedCredentials, stashedSession, pendingSelfCopilotRemoval, hasLoadedApp, isLoadingApp) * from the root Expensify component so that changes to these keys do not * re-render the entire navigation tree. */ @@ -24,6 +24,8 @@ function DelegateAccessHandler() { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [sessionAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); + const [isDelegateSession] = useOnyx(ONYXKEYS.SESSION, {selector: isDelegateSessionSelector}); + const [pendingSelfCopilotRemoval] = useOnyx(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL); useIsLoadingAppRecovery(); @@ -38,6 +40,14 @@ function DelegateAccessHandler() { disconnect({stashedCredentials, stashedSession}); }, [account?.delegatedAccess?.delegates, account?.delegatedAccess?.delegate, stashedCredentials, stashedSession]); + // Finish a self-removal from a delegator's copilots that a kill interrupted + useEffect(() => { + if (!pendingSelfCopilotRemoval || !sessionAccountID) { + return; + } + resumeSelfRemovalAsDelegate({delegatorEmail: pendingSelfCopilotRemoval, isDelegateSession: !!isDelegateSession}); + }, [pendingSelfCopilotRemoval, isDelegateSession, sessionAccountID]); + // Log delegate mismatch after the app has loaded useEffect(() => { if (hasLoggedDelegateMismatchRef.current || !hasLoadedApp || isLoadingApp) { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 67aed9f0ba9c..3e1c1496626d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -176,6 +176,9 @@ const ONYXKEYS = { SESSION: 'session', STASHED_SESSION: 'stashedSession', + /** Delegator the acting copilot is removing themselves from, kept until the removal is queued so a kill can't lose it */ + PENDING_SELF_COPILOT_REMOVAL: 'pendingSelfCopilotRemoval', + BETAS: 'betas', BETA_CONFIGURATION: 'betaConfiguration', @@ -828,6 +831,9 @@ const ONYXKEYS = { /** Signals the UI to show the Enable Global Reimbursements modal when a pay attempt fails because the workspace USD VBBA is not set up on Corpay */ RAM_ONLY_CORPAY_PAY_MODAL: 'corpayPayModal', + /** Answers to sensitive DynamicForm fields, such as SSNs, carried between the pages of one visit to the form instead of saved to its draft */ + RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS: 'dynamicFormSensitiveAnswers', + /** Stores the information about the state of side panel */ NVP_SIDE_PANEL: 'nvp_sidePanel', @@ -1048,6 +1054,7 @@ const ONYXKEYS = { // Shared NVPs /** Collection of agent prompts keyed by agent accountID, representing agents owned by the current user */ + /** Deprecated. Use agent_ instead */ SHARED_NVP_AGENT_PROMPT: 'sharedNVP_agentPrompt_', /** Collection of objects where each object represents the owner of the workspace that is past due billing AND the user is a member of. */ @@ -1216,6 +1223,8 @@ const ONYXKEYS = { COLLECT_DEPOSIT_ACCOUNT_FORM_DRAFT: 'collectDepositAccountFormDraft', INTERNATIONAL_BANK_ACCOUNT_FORM: 'internationalBankAccountForm', INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT: 'internationalBankAccountFormDraft', + DYNAMIC_FORM_LIST_ITEM_FORM: 'dynamicFormListItemForm', + DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT: 'dynamicFormListItemFormDraft', NEW_ROOM_FORM: 'newRoomForm', NEW_ROOM_FORM_DRAFT: 'newRoomFormDraft', NEW_TASK_FORM: 'newTaskForm', @@ -1248,6 +1257,8 @@ const ONYXKEYS = { SETTINGS_STATUS_SET_FORM_DRAFT: 'settingsStatusSetFormDraft', SETTINGS_STATUS_CLEAR_DATE_FORM: 'settingsStatusClearDateForm', SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT: 'settingsStatusClearDateFormDraft', + VACATION_DELEGATE_FORM: 'vacationDelegateForm', + VACATION_DELEGATE_FORM_DRAFT: 'vacationDelegateFormDraft', CHANGE_BILLING_CURRENCY_FORM: 'billingCurrencyForm', CHANGE_BILLING_CURRENCY_FORM_DRAFT: 'billingCurrencyFormDraft', PRIVATE_NOTES_FORM: 'privateNotesForm', @@ -1416,6 +1427,8 @@ const ONYXKEYS = { NEW_RULE_PROMPT_FORM_DRAFT: 'newRulePromptFormDraft', EDIT_AGENT_RULE_FORM: 'editAgentRuleForm', EDIT_AGENT_RULE_FORM_DRAFT: 'editAgentRuleFormDraft', + WORKSPACE_OFFICE_LOCATION_FORM: 'workspaceOfficeLocationForm', + WORKSPACE_OFFICE_LOCATION_FORM_DRAFT: 'workspaceOfficeLocationFormDraft', RILLET_CREDENTIALS_FORM: 'rilletCredentialsForm', RILLET_CREDENTIALS_FORM_DRAFT: 'rilletCredentialsFormDraft', DUALENTRY_CREDENTIALS_FORM: 'dualEntryCredentialsForm', @@ -1490,6 +1503,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm; + [ONYXKEYS.FORMS.VACATION_DELEGATE_FORM]: FormTypes.VacationDelegateForm; [ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM]: FormTypes.ChangeBillingCurrencyForm; [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm; [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm; @@ -1555,6 +1569,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.MERGE_ACCOUNT_DETAILS_FORM]: FormTypes.MergeAccountDetailsForm; [ONYXKEYS.FORMS.COLLECT_DEPOSIT_ACCOUNT_FORM]: FormTypes.CollectDepositAccountForm; [ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM]: FormTypes.InternationalBankAccountForm; + [ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM]: FormTypes.DynamicFormListItemForm; [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; [ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS]: FormTypes.EnableGlobalReimbursementsForm; [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM]: FormTypes.SearchEditMultipleDescriptionForm; @@ -1581,6 +1596,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM]: FormTypes.AddAgentRuleForm; [ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM]: FormTypes.NewRulePromptForm; [ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM]: FormTypes.EditAgentRuleForm; + [ONYXKEYS.FORMS.WORKSPACE_OFFICE_LOCATION_FORM]: FormTypes.WorkspaceOfficeLocationForm; [ONYXKEYS.FORMS.RILLET_CREDENTIALS_FORM]: FormTypes.RilletCredentialsForm; [ONYXKEYS.FORMS.DUALENTRY_CREDENTIALS_FORM]: FormTypes.DualEntryCredentialsForm; [ONYXKEYS.FORMS.CAMPFIRE_CREDENTIALS_FORM]: FormTypes.CampfireCredentialsForm; @@ -1592,7 +1608,7 @@ type OnyxFormDraftValuesMapping = { }; type OnyxCollectionValuesMapping = { - [ONYXKEYS.COLLECTION.AGENT]: OnyxTypes.AgentPrompt; + [ONYXKEYS.COLLECTION.AGENT]: OnyxTypes.Agent; [ONYXKEYS.COLLECTION.ATTACHMENT]: OnyxTypes.Attachment; [ONYXKEYS.COLLECTION.DOMAIN]: OnyxTypes.Domain; [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; @@ -1658,7 +1674,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_MERGE_ATS_INITIAL_SYNC_MODAL_SHOWN]: boolean; [ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults; [ONYXKEYS.COLLECTION.INSIGHTS]: OnyxTypes.InsightsDashboard; - [ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT]: OnyxTypes.AgentPrompt; + [ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT]: OnyxTypes.Agent; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER]: OnyxTypes.CardFeeds; [ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; @@ -1750,6 +1766,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; + [ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL]: string; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; [ONYXKEYS.AGENT_RULE_SUGGESTIONS]: OnyxTypes.SuggestedAgentRule[]; [ONYXKEYS.AGENT_TEMPLATES]: OnyxTypes.SuggestedAgent[]; @@ -1962,6 +1979,7 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string; [ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning; [ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL]: OnyxTypes.CorpayPayModal; + [ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS]: OnyxTypes.DynamicFormSensitiveAnswers; [ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined; [ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined; [ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a1f970ae15ac..aaa64657c8d1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -707,8 +707,8 @@ const DYNAMIC_ROUTES = { path: 'avatar-crop', entryScreens: [ SCREENS.SETTINGS.PROFILE.AVATAR, - SCREENS.SETTINGS.AGENTS.EDIT_AVATAR, - SCREENS.SETTINGS.AGENTS.ADD_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR, SCREENS.NEW_CHAT.NEW_CHAT_CONFIRM, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, @@ -1016,6 +1016,8 @@ const DYNAMIC_ROUTES = { SCREENS.SETTINGS.PROFILE.ADDRESS, SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS, SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS, + SCREENS.WORKSPACE.OFFICE_LOCATION_ADD, + SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT, SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS, SCREENS.TRAVEL.ENABLE, @@ -1032,6 +1034,8 @@ const DYNAMIC_ROUTES = { SCREENS.SETTINGS.PROFILE.ADDRESS, SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS, SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS, + SCREENS.WORKSPACE.OFFICE_LOCATION_ADD, + SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT, SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS, SCREENS.TRAVEL.ENABLE, @@ -1635,6 +1639,8 @@ const DYNAMIC_ROUTES = { SCREENS.SEARCH.ROOT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], + getRoute: (shouldShowQRCodeOnly?: boolean) => getUrlWithParams('share-code', {shouldShowQRCodeOnly: shouldShowQRCodeOnly ? 'true' : undefined}), + queryParams: ['shouldShowQRCodeOnly'], }, REFERRAL_DETAILS: { path: 'referral/:contentType', @@ -1646,6 +1652,10 @@ const DYNAMIC_ROUTES = { entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], getRoute: (connectionName: ConnectionName) => `details/export/${connectionName as string}` as const, }, + REPORT_DETAILS_SHARE_REPORT: { + path: 'share-report', + entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], + }, REPORT_CHANGE_WORKSPACE: { path: 'change-workspace', entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], @@ -2107,7 +2117,9 @@ const DYNAMIC_ROUTES = { }, MONEY_REQUEST_STEP_CATEGORY_CREATE: { path: 'add-category', - entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY], + entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY, SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE, SCREENS.SHARE.SUBMIT_DETAILS], + getRoute: (params: {action: IOUAction; iouType: IOUType; transactionID: string; reportID: string | undefined}) => getUrlWithParams('add-category', params), + queryParams: ['action', 'iouType', 'transactionID', 'reportID'], }, MONEY_REQUEST_ATTENDEE: { path: 'attendees', @@ -2151,6 +2163,55 @@ const DYNAMIC_ROUTES = { }, queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath', 'shouldReturnToConfirmation'], }, + AGENT_NEW: { + path: 'new-agent', + getRoute: ({policyID}: {policyID?: string} = {}) => { + const params = new URLSearchParams(); + if (policyID) { + params.set('policyID', policyID); + } + const query = params.toString(); + return `new-agent${query ? `?${query}` : ''}` as const; + }, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_ADD: { + path: 'custom', + getRoute: ({policyID}: {policyID?: string} = {}) => { + const params = new URLSearchParams(); + if (policyID) { + params.set('policyID', policyID); + } + const query = params.toString(); + return `custom${query ? `?${query}` : ''}` as const; + }, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW], + }, + AGENT_ADD_AVATAR: { + path: 'custom-avatar', + getRoute: () => `custom-avatar` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD], + }, + AGENT_EDIT: { + path: ':accountID/edit', + getRoute: (accountID: number) => `${accountID}/edit` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_EDIT_NAME: { + path: 'name', + getRoute: () => `name` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT], + }, + AGENT_EDIT_PROMPT: { + path: 'prompt', + getRoute: () => `prompt` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, SCREENS.DYNAMIC_PROFILE], + }, + AGENT_EDIT_AVATAR: { + path: 'avatar', + getRoute: () => `avatar` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT], + }, } as const satisfies DynamicRoutes; const ROUTES = { @@ -2671,45 +2732,6 @@ const ROUTES = { SETTINGS_WALLET_TRAVEL_CVV: 'settings/wallet/travel-cvv', SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT: `settings/wallet/travel-cvv/${VERIFY_ACCOUNT}`, SETTINGS_AGENTS: 'settings/agents', - SETTINGS_AGENTS_NEW: { - route: 'settings/agents/new', - getRoute: ({policyID}: {policyID?: string} = {}) => { - const params = new URLSearchParams(); - if (policyID) { - params.set('policyID', policyID); - } - const query = params.toString(); - return `settings/agents/new${query ? `?${query}` : ''}` as const; - }, - }, - SETTINGS_AGENTS_ADD: { - route: 'settings/agents/new/custom', - getRoute: ({policyID}: {policyID?: string} = {}) => { - const params = new URLSearchParams(); - if (policyID) { - params.set('policyID', policyID); - } - const query = params.toString(); - return `settings/agents/new/custom${query ? `?${query}` : ''}` as const; - }, - }, - SETTINGS_AGENTS_ADD_AVATAR: 'settings/agents/new/custom/avatar', - SETTINGS_AGENTS_EDIT: { - route: 'settings/agents/:accountID/edit', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit` as const, - }, - SETTINGS_AGENTS_EDIT_NAME: { - route: 'settings/agents/:accountID/edit/name', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/name` as const, - }, - SETTINGS_AGENTS_EDIT_PROMPT: { - route: 'settings/agents/:accountID/edit/prompt', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/prompt` as const, - }, - SETTINGS_AGENTS_EDIT_AVATAR: { - route: 'settings/agents/:accountID/edit/avatar', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/avatar` as const, - }, AGENT_REPORT: { route: 'settings/agents/r/:reportID', getRoute: (reportID: string) => `settings/agents/r/${reportID}` as const, @@ -2768,8 +2790,9 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER: 'settings/profile/status/clear-after', SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', - SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', - SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/status/vacation-delegate/missing-workspaces', + SETTINGS_VACATION_DELEGATE: 'settings/profile/vacation-delegate', + SETTINGS_VACATION_DELEGATE_SELECT: 'settings/profile/vacation-delegate/select', + SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/vacation-delegate/missing-workspaces', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_TROUBLESHOOT_SERVER: 'settings/troubleshoot/server', SETTINGS_HELP: 'settings/help', @@ -3372,6 +3395,14 @@ const ROUTES = { route: 'workspaces/:policyID/overview/share', getRoute: (policyID: string) => `workspaces/${policyID}/overview/share` as const, }, + WORKSPACE_OVERVIEW_OFFICE_LOCATION_ADD: { + route: 'workspaces/:policyID/overview/office-locations/new', + getRoute: (policyID: string) => `workspaces/${policyID}/overview/office-locations/new` as const, + }, + WORKSPACE_OVERVIEW_OFFICE_LOCATION_EDIT: { + route: 'workspaces/:policyID/overview/office-locations/:officeID', + getRoute: (policyID: string, officeID: string) => `workspaces/${policyID}/overview/office-locations/${officeID}` as const, + }, WORKSPACE_AVATAR: { route: 'workspaces/:policyID/avatar', getRoute: (policyID: string, fallbackLetter?: UpperCaseCharacters) => `workspaces/${policyID}/avatar${fallbackLetter ? `?letter=${fallbackLetter}` : ''}` as const, @@ -3522,6 +3553,15 @@ const ROUTES = { return `workspaces/${policyID}/members` as const; }, }, + WORKSPACE_AGENTS: { + route: 'workspaces/:policyID/agents', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_AGENTS route'); + } + return `workspaces/${policyID}/agents` as const; + }, + }, WORKSPACE_ROOMS: { route: 'workspaces/:policyID/rooms', getRoute: (policyID: string | undefined) => { @@ -5655,6 +5695,11 @@ const ROUTES = { getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/vacation-delegate` as const, }, + DOMAIN_VACATION_DELEGATE_SELECT: { + route: 'domain/:domainAccountID/members/:accountID/vacation-delegate/select', + getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/vacation-delegate/select` as const, + }, + MULTIFACTOR_AUTHENTICATION_AUTHORIZE_TRANSACTION: { route: 'multifactor-authentication/authorize-transaction/:transactionID', getRoute: (transactionID: string) => `multifactor-authentication/authorize-transaction/${transactionID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 502f881e4be5..abe67eb62955 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -117,7 +117,8 @@ const SCREENS = { STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After', STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date', STATUS_CLEAR_AFTER_TIME: 'Settings_Status_Clear_After_Time', - VACATION_DELEGATE: 'Settings_Status_Vacation_Delegate', + VACATION_DELEGATE: 'Settings_Profile_Vacation_Delegate', + VACATION_DELEGATE_SELECT: 'Settings_Profile_Vacation_Delegate_Select', VACATION_DELEGATE_MISSING_WORKSPACES: 'Settings_Status_Vacation_Delegate_Missing_Workspaces', STATUS: 'Settings_Status', PRONOUNS: 'Settings_Pronouns', @@ -141,13 +142,13 @@ const SCREENS = { AGENTS: { ROOT: 'Settings_Agents', - NEW: 'Settings_Agents_New', - ADD: 'Settings_Agents_Add', - ADD_AVATAR: 'Settings_Agents_Add_Avatar', - EDIT: 'Settings_Agents_Edit', - EDIT_NAME: 'Settings_Agents_Edit_Name', - EDIT_PROMPT: 'Settings_Agents_Edit_Prompt', - EDIT_AVATAR: 'Settings_Agents_Edit_Avatar', + DYNAMIC_NEW: 'Dynamic_Settings_Agents_New', + DYNAMIC_ADD: 'Dynamic_Settings_Agents_Add', + DYNAMIC_ADD_AVATAR: 'Dynamic_Settings_Agents_Add_Avatar', + DYNAMIC_EDIT: 'Dynamic_Settings_Agents_Edit', + DYNAMIC_EDIT_NAME: 'Dynamic_Settings_Agents_Edit_Name', + DYNAMIC_EDIT_PROMPT: 'Dynamic_Settings_Agents_Edit_Prompt', + DYNAMIC_EDIT_AVATAR: 'Dynamic_Settings_Agents_Edit_Avatar', }, RULES: { @@ -509,6 +510,7 @@ const SCREENS = { DYNAMIC_ROOT: 'Dynamic_Report_Details_Root', DYNAMIC_SHARE_CODE: 'Dynamic_Report_Details_Share_Code', DYNAMIC_EXPORT: 'Dynamic_Report_Details_Export', + DYNAMIC_SHARE_REPORT: 'Dynamic_Report_Details_Share_Report', }, REPORT_CHANGE_WORKSPACE: { @@ -856,6 +858,7 @@ const SCREENS = { DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE: 'Dynamic_Invoice_Fields_Initial_List_Value', INVOICE_FIELDS_TYPE_SELECTOR: 'Workspace_InvoiceFields_TypeSelector', MEMBERS: 'Workspace_Members', + AGENTS: 'Workspace_Agents', ROOMS: 'Workspace_Rooms', ROOM_CREATE: 'Workspace_Room_Create', MEMBERS_IMPORT: 'Members_Import', @@ -924,6 +927,8 @@ const SCREENS = { DESCRIPTION: 'Workspace_Overview_Description', SHARE: 'Workspace_Overview_Share', NAME: 'Workspace_Overview_Name', + OFFICE_LOCATION_ADD: 'Workspace_Overview_Office_Location_Add', + OFFICE_LOCATION_EDIT: 'Workspace_Overview_Office_Location_Edit', CLIENT_ID: 'Workspace_Overview_Client_ID', CATEGORY_CREATE: 'Category_Create', DYNAMIC_CATEGORY_CREATE: 'Dynamic_Category_Create', @@ -1227,6 +1232,7 @@ const SCREENS = { MEMBERS: 'Domain_Members', MEMBER_DETAILS: 'Member_Details', VACATION_DELEGATE: 'Vacation_Delegate', + VACATION_DELEGATE_SELECT: 'Vacation_Delegate_Select', RESET_DOMAIN: 'Domain_Reset', ADD_MEMBER: 'Domain_Add_Member', MEMBERS_SETTINGS: 'Members_Settings', diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 4f2c928a9243..a707afe9f413 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -7,13 +7,14 @@ import type {Country} from '@src/CONST'; import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; + +import type {ReactNode} from 'react'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React, {createContext, useCallback, useContext} from 'react'; import {View} from 'react-native'; -import type {FormOnyxValues} from './Form/types'; +import type {FormInputErrors, FormOnyxValues} from './Form/types'; import type {State} from './StateSelector'; import AddressSearch from './AddressSearch'; @@ -28,7 +29,9 @@ type CountryZipRegex = { samples?: string; }; -type AddressFormProps = { +type AddressFormID = typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM | typeof ONYXKEYS.FORMS.WORKSPACE_OFFICE_LOCATION_FORM; + +type AddressFormProps = { /** Address city field */ city?: string; @@ -51,7 +54,7 @@ type AddressFormProps = { onAddressChanged?: (value: unknown, key: unknown) => void; /** Callback which is executed when the user submits his address changes */ - onSubmit: (values: FormOnyxValues) => void; + onSubmit: (values: FormOnyxValues) => void; /** Whether or not should the form data should be saved as draft */ shouldSaveDraft?: boolean; @@ -60,7 +63,13 @@ type AddressFormProps = { submitButtonText?: string; /** A unique Onyx key identifying the form */ - formID: typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM; + formID: TFormID; + + /** The inputs of the form, which place the address inputs with ``. Defaults to the address inputs alone. */ + children?: ReactNode; + + /** Validates the inputs the form adds to the address inputs. Its errors are added to the address errors. */ + validate?: (values: FormOnyxValues) => Record; /** Whether to hide the country selector (e.g. when country cannot be changed) */ shouldHideCountrySelector?: boolean; @@ -86,26 +95,36 @@ type AddressFormProps = { addBottomSafeAreaPadding?: boolean; }; -function AddressForm({ - city = '', - country = '', - formID, - onAddressChanged = () => {}, - onSubmit, - shouldSaveDraft = false, - state = '', - street1 = '', - street2 = '', - submitButtonText = '', - zip = '', - shouldHideCountrySelector = false, - enabledWhenOffline: enabledWhenOfflineProp = true, - shouldRequireZip = false, - shouldValidatePhysicalAddress = false, - addBottomSafeAreaPadding = true, -}: AddressFormProps) { +/** The values the address inputs start from and report changes to, shared by AddressForm with its AddressForm.Fields */ +type AddressFieldsSettings = { + city: string; + country: Country | ''; + state: string; + street1: string; + street2: string; + zip: string; + onAddressChanged: (value: unknown, key: unknown) => void; + shouldSaveDraft: boolean; + shouldHideCountrySelector: boolean; +}; + +const AddressFieldsContext = createContext({ + city: '', + country: '', + state: '', + street1: '', + street2: '', + zip: '', + onAddressChanged: () => {}, + shouldSaveDraft: false, + shouldHideCountrySelector: false, +}); + +/** The address inputs of an AddressForm */ +function AddressFormFields() { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {city, country, state, street1, street2, zip, onAddressChanged, shouldSaveDraft, shouldHideCountrySelector} = useContext(AddressFieldsContext); const zipSampleFormat = (country && (COMMON_CONST.COUNTRY_ZIP_REGEX_DATA[country] as CountryZipRegex)?.samples) ?? ''; @@ -113,101 +132,8 @@ function AddressForm({ const isUSAForm = country === CONST.COUNTRY.US; - /** - * @param translate - translate function - * @param isUSAForm - selected country ISO code is US - * @param values - form input values - * @returns - An object containing the errors for each inputID - */ - - const validator = useCallback( - (rawValues: FormOnyxValues): Errors => { - // When hidden, the country input is unregistered so fall back to the country prop. - const values = shouldHideCountrySelector ? {...rawValues, country: rawValues.country || country} : rawValues; - - const errors: Errors & { - zipPostCode?: string | string[]; - } = {}; - const baseRequiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); - const requiredFields = shouldRequireZip ? ([...baseRequiredFields, 'zipPostCode'] as const) : baseRequiredFields; - - // Check "State" dropdown is a valid state if selected Country is USA - if (values.country === CONST.COUNTRY.US && !values.state) { - errors.state = translate('common.error.fieldRequired'); - } - - // Add "Field required" errors if any required field is empty - for (const fieldKey of requiredFields) { - const fieldValue = values[fieldKey] ?? ''; - if (isRequiredFulfilled(fieldValue)) { - continue; - } - - errors[fieldKey] = translate('common.error.fieldRequired'); - } - - if (values.addressLine1.length > CONST.FORM_CHARACTER_LIMIT) { - errors.addressLine1 = translate('common.error.characterLimitExceedCounter', values.addressLine1.length, CONST.FORM_CHARACTER_LIMIT); - } - - if (values.addressLine2.length > CONST.FORM_CHARACTER_LIMIT) { - errors.addressLine2 = translate('common.error.characterLimitExceedCounter', values.addressLine2.length, CONST.FORM_CHARACTER_LIMIT); - } - - if (values.city.length > CONST.FORM_CHARACTER_LIMIT) { - errors.city = translate('common.error.characterLimitExceedCounter', values.city.length, CONST.FORM_CHARACTER_LIMIT); - } - - if (values.country !== CONST.COUNTRY.US && values.state.length > CONST.STATE_CHARACTER_LIMIT) { - errors.state = translate('common.error.characterLimitExceedCounter', values.state.length, CONST.STATE_CHARACTER_LIMIT); - } - - if (shouldValidatePhysicalAddress) { - const addressLine1Error = getInvalidAddressErrorTranslationPath(values.addressLine1); - if (values.addressLine1 && addressLine1Error) { - errors.addressLine1 = translate(addressLine1Error); - } - - const addressLine2Error = getInvalidAddressErrorTranslationPath(values.addressLine2); - if (values.addressLine2 && addressLine2Error) { - errors.addressLine2 = translate(addressLine2Error); - } - } - - // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = (values.country ? COMMON_CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; - - // The postal code system might not exist for a country, so no regex either for them. - const countrySpecificZipRegex = countryRegexDetails?.regex; - const countryZipFormat = countryRegexDetails?.samples ?? ''; - - if (countrySpecificZipRegex) { - if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { - if (isRequiredFulfilled(values.zipPostCode?.trim())) { - errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat', countryZipFormat); - } else { - errors.zipPostCode = translate('common.error.fieldRequired'); - } - } - } else if (!COMMON_CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { - errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat'); - } - - return errors; - }, - [translate, shouldHideCountrySelector, country, shouldRequireZip, shouldValidatePhysicalAddress], - ); - return ( - + <> + + ); +} + +function AddressForm({ + city = '', + country = '', + formID, + onAddressChanged = () => {}, + onSubmit, + shouldSaveDraft = false, + state = '', + street1 = '', + street2 = '', + submitButtonText = '', + zip = '', + shouldHideCountrySelector = false, + enabledWhenOffline: enabledWhenOfflineProp = true, + shouldRequireZip = false, + shouldValidatePhysicalAddress = false, + addBottomSafeAreaPadding = true, + children, + validate, +}: AddressFormProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const validator = useCallback( + (rawValues: FormOnyxValues): FormInputErrors => { + // Every form rendered here has the home address fields, so they are validated the same way + const addressValues: FormOnyxValues = rawValues; + + // When hidden, the country input is unregistered so fall back to the country prop. + const values = shouldHideCountrySelector ? {...addressValues, country: addressValues.country || country} : addressValues; + + const errors: Record = {}; + const baseRequiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); + const requiredFields = shouldRequireZip ? ([...baseRequiredFields, 'zipPostCode'] as const) : baseRequiredFields; + + // Check "State" dropdown is a valid state if selected Country is USA + if (values.country === CONST.COUNTRY.US && !values.state) { + errors.state = translate('common.error.fieldRequired'); + } + + // Add "Field required" errors if any required field is empty + for (const fieldKey of requiredFields) { + const fieldValue = values[fieldKey] ?? ''; + if (isRequiredFulfilled(fieldValue)) { + continue; + } + + errors[fieldKey] = translate('common.error.fieldRequired'); + } + + if (values.addressLine1.length > CONST.FORM_CHARACTER_LIMIT) { + errors.addressLine1 = translate('common.error.characterLimitExceedCounter', values.addressLine1.length, CONST.FORM_CHARACTER_LIMIT); + } + + if (values.addressLine2.length > CONST.FORM_CHARACTER_LIMIT) { + errors.addressLine2 = translate('common.error.characterLimitExceedCounter', values.addressLine2.length, CONST.FORM_CHARACTER_LIMIT); + } + + if (values.city.length > CONST.FORM_CHARACTER_LIMIT) { + errors.city = translate('common.error.characterLimitExceedCounter', values.city.length, CONST.FORM_CHARACTER_LIMIT); + } + + if (values.country !== CONST.COUNTRY.US && values.state.length > CONST.STATE_CHARACTER_LIMIT) { + errors.state = translate('common.error.characterLimitExceedCounter', values.state.length, CONST.STATE_CHARACTER_LIMIT); + } + + if (shouldValidatePhysicalAddress) { + const addressLine1Error = getInvalidAddressErrorTranslationPath(values.addressLine1); + if (values.addressLine1 && addressLine1Error) { + errors.addressLine1 = translate(addressLine1Error); + } + + const addressLine2Error = getInvalidAddressErrorTranslationPath(values.addressLine2); + if (values.addressLine2 && addressLine2Error) { + errors.addressLine2 = translate(addressLine2Error); + } + } + + // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object + const countryRegexDetails = (values.country ? COMMON_CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; + + // The postal code system might not exist for a country, so no regex either for them. + const countrySpecificZipRegex = countryRegexDetails?.regex; + const countryZipFormat = countryRegexDetails?.samples ?? ''; + + if (countrySpecificZipRegex) { + if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { + if (isRequiredFulfilled(values.zipPostCode?.trim())) { + errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat', countryZipFormat); + } else { + errors.zipPostCode = translate('common.error.fieldRequired'); + } + } + } else if (!COMMON_CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { + errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat'); + } + + // TypeScript can't tell that the address input IDs are keys of a generic form, though every form in AddressFormID has them + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (validate ? {...errors, ...validate(rawValues)} : errors) as FormInputErrors; + }, + [translate, shouldHideCountrySelector, country, shouldRequireZip, shouldValidatePhysicalAddress, validate], + ); + + return ( + + + {children ?? } + ); } -export default AddressForm; +export default Object.assign(AddressForm, {Fields: AddressFormFields}); diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 9da049afcd37..926c78acd9ea 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -47,7 +47,10 @@ type AmountTextInputProps = { /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ rightHandSideComponent?: React.ReactNode; -} & Pick; +} & Pick< + BaseTextInputProps, + 'autoFocus' | 'autoGrowExtraSpace' | 'containerStyles' | 'submitBehavior' | 'ref' | 'onFocus' | 'onBlur' | 'disabled' | 'accessibilityLabel' | 'shouldAllowFocusInLandscapeMode' +>; function AmountTextInput({ formattedAmount, diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index 283479e6f26a..14f23760e0d0 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -16,6 +16,7 @@ import {dismissASAPSubmitExplanation} from '@userActions/User'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import type IconAsset from '@src/types/utils/IconAsset'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; @@ -32,18 +33,23 @@ function AutoSubmitModal() { const StyleUtils = useStyleUtils(); const illustrations = useMemoizedLazyIllustrations(['PaperAirplane', 'Pencil', 'ReceiptsStackedOnPin']); const menuSections = useMemo( - () => [ - { - icon: illustrations.PaperAirplane, - titleTranslationKey: 'autoSubmitModal.submittedExpensesTitle', - descriptionTranslationKey: 'autoSubmitModal.submittedExpensesDescription', - }, - { - icon: illustrations.Pencil, - titleTranslationKey: 'autoSubmitModal.pendingExpensesTitle', - descriptionTranslationKey: 'autoSubmitModal.pendingExpensesDescription', - }, - ], + () => + [ + { + icon: illustrations.PaperAirplane, + titleTranslationKey: 'autoSubmitModal.submittedExpensesTitle', + descriptionTranslationKey: 'autoSubmitModal.submittedExpensesDescription', + }, + { + icon: illustrations.Pencil, + titleTranslationKey: 'autoSubmitModal.pendingExpensesTitle', + descriptionTranslationKey: 'autoSubmitModal.pendingExpensesDescription', + }, + ] satisfies Array<{ + icon: IconAsset; + titleTranslationKey: TranslationPaths; + descriptionTranslationKey: TranslationPaths; + }>, [illustrations.PaperAirplane, illustrations.Pencil], ); @@ -111,8 +117,8 @@ function AutoSubmitModal() { additionalStyles={[styles.mr4]} /> - {translate(section.titleTranslationKey as TranslationPaths)} - {translate(section.descriptionTranslationKey as TranslationPaths)} + {translate(section.titleTranslationKey)} + {translate(section.descriptionTranslationKey)} ))} diff --git a/src/components/Avatar/connected/ChatThreadAvatar.tsx b/src/components/Avatar/connected/ChatThreadAvatar.tsx index 8782125c547d..805ecf8e6159 100644 --- a/src/components/Avatar/connected/ChatThreadAvatar.tsx +++ b/src/components/Avatar/connected/ChatThreadAvatar.tsx @@ -7,6 +7,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -28,7 +29,6 @@ import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import {reportAvatarFieldsSelector, reportAvatarKindSelector} from '@selectors/Report'; -import {getParentReportActionSelector} from '@selectors/ReportAction'; import React from 'react'; import useAccountIcons, {seedFallbackIcons} from './useAccountIcons'; @@ -73,9 +73,7 @@ function ChatThreadAvatar({reportID, size, backdropColor, containerStyle, subscr const [thread] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportAvatarFieldsSelector}); const parentReportID = getNonEmptyStringOnyxID(thread?.parentReportID); const [parentKind] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, {selector: reportAvatarKindSelector}); - const [parentAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - selector: (actions) => getParentReportActionSelector(actions, thread?.parentReportActionID), - }); + const parentAction = useParentReportAction(thread, {shouldIgnoreThreadReplies: true}); // A transaction thread points `chatReportID` at its parent, a comment thread leaves it unset. Only a linked thread shows the copilot, the revealed agent and the expense subscript. const isParentActionLinked = !!parentKind && !!thread?.chatReportID && thread.chatReportID === thread.parentReportID; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index d05894808357..3ec898ae2a38 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -88,22 +88,42 @@ type AvatarWithDisplayNameProps = { avatarBorderColor?: ColorValue; customDisplayNameStyle?: TextStyle; + + /** Whether the displayed report title should be directly selectable */ + isDisplayNameSelectable?: boolean; + + /** The style of the parent navigation subtitle text */ parentNavigationSubtitleTextStyles?: StyleProp; parentNavigationStatusContainerStyles?: StyleProp; }; -function getCustomDisplayName( - shouldUseCustomSearchTitleName: boolean, - report: OnyxEntry, - title: string, - displayNamesWithTooltips: DisplayNameWithTooltips, - transactions: TransactionListItemType[], - shouldUseFullTitle: boolean, - customSearchDisplayStyle: TextStyle[], - regularStyle: TextStyle[], - isAnonymous: boolean, - isMoneyRequestOrReport: boolean, -): React.ReactNode { +type CustomDisplayNameOptions = { + shouldUseCustomSearchTitleName: boolean; + report: OnyxEntry; + title: string; + displayNamesWithTooltips: DisplayNameWithTooltips; + transactions: TransactionListItemType[]; + shouldUseFullTitle: boolean; + customSearchDisplayStyle: TextStyle[]; + regularStyle: TextStyle[]; + isAnonymous: boolean; + isMoneyRequestOrReport: boolean; + isSelectable: boolean; +}; + +function getCustomDisplayName({ + shouldUseCustomSearchTitleName, + report, + title, + displayNamesWithTooltips, + transactions, + shouldUseFullTitle, + customSearchDisplayStyle, + regularStyle, + isAnonymous, + isMoneyRequestOrReport, + isSelectable, +}: CustomDisplayNameOptions): React.ReactNode { const reportName = report?.reportName ?? CONST.REPORT.DEFAULT_REPORT_NAME; const isIOUOrInvoice = report?.type === CONST.REPORT.TYPE.IOU || report?.type === CONST.REPORT.TYPE.INVOICE; const hasTransactions = transactions.length > 0; @@ -164,6 +184,7 @@ function getCustomDisplayName( numberOfLines={numberOfLines} textStyles={textStyles} shouldUseFullTitle={shouldUseFullTitleProp} + isSelectable={isSelectable} /> ); } @@ -180,6 +201,7 @@ function AvatarWithDisplayName({ avatarBorderColor: avatarBorderColorProp, shouldDisplayStatus = false, customDisplayNameStyle = {}, + isDisplayNameSelectable = false, parentNavigationSubtitleTextStyles, parentNavigationStatusContainerStyles = {}, }: AvatarWithDisplayNameProps) { @@ -272,18 +294,19 @@ function AvatarWithDisplayName({ }; const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const displayNameContent = getCustomDisplayName( + const displayNameContent = getCustomDisplayName({ shouldUseCustomSearchTitleName, report, title, displayNamesWithTooltips, transactions, shouldUseFullTitle, - [styles.headerText, styles.pre, customDisplayNameStyle], - [isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre], + customSearchDisplayStyle: [styles.headerText, styles.pre, customDisplayNameStyle], + regularStyle: [isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre], isAnonymous, isMoneyRequestOrReport, - ); + isSelectable: isDisplayNameSelectable, + }); const multipleAvatars = ( void; + + /** Sentry label for the add button, so each caller's presses are attributed to it */ + addCategorySentryLabel?: string; + /** Whether the pop-over may flip to the other side of the anchor when it overflows. It shifts by a whole pop-over height, so a caller that already picked the side turns this off. */ shouldSwitchPositionIfOverflow?: boolean; } & Omit; @@ -68,8 +86,13 @@ function CategoryPickerModal({ popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, shouldFitContentHeight = false, shouldSwitchPositionIfOverflow = true, + onAddCategory, + addCategorySentryLabel, }: CategoryPickerModalProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Plus']); const StyleUtils = useStyleUtils(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision (bottom-docked only when isSmallScreenWidth) const {isSmallScreenWidth} = useResponsiveLayout(); @@ -84,7 +107,8 @@ function CategoryPickerModal({ const categoriesCount = policyCategories ?? 0; const isSearchable = categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT; - const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}); + const headerHeight = onAddCategory ? CATEGORY_PICKER_HEADER_HEIGHT : 0; + const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}) + headerHeight; // The sheet pads itself above the home indicator, except while the keyboard already covers it. The offline indicator // is drawn on the page behind the sheet, so no room is kept for it. @@ -135,6 +159,45 @@ function CategoryPickerModal({ outerStyle={outerStyle} > + {!!onAddCategory && ( + + + + + + + + {translate('common.category')} + + + + + + + + )} diff --git a/src/components/Charts/BarChart/VerticalBarChartContent.tsx b/src/components/Charts/BarChart/VerticalBarChartContent.tsx index 5de315e73610..77f584ccec54 100644 --- a/src/components/Charts/BarChart/VerticalBarChartContent.tsx +++ b/src/components/Charts/BarChart/VerticalBarChartContent.tsx @@ -4,43 +4,41 @@ import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; +import type {CartesianCanvasInputs, HitTestArgs} from '@components/Charts/hooks'; import { - useChartFontManager, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain, useLabelHitTesting, + useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getBarLayout, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; -import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; +import {getBarLayout, getCartesianPlotBounds, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; +import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {LayoutChangeEvent} from 'react-native'; import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; -import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import {Bar, CartesianChart} from 'victory-native'; import type BarChartProps from './types'; +import type {BarChartContentProps} from './types'; -function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onBarPress, shouldShowLabels = true}: BarChartProps) { +type VerticalBarChartCanvasProps = Omit & CartesianCanvasInputs; + +function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'left', onBarPress, shouldShowLabels = true, chartWidth, fontManager}: VerticalBarChartCanvasProps) { const theme = useTheme(); const styles = useThemeStyles(); - const fontManager = useChartFontManager(); - const [chartWidth, setChartWidth] = useState(0); - const [barAreaWidth, setBarAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); + const StyleUtils = useStyleUtils(); const [{key: primarySeriesKey}] = series; const chartData = data.map((point, index) => ({ @@ -60,16 +58,21 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU } }; - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - - const barLayout = getBarLayout(barAreaWidth, data.length); - // Empty label data makes the measurement and layout hooks return early instead of laying out labels nobody sees. const labelData = shouldShowLabels ? data : []; const originalLabels = labelData.map(getXAxisLabel); + const {formatValue, formatCompactValue} = useChartLabelFormats({ + data, + unit: yAxisUnit, + unitPosition: yAxisUnitPosition, + }); + + const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VERTICAL_BAR_DOMAIN_PADDING); + const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap; + const plotBounds = getCartesianPlotBounds(chartWidth, chartPaddingRight); + const barLayout = getBarLayout(plotBounds.width, data.length); + const measurements = useChartLabelMeasurements(labelData, fontManager, variables.iconSizeExtraSmall); const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = useChartLabelLayout({ @@ -77,16 +80,10 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU fontManager, fontSize: variables.iconSizeExtraSmall, measurements, - tickSpacing: shouldShowLabels && barAreaWidth > 0 ? barLayout.barWidth + barLayout.gap : 0, - labelAreaWidth: barAreaWidth, - firstTickLeftSpace: boundsLeft + barLayout.edgeSpace, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + barLayout.edgeSpace : 0, - }); - - const {formatValue, formatCompactValue} = useChartLabelFormats({ - data, - unit: yAxisUnit, - unitPosition: yAxisUnitPosition, + tickSpacing: shouldShowLabels && plotBounds.width > 0 ? barLayout.barWidth + barLayout.gap : 0, + labelAreaWidth: plotBounds.width, + firstTickLeftSpace: plotBounds.left + barLayout.edgeSpace, + lastTickRightSpace: chartWidth - plotBounds.right + barLayout.edgeSpace, }); const barHitHalfWidth = useSharedValue(0); @@ -105,15 +102,9 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU }); const handleChartBoundsChange = (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const {barWidth, gap} = getBarLayout(domainWidth, data.length); - barHitHalfWidth.set(barWidth > 0 ? barWidth / 2 + gap * BAR_HIT_GAP_RATIO : 0); plotTop.set(bounds.top); plotBottom.set(bounds.bottom); yZero.set(0); - setBarAreaWidth(domainWidth); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); }; const checkIsOverBar = (args: HitTestArgs) => { @@ -128,16 +119,18 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU return isWithinX && args.cursorY >= plotTop.get() && args.cursorY <= plotBottom.get(); }; - const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition, onChartMoved} = useChartInteractions({ - handlePress: onBarPress ? handleBarPress : undefined, - checkIsOver: checkIsOverBar, - isCursorOverLabel: shouldShowLabels ? isCursorOverLabel : undefined, - resolveLabelTouchX: shouldShowLabels ? findLabelCursorX : undefined, - chartBottom, - yZero, - }); + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition, onChartMoved, onTooltipDismiss, onChartTouchStart} = + useChartInteractions({ + handlePress: onBarPress ? handleBarPress : undefined, + checkIsOver: checkIsOverBar, + isCursorOverLabel: shouldShowLabels ? isCursorOverLabel : undefined, + resolveLabelTouchX: shouldShowLabels ? findLabelCursorX : undefined, + chartBottom, + yZero, + }); const handleScaleChange = (xScale: Scale, yScale: Scale) => { + barHitHalfWidth.set(barLayout.barWidth > 0 ? barLayout.barWidth / 2 + barLayout.gap * BAR_HIT_GAP_RATIO : 0); yZero.set(yScale(0)); updateTickPositions(xScale, data.length); setPointPositions( @@ -168,7 +161,7 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU }; const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { - if (!fontManager || xAxisLabelHeight === undefined) { + if (xAxisLabelHeight === undefined) { return null; } @@ -208,21 +201,8 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU }; const labelSpace = shouldShowLabels ? VictoryTheme.axis.xAxisLabelGap + (xAxisLabelHeight ?? 0) : 0; - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; - const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VERTICAL_BAR_DOMAIN_PADDING); - const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, right: yAxisLabelWidth + GLYPH_PADDING}; - - if (isLoading || !fontManager) { - return ( - - - - ); - } - - if (data.length === 0) { - return null; - } + const chartHeight = CHART_CONTENT_MIN_HEIGHT + labelSpace; + const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, right: chartPaddingRight}; return ( - {chartWidth > 0 && ( - - {({points, chartBounds, yScale, yTicks}) => ( - <> - - {points.y.map((point) => renderBar(point, chartBounds))} - - )} - - )} + + {({points, chartBounds, yScale, yTicks}) => ( + <> + + {points.y.map((point) => renderBar(point, chartBounds))} + + )} + ); } +function VerticalBarChartContentBody({isLoading = false, chartWidth, ...canvasProps}: BarChartContentProps) { + const styles = useThemeStyles(); + const readyCanvas = useReadyCartesianCanvas(isLoading, chartWidth); + + if (!isLoading && canvasProps.data.length === 0) { + return null; + } + + if (!readyCanvas) { + return ( + + + + ); + } + + return ( + + ); +} + export default VerticalBarChartContentBody; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 63798b3dc21b..9aea26a2ab8a 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type BarChartProps from './types'; @@ -5,7 +7,16 @@ import type BarChartProps from './types'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } export default BarChart; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 50d908c8083e..e3b165f0a3b5 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -7,10 +8,14 @@ import type BarChartProps from './types'; const getBarChartContent = () => import('./BarChartContent'); function BarChart(props: BarChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/BarChart/types.ts b/src/components/Charts/BarChart/types.ts index 064e1322dc25..88eee9d07669 100644 --- a/src/components/Charts/BarChart/types.ts +++ b/src/components/Charts/BarChart/types.ts @@ -7,4 +7,10 @@ type BarChartProps = CartesianChartProps & { shouldShowLabels?: boolean; }; +type BarChartContentProps = BarChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; +}; + export default BarChartProps; +export type {BarChartContentProps}; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index fe42ec8f43bb..4f3be4254ea1 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -5,30 +5,38 @@ import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; +import type {CartesianCanvasInputs, HitTestArgs} from '@components/Charts/hooks'; import { ChartFontsProvider, - useChartFontManager, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain, useLabelHitTesting, + useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getDomainPaddingForEdgeSpace, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; -import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, DASH_INTERVALS, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from '@components/Charts/VictoryTheme'; - +import {getAxisLabelStep, getCartesianPlotBounds, getDomainPaddingForEdgeSpace, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; +import VictoryTheme, { + CARTESIAN_Y_AXIS, + CHART_CONTENT_MIN_HEIGHT, + DASH_INTERVALS, + GLYPH_PADDING, + LABEL_PADDING, + LABEL_ROTATIONS, + SIN_45, + X_AXIS_LABEL_MIN_GAP, +} from '@components/Charts/VictoryTheme'; + +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {LayoutChangeEvent} from 'react-native'; import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native'; import {DashPathEffect} from '@shopify/react-native-skia'; -import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; @@ -38,16 +46,22 @@ import type {CartesianChartProps, ChartDataPoint} from '..'; type LineChartProps = CartesianChartProps & { onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; + + /** Steps the x-axis may label points at, ascending. When set, labels stay horizontal and only every Nth point is labeled. */ + labelSteps?: readonly number[]; +}; + +type LineChartContentProps = LineChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; }; -function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { +type LineChartCanvasProps = Omit & CartesianCanvasInputs; + +function LineChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, labelSteps, chartWidth, fontManager}: LineChartCanvasProps) { const theme = useTheme(); const styles = useThemeStyles(); - const fontManager = useChartFontManager(); - const [chartWidth, setChartWidth] = useState(0); - const [plotAreaWidth, setPlotAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); + const StyleUtils = useStyleUtils(); const yAxisDomain = useDynamicYDomain(data); const isLastPointInProgress = !!data.at(-1)?.isInProgress; @@ -70,10 +84,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi } }; - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - const chartBottom = useSharedValue(0); const plotTop = useSharedValue(0); const plotLeft = useSharedValue(0); @@ -91,14 +101,15 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VictoryTheme.line.domainPadding); + const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap; + const {left: boundsLeft, right: boundsRight, width: plotAreaWidth} = getCartesianPlotBounds(chartWidth, chartPaddingRight); const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; - const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING; const domainPadding = (() => { if (!firstLabelWidth || !lastLabelWidth) { return VictoryTheme.line.domainPadding; } - const labelsExceedTickSpacing = tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; + const labelsExceedTickSpacing = !labelSteps && tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; let leftOverhang = firstLabelWidth / 2; let rightOverhang = lastLabelWidth / 2; @@ -110,7 +121,7 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const edgeSpace = { left: Math.max(0, leftOverhang - VictoryTheme.axis.padding.left), - right: Math.max(0, rightOverhang - chartPaddingRight - VictoryTheme.axis.labelGap), + right: Math.max(0, rightOverhang - chartPaddingRight), }; return {...VictoryTheme.line.domainPadding, ...getDomainPaddingForEdgeSpace(edgeSpace, plotAreaWidth)}; })(); @@ -118,17 +129,41 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const totalDomainPadding = domainPadding.left + domainPadding.right; const paddingScale = plotAreaWidth > 0 ? plotAreaWidth / (plotAreaWidth + totalDomainPadding) : 0; - const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = useChartLabelLayout({ + const rotatingLabelLayout = useChartLabelLayout({ data, fontManager, fontSize: variables.iconSizeExtraSmall, tickSpacing, - labelAreaWidth: plotAreaWidth, + // Stepped labels never rotate, so a zero width makes this hook return early instead of computing a rotation + labelAreaWidth: labelSteps ? 0 : plotAreaWidth, firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, + lastTickRightSpace: chartWidth - boundsRight + domainPadding.right * paddingScale, measurements, }); + const steppedLabelLayout = (() => { + if (!labelSteps || plotAreaWidth <= 0) { + return undefined; + } + const firstPointX = boundsLeft + domainPadding.left * paddingScale; + const lastPointX = boundsRight - domainPadding.right * paddingScale; + const pointXs = data.map((_, index) => (data.length > 1 ? firstPointX + (index * (lastPointX - firstPointX)) / (data.length - 1) : (firstPointX + lastPointX) / 2)); + const step = getAxisLabelStep({labelWidths, pointXs, steps: labelSteps, minGap: X_AXIS_LABEL_MIN_GAP}); + return { + labelRotation: LABEL_ROTATIONS.HORIZONTAL, + labelSkipInterval: step, + truncatedLabelWidths: labelWidths, + xAxisLabelHeight: lineHeight, + regularLabelMaxWidth: Infinity, + firstLabelMaxWidth: Infinity, + lastLabelMaxWidth: Infinity, + ellipsisWidth: measurements.ellipsisWidth, + }; + })(); + + const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = + steppedLabelLayout ?? rotatingLabelLayout; + const originalLabels = data.map(getXAxisLabel); const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ @@ -144,9 +179,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi plotTop.set(bounds.top); plotLeft.set(bounds.left); plotRight.set(bounds.right); - setPlotAreaWidth(bounds.right - bounds.left); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); }; const labelSpace = VictoryTheme.axis.xAxisLabelGap + (xAxisLabelHeight ?? 0); @@ -169,7 +201,18 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi return isInPlotArea(args) && isCursorOverLabel(args, activeIndex); }; - const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition, activePointPosition, onChartMoved} = useChartInteractions({ + const { + customGestures, + setPointPositions, + matchedIndex, + isTooltipActive, + isCursorOverClickable, + initialTooltipPosition, + activePointPosition, + onChartMoved, + onTooltipDismiss, + onChartTouchStart, + } = useChartInteractions({ handlePress: onPointPress ? handlePointPress : undefined, checkIsOver: checkIsOverBand, isCursorOverLabel: checkIsOverLabelInPlotArea, @@ -224,7 +267,7 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi )} - {xAxisLabelHeight !== undefined && !!fontManager && ( + {xAxisLabelHeight !== undefined && ( )} - {!!fontManager && ( - - )} + - - - ); - } - - if (data.length === 0) { - return null; - } - return ( - {chartWidth > 0 && ( - - {({yScale, yTicks, chartBounds}) => ( - - )} - - )} + + {({yScale, yTicks, chartBounds}) => ( + + )} + ); } -function LineChartContent(props: LineChartProps) { +function LineChartContentBody({isLoading = false, chartWidth, ...canvasProps}: LineChartContentProps) { + const styles = useThemeStyles(); + const readyCanvas = useReadyCartesianCanvas(isLoading, chartWidth); + + if (!isLoading && canvasProps.data.length === 0) { + return null; + } + + if (!readyCanvas) { + return ( + + + + ); + } + + return ( + + ); +} + +function LineChartContent(props: LineChartContentProps) { return ( diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx index 2c6ac7c2941f..2fd8ea813de1 100644 --- a/src/components/Charts/LineChart/index.native.tsx +++ b/src/components/Charts/LineChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type {LineChartProps} from './LineChartContent'; @@ -5,7 +7,16 @@ import type {LineChartProps} from './LineChartContent'; import LineChartContent from './LineChartContent'; function LineChart(props: LineChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } LineChart.displayName = 'LineChart'; diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 7de73f7fcf44..01d3da19af53 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -7,10 +8,14 @@ import type {LineChartProps} from './LineChartContent'; const getLineChartContent = () => import('./LineChartContent'); function LineChart(props: LineChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 736eac355ca2..691f22d55310 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -1,21 +1,19 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import ChartTooltip from '@components/Charts/components/ChartTooltip'; -import {TOOLTIP_BAR_GAP, useChartLabelFormats, useTooltipData} from '@components/Charts/hooks'; +import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; +import {TOOLTIP_BAR_GAP, useChartLabelFormats, useTouchPinnedTooltip} from '@components/Charts/hooks'; import type {ChartDataPoint, ChartProps, PieSlice, UnitPosition} from '@components/Charts/types'; import {findSliceAtPosition, getPrimaryValue, processDataIntoSlices} from '@components/Charts/utils'; -import VictoryTheme from '@components/Charts/VictoryTheme'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {LayoutChangeEvent} from 'react-native'; - import React, {useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Animated, {useSharedValue} from 'react-native-reanimated'; +import {Gesture, GestureDetector, PointerType} from 'react-native-gesture-handler'; +import Animated, {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; import {Pie, PolarChart} from 'victory-native'; @@ -34,13 +32,19 @@ type PieChartProps = ChartProps & { shouldShowLegend?: boolean; }; -function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, onSlicePress, shouldShowLegend = true}: PieChartProps) { +type PieChartContentProps = PieChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; +}; + +type PieChartCanvasProps = Omit & { + chartWidth: number; +}; + +function PieChartCanvas({data, series, valueUnit, valueUnitPosition, onSlicePress, shouldShowLegend = true, chartWidth}: PieChartCanvasProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const [canvasWidth, setCanvasWidth] = useState(0); - const [canvasHeight, setCanvasHeight] = useState(0); - const [activeSliceIndex, setActiveSliceIndex] = useState(-1); const [isHoveringOverPie, setIsHoveringOverPie] = useState(false); // Shared values for hover state @@ -49,32 +53,44 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, const cursorY = useSharedValue(0); const tooltipPosition = useSharedValue({x: 0, y: 0}); - const handleLayout = (event: LayoutChangeEvent) => { - setCanvasWidth(event.nativeEvent.layout.width); - setCanvasHeight(event.nativeEvent.layout.height); - }; + /** Data index of the slice under the cursor or the last touch tap, resolved on the UI thread together with the tooltip position */ + const matchedIndex = useSharedValue(-1); + const isTooltipActive = useDerivedValue(() => matchedIndex.get() >= 0); - // Calculate pie geometry - const radius = Math.min(canvasWidth, canvasHeight) / 2; + const canvasHeight = CHART_CONTENT_MIN_HEIGHT; + const radius = Math.min(chartWidth, canvasHeight) / 2; const innerRadius = radius * VictoryTheme.pie.innerRadiusRatio; - const pieGeometry = {radius, innerRadius, centerX: canvasWidth / 2, centerY: canvasHeight / 2}; + const pieGeometry = {radius, innerRadius, centerX: chartWidth / 2, centerY: canvasHeight / 2}; // Slices are sorted by absolute value (largest first) for color assignment, // so slice indices don't match the original data array. We map back via // originalIndex so the tooltip can display the original (possibly negative) value. const [{key: primarySeriesKey}] = series; const processedSlices = processDataIntoSlices(data, primarySeriesKey, pieGeometry); - const activeOriginalDataIndex = activeSliceIndex >= 0 ? (processedSlices.at(activeSliceIndex)?.originalIndex ?? -1) : -1; const {formatValue} = useChartLabelFormats({data, unit: valueUnit, unitPosition: valueUnitPosition}); - const tooltipData = useTooltipData(activeOriginalDataIndex, data, series, formatValue); - // Handle hover state updates - const updateActiveSlice = (x: number, y: number) => { + /** Data index of the slice at the given point, or -1 when the point is outside the slices */ + const findDataIndexAt = (x: number, y: number) => { + 'worklet'; + const {centerX, centerY} = pieGeometry; const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, innerRadius, processedSlices); - setActiveSliceIndex(sliceIndex); - setIsHoveringOverPie(sliceIndex >= 0); + return sliceIndex >= 0 ? (processedSlices.at(sliceIndex)?.originalIndex ?? -1) : -1; + }; + + const hoverAt = (x: number, y: number) => { + 'worklet'; + + cursorX.set(x); + cursorY.set(y); + tooltipPosition.set({x, y: y - TOOLTIP_BAR_GAP}); + const dataIndex = findDataIndexAt(x, y); + const wasOverPie = matchedIndex.get() >= 0; + matchedIndex.set(dataIndex); + if (wasOverPie !== dataIndex >= 0) { + scheduleOnRN(setIsHoveringOverPie, dataIndex >= 0); + } }; // Handle slice press callback @@ -99,59 +115,72 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, if (!isHovering.get()) { return; } - const x = cursorX.get() - deltaX; - const y = cursorY.get() - deltaY; - cursorX.set(x); - cursorY.set(y); - tooltipPosition.set({x, y: y - TOOLTIP_BAR_GAP}); - scheduleOnRN(updateActiveSlice, x, y); + hoverAt(cursorX.get() - deltaX, cursorY.get() - deltaY); }; const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); + const {setIsTouchPinned, onTooltipDismiss, onTapFinalize, onChartTouchStart} = useTouchPinnedTooltip(() => { + matchedIndex.set(-1); + setIsHoveringOverPie(false); + }); + // Hover gesture const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } isHovering.set(true); - cursorX.set(e.x); - cursorY.set(e.y); - tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); - scheduleOnRN(updateActiveSlice, e.x, e.y); + scheduleOnRN(setIsTouchPinned, false); + hoverAt(e.x, e.y); }) .onUpdate((e) => { 'worklet'; - cursorX.set(e.x); - cursorY.set(e.y); - tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); - scheduleOnRN(updateActiveSlice, e.x, e.y); + if (e.pointerType === PointerType.TOUCH) { + return; + } + hoverAt(e.x, e.y); }) - .onEnd(() => { + .onEnd((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } isHovering.set(false); - scheduleOnRN(setActiveSliceIndex, -1); + matchedIndex.set(-1); scheduleOnRN(setIsHoveringOverPie, false); }); // Tap gesture for click/tap navigation const tapGesture = () => - Gesture.Tap().onEnd((e) => { - 'worklet'; + Gesture.Tap() + .onFinalize(onTapFinalize) + .onEnd((e) => { + 'worklet'; + + // Touch taps show the slice tooltip instead of drilling in, and the next touch anywhere on the screen hides it + if (e.pointerType === PointerType.TOUCH) { + hoverAt(e.x, e.y); + scheduleOnRN(setIsTouchPinned, matchedIndex.get() >= 0); + return; + } - const {centerX, centerY} = pieGeometry; - const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, innerRadius, processedSlices); + const {centerX, centerY} = pieGeometry; + const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, innerRadius, processedSlices); - if (sliceIndex >= 0) { - scheduleOnRN(handleSlicePress, sliceIndex); - } - }); + if (sliceIndex >= 0) { + scheduleOnRN(handleSlicePress, sliceIndex); + } + }); // Combined gestures - Race allows both hover and tap to work independently - const combinedGesture = onSlicePress ? Gesture.Race(hoverGesture(), tapGesture()) : hoverGesture(); + const combinedGesture = Gesture.Race(hoverGesture(), tapGesture()); const renderLegendItem = (slice: PieSlice) => { return ( @@ -160,10 +189,10 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, style={[styles.flexRow, styles.alignItemsCenter]} onMouseEnter={() => { tooltipPosition.set(slice.tooltipPosition); - setActiveSliceIndex(slice.ordinalIndex); + matchedIndex.set(slice.originalIndex); }} onMouseLeave={() => { - setActiveSliceIndex(-1); + matchedIndex.set(-1); }} > @@ -172,18 +201,6 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, ); }; - if (isLoading) { - return ( - - - - ); - } - - if (data.length === 0) { - return null; - } - return ( <> {processedSlices.length > 0 && ( = 0 && !!tooltipData && ( - - )} + {shouldShowLegend && {processedSlices.map((slice) => renderLegendItem(slice))}} @@ -250,5 +268,28 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, ); } +function PieChartContent({isLoading = false, chartWidth, ...canvasProps}: PieChartContentProps) { + const styles = useThemeStyles(); + + if (!isLoading && canvasProps.data.length === 0) { + return null; + } + + if (isLoading || chartWidth === null) { + return ( + + + + ); + } + + return ( + + ); +} + export default PieChartContent; export type {PieChartProps}; diff --git a/src/components/Charts/PieChart/index.native.tsx b/src/components/Charts/PieChart/index.native.tsx index 961f5bdfb82c..97d224a27cbf 100644 --- a/src/components/Charts/PieChart/index.native.tsx +++ b/src/components/Charts/PieChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type {PieChartProps} from './PieChartContent'; @@ -5,7 +7,16 @@ import type {PieChartProps} from './PieChartContent'; import PieChartContent from './PieChartContent'; function PieChart(props: PieChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } PieChart.displayName = 'PieChart'; diff --git a/src/components/Charts/PieChart/index.tsx b/src/components/Charts/PieChart/index.tsx index 66999fee714f..1441e2cdc847 100644 --- a/src/components/Charts/PieChart/index.tsx +++ b/src/components/Charts/PieChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -8,10 +9,14 @@ const getPieChartContent = () => import('./PieChartContent'); function PieChart(props: PieChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/VictoryTheme.ts b/src/components/Charts/VictoryTheme.ts index ae1366c61bf7..2af60fdd94ad 100644 --- a/src/components/Charts/VictoryTheme.ts +++ b/src/components/Charts/VictoryTheme.ts @@ -91,6 +91,12 @@ const VictoryTheme = { }, } as const; +const CARTESIAN_Y_AXIS = { + tickCount: VictoryTheme.axis.tickCount, + axisSide: 'right', + lineWidth: 0, +} as const; + /** Minimum height for the chart content area (bars, Y-axis, grid lines) */ const CHART_CONTENT_MIN_HEIGHT = 250; @@ -109,6 +115,9 @@ const SIN_45 = Math.sin(Math.PI / 4); /** Minimum gap between adjacent labels (px) */ const LABEL_PADDING = 4; +/** Minimum clear space between adjacent horizontal x-axis labels on stepped axes (px) */ +const X_AXIS_LABEL_MIN_GAP = 24; + const ELLIPSIS = '...'; /** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ @@ -131,7 +140,9 @@ const DASH_INTERVALS = [4, 8]; const GLYPH_PADDING = 4; export { + CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, + X_AXIS_LABEL_MIN_GAP, CHART_Y_SCALE_HEIGHT, LABEL_ROTATIONS, SIN_45, diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index dd13d3e6b78c..07a5ce168780 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -1,4 +1,5 @@ import {TOOLTIP_BAR_GAP} from '@components/Charts/hooks'; +import {pinChartTooltip, unpinChartTooltip} from '@components/Charts/utils/pinnedChartTooltip'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -8,11 +9,11 @@ import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUti import variables from '@styles/variables'; -import type {ComponentRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {DerivedValue, SharedValue} from 'react-native-reanimated'; -import React, {useLayoutEffect, useRef, useState} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import Animated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -41,8 +42,20 @@ type ChartTooltipProps = { /** Whether the tooltip should be shown, treated as always shown when omitted */ isVisible?: DerivedValue; + /** Whether the rendered content belongs to the current target, so the old content is never drawn at the new position */ + isContentCurrent?: DerivedValue; + /** Updates the hovered point after the chart is moved in the window (e.g. via scroll) by the given offset */ onChartMoved?: (deltaX: number, deltaY: number) => void; + + /** Hides a tooltip pinned by a touch tap, passed only while it is pinned */ + onDismiss?: () => void; + + /** Identifies the item the content belongs to, so the content is mounted again and laid out on each change of the item */ + contentKey?: number; + + /** Called once the content is laid out, which is the first moment it is on screen on native */ + onContentLayout?: () => void; }; function getAmountContent(amount: string, percentage?: string): string { @@ -53,7 +66,20 @@ function getAmountContent(amount: string, percentage?: string): string { return `${amount} (${percentage})`; } -function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, initialTooltipPosition, isVisible, onChartMoved}: ChartTooltipProps) { +function ChartTooltip({ + label, + amount, + percentage, + expenseCount, + chartWidth, + initialTooltipPosition, + isVisible, + isContentCurrent, + onChartMoved, + onDismiss, + contentKey, + onContentLayout, +}: ChartTooltipProps) { const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); @@ -73,27 +99,16 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init const [isShown, setIsShown] = useState(false); const amountContent = getAmountContent(amount, percentage); - const content = [label, amountContent, expenseCount].join('|'); - - const tooltipWrapperRef = useRef>(null); - - useLayoutEffect(() => { - tooltipWrapperRef.current?.measure((x: number, y: number, width: number, height: number) => { - if (width <= 0) { - return; - } - tooltipMeasuredWidth.set(width); - tooltipMeasuredHeight.set(height); - }); - }, [content, chartWidth, tooltipMeasuredWidth, tooltipMeasuredHeight]); - const updateMeasuredSize = (event: LayoutChangeEvent) => { + // The size is set before the content is reported as laid out, so the tooltip is never revealed with the previous content's size + const handleContentLayout = (event: LayoutChangeEvent) => { const {width, height} = event.nativeEvent.layout; if (width <= 0) { return; } tooltipMeasuredWidth.set(width); tooltipMeasuredHeight.set(height); + onContentLayout?.(); }; useAnimatedReaction( @@ -109,6 +124,22 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init }, ); + const isFocused = useIsFocused(); + + // A pinned tooltip is registered for the screen's touch start to hide it + useEffect(() => { + if (!isShown || !onDismiss) { + return; + } + + if (!isFocused) { + onDismiss(); + return; + } + pinChartTooltip(onDismiss); + return () => unpinChartTooltip(onDismiss); + }, [isShown, isFocused, onDismiss]); + const handleOriginChange = (x: number, y: number) => { const previousOrigin = origin.get(); const wasOriginMeasured = isOriginMeasured.get(); @@ -142,7 +173,7 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init const topAbove = originY + y - height; const shiftedLeft = left + PopoverWithMeasuredContentUtils.computeHorizontalShift(left, width, windowWidth); const shiftedTop = topAbove + PopoverWithMeasuredContentUtils.computeVerticalShift(topAbove, height, windowHeight, 2 * TOOLTIP_BAR_GAP, true); - const isTooltipShown = (isVisible?.get() ?? true) && isOriginMeasured.get() && width > 0; + const isTooltipShown = (isVisible?.get() ?? true) && (isContentCurrent?.get() ?? true) && isOriginMeasured.get() && width > 0; return { left: Math.max(0, Math.min(windowWidth - width, shiftedLeft)), @@ -161,10 +192,12 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init - + {label} {!!amountContent && {amountContent}} {!!expenseCount && {expenseCount}} diff --git a/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx b/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx index 2fc80243b4ef..4b9fc76b5fb1 100644 --- a/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx +++ b/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx @@ -1,9 +1,44 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {ComponentRef} from 'react'; + +import React, {useEffect, useRef} from 'react'; +import {DeviceEventEmitter, View} from 'react-native'; + import type ChartTooltipAnchorProps from './types'; -// The chart tooltip is not shown on native yet, the anchor will be implemented together with the mobile tooltip (https://github.com/Expensify/App/issues/102865) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function ChartTooltipAnchor(props: ChartTooltipAnchorProps) { - return null; +/** Zero-size view pinned to the chart's top-left corner, which reports where the chart sits in the window when the tooltip shows and after each scroll */ +function ChartTooltipAnchor({isShown, onOriginChange}: ChartTooltipAnchorProps) { + const styles = useThemeStyles(); + const anchorRef = useRef>(null); + + useEffect(() => { + if (!isShown) { + return; + } + + const measureOrigin = () => anchorRef.current?.measureInWindow((x, y) => onOriginChange(x, y)); + measureOrigin(); + + // Native views can't be observed while they move, so the position is measured again once the scroll ends + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (isScrolling: boolean) => { + if (isScrolling) { + return; + } + measureOrigin(); + }); + return () => scrollingListener.remove(); + }, [isShown, onOriginChange]); + + return ( + + ); } export default ChartTooltipAnchor; diff --git a/src/components/Charts/components/ChartTooltipLayer.tsx b/src/components/Charts/components/ChartTooltipLayer.tsx index 32a46f1cbb12..a398f31ef11b 100644 --- a/src/components/Charts/components/ChartTooltipLayer.tsx +++ b/src/components/Charts/components/ChartTooltipLayer.tsx @@ -5,7 +5,7 @@ import type {DerivedValue, SharedValue} from 'react-native-reanimated'; import type {NonEmptyTuple} from 'type-fest'; import React, {useState} from 'react'; -import {useAnimatedReaction} from 'react-native-reanimated'; +import {useAnimatedReaction, useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import ChartTooltip from './ChartTooltip'; @@ -33,15 +33,21 @@ type ChartTooltipLayerProps = { /** Called with how far the chart moved in the window while the tooltip is shown */ onChartMoved?: (deltaX: number, deltaY: number) => void; + + /** Hides a tooltip pinned by a touch tap, passed only while it is pinned */ + onDismiss?: () => void; }; /** * Renders the chart tooltip in an isolated subtree so that hover-driven state changes * (active index, visibility) only re-render this lightweight component, not the chart itself. */ -function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatValue, chartWidth, initialTooltipPosition, onChartMoved}: ChartTooltipLayerProps) { +function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatValue, chartWidth, initialTooltipPosition, onChartMoved, onDismiss}: ChartTooltipLayerProps) { const [activeDataIndex, setActiveDataIndex] = useState(-1); + // Set from the content's layout, since on native a value set during render can reach the UI thread before the new content is mounted + const laidOutIndex = useSharedValue(-1); + useAnimatedReaction( () => matchedIndex.get(), (idx) => { @@ -49,6 +55,7 @@ function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatV }, ); + const isContentCurrent = useDerivedValue(() => matchedIndex.get() === laidOutIndex.get()); const tooltipData = useTooltipData(activeDataIndex, data, series, formatValue); if (!tooltipData) { @@ -64,7 +71,11 @@ function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatV chartWidth={chartWidth} initialTooltipPosition={initialTooltipPosition} isVisible={isTooltipActive} + isContentCurrent={isContentCurrent} onChartMoved={onChartMoved} + onDismiss={onDismiss} + contentKey={activeDataIndex} + onContentLayout={() => laidOutIndex.set(activeDataIndex)} /> ); } diff --git a/src/components/Charts/components/ChartTooltipPortal/index.native.tsx b/src/components/Charts/components/ChartTooltipPortal/index.native.tsx index cba811a14e5a..25dbe332d2c9 100644 --- a/src/components/Charts/components/ChartTooltipPortal/index.native.tsx +++ b/src/components/Charts/components/ChartTooltipPortal/index.native.tsx @@ -1,9 +1,11 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// The chart tooltip is not shown on native yet, the portal will be implemented together with the mobile tooltip (https://github.com/Expensify/App/issues/102865) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function ChartTooltipPortal(props: ChildrenProps) { - return null; +import {Portal} from '@gorhom/portal'; +import React from 'react'; + +/** Renders the chart tooltip in the root portal host so no clipping ancestor (e.g. a ScrollView) can cut it off */ +function ChartTooltipPortal({children}: ChildrenProps) { + return {children}; } export default ChartTooltipPortal; diff --git a/src/components/Charts/components/ChartWidthBox.tsx b/src/components/Charts/components/ChartWidthBox.tsx new file mode 100644 index 000000000000..4d71efe2c303 --- /dev/null +++ b/src/components/Charts/components/ChartWidthBox.tsx @@ -0,0 +1,39 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; + +import {useState} from 'react'; +import {View} from 'react-native'; + +type ChartWidthBoxProps = { + /** Receives the card's width, or null until the card has been measured. */ + children: (chartWidth: number | null) => ReactNode; +}; + +/** On web the chart component mounts only once the chart engine has downloaded, so measuring here keeps the width across that mount. */ +function ChartWidthBox({children}: ChartWidthBoxProps) { + const styles = useThemeStyles(); + const [chartWidth, setChartWidth] = useState(null); + + const onLayout = (event: LayoutChangeEvent) => { + const {width} = event.nativeEvent.layout; + + // A hidden card reports a width of 0. Keeping the last width keeps the chart and its hover state mounted until the card shows again. + if (width <= 0) { + return; + } + setChartWidth(width); + }; + + return ( + + {children(chartWidth)} + + ); +} + +export default ChartWidthBox; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 5fa23151604b..496999231eb1 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,7 +1,6 @@ export {default as useChartLabelLayout} from './useChartLabelLayout'; export {default as useChartLabelMeasurements} from './useChartLabelMeasurements'; export {default as useChartParagraphs} from './useChartParagraphs'; -export {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; export {default as ChartFontsProvider} from '@components/Charts/context/ChartFontsProvider'; export {useChartInteractions, TOOLTIP_BAR_GAP} from './useChartInteractions'; export type {HitTestArgs, ResolveTargetIndexArgs} from './useChartInteractions'; @@ -9,3 +8,6 @@ export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {default as useTooltipData} from './useTooltipData'; export {default as useLabelHitTesting} from './useLabelHitTesting'; +export {default as useReadyCartesianCanvas} from './useReadyCartesianCanvas'; +export type {CartesianCanvasInputs} from './useReadyCartesianCanvas'; +export {default as useTouchPinnedTooltip} from './useTouchPinnedTooltip'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index efe5aeb613f5..ff0452868fad 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,11 +1,12 @@ import type {SharedValue} from 'react-native-reanimated'; import {useCallback} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {Gesture, PointerType} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; import useChartInteractionState from './useChartInteractionState'; +import useTouchPinnedTooltip from './useTouchPinnedTooltip'; /** Gap between bar top and tooltip bottom */ const TOOLTIP_BAR_GAP = 8; @@ -100,6 +101,9 @@ type UseChartInteractionsProps = { /** Scale applied to the rendered chart container */ coordinateScale?: number; + + /** Whether a touch tap shows the tooltip for the tapped data point */ + shouldPinTooltipOnTouch?: boolean; }; function normalizeChartCoordinate(coordinate: number, coordinateScale: number): number { @@ -172,6 +176,7 @@ function useChartInteractions({ chartBottom, yZero, coordinateScale = 1, + shouldPinTooltipOnTouch = true, }: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState} = useChartInteractionState(); @@ -303,22 +308,29 @@ function useChartInteractions({ return updateInteractionFlags(hitTestArgs.targetIndex, hitTestArgs.cursorX, hitTestArgs.cursorY, hitTestArgs.chartBottom); }; + /** Resolves the target at the given chart position, then updates the interaction flags and returns whether the cursor is over the target */ + const matchTargetAt = (cursorX: number, cursorY: number) => { + 'worklet'; + + const bottom = chartBottom?.get() ?? cursorY; + const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; + const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); + applyTargetIndex(targetIndex); + return updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + }; + /** Moves the hover cursor to the given chart position and updates the matched target and the tooltip visibility */ const updateHoverAt = (cursorX: number, cursorY: number) => { 'worklet'; chartInteractionState.cursor.x.set(cursorX); chartInteractionState.cursor.y.set(cursorY); - const bottom = chartBottom?.get() ?? cursorY; const isOverCurrentTarget = updateCurrentInteractionFlags(); // Only update the matched index when the cursor is not over the current target. // This keeps the active index locked while hovering over a bar/point/label, // preventing it from jumping to a different point during continuous movement. if (!isOverCurrentTarget) { - const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; - const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); - applyTargetIndex(targetIndex); - updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + matchTargetAt(cursorX, cursorY); } }; @@ -337,6 +349,20 @@ function useChartInteractions({ const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); + const hideTooltip = () => { + 'worklet'; + + chartInteractionState.isActive.set(false); + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); + }; + + const {setIsTouchPinned, dismissTouchTooltip, onTooltipDismiss, onTapFinalize, onChartTouchStart} = useTouchPinnedTooltip(() => { + hideTooltip(); + applyTargetIndex(-1); + }); + /** * Hover gesture to be placed on the full-height outer container (chart + label area). * Clamps the y coordinate to chartBottom before passing to Victory so that hovering @@ -350,60 +376,84 @@ function useChartInteractions({ .onBegin((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } + scheduleOnRN(setIsTouchPinned, false); const cursorX = normalizeChartCoordinate(e.x, coordinateScale); const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.isActive.set(true); chartInteractionState.cursor.x.set(cursorX); chartInteractionState.cursor.y.set(cursorY); - const bottom = chartBottom?.get() ?? cursorY; - const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; - const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); - applyTargetIndex(targetIndex); - updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + matchTargetAt(cursorX, cursorY); }) .onUpdate((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } updateHoverAt(normalizeChartCoordinate(e.x, coordinateScale), normalizeChartCoordinate(e.y, coordinateScale)); }) - .onEnd(() => { + .onEnd((e) => { 'worklet'; - chartInteractionState.isActive.set(false); - isCursorOverTarget.set(false); - isCursorOverClickable.set(false); - isTooltipActive.set(false); + if (e.pointerType === PointerType.TOUCH) { + return; + } + hideTooltip(); }); + const handleTouchTap = (cursorX: number, cursorY: number) => { + 'worklet'; + + chartInteractionState.isActive.set(true); + const isOverTarget = matchTargetAt(cursorX, cursorY); + if (!isOverTarget) { + scheduleOnRN(dismissTouchTooltip); + return; + } + scheduleOnRN(setIsTouchPinned, true); + }; + /** - * Tap gesture. Resolves the nearest data point entirely on the UI thread, - * then schedules onPress on the JS thread if the cursor is over the target. + * Tap gesture. Resolves the nearest data point entirely on the UI thread. + * Touch taps show the tooltip; other pointers schedule onPress on the JS thread if the cursor is over the target. */ - const tapGesture = (onPress: (index: number) => void) => - Gesture.Tap().onEnd((e) => { - 'worklet'; - - const cursorX = normalizeChartCoordinate(e.x, coordinateScale); - const cursorY = normalizeChartCoordinate(e.y, coordinateScale); - chartInteractionState.cursor.x.set(cursorX); - chartInteractionState.cursor.y.set(cursorY); - const ox = pointOX.get(); - const oy = pointOY.get(); - const idx = getResolvedTargetIndex(cursorX, cursorY, cursorX); - applyTargetIndex(idx); - if (idx < 0) { - return; - } - const targetX = ox.at(idx) ?? 0; - const targetY = oy.at(idx) ?? 0; - const currentChartBottom = chartBottom?.get() ?? 0; - const hitTestArgs = getHitTestArgs(idx, cursorX, cursorY, targetX, targetY, currentChartBottom); - const isClickable = (checkIsClickable ?? checkIsOver)(hitTestArgs); - updateInteractionFlags(idx, cursorX, cursorY, currentChartBottom); - if (isClickable) { - scheduleOnRN(onPress, idx); - } - }); + const tapGesture = (onPress?: (index: number) => void) => + Gesture.Tap() + .onFinalize(onTapFinalize) + .onEnd((e) => { + 'worklet'; + + const cursorX = normalizeChartCoordinate(e.x, coordinateScale); + const cursorY = normalizeChartCoordinate(e.y, coordinateScale); + chartInteractionState.cursor.x.set(cursorX); + chartInteractionState.cursor.y.set(cursorY); + if (e.pointerType === PointerType.TOUCH && shouldPinTooltipOnTouch) { + handleTouchTap(cursorX, cursorY); + return; + } + if (!onPress) { + return; + } + const ox = pointOX.get(); + const oy = pointOY.get(); + const idx = getResolvedTargetIndex(cursorX, cursorY, cursorX); + applyTargetIndex(idx); + if (idx < 0) { + return; + } + const targetX = ox.at(idx) ?? 0; + const targetY = oy.at(idx) ?? 0; + const currentChartBottom = chartBottom?.get() ?? 0; + const hitTestArgs = getHitTestArgs(idx, cursorX, cursorY, targetX, targetY, currentChartBottom); + const isClickable = (checkIsClickable ?? checkIsOver)(hitTestArgs); + updateInteractionFlags(idx, cursorX, cursorY, currentChartBottom); + if (isClickable) { + scheduleOnRN(onPress, idx); + } + }); /** * Raw tooltip positioning data. @@ -430,7 +480,7 @@ function useChartInteractions({ y: chartInteractionState.y.y.position.get(), })); - const customGestures = handlePress ? Gesture.Race(hoverGesture(), tapGesture(handlePress)) : hoverGesture(); + const customGestures = handlePress || shouldPinTooltipOnTouch ? Gesture.Race(hoverGesture(), tapGesture(handlePress)) : hoverGesture(); return { /** Custom gestures to be passed to CartesianChart */ @@ -452,6 +502,10 @@ function useChartInteractions({ activePointPosition, /** Call with how far the chart moved in the window, e.g. on page scroll, to re-check the hover under a still cursor */ onChartMoved, + /** Hides the tooltip pinned by a touch tap, undefined while nothing is pinned */ + onTooltipDismiss, + /** Marks a touch on this chart, so the screen's touch start keeps the tooltip and lets the tap switch it */ + onChartTouchStart, }; } diff --git a/src/components/Charts/hooks/useReadyCartesianCanvas.ts b/src/components/Charts/hooks/useReadyCartesianCanvas.ts new file mode 100644 index 000000000000..13e7d3288bfa --- /dev/null +++ b/src/components/Charts/hooks/useReadyCartesianCanvas.ts @@ -0,0 +1,21 @@ +import {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; + +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; + +type CartesianCanvasInputs = { + chartWidth: number; + fontManager: SkTypefaceFontProvider; +}; + +function useReadyCartesianCanvas(isLoading: boolean, chartWidth: number | null): CartesianCanvasInputs | null { + const fontManager = useChartFontManager(); + + if (isLoading || !fontManager || chartWidth === null) { + return null; + } + + return {chartWidth, fontManager}; +} + +export default useReadyCartesianCanvas; +export type {CartesianCanvasInputs}; diff --git a/src/components/Charts/hooks/useTouchPinnedTooltip.ts b/src/components/Charts/hooks/useTouchPinnedTooltip.ts new file mode 100644 index 000000000000..fa5efe1fe9a8 --- /dev/null +++ b/src/components/Charts/hooks/useTouchPinnedTooltip.ts @@ -0,0 +1,45 @@ +import {markChartTouch} from '@components/Charts/utils/pinnedChartTooltip'; + +import {useState} from 'react'; +import {PointerType} from 'react-native-gesture-handler'; +import {scheduleOnRN} from 'react-native-worklets'; + +/** + * Tracks the tooltip pinned by a touch tap, which a touch anywhere on the screen hides. + * @param hideTooltip - hides the chart's tooltip and clears its matched target + */ +function useTouchPinnedTooltip(hideTooltip: () => void) { + /** True while a tooltip opened by a touch tap is shown */ + const [isTouchPinned, setIsTouchPinned] = useState(false); + + const dismissTouchTooltip = () => { + hideTooltip(); + setIsTouchPinned(false); + }; + + const onTooltipDismiss = isTouchPinned ? dismissTouchTooltip : undefined; + + const onTapFinalize = (e: {pointerType: PointerType}, success: boolean) => { + 'worklet'; + + if (success || e.pointerType !== PointerType.TOUCH || !isTouchPinned) { + return; + } + scheduleOnRN(dismissTouchTooltip); + }; + + return { + /** Sets whether the tooltip is pinned by a touch tap */ + setIsTouchPinned, + /** Hides the tooltip and unpins it */ + dismissTouchTooltip, + /** Hides the pinned tooltip, undefined while nothing is pinned */ + onTooltipDismiss, + /** Pass to the tap gesture's onFinalize */ + onTapFinalize, + /** Marks a touch on this chart, so the screen's touch start keeps the tooltip and lets the tap switch it */ + onChartTouchStart: () => markChartTouch(onTooltipDismiss), + }; +} + +export default useTouchPinnedTooltip; diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index fe3f59fb994e..890f3a0e61a8 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -455,6 +455,16 @@ function getBarLayout(plotWidth: number, barCount: number): {barWidth: number; g return {barWidth, gap, edgeSpace, xDomain: [-edgeSpaceInXUnits, barCount - 1 + edgeSpaceInXUnits]}; } +/** + * Horizontal plot bounds of a cartesian chart whose y axis is `CARTESIAN_Y_AXIS`, mirroring victory-native's layout, + * so labels can be laid out from the width before the chart mounts. + */ +function getCartesianPlotBounds(chartWidth: number, paddingRight: number): {left: number; right: number; width: number} { + const left = VictoryTheme.axis.padding.left; + const right = Math.max(left, chartWidth - paddingRight); + return {left, right, width: right - left}; +} + /** * Domain padding that leaves `edgeSpace` px between the plot edges and the first and last points. * victory-native fits the padded domain back into the plot width, so the space on screen is smaller than the padding. @@ -467,6 +477,44 @@ function getDomainPaddingForEdgeSpace(edgeSpace: {left: number; right: number}, return {left: (edgeSpace.left * plotWidth) / pointsSpan, right: (edgeSpace.right * plotWidth) / pointsSpan}; } +type AxisLabelStepParams = { + /** Pixel width of each point's label */ + labelWidths: number[]; + + /** Canvas x position of each point */ + pointXs: number[]; + + /** Allowed steps between labeled points, ascending */ + steps: readonly number[]; + + /** Minimum clear space between adjacent labels (px) */ + minGap: number; +}; + +/** + * Picks the smallest step whose labels (points 0, step, 2 * step, ...), centered under their points, don't overlap. + * When no step fits, multiples of the last step are tried, down to labeling only the first point. + */ +function getAxisLabelStep({labelWidths, pointXs, steps, minGap}: AxisLabelStepParams): number { + const getLeft = (i: number) => (pointXs.at(i) ?? 0) - (labelWidths.at(i) ?? 0) / 2; + const fits = (step: number) => { + for (let i = step; i < pointXs.length; i += step) { + const previousRight = getLeft(i - step) + (labelWidths.at(i - step) ?? 0); + if (getLeft(i) - previousRight < minGap) { + return false; + } + } + return true; + }; + + const candidates = [...steps]; + const lastStep = steps.at(-1) ?? 1; + for (let step = lastStep * 2; step < pointXs.length; step += lastStep) { + candidates.push(step); + } + return candidates.find(fits) ?? Math.max(1, pointXs.length); +} + /** Returns the pixel width needed for Y-axis labels given the chart data. */ function getYAxisLabelWidth( data: ChartDataPoint[], @@ -514,8 +562,10 @@ export { isCursorOverChartLabel, getNiceYAxisTicks, getYAxisLabelWidth, + getCartesianPlotBounds, getBarLayout, getDomainPaddingForEdgeSpace, + getAxisLabelStep, }; export type {ChartLabelHitTestParams}; diff --git a/src/components/Charts/utils/pinnedChartTooltip.ts b/src/components/Charts/utils/pinnedChartTooltip.ts new file mode 100644 index 000000000000..01e1eba77527 --- /dev/null +++ b/src/components/Charts/utils/pinnedChartTooltip.ts @@ -0,0 +1,39 @@ +/** Hides the tooltip pinned by a touch tap */ +let dismissPinnedTooltip: (() => void) | undefined; + +/** Tooltip whose chart received the current touch */ +let touchedTooltip: (() => void) | undefined; + +/** Pins the given tooltip and hides the previous one */ +function pinChartTooltip(dismiss: () => void) { + if (dismissPinnedTooltip !== dismiss) { + dismissPinnedTooltip?.(); + } + dismissPinnedTooltip = dismiss; +} + +/** Forgets the given tooltip if it is still the pinned one */ +function unpinChartTooltip(dismiss: () => void) { + if (dismissPinnedTooltip !== dismiss) { + return; + } + dismissPinnedTooltip = undefined; +} + +function markChartTouch(dismiss: (() => void) | undefined) { + touchedTooltip = dismiss; +} + +/** Hides the pinned tooltip, unless the touch landed on its own chart */ +function dismissPinnedChartTooltip() { + const isOwnChartTouch = !!touchedTooltip && touchedTooltip === dismissPinnedTooltip; + touchedTooltip = undefined; + if (isOwnChartTouch) { + return; + } + const dismiss = dismissPinnedTooltip; + dismissPinnedTooltip = undefined; + dismiss?.(); +} + +export {pinChartTooltip, unpinChartTooltip, markChartTouch, dismissPinnedChartTooltip}; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index 77d0f0c47bc6..4d9ec5ee97ff 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -48,6 +48,9 @@ type CheckboxWithLabelProps = RequiredLabelProps & { defaultValue?: boolean; + /** Whether the checkbox can't be toggled */ + disabled?: boolean; + /** The ID used to uniquely identify the input in a Form */ /* eslint-disable-next-line react/no-unused-prop-types */ inputID?: string; @@ -70,6 +73,7 @@ function CheckboxWithLabel({ accessibilityLabel, style, value, + disabled = false, ref, }: CheckboxWithLabelProps) { const styles = useThemeStyles(); @@ -96,6 +100,7 @@ function CheckboxWithLabel({ onPress={toggleCheckbox} style={[styles.checkboxWithLabelCheckboxStyle]} hasError={!!errorText} + disabled={disabled} ref={ref} accessibilityLabel={accessibilityLabel ?? label ?? ''} /> @@ -103,6 +108,7 @@ function CheckboxWithLabel({ tabIndex={-1} accessible={false} onPress={toggleCheckbox} + disabled={disabled} pressDimmingValue={variables.checkboxLabelActiveOpacity} // We want to disable hover dimming hoverDimmingValue={variables.checkboxLabelHoverOpacity} diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index 2bec67d4e35e..0d1bdafffcd7 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -107,6 +107,13 @@ function ConfirmationPage({ const illustrations = useMemoizedLazyIllustrations(['Fireworks']); const isLottie = isIllustrationLottieAnimation(illustration); const shouldShowStaticFallback = isLottie && isReduceMotionEnabled && illustration === LottieAnimations.Fireworks; + const flattenedIllustrationStyle = StyleSheet.flatten(illustrationStyle); + const flattenedIllustrationWidth = flattenedIllustrationStyle?.width; + const flattenedIllustrationHeight = flattenedIllustrationStyle?.height; + const webIllustrationWidth = + typeof flattenedIllustrationWidth === 'string' || typeof flattenedIllustrationWidth === 'number' ? flattenedIllustrationWidth : styles.confirmationAnimation.width; + const webIllustrationHeight = + typeof flattenedIllustrationHeight === 'string' || typeof flattenedIllustrationHeight === 'number' ? flattenedIllustrationHeight : styles.confirmationAnimation.height; return ( @@ -132,8 +139,8 @@ function ConfirmationPage({ loop style={[styles.confirmationAnimation, illustrationStyle]} webStyle={{ - width: (StyleSheet.flatten(illustrationStyle)?.width as number) ?? styles.confirmationAnimation.width, - height: (StyleSheet.flatten(illustrationStyle)?.height as number) ?? styles.confirmationAnimation.height, + width: webIllustrationWidth, + height: webIllustrationHeight, }} /> ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index fd76763912b1..58d4d0f13fcd 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -5,6 +5,7 @@ import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -51,9 +52,14 @@ function DatePicker({ rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + icon, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + // The clear button takes the calendar icon's place once a date is picked, so it gets the same horizontal padding as the icon's + // container instead of its own margins. Otherwise the X sits in a different spot than the icon it replaces. + const {paddingHorizontal: iconPaddingHorizontal} = StyleUtils.getTextInputIconContainerStyles(true); const {windowHeight, windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); @@ -225,7 +231,7 @@ function DatePicker({ ref={combinedTextInputRef} inputID={inputID} forceActiveLabel - icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar} + icon={selectedDate || shouldHideCalendarIcon ? null : (icon ?? icons.Calendar)} iconContainerStyle={styles.pr0} label={label} accessibilityLabel={label} @@ -243,6 +249,7 @@ function DatePicker({ textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} + clearButtonStyle={[styles.mh0, {paddingHorizontal: iconPaddingHorizontal}]} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} disableKeyboard diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 2cd3fc73e91c..12e99fdf4b84 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -4,6 +4,7 @@ import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {ComponentRef, RefObject} from 'react'; @@ -34,6 +35,9 @@ type DisplayNamesTooltipItemProps = ForwardedFSClassProps & { /** Refs to all the names which will be used to correct the horizontal position of the tooltip */ childRefs: RefObject>>; + + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; function DisplayNamesTooltipItem({ @@ -46,6 +50,7 @@ function DisplayNamesTooltipItem({ textStyles = [], childRefs = {current: []}, forwardedFSClass, + isSelectable = false, }: DisplayNamesTooltipItemProps) { const styles = useThemeStyles(); const tooltipIndexBridge = useCallback(() => getTooltipShiftX(index), [getTooltipShiftX, index]); @@ -72,6 +77,8 @@ function DisplayNamesTooltipItem({ }} style={[textStyles, styles.pre]} fsClass={forwardedFSClass} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {displayName} diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 28ab5c120ff0..274774ed863d 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -4,6 +4,7 @@ import Tooltip from '@components/Tooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import {formatReportLastMessageText} from '@libs/ReportUtils'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports @@ -28,6 +29,7 @@ function DisplayNamesWithToolTip({ renderAdditionalText, forwardedFSClass, accessibilityLabel, + isSelectable = false, }: DisplayNamesProps) { const styles = useThemeStyles(); const containerRef = useRef(null); @@ -73,6 +75,8 @@ function DisplayNamesWithToolTip({ ref={containerRef} testID="DisplayNamesWithToolTip" fsClass={forwardedFSClass} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {shouldUseFullTitle ? formatReportLastMessageText(fullTitle) @@ -89,6 +93,7 @@ function DisplayNamesWithToolTip({ textStyles={textStyles} childRefs={childRefs} forwardedFSClass={forwardedFSClass} + isSelectable={isSelectable} /> {index < displayNamesWithTooltips.length - 1 && , } {shouldAddEllipsis && index === displayNamesWithTooltips.length - 1 && ...} diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx index 9d609fc6eece..7dd8e0f7649e 100644 --- a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx @@ -3,6 +3,7 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {StyleProp, TextStyle} from 'react-native'; @@ -22,9 +23,20 @@ type DisplayNamesWithoutTooltipProps = ForwardedFSClassProps & { /** Overrides the text read by screen readers. */ accessibilityLabel?: string; + + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; -function DisplayNamesWithoutTooltip({textStyles = [], numberOfLines = 1, fullTitle = '', renderAdditionalText, forwardedFSClass, accessibilityLabel}: DisplayNamesWithoutTooltipProps) { +function DisplayNamesWithoutTooltip({ + textStyles = [], + numberOfLines = 1, + fullTitle = '', + renderAdditionalText, + forwardedFSClass, + accessibilityLabel, + isSelectable = false, +}: DisplayNamesWithoutTooltipProps) { const styles = useThemeStyles(); return ( {fullTitle} {renderAdditionalText?.()} diff --git a/src/components/DisplayNames/index.native.tsx b/src/components/DisplayNames/index.native.tsx index b3c62e101399..b07d4578867e 100644 --- a/src/components/DisplayNames/index.native.tsx +++ b/src/components/DisplayNames/index.native.tsx @@ -13,7 +13,17 @@ import React, {useMemo} from 'react'; import type DisplayNamesProps from './types'; // As we don't have to show tooltips of the Native platform so we simply render the full display names list. -function DisplayNames({accessibilityLabel, fullTitle, textStyles = [], numberOfLines = 1, renderAdditionalText, forwardedFSClass, testID, shouldParseFullTitle = true}: DisplayNamesProps) { +function DisplayNames({ + accessibilityLabel, + fullTitle, + textStyles = [], + numberOfLines = 1, + renderAdditionalText, + forwardedFSClass, + testID, + shouldParseFullTitle = true, + isSelectable = false, +}: DisplayNamesProps) { const {translate} = useLocalize(); const titleContainsTextAndCustomEmoji = useMemo(() => containsCustomEmoji(fullTitle) && !containsOnlyCustomEmoji(fullTitle), [fullTitle]); const title = useMemo(() => { @@ -28,6 +38,7 @@ function DisplayNames({accessibilityLabel, fullTitle, textStyles = [], numberOfL numberOfLines={numberOfLines} testID={`DisplayNames${testID !== undefined ? `-${testID}` : ''}`} fsClass={forwardedFSClass} + selectable={isSelectable} > {titleContainsTextAndCustomEmoji ? ( { @@ -38,6 +39,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} + isSelectable={isSelectable} /> ); } @@ -52,6 +54,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} + isSelectable={isSelectable} /> ); } @@ -66,6 +69,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} + isSelectable={isSelectable} /> ); } diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts index 55d4345f5340..89faf6d4b350 100644 --- a/src/components/DisplayNames/types.ts +++ b/src/components/DisplayNames/types.ts @@ -49,6 +49,9 @@ type DisplayNamesProps = ForwardedFSClassProps & { /** If we should add an ellipsis after the participants list */ shouldAddEllipsis?: boolean; + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; + /** Whether to parse HTML in the title */ shouldParseHtml?: boolean; diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 8fd00ab37379..eb4a2bec6e91 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; +import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; @@ -43,6 +44,8 @@ type DotIndicatorMessageProps = { textStyles?: StyleProp; + isSelectable?: boolean; + // Unused here, but OfflineWithFeedback still passes it to every error row. // eslint-disable-next-line react/no-unused-prop-types dismissError?: () => void; @@ -50,7 +53,7 @@ type DotIndicatorMessageProps = { onRetryReceiptUpload?: () => void; }; -function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryReceiptUpload}: DotIndicatorMessageProps) { +function DotIndicatorMessage({messages = {}, style, type, textStyles, isSelectable, onRetryReceiptUpload}: DotIndicatorMessageProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -74,7 +77,12 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec const isErrorMessage = type === 'error'; const receiptError = uniqueMessages.find(isReceiptError); - const isTextSelectable = !canUseTouchScreen() || !shouldUseNarrowLayout; + const isWeb = getPlatform() === CONST.PLATFORM.WEB; + const defaultTextSelectable = !canUseTouchScreen() || !shouldUseNarrowLayout; + const isTextSelectable = isWeb ? (isSelectable ?? defaultTextSelectable) : defaultTextSelectable; + const shouldDisableTextSelection = isWeb && isSelectable === false; + const selectionDataSet = isTextSelectable ? undefined : {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}; + const receiptSelectionDataSet = shouldDisableTextSelection ? selectionDataSet : undefined; const renderMessage = (message: string | ReceiptError | ReactElement, index: number) => { if (isReceiptError(message)) { @@ -125,7 +133,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec const messageRow = ( @@ -157,14 +165,20 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec ); if (!isStackedLayout) { return ( - + {messageRow} {buttonsRow} ); } return ( - + {messageRow} {buttonsRow} @@ -172,7 +186,10 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec } return ( - + , 'addTitle' | 'addDescription'> & + ForwardedFSClassProps & { + /** Entries added so far */ + value?: DynamicFormListItem[]; + + /** Called with every entry after one is added, edited or removed */ + onInputChange?: (value: DynamicFormListItem[]) => void; + + /** Called with the remaining entries after one is removed, for a list the flow keeps in memory */ + onItemsChange?: (value: DynamicFormListItem[]) => void; + + /** Validation error shown under the list */ + errorText?: string; + + /** Names an entry whose answers give it no name, when the list has no `itemLabel` */ + label: string; + + /** What one entry is, such as "owner", for the remove confirmation and for an entry whose answers give it no name */ + itemLabel?: string; + + /** The fields of one entry, which name and describe its row */ + itemFields: DynamicFormField[]; + + /** The list is marked `sensitive`, so none of its entries' answers is drafted while the modal edits one */ + isSensitive?: boolean; + + /** Hides the add row once the list holds this many entries */ + maxItems?: number; + + /** Opens the flow's editor page for a new entry. Without it, entries are edited in a modal on this page. */ + onAdd?: () => void; + + /** Opens the flow's editor page of an entry */ + onEdit?: (itemID: string) => void; + + /** Draws the entry's fields inside the modal editor */ + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; + }; + +/** The entries of a list field as rows. Removing one updates FormProvider's value. Adding and editing open the flow's editor page, or a modal outside the flow. */ +function ListFieldAdapter({ + value, + onInputChange = () => {}, + onItemsChange, + errorText, + label, + itemLabel, + itemFields, + isSensitive = false, + maxItems, + addTitle, + addDescription, + onAdd, + onEdit, + renderFields, + forwardedFSClass, +}: ListFieldAdapterProps) { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const [editingID, setEditingID] = useState(); + const items = Array.isArray(value) ? value : []; + const editingItem = items.find((item) => item.id === editingID); + const summarize = (item: DynamicFormListItem) => summarizeListItem(item, itemFields, translate, itemLabel ?? label); + const modalTitle = editingItem ? summarize(editingItem).title : addTitle; + const sensitiveKeys = new Set(itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys)); + const isKeptOutOfDraft = (key: string) => key !== 'id' && (isSensitive || sensitiveKeys.has(key)); + + // Sensitive answers are not drafted, so the modal's inputs start from the stored entry instead. Every answer of a sensitive list is. + const openModal = (item?: DynamicFormListItem) => { + const answers = Object.fromEntries(Object.entries(item ?? {}).filter(([key]) => key !== 'id' && !isKeptOutOfDraft(key))); + startListItemEdit(answers).then(() => setEditingID(item?.id ?? Str.guid())); + }; + + const closeModal = () => { + setEditingID(undefined); + clearListItemEdit(); + }; + + const saveItem = (answers: DynamicFormValues) => { + if (!editingID) { + return; + } + const item: DynamicFormListItem = {...answers, id: editingID}; + onInputChange(editingItem ? items.map((existing) => (existing.id === editingID ? item : existing)) : [...items, item]); + closeModal(); + }; + + // The modal title takes no FullStory class, so only the masked prompt names the entry + const confirmRemoval = (id: string) => { + const item = items.find((candidate) => candidate.id === id); + if (!item) { + return; + } + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: itemLabel ? translate('dynamicForm.removeItemTitle', {item: itemLabel}) : translate('common.remove'), + prompt: ( + + {translate('dynamicForm.removeItemPrompt', {name: summarize(item).title})} + + ), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + const remainingItems = items.filter((candidate) => candidate.id !== id); + onInputChange(remainingItems); + onItemsChange?.(remainingItems); + }); + }; + + // ListField takes no FullStory class, so the mask goes on a View around it + return ( + <> + + ({id: item.id, ...summarize(item)}))} + canAddMore={maxItems === undefined || items.length < maxItems} + errorText={errorText} + onAdd={onAdd ?? (() => openModal())} + onEdit={onEdit ?? ((id) => openModal(items.find((item) => item.id === id)))} + onRemove={confirmRemoval} + /> + + {!onAdd && ( + isKeptOutOfDraft(key)))} + isSensitive={isSensitive} + renderFields={renderFields} + onSave={saveItem} + onClose={closeModal} + /> + )} + + ); +} + +export default ListFieldAdapter; diff --git a/src/components/DynamicForm/components/DynamicFormConfirmationPage.tsx b/src/components/DynamicForm/components/DynamicFormConfirmationPage.tsx new file mode 100644 index 000000000000..063feebcbe28 --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormConfirmationPage.tsx @@ -0,0 +1,31 @@ +import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import getConfirmationItems from '@components/DynamicForm/utils/getConfirmationItems'; +import ConfirmationStep from '@components/SubStepForms/ConfirmationStep'; + +import useLocalize from '@hooks/useLocalize'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +/** The last page of a dynamic form flow: every answer as a row the user can tap to edit, and a Confirm button */ +function DynamicFormConfirmationPage({isEditing, onNext, onMove, pages, allFields, values, confirmationTitle, isSubmitting, submitError, onOpenListItemEditor}: DynamicFormSubPageProps) { + const {translate} = useLocalize(); + + return ( + onNext()} + onMove={onMove} + pageTitle={confirmationTitle} + groups={getConfirmationItems(pages, allFields, values, translate, onMove, onOpenListItemEditor)} + showOnfidoLinks={false} + isLoading={isSubmitting} + error={submitError} + forwardedFSClass={CONST.FULLSTORY.CLASS.MASK} + /> + ); +} + +export default DynamicFormConfirmationPage; diff --git a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx index 2f88ec864c17..44c4a88c9f19 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx @@ -1,8 +1,11 @@ import renderDynamicField from '@components/DynamicForm/renderDynamicField'; +import type {DynamicFieldContext} from '@components/DynamicForm/renderers/types'; import formatDynamicFieldValue from '@components/DynamicForm/utils/formatDynamicFieldValue'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {getListItems} from '@components/DynamicForm/utils/listItems'; +import summarizeListItem, {getListItemFallbackTitle} from '@components/DynamicForm/utils/summarizeListItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -16,30 +19,55 @@ import {View} from 'react-native'; import type {DynamicFormFieldsProps} from './DynamicFormFields'; -type DynamicFormFieldRowProps = Pick & - Required> & { +type DynamicFormFieldRowProps = Pick & + Required> & + Pick & { field: DynamicFormField; /** The field is the page's only question, so a choice is drawn as the page itself instead of as a row */ isLoneField: boolean; + + /** The page title already says what the field asks, so its label is not drawn above the input. Inputs keep their own accessibility name. */ + shouldHideLabel?: boolean; }; /** Typed answers change on every keystroke, so they ask for new requirements when the user leaves the input instead */ const TYPED_FIELD_TYPES = new Set(['text', 'number', 'amount', 'percent']); /** One field of a schema-driven form: its input, or its value when the field is readonly */ -function DynamicFormFieldRow({field, values, currency, maxFileSize, isLoneField, onRefreshRequirements}: DynamicFormFieldRowProps) { +function DynamicFormFieldRow({ + field, + values, + currency, + maxFileSize, + isLoneField, + shouldHideLabel = false, + onRefreshRequirements, + onOpenListItemEditor, + onListItemsChange, + isSensitive = false, + renderFields, +}: DynamicFormFieldRowProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const label = getFieldLabel(field, translate); if (field.readonly) { + // A list's answer is its entries, which formatDynamicFieldValue cannot name, so they are listed by their titles + const readonlyValue = + field.type === 'list' + ? getListItems(values[field.key]) + .map((item) => summarizeListItem(item, field.itemFields, translate, getListItemFallbackTitle(field, translate)).title) + .join(', ') + : formatDynamicFieldValue(field, values, translate); return ( - - + ); @@ -57,10 +85,10 @@ function DynamicFormFieldRow({field, values, currency, maxFileSize, isLoneField, const shouldRefreshOnChange = !!field.refreshOnChange && !isTypedField; const {input, isMenuRow, labelAbove, showsDescription} = renderDynamicField( field, - {values, translate, styles, currency, maxFileSize, isLoneField}, + {values, translate, styles, currency, maxFileSize, isLoneField, onOpenListItemEditor, onListItemsChange, isSensitive, renderFields}, { inputID: field.key, - shouldSaveDraft: !isSensitiveField(field), + shouldSaveDraft: !isSensitive && !isSensitiveField(field), forwardedFSClass: CONST.FULLSTORY.CLASS.MASK, onValueChange: shouldRefreshOnChange ? (value, key) => onRefreshRequirements?.(key, value) : undefined, onBlur: shouldRefreshOnBlur ? refreshRequirements : undefined, @@ -68,9 +96,13 @@ function DynamicFormFieldRow({field, values, currency, maxFileSize, isLoneField, ); const description = showsDescription ? undefined : getLocalizedText(translate, field.descriptionKey, field.description); + // Some inputs, such as CountryPicker, do not pass the FullStory class on, so the whole row is masked return ( - - {!!labelAbove && ( + + {!!labelAbove && !shouldHideLabel && ( void; + + /** Opens the editor page of a list entry, or of a new one without `itemID`. DynamicFormFlow passes it. Without it, list entries are edited in a modal. */ + onOpenListItemEditor?: (listKey: string, itemID?: string) => void; + + /** Called with a list's entries after the user removes one, so the flow can keep a sensitive list in memory up to date */ + onListItemsChange?: (listKey: string, items: DynamicFormListItem[]) => void; + + /** Every answer here is sensitive, as in the editor of an entry of a sensitive list: none is drafted, and each starts from the answer in `values` */ + isSensitive?: boolean; }; /** The inputs of a schema-driven form. Render it inside a FormProvider and validate the fields of the same content with getDynamicFieldErrors. */ -function DynamicFormFields({content, values, currency = CONST.CURRENCY.USD, maxFileSize, onRefreshRequirements}: DynamicFormFieldsProps) { - const visibleContent = getVisibleContent(content, values); +function DynamicFormFields({ + content, + values, + allFields, + isLoneFieldInHeadline = false, + currency = CONST.CURRENCY.USD, + maxFileSize, + onRefreshRequirements, + onOpenListItemEditor, + onListItemsChange, + isSensitive = false, +}: DynamicFormFieldsProps) { + const visibleContent = getVisibleContent(content, values, allFields); const loneField = getLoneField(getContentFields(visibleContent)); + const renderFields = (itemFields: DynamicFormField[], itemValues: DynamicFormValues, isItemSensitive: boolean) => ( + + ); return visibleContent.map((item, index) => isHeading(item) ? ( @@ -49,7 +85,12 @@ function DynamicFormFields({content, values, currency = CONST.CURRENCY.USD, maxF currency={currency} maxFileSize={maxFileSize} isLoneField={item === loneField} + shouldHideLabel={isLoneFieldInHeadline && item === loneField} onRefreshRequirements={onRefreshRequirements} + onOpenListItemEditor={onOpenListItemEditor} + onListItemsChange={onListItemsChange} + isSensitive={isSensitive} + renderFields={renderFields} /> ), ); diff --git a/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx new file mode 100644 index 000000000000..84db6032af4b --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx @@ -0,0 +1,100 @@ +import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import getContentFields from '@components/DynamicForm/utils/getContentFields'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; +import getPageTitle from '@components/DynamicForm/utils/getPageTitle'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; +import FormProvider from '@components/Form/FormProvider'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import DynamicFormFields from './DynamicFormFields'; + +/** One page of a dynamic form flow: its title and description, its fields and a Next button */ +function DynamicFormFieldsPage({ + currentPageName, + isEditing, + isLastPage, + onNext, + formID, + pages, + allFields, + values, + currency, + maxFileSize, + onRefreshRequirements, + onPageSubmit, + onOpenListItemEditor, + onListItemsChange, +}: DynamicFormSubPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const page = pages.find((candidate) => candidate.key === currentPageName); + if (!page) { + return null; + } + // A sensitive list lives in memory, which the editor page and a removal update, so the form's own copy of it may be stale + const pageKeys = new Set(getContentFields(page.content).map((field) => field.key)); + const memoryListValues = Object.fromEntries( + allFields.filter((field) => field.type === 'list' && isSensitiveField(field) && pageKeys.has(field.key)).map((field) => [field.key, values[field.key]]), + ); + const withInputValues = (inputValues: unknown) => ({...values, ...toDynamicFormValues(inputValues), ...memoryListValues}); + + return ( + getDynamicFieldErrors(getContentFields(page.content), withInputValues(inputValues), translate, allFields)} + onSubmit={(inputValues) => { + const pageValues = toDynamicFormValues(inputValues); + onPageSubmit(page, pageValues); + onNext(pageValues); + }} + style={[styles.mh5, styles.flexGrow1]} + submitButtonStyles={styles.mb0} + keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} + // An offline request is saved to disk, so the last page submits only online, as the confirmation page does + enabledWhenOffline={!isLastPage} + > + {({inputValues}) => { + const currentValues = withInputValues(inputValues); + const title = getPageTitle(page, currentValues, translate, allFields); + const hasOwnTitle = !!getLocalizedText(translate, page.titleKey, page.title); + const description = getLocalizedText(translate, page.descriptionKey, page.description); + return ( + <> + {!!title && ( + + {title} + + )} + {!!description && {description}} + + + ); + }} + + ); +} + +export default DynamicFormFieldsPage; diff --git a/src/components/DynamicForm/components/DynamicFormListItemModal.tsx b/src/components/DynamicForm/components/DynamicFormListItemModal.tsx new file mode 100644 index 000000000000..acd3f61b13f4 --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormListItemModal.tsx @@ -0,0 +1,94 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; +import FormProvider from '@components/Form/FormProvider'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import Modal from '@components/Modal'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormField} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type DynamicFormListItemModalProps = ForwardedFSClassProps & { + /** Whether the modal is open */ + isVisible: boolean; + + /** Header title: the entry's name, or the add title for a new entry */ + title: string; + + /** The fields of one entry */ + itemFields: DynamicFormField[]; + + /** Stored answers that are not drafted, such as sensitive ones, which the inputs start from */ + keptAnswers: DynamicFormValues; + + /** Every answer of the entry is sensitive, as in a list marked `sensitive`: none is drafted */ + isSensitive: boolean; + + /** Draws the entry's fields inside the modal's form */ + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; + + /** Called with the entry's answers when the user saves */ + onSave: (answers: DynamicFormValues) => void; + + /** Called when the user closes the modal without saving */ + onClose: () => void; +}; + +/** The editor of one list entry outside DynamicFormFlow, as a modal over the page holding the list */ +function DynamicFormListItemModal({isVisible, title, itemFields, keptAnswers, isSensitive, renderFields, onSave, onClose, forwardedFSClass}: DynamicFormListItemModalProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [, itemDraftMetadata] = useOnyx(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT); + // Kept answers fill the inputs, so clearing one in the form clears it on save, as on the flow's editor page + const withInputValues = (inputValues: unknown): DynamicFormValues => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); + + return ( + + + + {isVisible && !isLoadingOnyxValue(itemDraftMetadata) && ( + getDynamicFieldErrors(itemFields, withInputValues(inputValues), translate)} + onSubmit={(inputValues) => onSave(withInputValues(inputValues))} + style={[styles.mh5, styles.flexGrow1]} + submitButtonStyles={styles.mb0} + enabledWhenOffline + > + {({inputValues}) => renderFields(itemFields, withInputValues(inputValues), isSensitive)} + + )} + + + ); +} + +export default DynamicFormListItemModal; diff --git a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx new file mode 100644 index 000000000000..460a865eee2e --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx @@ -0,0 +1,105 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import {getListItems, getListItemSensitiveAnswers, parseListItemPageName} from '@components/DynamicForm/utils/listItems'; +import summarizeListItem, {getListItemFallbackTitle} from '@components/DynamicForm/utils/summarizeListItem'; +import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; +import FormProvider from '@components/Form/FormProvider'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearListItemEdit, startListItemEdit} from '@userActions/DynamicForm'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormListField} from '@src/types/onyx'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +import DynamicFormFields from './DynamicFormFields'; + +/** The editor of one list entry, or of a new one: the entry's fields and a Save button */ +function DynamicFormListItemPage({currentPageName, allFields, values, currency, maxFileSize, onRefreshRequirements, onListItemSave}: DynamicFormSubPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const page = parseListItemPageName(currentPageName); + const listField = allFields.find((field): field is DynamicFormListField => field.type === 'list' && field.key === page?.listKey); + const item = listField ? getListItems(values[listField.key]).find((candidate) => candidate.id === page?.itemID) : undefined; + const [filledPageName, setFilledPageName] = useState(); + // The entries of a sensitive list are never drafted, so the editor starts empty and its inputs take the entry's answers from memory + const isSensitiveList = !!listField && isSensitiveField(listField); + + // The editor form is shared by every entry, so it is filled with this entry's answers before its inputs mount + useEffect(() => { + let isCancelled = false; + startListItemEdit(isSensitiveList ? {} : (item ?? {})).then(() => { + if (isCancelled) { + return; + } + setFilledPageName(currentPageName); + }); + return () => { + isCancelled = true; + }; + }, [currentPageName, item, isSensitiveList]); + + useEffect(() => clearListItemEdit, []); + + if (!page || !listField) { + return null; + } + // The flow's header stays above this page, so a spinner in the page is enough while the editor fills + if (filledPageName !== currentPageName) { + return ( + + + + ); + } + const sensitiveAnswers = item ? getListItemSensitiveAnswers(listField, item.id, values) : {}; + const keptAnswers = isSensitiveList ? {...item, ...sensitiveAnswers} : sensitiveAnswers; + const withInputValues = (inputValues: unknown) => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); + const itemLabel = getLocalizedText(translate, listField.itemLabelKey, listField.itemLabel); + const addTitle = itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add'); + const title = item ? summarizeListItem(item, listField.itemFields, translate, getListItemFallbackTitle(listField, translate)).title : addTitle; + + return ( + getDynamicFieldErrors(listField.itemFields, withInputValues(inputValues), translate)} + onSubmit={(inputValues) => onListItemSave(listField.key, item?.id, withInputValues(inputValues))} + style={[styles.mh5, styles.flexGrow1]} + submitButtonStyles={styles.mb0} + keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} + enabledWhenOffline + > + {({inputValues}) => ( + <> + + {title} + + + + )} + + ); +} + +export default DynamicFormListItemPage; diff --git a/src/components/DynamicForm/components/DynamicFormShell.tsx b/src/components/DynamicForm/components/DynamicFormShell.tsx new file mode 100644 index 000000000000..176ceac06c93 --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormShell.tsx @@ -0,0 +1,63 @@ +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type DynamicFormShellProps = { + /** The current page */ + children: ReactNode; + + /** testID of the screen wrapper */ + testID: string; + + /** Title in the header */ + headerTitle: string; + + /** Called when the user presses the header's back button */ + onBackButtonPress: () => void; + + /** Unique page keys in order, for the step indicator */ + stepNames: string[]; + + /** Index of the highlighted step */ + stepIndex: number; + + /** Shows the step indicator under the header */ + shouldShowStepIndicator: boolean; +}; + +/** Header, and a step indicator when asked for, around one dynamic form page */ +function DynamicFormShell({children, testID, headerTitle, onBackButtonPress, stepNames, stepIndex, shouldShowStepIndicator}: DynamicFormShellProps) { + if (shouldShowStepIndicator) { + return ( + + {children} + + ); + } + return ( + + + {children} + + ); +} + +export default DynamicFormShell; diff --git a/src/components/DynamicForm/index.tsx b/src/components/DynamicForm/index.tsx new file mode 100644 index 000000000000..851ea83d86a2 --- /dev/null +++ b/src/components/DynamicForm/index.tsx @@ -0,0 +1,344 @@ +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSubPage from '@hooks/useSubPage'; + +import Navigation from '@libs/Navigation/Navigation'; +import {findLastPageIndex} from '@libs/SubPageUtils'; + +import {clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers} from '@userActions/DynamicForm'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormListField, DynamicFormListItem, DynamicFormPage} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import {Str} from 'expensify-common'; +import React, {useEffect, useRef, useState} from 'react'; + +import type {DynamicFormFlowProps, DynamicFormSubPageProps, DynamicFormValues} from './types'; + +import DynamicFormConfirmationPage from './components/DynamicFormConfirmationPage'; +import DynamicFormFieldsPage from './components/DynamicFormFieldsPage'; +import DynamicFormListItemPage from './components/DynamicFormListItemPage'; +import DynamicFormShell from './components/DynamicFormShell'; +import getAnswerKeys from './utils/getAnswerKeys'; +import getContentFields from './utils/getContentFields'; +import getDynamicFieldErrors from './utils/getDynamicFieldErrors'; +import {isPageShown} from './utils/getPageTitle'; +import getSubmittedAnswers from './utils/getSubmittedAnswers'; +import hasSensitiveAnswer from './utils/hasSensitiveAnswer'; +import isSensitiveField from './utils/isSensitiveField'; +import isSupportedField from './utils/isSupportedField'; +import {getListItemPageName, getListItems, getListItemSensitiveKey, getRemovedListItemSensitiveKeys, parseListItemPageName} from './utils/listItems'; +import {getAllFields, getPages} from './utils/paginate'; +import toDynamicFormValues from './utils/toDynamicFormValues'; + +/** An answer the user gave, including `false`, which counts as progress when deciding where a visit resumes */ +function hasDraftAnswer(answer: DynamicFormValues[string]): boolean { + return answer !== undefined && answer !== null && answer !== '' && !(Array.isArray(answer) && answer.length === 0); +} + +/** A whole dynamic form: one page per schema page, skipping pages with nothing to ask, then a confirmation page */ +function DynamicFormFlow({ + schema, + formID, + headerTitle, + confirmationTitle, + testID, + buildRoute, + shouldReplaceRoute, + onSubmit, + onPageSubmit, + onBack, + currency, + maxFileSize, + onRefreshRequirements, + isSubmitting, + submitError, + layout = 'auto', + hasConfirmation = true, +}: DynamicFormFlowProps) { + const {translate} = useLocalize(); + const [draft, draftMetadata] = useOnyx(`${formID}Draft`); + const [sensitiveAnswersByForm] = useOnyx(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS); + const isDraftLoading = isLoadingOnyxValue(draftMetadata); + const draftValues = toDynamicFormValues(draft); + const values: DynamicFormValues = {...draftValues, ...sensitiveAnswersByForm?.[formID]}; + + const pages = getPages(schema); + const allFields = getAllFields(pages); + const listFields = allFields.filter((field): field is DynamicFormListField => field.type === 'list'); + const shownPages = pages.filter((page) => isPageShown(page, values, allFields)); + const hasPageToShow = shownPages.length > 0; + + // useSubPage needs at least one page, so a form without pages gets the confirmation page + const hasConfirmationPage = pages.length === 0 || hasConfirmation; + // Entry editors come after the confirmation page and are skipped by Next and Back, so only an entry's Add, Edit or row opens them. A readonly list has no editors. + const listItemPageNames = listFields + .filter((field) => !field.readonly) + .flatMap((field) => [getListItemPageName(field.key), ...getListItems(values[field.key]).map((item) => getListItemPageName(field.key, item.id))]); + const subPages = [ + ...pages.map((page) => ({pageName: page.key, component: DynamicFormFieldsPage})), + ...(hasConfirmationPage ? [{pageName: CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY, component: DynamicFormConfirmationPage}] : []), + ...listItemPageNames.map((pageName) => ({pageName, component: DynamicFormListItemPage})), + ]; + // While nothing is shown no page is skipped, since useSubPage needs a page that is not skipped + const skippedPages = hasPageToShow ? pages.filter((page) => !shownPages.includes(page)) : []; + const skipPages = [...skippedPages.map((page) => page.key), ...listItemPageNames]; + const firstShownPageIndex = subPages.findIndex((page) => !skipPages.includes(page.pageName)); + const lastShownPageIndex = findLastPageIndex(subPages, skipPages); + + /** Pages the answers hide are never asked, so they cannot be the first gap */ + const getFirstIncompletePageIndex = (answers: DynamicFormValues) => + pages.findIndex((page) => isPageShown(page, answers, allFields) && Object.keys(getDynamicFieldErrors(getContentFields(page.content), answers, translate, allFields)).length > 0); + + // A visit opened without a page in the URL drops the sensitive answers of earlier visits, so it starts from what the draft holds + let startFrom = firstShownPageIndex; + if (isDraftLoading) { + startFrom = -1; + } else if (allFields.some((field) => hasDraftAnswer(draftValues[field.key]))) { + const firstIncompleteIndex = getFirstIncompletePageIndex(draftValues); + startFrom = firstIncompleteIndex === -1 ? lastShownPageIndex : firstIncompleteIndex; + } + + const {CurrentPage, isEditing, currentPageName, pageIndex, nextPage, prevPage, moveTo, resetToPage, isRedirecting} = useSubPage({ + pages: subPages, + skipPages, + startFrom, + buildRoute, + shouldReplaceRoute, + onFinished: submitIfComplete, + }); + + /** Goes back to a page without stacking a second copy of it. Replaced routes leave nothing in the stack to go back to. */ + const returnTo = (pageName: string) => { + if (shouldReplaceRoute) { + resetToPage(pageName); + return; + } + Navigation.goBack(buildRoute(pageName)); + }; + + /** The confirmation page can be opened by URL before every page is answered, so an incomplete form goes back to its first gap instead. Without a confirmation page the last page's answers arrive here, before their draft is saved. */ + function submitIfComplete(lastPageValues?: unknown) { + const answers = {...values, ...toDynamicFormValues(lastPageValues)}; + const firstIncompleteIndex = getFirstIncompletePageIndex(answers); + const incompletePage = firstIncompleteIndex === -1 ? undefined : pages.at(firstIncompleteIndex); + if (incompletePage) { + returnTo(incompletePage.key); + return; + } + // Fields of a page the answers skip are not submitted + const shownFields = getAllFields(pages.filter((page) => isPageShown(page, answers, allFields))); + const submittedAnswers = getSubmittedAnswers(shownFields, answers, allFields); + if (isSubmitting === undefined) { + clearSensitiveAnswers(formID); + } + onSubmit(submittedAnswers); + } + + const listItemPage = parseListItemPageName(currentPageName); + const findListPage = (listKey: string) => pages.find((page) => getContentFields(page.content).some((field) => field.key === listKey)); + const isOnSkippedPage = !listItemPage && !!currentPageName && skipPages.includes(currentPageName); + const isOnUnavailablePage = !isDraftLoading && !isRedirecting && (!subPages.some((page) => page.pageName === currentPageName) || isOnSkippedPage); + + useEffect(() => { + if (!isRedirecting) { + return; + } + clearSensitiveAnswers(formID); + }, [isRedirecting, formID]); + + // A submission that finishes without an error drops the sensitive answers + const wasSubmittingRef = useRef(false); + useEffect(() => { + if (isSubmitting) { + wasSubmittingRef.current = true; + return; + } + if (!wasSubmittingRef.current || submitError) { + return; + } + wasSubmittingRef.current = false; + clearSensitiveAnswers(formID); + }, [isSubmitting, submitError, formID]); + + // A page the answers now skip is replaced by the next shown page, or the previous one, so Back never lands on it. A stale link opens where a new visit would start. + const pageNames = subPages.map((page) => page.pageName); + const nextShownPageName = pageNames.find((pageName, index) => index > pageIndex && !skipPages.includes(pageName)); + const previousShownPageName = pageNames.findLast((pageName, index) => index < pageIndex && !skipPages.includes(pageName)); + const unavailablePageTarget = isOnSkippedPage ? (nextShownPageName ?? previousShownPageName) : subPages.at(startFrom)?.pageName; + + useEffect(() => { + if (!isOnUnavailablePage || !unavailablePageTarget) { + return; + } + Navigation.navigate(buildRoute(unavailablePageTarget), {forceReplace: true}); + }, [isOnUnavailablePage, buildRoute, unavailablePageTarget]); + + /** Typed answers are drafted as FormProvider cleaned them and inputs left untouched are drafted with their defaults, so the draft matches what the page validated. Sensitive answers go to memory only, and only the draft ones reach the screen. Lists are drafted as they change, so the screen gets their drafted entries, which hold no sensitive answers. */ + const handlePageSubmit = (page: DynamicFormPage, pageValues: DynamicFormValues) => { + const currentValues = {...values, ...pageValues}; + const pageFields = getContentFields(page.content).filter(isSupportedField); + const answers = getSubmittedAnswers(pageFields, currentValues, allFields); + const sensitiveKeys = new Set(pageFields.filter((field) => isSensitiveField(field)).flatMap((field) => getAnswerKeys(field))); + const listKeys = new Set(listFields.map((field) => field.key)); + const draftAnswers: DynamicFormValues = {}; + const listAnswers: DynamicFormValues = {}; + const sensitiveAnswers: DynamicFormValues = {}; + for (const [key, answer] of Object.entries(answers)) { + if (sensitiveKeys.has(key)) { + sensitiveAnswers[key] = answer; + } else if (listKeys.has(key)) { + listAnswers[key] = getListItems(currentValues[key]); + } else { + draftAnswers[key] = answer; + } + } + saveDraftAnswers(formID, draftAnswers); + if (Object.keys(sensitiveAnswers).length > 0) { + saveSensitiveAnswers(formID, sensitiveAnswers); + } + const removedSensitiveKeys = listFields.filter((field) => pageFields.includes(field)).flatMap((field) => getRemovedListItemSensitiveKeys(field, currentValues)); + if (removedSensitiveKeys.length > 0) { + forgetSensitiveAnswers(formID, removedSensitiveKeys); + } + onPageSubmit?.(page, {...draftAnswers, ...listAnswers}); + }; + + /** An entry opened from the confirmation page, or from a page edited from it, returns there */ + const closeListItemEditor = (listKey: string) => { + const listPage = findListPage(listKey); + returnTo(isEditing || !listPage ? CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY : listPage.key); + }; + + const openListItemEditor = (listKey: string, itemID?: string) => { + const editorIndex = subPages.findIndex((page) => page.pageName === getListItemPageName(listKey, itemID)); + // moveTo(-1) would open the last sub-page, which is another entry's editor + if (editorIndex === -1) { + return; + } + moveTo(editorIndex, isEditing || currentPageName === CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY); + }; + + /** The entry is drafted with the form, except its sensitive answers, which are kept in memory under the entry. A sensitive list is kept in memory whole. */ + const saveListItem = (listKey: string, itemID: string | undefined, itemValues: DynamicFormValues) => { + const listField = listFields.find((field) => field.key === listKey); + if (!listField) { + return; + } + const items = getListItems(values[listKey]); + // The add row is hidden once the list is full, but a stale link can still open the editor of a new entry + if (!itemID && listField.maxItems !== undefined && items.length >= listField.maxItems) { + closeListItemEditor(listKey); + return; + } + const id = itemID ?? Str.guid(); + const sensitiveKeys = new Set(listField.itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys)); + const item: DynamicFormListItem = {id}; + const sensitiveAnswers: DynamicFormValues = {}; + for (const [key, answer] of Object.entries(getSubmittedAnswers(listField.itemFields, itemValues))) { + if (sensitiveKeys.has(key)) { + sensitiveAnswers[getListItemSensitiveKey(listKey, id, key)] = answer; + } else { + item[key] = answer; + } + } + const savedItems = itemID ? items.map((existing) => (existing.id === id ? item : existing)) : [...items, item]; + const saveListAnswer = isSensitiveField(listField) ? saveSensitiveAnswers : saveDraftAnswers; + saveListAnswer(formID, {[listKey]: savedItems}); + if (Object.keys(sensitiveAnswers).length > 0) { + saveSensitiveAnswers(formID, sensitiveAnswers); + } + // The editor saves outside the list's input, so a list that asks for fresh requirements on change asks here, as a removal does through its input + if (listField.refreshOnChange) { + onRefreshRequirements?.(listKey, savedItems); + } + closeListItemEditor(listKey); + }; + + /** A sensitive list is kept in memory, so removing an entry is saved right away for the next entry added to build on. A drafted list is saved as it changes. */ + const changeListItems = (listKey: string, items: DynamicFormListItem[]) => { + const listField = listFields.find((field) => field.key === listKey); + if (!listField || !isSensitiveField(listField)) { + return; + } + saveSensitiveAnswers(formID, {[listKey]: items}); + const removedSensitiveKeys = getRemovedListItemSensitiveKeys(listField, {...values, [listKey]: items}); + if (removedSensitiveKeys.length > 0) { + forgetSensitiveAnswers(formID, removedSensitiveKeys); + } + }; + + const goBack = () => { + if (listItemPage) { + closeListItemEditor(listItemPage.listKey); + return; + } + if (isEditing) { + returnTo(CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY); + return; + } + if (pageIndex <= firstShownPageIndex) { + clearSensitiveAnswers(formID); + onBack(); + return; + } + prevPage(); + }; + + // An entry editor highlights its list's step, and the confirmation page the last one + const stepPage = shownPages.find((page) => page.key === currentPageName) ?? (listItemPage && findListPage(listItemPage.listKey)); + // The indicator draws no names and keys its steps by them, so the unique page keys stand in for titles an untitled page does not have + const stepNames = [...shownPages.map((page) => page.key), ...(hasConfirmationPage ? [CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY] : [])]; + const stepPageIndex = stepPage ? shownPages.indexOf(stepPage) : -1; + const stepIndex = stepPageIndex === -1 ? stepNames.length - 1 : stepPageIndex; + // The shell swaps its root when the indicator toggles, which remounts the page. So `auto` decides once, from the answers the draft held on arrival, and keeps it while the user types. + const [isAutoStepIndicatorAsked, setIsAutoStepIndicatorAsked] = useState(); + if (!isDraftLoading && isAutoStepIndicatorAsked === undefined) { + setIsAutoStepIndicatorAsked(shownPages.length >= CONST.DYNAMIC_FORM.STEP_INDICATOR_MIN_PAGES); + } + const isStepIndicatorAsked = layout === 'auto' ? !!isAutoStepIndicatorAsked : layout === 'stepper'; + + return ( + 1} + > + {isDraftLoading || isRedirecting || isOnUnavailablePage || (!hasPageToShow && pages.length > 0) ? ( + + ) : ( + + )} + + ); +} + +export default DynamicFormFlow; diff --git a/src/components/DynamicForm/renderDynamicField.tsx b/src/components/DynamicForm/renderDynamicField.tsx index 63d45bd53110..6933b55ced95 100644 --- a/src/components/DynamicForm/renderDynamicField.tsx +++ b/src/components/DynamicForm/renderDynamicField.tsx @@ -6,19 +6,22 @@ import type {DynamicFormFieldOfType} from './types'; import renderAddress from './renderers/addressRenderer'; import CHOICE_RENDERERS from './renderers/choiceRenderers'; import renderFile from './renderers/fileRenderer'; +import renderList from './renderers/listRenderer'; import TEXT_RENDERERS from './renderers/textRenderers'; +import getSensitiveDefaultValue from './utils/getSensitiveDefaultValue'; const RENDERERS: DynamicFieldRendererMap = { ...TEXT_RENDERERS, ...CHOICE_RENDERERS, address: renderAddress, file: renderFile, + list: renderList, }; /** The input for one field, and how the renderer lays it out */ function renderDynamicField(field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps): DynamicFieldInput { const render: DynamicFieldRenderer = RENDERERS[field.type]; - return render(field, context, inputProps); + return render(field, context, {...inputProps, defaultValue: getSensitiveDefaultValue(field, context.values, context.isSensitive)}); } export default renderDynamicField; diff --git a/src/components/DynamicForm/renderers/addressRenderer.tsx b/src/components/DynamicForm/renderers/addressRenderer.tsx index e4bfe3ffb426..2b178ba0ac90 100644 --- a/src/components/DynamicForm/renderers/addressRenderer.tsx +++ b/src/components/DynamicForm/renderers/addressRenderer.tsx @@ -1,4 +1,5 @@ import getAddressInputKeys, {getAddressCountry, isStateAsked} from '@components/DynamicForm/utils/getAddressInputKeys'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields'; @@ -7,9 +8,14 @@ import React from 'react'; import type {DynamicFieldRenderer} from './types'; /** An address drawn as one input per part, each under its own draft key */ -const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {shouldSaveDraft, forwardedFSClass, onValueChange}) => { +const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {shouldSaveDraft, forwardedFSClass, onValueChange, defaultValue}) => { const addressKeys = getAddressInputKeys(field.key); const country = getAddressCountry(field.key, values); + // The street arrives as the field's answer. The other parts of a sensitive address are not drafted either, so they start from memory too. + const getSensitivePart = (key: string) => { + const part = values[key]; + return isSensitiveField(field) && typeof part === 'string' ? part : undefined; + }; return { isMenuRow: false, labelAbove: 'heading', @@ -17,7 +23,13 @@ const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {should { return Object.fromEntries(choices.map((choice) => [choice.value, choice.label])); } -/** A lone choice field is the page itself: its options are listed inline, and the page title asks the question */ -function renderInlineChoice(label: string, choices: Choice[], inputProps: DynamicFieldInputProps, {canSelectMultiple, isSearchable}: InlineChoiceOptions): DynamicFieldInput { +/** A lone choice field is the page itself: its options are listed inline under its question, which a page without its own title shows as its title instead */ +function renderInlineChoice( + label: string, + choices: Choice[], + inputProps: DynamicFieldInputProps, + {canSelectMultiple, isSearchable}: InlineChoiceOptions, +): DynamicFieldInput { const listProps = {items: choices, isSearchable, searchInputLabel: label}; return { isMenuRow: true, + labelAbove: 'heading', input: ( , isLoneField: boolean): DynamicFieldInput { return { isMenuRow: false, - labelAbove: isLoneField ? undefined : 'prompt', + labelAbove: isLoneField ? 'heading' : 'prompt', input: ( LONG_CHOICE_LIST_THRESHOLD}); + return renderInlineChoice(label, choices, inputProps, {canSelectMultiple: true, isSearchable: choices.length > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS}); } return { isMenuRow: true, @@ -89,7 +94,7 @@ const CHOICE_RENDERERS: Pick { const label = getFieldLabel(field, translate); const choices = getFieldChoices(field, values, translate); - const isLong = choices.length > LONG_CHOICE_LIST_THRESHOLD; + const isLong = choices.length > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS; if (field.presentation === 'tabs') { return renderTabs(label, choices, inputProps, isLoneField); } @@ -131,7 +136,7 @@ const CHOICE_RENDERERS: Pick { - if (isLoneField) { + if (field.presentation !== 'checkbox') { return { isMenuRow: true, + labelAbove: isLoneField ? 'heading' : 'prompt', input: ( = (field, {translate, values, onOpenListItemEditor, onListItemsChange, renderFields}, inputProps) => { + const itemLabel = getLocalizedText(translate, field.itemLabelKey, field.itemLabel); + // A sensitive list is not drafted, so inside the flow its entries come from memory, which the editor page and a removal both update + const isMemoryList = !!onOpenListItemEditor && isSensitiveField(field); + return { + isMenuRow: true, + labelAbove: 'heading', + input: ( + onListItemsChange?.(field.key, items) : undefined} + label={getFieldLabel(field, translate)} + itemLabel={itemLabel} + itemFields={field.itemFields} + isSensitive={isSensitiveField(field)} + maxItems={field.maxItems} + addTitle={itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add')} + addDescription={getLocalizedText(translate, field.addItemDescriptionKey, field.addItemDescription)} + onAdd={onOpenListItemEditor && (() => onOpenListItemEditor(field.key))} + onEdit={onOpenListItemEditor && ((itemID) => onOpenListItemEditor(field.key, itemID))} + renderFields={renderFields} + /> + ), + }; +}; + +export default renderList; diff --git a/src/components/DynamicForm/renderers/textRenderers.tsx b/src/components/DynamicForm/renderers/textRenderers.tsx index 5033688a9dd3..7a240126b953 100644 --- a/src/components/DynamicForm/renderers/textRenderers.tsx +++ b/src/components/DynamicForm/renderers/textRenderers.tsx @@ -1,7 +1,6 @@ import AmountForm from '@components/AmountForm'; import CurrencyPicker from '@components/CurrencyPicker'; import DatePicker from '@components/DatePicker'; -import type {DynamicFormValues} from '@components/DynamicForm/types'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import InputWrapper from '@components/Form/InputWrapper'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; @@ -15,6 +14,7 @@ import type {DynamicFormNumberField, DynamicFormTextField} from '@src/types/onyx import type {InputModeOptions} from 'react-native'; +import {subYears} from 'date-fns'; import React from 'react'; import {View} from 'react-native'; @@ -41,16 +41,10 @@ function getTextHint(field: DynamicFormTextField | DynamicFormNumberField, trans return translate('dynamicForm.exampleHint', {example: field.example}); } -/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory */ -function getSensitiveDefaultValue(field: DynamicFormTextField | DynamicFormNumberField, values: DynamicFormValues): string | undefined { - const value = values[field.key]; - return field.sensitive && typeof value === 'string' ? value : undefined; -} - function renderTextInput( field: DynamicFormTextField | DynamicFormNumberField, - {values, translate}: DynamicFieldContext, - inputProps: DynamicFieldInputProps, + {translate}: DynamicFieldContext, + inputProps: DynamicFieldInputProps, inputMode: InputModeOptions | undefined, isMultiline = false, ): DynamicFieldInput { @@ -61,7 +55,6 @@ function renderTextInput( = { text: (field, context, inputProps) => renderTextInput(field, context, inputProps, getTextInputMode(field), field.multiline), number: (field, context, inputProps) => renderTextInput(field, context, inputProps, CONST.INPUT_MODE.NUMERIC), - date: (field, {translate}, inputProps) => ({ - isMenuRow: false, - input: ( - - ), - }), + date: (field, {translate}, inputProps) => { + const isDateOfBirth = field.rule === 'dateOfBirth'; + return { + isMenuRow: false, + input: ( + + ), + }; + }, amount: (field, {values, translate, styles, currency}, inputProps) => { const chosenCurrency = field.currencyKey ? values[field.currencyKey] : undefined; + const amountCurrency = typeof chosenCurrency === 'string' && chosenCurrency !== '' ? chosenCurrency : currency; // AmountForm takes no FullStory class, so the mask goes on a View around it const amountInput = ( @@ -98,7 +98,7 @@ const TEXT_RENDERERS: Pick @@ -117,7 +117,8 @@ const TEXT_RENDERERS: Pick diff --git a/src/components/DynamicForm/renderers/types.ts b/src/components/DynamicForm/renderers/types.ts index 89825a0825b7..d13ec5e7dd04 100644 --- a/src/components/DynamicForm/renderers/types.ts +++ b/src/components/DynamicForm/renderers/types.ts @@ -1,29 +1,55 @@ import type {DynamicFormFieldsProps} from '@components/DynamicForm/components/DynamicFormFields'; -import type {DynamicFormFieldOfType} from '@components/DynamicForm/types'; -import type {InputComponentBaseProps} from '@components/Form/types'; +import type {DynamicFormFieldOfType, DynamicFormValues} from '@components/DynamicForm/types'; +import type {FormValue, InputComponentBaseProps} from '@components/Form/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {ThemeStyles} from '@src/styles'; -import type {DynamicFormFieldType} from '@src/types/onyx'; +import type {DynamicFormField, DynamicFormFieldType, DynamicFormListItem} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; -import type {ReactElement} from 'react'; +import type {ReactElement, ReactNode} from 'react'; -type DynamicFieldContext = Pick & +type DynamicFieldContext = Pick & Required> & { translate: LocalizedTranslate; styles: ThemeStyles; /** The field is the page's only question, so a choice is drawn as the page itself instead of as a row */ isLoneField: boolean; + + /** Draws a list entry's fields in the modal editor used outside DynamicFormFlow */ + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; }; +/** The answer each field type's input holds. An address holds its street, and its other parts sit under their own keys. */ +type DynamicFieldAnswerMap = { + text: string; + number: string; + select: string; + radio: string; + multiselect: string[]; + date: string; + address: string; + file: FileObject[]; + amount: string; + country: string; + countryMultiselect: string[]; + boolean: boolean; + currency: string; + percent: string; + list: DynamicFormListItem[]; +}; + /** Props every input gets from the renderer */ -type DynamicFieldInputProps = Required & +type DynamicFieldInputProps = Required & Required> & Pick & { onBlur?: () => void; + + /** A sensitive answer is not drafted, so the input starts from the answer the flow keeps in memory */ + defaultValue?: TAnswer; }; type DynamicFieldInput = { @@ -40,9 +66,13 @@ type DynamicFieldInput = { showsDescription?: boolean; }; -type DynamicFieldRenderer = (field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps) => DynamicFieldInput; +type DynamicFieldRenderer = ( + field: DynamicFormFieldOfType, + context: DynamicFieldContext, + inputProps: DynamicFieldInputProps, +) => DynamicFieldInput; /** One renderer per field type, so typecheck fails when a type has none */ type DynamicFieldRendererMap = {[TType in DynamicFormFieldType]: DynamicFieldRenderer}; -export type {DynamicFieldContext, DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap}; +export type {DynamicFieldAnswerMap, DynamicFieldContext, DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap}; diff --git a/src/components/DynamicForm/types.ts b/src/components/DynamicForm/types.ts index dacf69a06497..596f229297fd 100644 --- a/src/components/DynamicForm/types.ts +++ b/src/components/DynamicForm/types.ts @@ -1,6 +1,11 @@ import type {FormValue} from '@components/Form/types'; -import type {DynamicFormField, DynamicFormFieldType} from '@src/types/onyx'; +import type {SubPageProps, UseSubPageProps} from '@hooks/useSubPage/types'; + +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import type {DynamicFormField, DynamicFormFieldType, DynamicFormPage, DynamicFormSchema} from '@src/types/onyx'; + +import type {DynamicFormFieldsProps} from './components/DynamicFormFields'; /** Current answers keyed by draft key. Address parts sit under `.`. */ type DynamicFormValues = Partial>; @@ -8,4 +13,75 @@ type DynamicFormValues = Partial>; /** The variant of one type. An intersection rather than Extract, since one variant can cover several types, such as select and radio. */ type DynamicFormFieldOfType = DynamicFormField & {type: TType}; -export type {DynamicFormFieldOfType, DynamicFormValues}; +/** `auto` shows a step indicator at three or more pages, `stepper` always, `pages` never */ +type DynamicFormLayout = 'auto' | 'pages' | 'stepper'; + +type DynamicFormFlowProps = Pick & { + /** The pages as the server groups them, or a flat list of fields the App paginates. Fields of a type this App version does not know are left out. */ + schema: DynamicFormSchema; + + /** The form whose draft holds the answers. Sensitive answers stay in memory instead. */ + formID: OnyxFormKey; + + /** Title in the header of every page */ + headerTitle: string; + + /** Title of the confirmation page */ + confirmationTitle: string; + + /** testID of the screen wrapper */ + testID: string; + + /** Route of a page. The flow passes `edit` when the user opens a page from the confirmation page. */ + buildRoute: UseSubPageProps['buildRoute']; + + /** Replaces the route on each page change instead of pushing a new one, for flows on dynamic routes */ + shouldReplaceRoute?: UseSubPageProps['shouldReplaceRoute']; + + /** Receives the answers to every visible field, sensitive ones included. The flow drops sensitive answers once `isSubmitting` turns false without `submitError`, or right away when `isSubmitting` is not passed. */ + onSubmit: (answers: DynamicFormValues) => void; + + /** Receives a page and its draft answers when the user leaves it with Next, for flows that save each page. Sensitive answers are not passed, since a page save would persist them in the request queue. */ + onPageSubmit?: (page: DynamicFormPage, answers: DynamicFormValues) => void; + + /** Leaves the flow from its first page */ + onBack: () => void; + + /** Shows the confirmation page's Confirm button loading while the screen sends the answers */ + isSubmitting?: boolean; + + /** Error from sending the answers, shown on the confirmation page */ + submitError?: string; + + /** When the step indicator shows: `auto` from three shown pages on, `stepper` always, `pages` never */ + layout?: DynamicFormLayout; + + /** Whether a confirmation page follows the last page. On by default. A form with no page always gets one, since there has to be a page to land on. */ + hasConfirmation?: boolean; +}; + +/** Props of every page in the flow: useSubPage's routing props plus the form's data */ +type DynamicFormSubPageProps = SubPageProps & + Pick & { + pages: DynamicFormPage[]; + + /** Every supported field of the form, which is where a `showWhen` finds its controller */ + allFields: DynamicFormField[]; + + /** Draft answers merged with the sensitive answers kept in memory */ + values: DynamicFormValues; + + onPageSubmit: (page: DynamicFormPage, pageValues: DynamicFormValues) => void; + + onOpenListItemEditor: NonNullable; + + onListItemsChange: NonNullable; + + /** The last page submits the form, since no confirmation page follows */ + isLastPage: boolean; + + /** Saves a list entry from its editor page. Without `itemID` it adds a new entry. */ + onListItemSave: (listKey: string, itemID: string | undefined, answers: DynamicFormValues) => void; + }; + +export type {DynamicFormFieldOfType, DynamicFormFlowProps, DynamicFormLayout, DynamicFormSubPageProps, DynamicFormValues}; diff --git a/src/components/DynamicForm/utils/getAnswerKeys.ts b/src/components/DynamicForm/utils/getAnswerKeys.ts new file mode 100644 index 000000000000..ff3ee4717b1e --- /dev/null +++ b/src/components/DynamicForm/utils/getAnswerKeys.ts @@ -0,0 +1,17 @@ +import type {DynamicFormField} from '@src/types/onyx'; + +import getAddressInputKeys from './getAddressInputKeys'; + +/** The draft keys a field writes to: its own, plus its address parts or its chosen currency */ +function getAnswerKeys(field: DynamicFormField): string[] { + switch (field.type) { + case 'address': + return Object.values(getAddressInputKeys(field.key)).filter(Boolean); + case 'amount': + return field.currencyKey ? [field.key, field.currencyKey] : [field.key]; + default: + return [field.key]; + } +} + +export default getAnswerKeys; diff --git a/src/components/DynamicForm/utils/getConfirmationItems.ts b/src/components/DynamicForm/utils/getConfirmationItems.ts new file mode 100644 index 000000000000..6117ad965157 --- /dev/null +++ b/src/components/DynamicForm/utils/getConfirmationItems.ts @@ -0,0 +1,96 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {SummaryGroup, SummaryGroupRow} from '@components/SubStepForms/ConfirmationStep'; + +import {getLetterAvatarURLForName} from '@libs/UserAvatarUtils'; + +import type {DynamicFormField, DynamicFormListField, DynamicFormPage} from '@src/types/onyx'; + +import noop from 'lodash/noop'; + +import formatDynamicFieldValue from './formatDynamicFieldValue'; +import getContentFields from './getContentFields'; +import {getFieldLabel} from './getLocalizedText'; +import {getPageSectionName, isPageShown} from './getPageTitle'; +import getVisibleFields from './getVisibleFields'; +import isSensitiveField from './isSensitiveField'; +import isSupportedField from './isSupportedField'; +import {getListItems} from './listItems'; +import maskSensitiveValue from './maskSensitiveValue'; +import summarizeListItem, {getListItemFallbackTitle} from './summarizeListItem'; + +/** One avatar row per list entry, which opens the entry. A readonly list gets plain rows that open nothing. */ +function getListItemRows( + field: DynamicFormListField, + values: DynamicFormValues, + translate: LocalizedTranslate, + onEditListItem: (listKey: string, itemID: string) => void, +): SummaryGroupRow[] { + return getListItems(values[field.key]).map((item): SummaryGroupRow => { + const {title, description} = summarizeListItem(item, field.itemFields, translate, getListItemFallbackTitle(field, translate)); + if (field.readonly) { + return {id: `${field.key}-${item.id}`, title, description, shouldShowRightIcon: false, onPress: noop, interactive: false}; + } + return { + kind: 'avatar', + id: `${field.key}-${item.id}`, + title, + description, + avatarSource: getLetterAvatarURLForName(title), + onPress: () => onEditListItem(field.key, item.id), + }; + }); +} + +/** One section per shown page holding its visible answers, with each list entry as an avatar row and an empty list as a plain row. Tapping a row opens the page or entry to edit. Readonly rows open nothing. */ +function getConfirmationItems( + pages: DynamicFormPage[], + allFields: DynamicFormField[], + values: DynamicFormValues, + translate: LocalizedTranslate, + onEditPage: (pageIndex: number) => void, + onEditListItem: (listKey: string, itemID: string) => void, +): SummaryGroup[] { + return pages.flatMap((page, pageIndex): SummaryGroup[] => { + if (!isPageShown(page, values, allFields)) { + return []; + } + + const fields = getVisibleFields(getContentFields(page.content).filter(isSupportedField), values, allFields); + + if (fields.length === 0) { + return []; + } + + const handleEdit = () => onEditPage(pageIndex); + + return [ + { + id: page.key, + name: getPageSectionName(page, values, translate, allFields), + rows: fields.flatMap((field): SummaryGroupRow[] => { + const listItemRows = field.type === 'list' ? getListItemRows(field, values, translate, onEditListItem) : []; + if (listItemRows.length > 0) { + return listItemRows; + } + + const answer = formatDynamicFieldValue(field, values, translate); + const isEditable = !field.readonly; + + return [ + { + id: field.key, + description: getFieldLabel(field, translate), + title: isSensitiveField(field) ? maskSensitiveValue(answer) : answer, + shouldShowRightIcon: isEditable, + onPress: isEditable ? handleEdit : noop, + interactive: isEditable, + }, + ]; + }), + }, + ]; + }); +} + +export default getConfirmationItems; diff --git a/src/components/DynamicForm/utils/getContentFields.ts b/src/components/DynamicForm/utils/getContentFields.ts index aa7d39ce4744..4d40f29a1fbf 100644 --- a/src/components/DynamicForm/utils/getContentFields.ts +++ b/src/components/DynamicForm/utils/getContentFields.ts @@ -1,10 +1,10 @@ -import type {DynamicFormContentItem, DynamicFormHeading} from '@src/types/onyx'; +import type {DynamicFormContentItem} from '@src/types/onyx'; import isHeading from './isHeading'; -/** The fields of a page's content, for getDynamicFieldErrors and other field-only checks */ -function getContentFields(content: TItem[]): Array> { - return content.filter((item): item is Exclude => !isHeading(item)); +/** The fields of a page's content, for getDynamicFieldErrors and other field-only checks. Extract rather than Exclude, since every field is structurally a heading too. */ +function getContentFields(content: TItem[]): Array> { + return content.filter((item): item is Extract => !isHeading(item)); } export default getContentFields; diff --git a/src/components/DynamicForm/utils/getDynamicFieldErrors.ts b/src/components/DynamicForm/utils/getDynamicFieldErrors.ts index b1e393910e3b..de0bfcb55b61 100644 --- a/src/components/DynamicForm/utils/getDynamicFieldErrors.ts +++ b/src/components/DynamicForm/utils/getDynamicFieldErrors.ts @@ -19,6 +19,7 @@ import type { DynamicFormChoiceField, DynamicFormField, DynamicFormFieldType, + DynamicFormListField, DynamicFormNumberField, DynamicFormSchemaField, DynamicFormTextField, @@ -26,9 +27,11 @@ import type { import getAddressInputKeys, {getAddressCountry, isStateAsked} from './getAddressInputKeys'; import getFieldOptions from './getFieldOptions'; -import getVisibleFields, {getLoneField} from './getVisibleFields'; +import getVisibleFields from './getVisibleFields'; import isSupportedField from './isSupportedField'; +import {getListItems, getListItemSensitiveAnswers} from './listItems'; import logSchemaProblem from './logSchemaProblem'; +import summarizeListItem, {getListItemFallbackTitle} from './summarizeListItem'; /** Checks a field the user has answered. Unanswered fields only get the required check. */ type FieldValidator = (field: DynamicFormFieldOfType, values: DynamicFormValues, translate: LocalizedTranslate) => string[]; @@ -100,6 +103,27 @@ function getMultiChoiceErrors(field: DynamicFormFieldOfType<'multiselect' | 'cou return !hasNonStringEntry && keys.every((key) => offeredKeys.has(key)) ? [] : [translate('dynamicForm.error.invalidOption')]; } +/** The list shows one error, so the first entry with a problem gives it, prefixed by the entry title so the user knows which one to open. The flow keeps sensitive answers in memory apart from their entry, so one lost with the memory is asked for again. */ +function getListErrors(field: DynamicFormListField, values: DynamicFormValues, translate: LocalizedTranslate): string[] { + const items = getListItems(values[field.key]); + const messages: string[] = []; + if (field.minItems !== undefined && items.length < field.minItems) { + messages.push(translate('dynamicForm.error.tooFewItems', {min: field.minItems})); + } + if (field.maxItems !== undefined && items.length > field.maxItems) { + messages.push(translate('dynamicForm.error.tooManyItems', {max: field.maxItems})); + } + const fallbackTitle = getListItemFallbackTitle(field, translate); + for (const item of items) { + const error = Object.values(getDynamicFieldErrors(field.itemFields, {...item, ...getListItemSensitiveAnswers(field, item.id, values)}, translate)).at(0); + if (error) { + messages.push(translate('dynamicForm.error.itemError', {item: summarizeListItem(item, field.itemFields, translate, fallbackTitle).title, error})); + break; + } + } + return messages; +} + /** For inputs that only accept valid answers: UploadFile checks type and count, AmountForm rejects invalid typing */ const noChecks = () => []; @@ -153,6 +177,7 @@ const VALIDATORS: {[TType in DynamicFormFieldType]: FieldValidator} = { const percent = Number(getStringAnswer(field, values)); return Number.isFinite(percent) && percent >= PERCENT_MIN && percent <= PERCENT_MAX ? [] : [translate('dynamicForm.error.outOfRange', {min: PERCENT_MIN, max: PERCENT_MAX})]; }, + list: getListErrors, }; /** An optional address the user has started filling in is checked like a required one, so it is never submitted in part */ @@ -191,11 +216,13 @@ function validateField(field: DynamicFormFie return validate(field, values, translate); } -/** Errors for the fields the user can see, keyed by draft key, in the shape FormProvider's `validate` returns. */ -function getDynamicFieldErrors(fields: DynamicFormSchemaField[], values: DynamicFormValues, translate: LocalizedTranslate): Record { +/** + * Errors for the fields the user can see, keyed by draft key, in the shape FormProvider's `validate` returns. + * Pass the whole form as `allFields` when a `showWhen` can name a field on another page. + */ +function getDynamicFieldErrors(fields: DynamicFormSchemaField[], values: DynamicFormValues, translate: LocalizedTranslate, allFields?: DynamicFormField[]): Record { const errors: Record = {}; - const visibleFields = getVisibleFields(fields.filter(isSupportedField), values); - const loneField = getLoneField(visibleFields); + const visibleFields = getVisibleFields(fields.filter(isSupportedField), values, allFields); for (const field of visibleFields) { if (field.readonly) { continue; @@ -205,7 +232,7 @@ function getDynamicFieldErrors(fields: DynamicFormSchemaField[], values: Dynamic addErrorMessage(errors, inputID, message); } } - if (!isAnswered(values[field.key], field.type === 'boolean' && field === loneField)) { + if (!isAnswered(values[field.key], field.type === 'boolean' && field.presentation !== 'checkbox')) { if (field.type === 'address' ? isAddressRequired(field, values) : field.required) { addErrorMessage(errors, field.key, translate('common.error.fieldRequired')); } diff --git a/src/components/DynamicForm/utils/getPageTitle.ts b/src/components/DynamicForm/utils/getPageTitle.ts new file mode 100644 index 000000000000..7caeeda107cb --- /dev/null +++ b/src/components/DynamicForm/utils/getPageTitle.ts @@ -0,0 +1,39 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import type {DynamicFormField, DynamicFormPage} from '@src/types/onyx'; + +import getContentFields from './getContentFields'; +import getLocalizedText, {getFieldLabel} from './getLocalizedText'; +import {getLoneField, getVisibleContent, isFieldVisible} from './getVisibleFields'; + +/** The page's own title, else the schema's wording of its lone question. A label never names a section, since the label is already on the question's row. */ +function getPageSectionName(page: DynamicFormPage, values: DynamicFormValues, translate: LocalizedTranslate, allFields?: DynamicFormField[]): string { + const ownTitle = getLocalizedText(translate, page.titleKey, page.title); + if (ownTitle) { + return ownTitle; + } + const loneField = getLoneField(getContentFields(getVisibleContent(page.content, values, allFields))); + return (loneField && getLocalizedText(translate, loneField.questionKey, loneField.question)) ?? ''; +} + +/** The page's section name, else the label of its lone question. An untitled page with several questions has no headline, since no one of them names the page. */ +function getPageTitle(page: DynamicFormPage, values: DynamicFormValues, translate: LocalizedTranslate, allFields?: DynamicFormField[]): string { + const sectionName = getPageSectionName(page, values, translate, allFields); + if (sectionName) { + return sectionName; + } + const loneField = getLoneField(getContentFields(getVisibleContent(page.content, values, allFields))); + return loneField ? getFieldLabel(loneField, translate) : ''; +} + +/** A page is shown when its own `showWhen` matches and it has a field to show. A page that is not shown is skipped, and its fields are not asked or submitted. */ +function isPageShown(page: DynamicFormPage, values: DynamicFormValues, allFields: DynamicFormField[]): boolean { + if (!isFieldVisible(page, values, allFields)) { + return false; + } + return getContentFields(getVisibleContent(page.content, values, allFields)).length > 0; +} + +export default getPageTitle; +export {getPageSectionName, isPageShown}; diff --git a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts new file mode 100644 index 000000000000..6e799e8bb578 --- /dev/null +++ b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts @@ -0,0 +1,62 @@ +import type {DynamicFieldAnswerMap} from '@components/DynamicForm/renderers/types'; +import type {DynamicFormFieldOfType, DynamicFormValues} from '@components/DynamicForm/types'; +import type {FormValue} from '@components/Form/types'; + +import type {DynamicFormFieldType, DynamicFormListItem} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import isSensitiveField from './isSensitiveField'; +import {isListItem} from './listItems'; + +type AnswerGuards = {[TType in DynamicFormFieldType]: (answer: FormValue) => answer is DynamicFieldAnswerMap[TType]}; + +function isString(answer: FormValue): answer is string { + return typeof answer === 'string'; +} + +function isStringList(answer: FormValue): answer is string[] { + return Array.isArray(answer) && answer.every((item) => typeof item === 'string'); +} + +function isBoolean(answer: FormValue): answer is boolean { + return typeof answer === 'boolean'; +} + +function isFileList(answer: FormValue): answer is FileObject[] { + return Array.isArray(answer) && answer.every((item) => typeof item === 'object'); +} + +function isListItems(answer: FormValue): answer is DynamicFormListItem[] { + return Array.isArray(answer) && answer.every(isListItem); +} + +const ANSWER_GUARDS: AnswerGuards = { + text: isString, + number: isString, + select: isString, + radio: isString, + multiselect: isStringList, + date: isString, + address: isString, + file: isFileList, + amount: isString, + country: isString, + countryMultiselect: isStringList, + boolean: isBoolean, + currency: isString, + percent: isString, + list: isListItems, +}; + +/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory. `isInSensitiveForm` treats every field as sensitive. */ +function getSensitiveDefaultValue( + field: DynamicFormFieldOfType, + values: DynamicFormValues, + isInSensitiveForm = false, +): DynamicFieldAnswerMap[TType] | undefined { + const answer = values[field.key]; + const isAnswer: AnswerGuards[TType] = ANSWER_GUARDS[field.type]; + return (isInSensitiveForm || isSensitiveField(field)) && answer !== undefined && isAnswer(answer) ? answer : undefined; +} + +export default getSensitiveDefaultValue; diff --git a/src/components/DynamicForm/utils/getSubmittedAnswers.ts b/src/components/DynamicForm/utils/getSubmittedAnswers.ts new file mode 100644 index 000000000000..47463db3d78c --- /dev/null +++ b/src/components/DynamicForm/utils/getSubmittedAnswers.ts @@ -0,0 +1,29 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; + +import type {DynamicFormField} from '@src/types/onyx'; + +import getAnswerKeys from './getAnswerKeys'; +import getVisibleFields from './getVisibleFields'; +import {getListItems, getListItemSensitiveAnswers} from './listItems'; + +/** The answers to the fields the user can see, so answers left on fields hidden later are not submitted. List entries get their sensitive answers back from memory. Pass the whole form as `allFields` when `fields` is one page of it. */ +function getSubmittedAnswers(fields: DynamicFormField[], values: DynamicFormValues, allFields = fields): DynamicFormValues { + const answers: DynamicFormValues = {}; + for (const field of getVisibleFields(fields, values, allFields)) { + for (const key of getAnswerKeys(field)) { + if (values[key] !== undefined) { + answers[key] = values[key]; + } + } + if (field.type === 'list' && values[field.key] !== undefined) { + answers[field.key] = getListItems(values[field.key]).map((item) => ({ + id: item.id, + ...getSubmittedAnswers(field.itemFields, {...item, ...getListItemSensitiveAnswers(field, item.id, values)}), + })); + } + } + return answers; +} + +export default getSubmittedAnswers; +export {getAnswerKeys}; diff --git a/src/components/DynamicForm/utils/hasSensitiveAnswer.ts b/src/components/DynamicForm/utils/hasSensitiveAnswer.ts new file mode 100644 index 000000000000..0cd13f5f5f18 --- /dev/null +++ b/src/components/DynamicForm/utils/hasSensitiveAnswer.ts @@ -0,0 +1,10 @@ +import type {DynamicFormField} from '@src/types/onyx'; + +import isSensitiveField from './isSensitiveField'; + +/** The field is sensitive, or is a list whose entries hold a sensitive answer at any depth, so its answer must stay out of the draft */ +function hasSensitiveAnswer(field: DynamicFormField): boolean { + return isSensitiveField(field) || (field.type === 'list' && field.itemFields.some(hasSensitiveAnswer)); +} + +export default hasSensitiveAnswer; diff --git a/src/components/DynamicForm/utils/isSupportedField.ts b/src/components/DynamicForm/utils/isSupportedField.ts index 13f3c55328bc..935a777668c9 100644 --- a/src/components/DynamicForm/utils/isSupportedField.ts +++ b/src/components/DynamicForm/utils/isSupportedField.ts @@ -17,6 +17,7 @@ const SUPPORTED_FIELD_TYPES: Record = { file: true, amount: true, percent: true, + list: true, }; /** A field of a type this App version does not know, or with no key to store its answer under, is left out, so it neither renders nor blocks submission */ diff --git a/src/components/DynamicForm/utils/listItems.ts b/src/components/DynamicForm/utils/listItems.ts new file mode 100644 index 000000000000..f0e77da270b7 --- /dev/null +++ b/src/components/DynamicForm/utils/listItems.ts @@ -0,0 +1,63 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; + +import type {DynamicFormListField, DynamicFormListItem} from '@src/types/onyx'; + +import getAnswerKeys from './getAnswerKeys'; +import hasSensitiveAnswer from './hasSensitiveAnswer'; + +const PAGE_NAME_SEPARATOR = '~'; +const NEW_ITEM_ID = 'new'; + +/** Route segment of a list entry's editor page, or of the page that adds one */ +function getListItemPageName(listKey: string, itemID = NEW_ITEM_ID): string { + return `${listKey}${PAGE_NAME_SEPARATOR}${itemID}`; +} + +/** The list key and entry ID an editor page name points at, or undefined for any other page */ +function parseListItemPageName(pageName: string | undefined): {listKey: string; itemID: string | undefined} | undefined { + if (!pageName?.includes(PAGE_NAME_SEPARATOR)) { + return undefined; + } + const [listKey = '', itemID] = pageName.split(PAGE_NAME_SEPARATOR); + return {listKey, itemID: itemID === NEW_ITEM_ID ? undefined : itemID}; +} + +/** Memory key of one sensitive answer of a list entry, since sensitive answers never reach the draft */ +function getListItemSensitiveKey(listKey: string, itemID: string, fieldKey: string): string { + return [listKey, itemID, fieldKey].join(PAGE_NAME_SEPARATOR); +} + +function isListItem(value: unknown): value is DynamicFormListItem { + return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string'; +} + +/** The entries stored under a list field's key, ignoring anything malformed */ +function getListItems(value: unknown): DynamicFormListItem[] { + if (!Array.isArray(value)) { + return []; + } + const items: unknown[] = value; + return items.filter(isListItem); +} + +/** An entry's sensitive answers, keyed by field key, from the answers kept in memory */ +function getListItemSensitiveAnswers(field: DynamicFormListField, itemID: string, values: DynamicFormValues): DynamicFormValues { + return Object.fromEntries( + field.itemFields + .filter(hasSensitiveAnswer) + .flatMap(getAnswerKeys) + .flatMap((answerKey) => { + const answer = values[getListItemSensitiveKey(field.key, itemID, answerKey)]; + return answer === undefined ? [] : [[answerKey, answer]]; + }), + ); +} + +/** Memory keys of sensitive answers whose entry the user has removed from the list */ +function getRemovedListItemSensitiveKeys(field: DynamicFormListField, values: DynamicFormValues): string[] { + const sensitiveKeys = field.itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys); + const keptKeys = new Set(getListItems(values[field.key]).flatMap((item) => sensitiveKeys.map((answerKey) => getListItemSensitiveKey(field.key, item.id, answerKey)))); + return Object.keys(values).filter((key) => key.startsWith(`${field.key}${PAGE_NAME_SEPARATOR}`) && !keptKeys.has(key)); +} + +export {getListItemPageName, getListItemSensitiveAnswers, getListItemSensitiveKey, getListItems, getRemovedListItemSensitiveKeys, isListItem, parseListItemPageName}; diff --git a/src/components/DynamicForm/utils/maskSensitiveValue.ts b/src/components/DynamicForm/utils/maskSensitiveValue.ts new file mode 100644 index 000000000000..d760451921e9 --- /dev/null +++ b/src/components/DynamicForm/utils/maskSensitiveValue.ts @@ -0,0 +1,12 @@ +import {isValidSSNFullNine} from '@libs/ValidationUtils'; + +const VISIBLE_CHARACTERS = 4; + +/** All but the last four characters hidden, as in •••-••-6789, so the user can tell which number they entered. A short value shows at most half of itself. */ +function maskSensitiveValue(value: string): string { + const visibleCharacters = Math.min(VISIBLE_CHARACTERS, Math.floor(value.length / 2)); + const masked = '•'.repeat(value.length - visibleCharacters) + value.slice(value.length - visibleCharacters); + return isValidSSNFullNine(value) ? `${masked.slice(0, 3)}-${masked.slice(3, 5)}-${masked.slice(5)}` : masked; +} + +export default maskSensitiveValue; diff --git a/src/components/DynamicForm/utils/paginate.ts b/src/components/DynamicForm/utils/paginate.ts new file mode 100644 index 000000000000..986f0d565ad3 --- /dev/null +++ b/src/components/DynamicForm/utils/paginate.ts @@ -0,0 +1,90 @@ +import CONST from '@src/CONST'; +import type {DynamicFormField, DynamicFormPage, DynamicFormSchema, DynamicFormSchemaField} from '@src/types/onyx'; + +import getContentFields from './getContentFields'; +import isSupportedField from './isSupportedField'; + +const OWN_PAGE_TYPES = new Set(['address', 'file', 'multiselect', 'countryMultiselect', 'list']); + +function needsOwnPage(field: DynamicFormField): boolean { + if (OWN_PAGE_TYPES.has(field.type)) { + return true; + } + if (field.type !== 'select') { + return false; + } + return !!field.dependsOn || (field.values?.length ?? 0) > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS; +} + +function toSlug(key: string): string { + return key + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, ''); +} + +/** + * Groups a flat list of fields into pages. Fields that need room (addresses, files, multi-selects, lists, dependent or large selects) get a page of their own, + * and the rest share a page up to `MAX_FIELDS_PER_PAGE` in a row. A field with `showWhen` joins the page of its controller and does not count toward that limit. + * The pages have no title and no key, `getPages` names them. + */ +function paginate(fields: DynamicFormSchemaField[]): DynamicFormPage[] { + const pages: DynamicFormPage[] = []; + const pageOfKey = new Map(); + let sharedPage: DynamicFormPage | undefined; + let sharedQuestionCount = 0; + + for (const field of fields.filter(isSupportedField)) { + const controllerPage = field.showWhen ? pageOfKey.get(field.showWhen.key) : undefined; + if (controllerPage) { + controllerPage.content.push(field); + pageOfKey.set(field.key, controllerPage); + continue; + } + + if (needsOwnPage(field)) { + const ownPage: DynamicFormPage = {key: '', content: [field]}; + pages.push(ownPage); + pageOfKey.set(field.key, ownPage); + sharedPage = undefined; + continue; + } + + if (!sharedPage || sharedQuestionCount >= CONST.DYNAMIC_FORM.MAX_FIELDS_PER_PAGE) { + sharedPage = {key: '', content: []}; + sharedQuestionCount = 0; + pages.push(sharedPage); + } + sharedPage.content.push(field); + sharedQuestionCount++; + pageOfKey.set(field.key, sharedPage); + } + return pages; +} + +/** Every page's key becomes a URL-safe route segment that is unique and never the confirmation page's. Pages with no usable key get `page-N`. */ +function withRouteKeys(pages: DynamicFormPage[]): DynamicFormPage[] { + const takenKeys = new Set([CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY]); + return pages.map((page, index) => { + const baseKey = toSlug(page.key) || `page-${index + 1}`; + let key = baseKey; + for (let suffix = 2; takenKeys.has(key); suffix++) { + key = `${baseKey}-${suffix}`; + } + takenKeys.add(key); + return {...page, key}; + }); +} + +/** The pages of a schema, as the server grouped them or as `paginate` builds them */ +function getPages(schema: DynamicFormSchema): DynamicFormPage[] { + return withRouteKeys('pages' in schema ? schema.pages : paginate(schema.fields)); +} + +/** Every field of every page, which is where a `showWhen` finds its controller */ +function getAllFields(pages: DynamicFormPage[]): DynamicFormField[] { + return pages.flatMap((page) => getContentFields(page.content).filter(isSupportedField)); +} + +export default paginate; +export {getAllFields, getPages}; diff --git a/src/components/DynamicForm/utils/summarizeListItem.ts b/src/components/DynamicForm/utils/summarizeListItem.ts new file mode 100644 index 000000000000..133bb472f999 --- /dev/null +++ b/src/components/DynamicForm/utils/summarizeListItem.ts @@ -0,0 +1,45 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import type {DynamicFormField, DynamicFormListField, DynamicFormListItem} from '@src/types/onyx'; + +import formatDynamicFieldValue from './formatDynamicFieldValue'; +import getLocalizedText, {getFieldLabel} from './getLocalizedText'; +import getVisibleFields from './getVisibleFields'; +import isSensitiveField from './isSensitiveField'; + +const DESCRIPTION_ANSWER_LIMIT = 2; + +/** Long answers, such as a full address, would crowd the row, so they are left out of its description */ +const TYPES_LEFT_OUT_OF_DESCRIPTION = new Set(['date', 'address', 'country', 'file']); + +/** The noun for one entry of the list, or the list's label, which names an entry whose answers give it no name */ +function getListItemFallbackTitle(field: DynamicFormListField, translate: LocalizedTranslate): string { + return getLocalizedText(translate, field.itemLabelKey, field.itemLabel) ?? getFieldLabel(field, translate); +} + +/** The leading run of text answers names the row, as first and last name do. Up to two short remaining answers describe it. Hidden answers are left out, and an entry with no shown answer is named by `fallbackTitle`. */ +function summarizeListItem(item: DynamicFormListItem, itemFields: DynamicFormField[], translate: LocalizedTranslate, fallbackTitle: string): {title: string; description: string} { + const shownFields = getVisibleFields(itemFields, item).filter((field) => !isSensitiveField(field) && formatDynamicFieldValue(field, item, translate) !== ''); + const firstTextIndex = shownFields.findIndex((field) => field.type === 'text'); + const titleFields: DynamicFormField[] = []; + for (const field of shownFields.slice(Math.max(firstTextIndex, 0))) { + if (field.type !== 'text') { + break; + } + titleFields.push(field); + } + const firstShownField = shownFields.at(0); + if (titleFields.length === 0 && firstShownField) { + titleFields.push(firstShownField); + } + const title = titleFields.map((field) => formatDynamicFieldValue(field, item, translate)).join(' ') || fallbackTitle; + const description = shownFields + .filter((field) => !titleFields.includes(field) && !TYPES_LEFT_OUT_OF_DESCRIPTION.has(field.type)) + .slice(0, DESCRIPTION_ANSWER_LIMIT) + .map((field) => formatDynamicFieldValue(field, item, translate)) + .join(', '); + return {title, description}; +} + +export default summarizeListItem; +export {getListItemFallbackTitle}; diff --git a/src/components/DynamicForm/utils/toDynamicFormValues.ts b/src/components/DynamicForm/utils/toDynamicFormValues.ts index 2d25c264db05..fa24cb81af08 100644 --- a/src/components/DynamicForm/utils/toDynamicFormValues.ts +++ b/src/components/DynamicForm/utils/toDynamicFormValues.ts @@ -1,6 +1,8 @@ import type {DynamicFormValues} from '@components/DynamicForm/types'; import type {FormValue} from '@components/Form/types'; +import {isListItem} from './listItems'; + function isFormValue(value: unknown): value is FormValue { if (typeof value === 'string' || typeof value === 'boolean' || value instanceof Date) { return true; @@ -12,7 +14,8 @@ function isFormValue(value: unknown): value is FormValue { return ( items.every((item) => typeof item === 'string') || items.every((item) => typeof item === 'boolean') || - items.every((item) => typeof item === 'object' && item !== null && ('name' in item || 'uri' in item)) + items.every((item) => typeof item === 'object' && item !== null && ('name' in item || 'uri' in item)) || + items.every(isListItem) ); } diff --git a/src/components/EmojiPicker/CategoryShortcutButton.tsx b/src/components/EmojiPicker/CategoryShortcutButton.tsx index ea285eb09a8c..44790c1606cb 100644 --- a/src/components/EmojiPicker/CategoryShortcutButton.tsx +++ b/src/components/EmojiPicker/CategoryShortcutButton.tsx @@ -8,6 +8,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; +import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -17,6 +18,19 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React, {useState} from 'react'; +const CATEGORY_TRANSLATION_KEYS: ReadonlyMap = new Map([ + ['frequentlyUsed', 'emojiPicker.headers.frequentlyUsed'], + ['smileysAndEmotion', 'emojiPicker.headers.smileysAndEmotion'], + ['animalsAndNature', 'emojiPicker.headers.animalsAndNature'], + ['foodAndDrink', 'emojiPicker.headers.foodAndDrink'], + ['travelAndPlaces', 'emojiPicker.headers.travelAndPlaces'], + ['activities', 'emojiPicker.headers.activities'], + ['objects', 'emojiPicker.headers.objects'], + ['symbols', 'emojiPicker.headers.symbols'], + ['flags', 'emojiPicker.headers.flags'], +]); +const FALLBACK_CATEGORY_TRANSLATION_KEY: TranslationPaths = 'emojiPicker.headers.frequentlyUsed'; + type CategoryShortcutButtonProps = { /** The emoji code of the category header */ code: string; @@ -37,10 +51,15 @@ function CategoryShortcutButton({code, icon, onPress, isSelected = false}: Categ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const [isHighlighted, setIsHighlighted] = useState(false); + const translationKey = CATEGORY_TRANSLATION_KEYS.get(code); + if (!translationKey) { + Log.alert(`No emoji category translation mapped for code: ${code}`); + } + const label = translate(translationKey ?? FALLBACK_CATEGORY_TRANSLATION_KEY); return ( ; diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx index 4e737bf5ab04..35428a985e3e 100644 --- a/src/components/FormHelpMessage.tsx +++ b/src/components/FormHelpMessage.tsx @@ -8,7 +8,7 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewProps, ViewStyle} from 'react-native'; import isEmpty from 'lodash/isEmpty'; import React, {useContext, useMemo} from 'react'; @@ -32,6 +32,13 @@ type FormHelpMessageProps = { /** Container style props */ style?: StyleProp; + /** Message text style props */ + messageStyle?: StyleProp; + + /** Data attributes applied to the message container. */ + dataSet?: ViewProps['dataSet']; + + /** Whether to show dot indicator */ shouldShowRedDotIndicator?: boolean; /** Whether should render error text as HTML or as Text */ @@ -54,6 +61,8 @@ function FormHelpMessage({ children, isError = true, style, + messageStyle, + dataSet, shouldShowRedDotIndicator = true, shouldRenderMessageAsHTML = false, isInfo = false, @@ -116,7 +125,10 @@ function FormHelpMessage({ } return ( - + {isError && shouldShowRedDotIndicator && ( ) : ( ('concierge-link') ?? {}; @@ -45,7 +47,15 @@ function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { */ const navigateToConciergeChat = () => { onPressFromProps?.(); - navigateToConciergeChatAction({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChatAction({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }; // Define link style based on context diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 54669fc1fab9..c411b025498a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -5,7 +5,6 @@ import type {CartesianChartData, YKey} from '@components/HTMLEngineProvider/HTML import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; -import getStaticChartCanvasProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps'; import resolveChartThemeColor from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; @@ -29,9 +28,6 @@ type VictoryChartCartesianProps = { /** When true, renders without visible chrome (used for snapshots/tests) */ headless?: boolean; - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; - onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; @@ -39,7 +35,7 @@ type VictoryChartCartesianProps = { * Renders the CartesianChart with data, axes, and domain config drawn from context. * Labels and legend overlays are handled internally via `renderOutside`. */ -function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, onRenderArgs}: VictoryChartCartesianProps) { +function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles, pixelScale} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -69,7 +65,6 @@ function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, o domainPadding={domainPadding} padding={padding} {...getChartLayoutModeProps(explicitSize, headless)} - canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} renderOutside={(renderArgs) => { const overlayContent = ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx index fbd751310a73..21904ab9e5ea 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx @@ -9,12 +9,9 @@ import VictoryChartPolar from './VictoryChartPolar'; type VictoryChartContentProps = { explicitSize?: {width: number; height: number}; headless?: boolean; - - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; }; -function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartContentProps) { +function VictoryChartContent({explicitSize, headless}: VictoryChartContentProps) { const {type} = useVictoryChartContext(); switch (type) { case CHART_TYPE.CARTESIAN: @@ -22,7 +19,6 @@ function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: Vi ); case CHART_TYPE.POLAR: @@ -30,7 +26,6 @@ function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: Vi ); default: diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index a104930fc5a0..10db813a50e9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -103,7 +103,7 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr styles.overflowHidden, ]} > - + ))} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx index e17cbf07a196..5f4450d414f1 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -53,7 +53,6 @@ function ExpandedChartBox({width, height, clippedHeight, providerScale, backgrou diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index 1e6c3891c7b8..c14a048ec76e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -4,7 +4,6 @@ import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRendere import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; -import getStaticChartCanvasProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; import useTheme from '@hooks/useTheme'; @@ -21,15 +20,12 @@ import VictoryChartLegend from './VictoryChartLegend'; type VictoryChartPolarProps = { explicitSize?: {width: number; height: number}; headless?: boolean; - - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; }; /** * Renders the PolarChart with data drawn from context. */ -function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartPolarProps) { +function VictoryChartPolar({explicitSize, headless}: VictoryChartPolarProps) { const {tnode, data, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -67,7 +63,6 @@ function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: Vict valueKey={VALUE_KEY} colorKey={COLOR_KEY} {...getChartLayoutModeProps(explicitSize, headless)} - canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} > {headless ? ( {chartContent} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts index ef4ccb363cab..108142cbb336 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts @@ -226,6 +226,7 @@ function useVictoryBarInteractions() { chartBottom, yZero, coordinateScale, + shouldPinTooltipOnTouch: false, }); const [activeBarIndex, setActiveBarIndex] = useState(-1); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts deleted file mode 100644 index a06c1d23f515..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type GetStaticChartCanvasProps from './types'; - -/** Native Skia views don't flash on re-composite, so the chart keeps its regular renderer. */ -const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => undefined; - -export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts deleted file mode 100644 index 8b898def5617..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type GetStaticChartCanvasProps from './types'; - -/** - * Web: renders the chart into a plain 2D canvas bitmap and releases the WebGL context right after - * drawing. A bitmap stays visible through the modal close animation (a live WebGL canvas flashes - * white when re-composited) and doesn't hold a GPU context per expanded chart. - */ -// eslint-disable-next-line @typescript-eslint/naming-convention -- prop name is defined by react-native-skia -const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => ({__destroyWebGLContextAfterRender: true}); - -export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts deleted file mode 100644 index 4d13cf87d8f2..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {CanvasProps} from '@shopify/react-native-skia'; - -type StaticChartCanvasProps = Pick | undefined; - -type GetStaticChartCanvasProps = () => StaticChartCanvasProps; - -export default GetStaticChartCanvasProps; diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index 0eadf12bd75a..ea11f5046a3a 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -33,6 +33,7 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import ImageSVG from './ImageSVG'; import RenderHTML from './RenderHTML'; import ScreenWrapper from './ScreenWrapper'; +import ScrollView from './ScrollView'; import Text from './Text'; type ImportSpreadsheetProps = { @@ -285,33 +286,35 @@ function ImportSpreadsheet({backTo, goTo, shouldForceReplaceNavigation = false, }} /> - - {!(isDraggingOver ?? isDraggingOver) && desktopView} + + + {!(isDraggingOver ?? isDraggingOver) && desktopView} - { - const file = e?.dataTransfer?.files[0]; - if (!file || isReadingFile) { - return; - } - readFile(file); - }} - > - - - - {translate('common.dropTitle')} - {translate('common.dropMessage')} + { + const file = e?.dataTransfer?.files[0]; + if (!file || isReadingFile) { + return; + } + readFile(file); + }} + > + + + + {translate('common.dropTitle')} + {translate('common.dropMessage')} + - - - + + + )} diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index a97c48504e7e..95fb562daa7d 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -26,6 +26,9 @@ type InteractiveStepWrapperProps = { startStepIndex?: number; stepNames?: readonly string[]; + + /** Remounts the sub header when `startStepIndex` changes, for flows that move between steps on one screen. Other flows drive the sub header through its own state. */ + shouldRemountSubHeaderOnStepChange?: boolean; shouldEnableMaxHeight?: boolean; shouldShowOfflineIndicator?: boolean; shouldShowOfflineIndicatorInWideScreen?: boolean; @@ -59,6 +62,7 @@ function InteractiveStepWrapper({ headerSubtitle, startStepIndex, stepNames, + shouldRemountSubHeaderOnStepChange = false, shouldEnableMaxHeight, shouldShowOfflineIndicator, shouldShowOfflineIndicatorInWideScreen, @@ -94,6 +98,8 @@ function InteractiveStepWrapper({ {!!stepNames && ( >(null); const route = useRoute(); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const reportAttributes = useReportAttributes(); - const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [personalDetails] = useAllPersonalDetails(); - const styles = useThemeStyles(); + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, {hasBottomSafeAreaPadding: true}); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; // When the first item renders we want to call the onFirstItemRendered callback. @@ -70,31 +63,10 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio if (!item) { return null; } - const reportID = item.reportID; - const itemReportAttributes = reportAttributes?.[reportID]; - const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; - const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; - - let invoiceReceiverPolicyID = '-1'; - if (item.invoiceReceiver && 'policyID' in item.invoiceReceiver) { - invoiceReceiverPolicyID = item.invoiceReceiver.policyID; - } - if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) { - invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID; - } - const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; - const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; - return ( ); }, - [reportAttributes, reports, policy, personalDetails, optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], + [optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], ); - const extraData = useMemo( - () => [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - ); + const extraData = useMemo(() => [data.length, optionMode, isOffline], [data.length, optionMode, isOffline]); const previousOptionMode = usePrevious(optionMode); @@ -162,10 +131,11 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio ): string | undefined { + return report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; +} + +function invoiceReceiverAccountIDSelector(report: OnyxEntry): number | undefined { + return report?.invoiceReceiver && 'accountID' in report.invoiceReceiver ? report.invoiceReceiver.accountID : undefined; +} + +function getActionAccountIDs(action: OnyxEntry): Array { + if (!action) { + return []; + } + const accountIDs = [action.actorAccountID, getReportActionActorAccountID(action, undefined, undefined)]; + if (isActionableJoinRequest(action)) { + accountIDs.push(getOriginalMessage(action)?.accountID); + } + if (isActionableMentionWhisper(action)) { + accountIDs.push(...(getOriginalMessage(action)?.inviteeAccountIDs ?? [])); + } + return accountIDs; +} + +function getActionReportIDs(action: OnyxEntry): Array { + if (!action) { + return []; + } + const {fromReportID, displayReportID} = parseMovedTransactionReportIDs(action); + return [fromReportID, displayReportID, getIOUReportIDFromReportActionPreview(action)]; +} + /* * This component gets the data from onyx for the actual * OptionRowLHN component. * OptionRowLHN is auto-memoized by React Compiler, so it will * only re-render when the inputs derived from this wrapper change. */ -function OptionRowLHNData({ - isOptionFocused = false, - fullReport, - reportAttributes, - reportAttributesDerived, - oneTransactionThreadReport, - personalDetails = {}, - policy, - invoiceReceiverPolicy, - viewMode = 'default', - reportID, - ...propsToForward -}: OptionRowLHNDataProps) { + +function OptionRowLHNData({isOptionFocused = false, fullReport, viewMode = 'default', reportID, ...propsToForward}: OptionRowLHNDataProps) { const styles = useThemeStyles(); const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const isReportFocused = isOptionFocused && currentReportIDValue === reportID; @@ -56,7 +91,16 @@ function OptionRowLHNData({ const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const reportAttributes = useReportAttributesByID(reportID); + const [oneTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportAttributes?.oneTransactionThreadReportID)}`); const oneTransactionThreadReportID = oneTransactionThreadReport?.reportID; + + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(fullReport?.policyID)}`); + const [parentReportInvoiceReceiverPolicyID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(fullReport?.parentReportID)}`, { + selector: invoiceReceiverPolicyIDSelector, + }); + const invoiceReceiverPolicyID = parentReportInvoiceReceiverPolicyID ?? invoiceReceiverPolicyIDSelector(fullReport); + const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(invoiceReceiverPolicyID)}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); // Per-item report actions subscriptions (scoped by specific report ID) @@ -129,6 +173,38 @@ function OptionRowLHNData({ const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); + + // getLastMessageTextForReport swaps an IOU CREATE/TRACK last action of a one-transaction report for the report's own last action + const ownLastAction = + oneTransactionThreadReportID && isMoneyRequestAction(lastAction) + ? getLastVisibleAction(reportID, canUserPerformWrite, {}, {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined}, visibleReportActionsData) + : undefined; + const reportNameIDs = [reportID, lastActionReportID ? String(lastActionReportID) : undefined, ...getActionReportIDs(lastAction), ...getActionReportIDs(ownLastAction)]; + const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: (attributes: OnyxEntry) => reportNamesByIDsSelector(attributes, reportNameIDs), + }); + + const isInvoice = fullReport?.type === CONST.REPORT.TYPE.INVOICE; + const [invoiceChatReceiverAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(isInvoice ? fullReport?.chatReportID : undefined)}`, { + selector: invoiceReceiverAccountIDSelector, + }); + const parentReportActionActorAccountID = getReportActionActorAccountID(parentReportAction, fullReport, fullReport); + + const [personalDetails] = usePersonalDetailsByIDs([ + ...Object.keys(fullReport?.participants ?? {}).map(Number), + fullReport?.ownerAccountID, + fullReport?.managerID, + fullReport?.lastActorAccountID, + parentReportAction?.actorAccountID, + parentReportActionActorAccountID, + invoiceReceiverAccountIDSelector(fullReport), + invoiceChatReceiverAccountID, + ...getActionAccountIDs(lastAction), + ...getActionAccountIDs(ownLastAction), + CONST.ACCOUNT_ID.CONCIERGE, + CONST.ACCOUNT_ID.NOTIFICATIONS, + currentUserAccountID, + ]); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fullReport?.policyID}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -153,7 +229,7 @@ function OptionRowLHNData({ reportAttributes, oneTransactionThreadReport, reportNameValuePairs, - personalDetails, + personalDetails: personalDetails ?? EMPTY_PERSONAL_DETAILS, policy, parentReportAction, conciergeReportID, @@ -170,7 +246,7 @@ function OptionRowLHNData({ movedFromReport, movedToReport, currentUserAccountID, - reportAttributesDerived, + reportAttributesDerived: reportNames, visibleReportActionsData, policyTags, currentUserLogin: login ?? '', diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index c95083c09ce1..583b4f8c286e 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -1,7 +1,6 @@ import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; -import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx'; -import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {Report} from '@src/types/onyx'; import type {ComponentRef, RefObject} from 'react'; import type {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; @@ -35,14 +34,7 @@ type OptionRowLHNDataProps = { /** Whether row should be focused */ isOptionFocused?: boolean; - personalDetails?: PersonalDetailsList; fullReport: OnyxEntry; - oneTransactionThreadReport: OnyxEntry; - - /** The policy which the user has access to and which the report could be tied to */ - policy?: OnyxEntry; - - invoiceReceiverPolicy?: OnyxEntry; reportID: string; /** Toggle between compact and default view */ @@ -54,9 +46,6 @@ type OptionRowLHNDataProps = { /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; - reportAttributes: OnyxEntry; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; - /** TestID of the row, indicating order */ testID: number; }; diff --git a/src/components/ListField.tsx b/src/components/ListField.tsx new file mode 100644 index 000000000000..64a0b6aebe6f --- /dev/null +++ b/src/components/ListField.tsx @@ -0,0 +1,137 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getLetterAvatarURLForName} from '@libs/UserAvatarUtils'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import variables from '@src/styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; + +import UserAvatar from './Avatar/UserAvatar'; +import Button from './Button'; +import FormHelpMessage from './FormHelpMessage'; +import Icon from './Icon'; +import MenuItem from './MenuItem'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; + +type ListFieldRow = { + /** ID of the entry the row shows */ + id: string; + + /** Names the row and its buttons for screen readers, and the letter avatar is built from it */ + title: string; + + /** Second line of the row */ + description?: string; +}; + +type ListFieldProps = { + /** Entries, one row each */ + rows: ListFieldRow[]; + + /** Title of the add row, such as "Add owner" */ + addTitle: string; + + /** Hint shown under the add row */ + addDescription?: string; + + /** Hides the add row once the list is full */ + canAddMore?: boolean; + + /** Validation error shown under the list */ + errorText?: string; + + /** Called when the user taps the add row */ + onAdd: () => void; + + /** Called when the user taps Edit on a row */ + onEdit: (id: string) => void; + + /** Called when the user taps a row's remove button, so the caller can confirm before removing */ + onRemove: (id: string) => void; +}; + +/** A list of named items on a page, each with an avatar, an Edit button and a remove button, followed by an add row */ +function ListField({rows, addTitle, addDescription, canAddMore = true, errorText = '', onAdd, onEdit, onRemove}: ListFieldProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Close']); + + return ( + <> + {rows.map((row) => ( + // The row itself does nothing, so it stays out of the accessibility tree and screen readers can reach its buttons + + + + + + + {row.title} + {!!row.description && {row.description}} + + + + + onRemove(row.id)} + > + + + + + + + ))} + {canAddMore && ( + + + + + + + {addTitle} + {!!addDescription && {addDescription}} + + + + )} + {!!errorText && ( + + + + )} + + ); +} + +export default ListField; +export type {ListFieldRow}; diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 3c36e9ba6d81..5ce176946619 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -24,6 +24,7 @@ import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import getContextMenuAccessibilityHint from '@components/utils/getContextMenuAccessibilityHint'; import getContextMenuAccessibilityProps from '@components/utils/getContextMenuAccessibilityProps'; +import useCopyableTextRowPress from '@hooks/useCopyableTextRowPress'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -37,8 +38,10 @@ import {canUseTouchScreen, hasHoverSupport} from '@libs/DeviceCapabilities'; import {containsCustomEmoji, containsOnlyCustomEmoji} from '@libs/EmojiUtils'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getButtonState from '@libs/getButtonState'; +import getPlatform from '@libs/getPlatform'; import mergeRefs from '@libs/mergeRefs'; import Parser from '@libs/Parser'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; @@ -194,6 +197,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Hint to display at the bottom of the component */ hintText?: string | ReactNode; + /** Any additional styles to pass to hint text. */ + hintTextStyle?: StyleProp; + /** Should the error text red dot indicator be shown */ shouldShowRedDotIndicator?: boolean; @@ -387,6 +393,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Should enable copy to clipboard action */ copyable?: boolean; + /** Whether the title text should be directly selectable */ + isTitleSelectable?: boolean; + /** Plaid image for the bank */ plaidUrl?: string; @@ -484,6 +493,7 @@ function MenuItem({ errorTextStyle, shouldShowRedDotIndicator, hintText, + hintTextStyle, success = false, iconReportID, focused = false, @@ -557,6 +567,7 @@ function MenuItem({ plaidUrl, copyValue = title, copyable = false, + isTitleSelectable = false, hasSubMenuItems = false, forwardedFSClass, ref, @@ -581,6 +592,15 @@ function MenuItem({ const {singleExecution, waitForNavigate} = useMenuItemGroupActions() ?? {}; const popoverAnchor = useRef>(null); const pressableRef = useRef>(null); + const { + didTouchStartOnCopyableText, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + } = useCopyableTextRowPress({shouldTrackTouchStart: true}); useRemoveNonInteractiveClickHandler(pressableRef, interactive); const deviceHasHoverSupport = hasHoverSupport(); const isCompactMenu = useIsCompactMenu(); @@ -658,6 +678,7 @@ function MenuItem({ {description} @@ -681,6 +702,7 @@ function MenuItem({ }, [helperText, shouldParseHelperText, shouldEscapeText]); const shouldRenderTitleAsHTML = shouldRenderAsHTML && !!title && Parser.isHTML(title); + const shouldEnableTextSelection = isTitleSelectable && getPlatform() === CONST.PLATFORM.WEB; const processedTitle = useMemo(() => { let titleToWrap = ''; @@ -748,6 +770,10 @@ function MenuItem({ return; } + if (shouldSuppressCopyableTextRowPress(isTitleSelectable)) { + return; + } + if (event?.type === 'click') { (event.currentTarget as HTMLElement).blur(); } @@ -766,6 +792,9 @@ function MenuItem({ }; const secondaryInteraction = (event: GestureResponderEvent | MouseEvent) => { + if (isTitleSelectable && (shouldSuppressCopyableTextRowLongPress() || isPressStartOnCopyableText(event))) { + return; + } if (!copyValue) { return; } @@ -777,6 +806,7 @@ function MenuItem({ }); onSecondaryInteraction?.(event); }; + const secondaryInteractionHandler = copyable && !deviceHasHoverSupport ? secondaryInteraction : onSecondaryInteraction; const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; @@ -813,9 +843,23 @@ function MenuItem({ {(isHovered) => ( shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen() && ControlSelection.block()} + onMouseDown={(event) => { + markMouseDownOnCopyableText(event?.target, isTitleSelectable); + }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); + }} + shouldAllowTextSelection={isTitleSelectable} + preventDefaultContextMenu={(event) => deviceHasHoverSupport || !isTitleSelectable || !isPressStartOnCopyableText(event)} + onPressIn={() => + handleCopyableTextRowPressIn({ + shouldEnableTextSelection, + shouldBlockSelection: shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen(), + }) + } onPressOut={ControlSelection.unblock} - onSecondaryInteraction={copyable && !deviceHasHoverSupport ? secondaryInteraction : onSecondaryInteraction} + // Keep the native selection menu available through press-out; reset on the next pointer start. + onSecondaryInteraction={shouldEnableTextSelection && didTouchStartOnCopyableText ? undefined : secondaryInteractionHandler} wrapperStyle={outerWrapperStyle} activeOpacity={!interactive ? 1 : variables.pressDimValue} opacityAnimationDuration={variables.instantAnimationDuration} @@ -996,12 +1040,23 @@ function MenuItem({ fsClass={forwardedFSClass} > {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( - + {/* Use Text instead of RenderHTML when the title is plain text. Titles with shouldRenderAsHTML use baseFontStyle, which differs from combinedTitleTextStyle below. */} {shouldRenderTitleAsHTML || shouldParseTitle ? ( - + ) : ( {convertToLTR(Parser.htmlToText(processedTitle))} )} @@ -1011,7 +1066,10 @@ function MenuItem({ {renderTitleContent()} @@ -1209,6 +1267,7 @@ function MenuItem({ shouldShowRedDotIndicator={!!shouldShowRedDotIndicator} message={errorText} style={[styles.menuItemError, errorTextStyle]} + dataSet={isTitleSelectable ? {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true} : undefined} shouldRenderMessageAsHTML={shouldRenderErrorAsHTML} /> )} @@ -1218,6 +1277,8 @@ function MenuItem({ shouldShowRedDotIndicator={false} message={hintText} style={styles.menuItemError} + messageStyle={hintTextStyle} + dataSet={isTitleSelectable ? {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true} : undefined} shouldRenderMessageAsHTML={shouldRenderHintAsHTML} /> )} diff --git a/src/components/MenuItem/MenuItemContext.tsx b/src/components/MenuItem/MenuItemContext.tsx index 8fbdb4f7b5f4..b0c0895ad5af 100644 --- a/src/components/MenuItem/MenuItemContext.tsx +++ b/src/components/MenuItem/MenuItemContext.tsx @@ -7,6 +7,9 @@ type MenuItemConfig = { /** Whether the row responds to interactions */ isInteractive: boolean; + + /** Whether the row opts into browser selection of explicitly marked values */ + shouldAllowTextSelection: boolean; }; /** Interaction state of the menu item row */ @@ -21,6 +24,7 @@ type MenuItemInteraction = { const DEFAULT_CONFIG: MenuItemConfig = { isDisabled: false, isInteractive: false, + shouldAllowTextSelection: false, }; const DEFAULT_INTERACTION: MenuItemInteraction = { diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index ce2105008840..39b0502756ba 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -6,6 +6,7 @@ import {MenuItemConfigContext, MenuItemInteractionContext} from '@components/Men import MenuItemSecondaryInteractionContext, {useMenuItemSecondaryInteractionRegistry} from '@components/MenuItem/MenuItemSecondaryInteractionContext'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import useCopyableTextRowPress from '@hooks/useCopyableTextRowPress'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; +import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; @@ -47,17 +49,47 @@ type MenuItemRootProps = PropsWithChildren & */ style?: StyleProp; + /** Whether explicitly marked child text can start native browser text selection */ + shouldAllowTextSelection?: boolean; + /** Accessibility state for the row, e.g. `{expanded}`. Lands on the pressable, which is what a screen reader focuses. */ accessibilityState?: AccessibilityState; + + /** + * Whether the row is one element for screen readers. Turn it off for a row without `onPress` whose + * buttons must stay reachable, since an accessible parent hides its children on iOS. + */ + shouldBeAccessible?: boolean; }; -function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState}: MenuItemRootProps) { +function MenuItemRoot({ + children, + onPress, + isDisabled = false, + sentryLabel, + testID, + accessibilityLabel, + style, + shouldAllowTextSelection = false, + accessibilityState, + shouldBeAccessible = true, +}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); const isCompactPopover = useIsCompactPopover(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isInteractive = !!onPress; + const shouldEnableTextSelection = shouldAllowTextSelection && getPlatform() === CONST.PLATFORM.WEB; + const { + didTouchStartOnCopyableText, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + } = useCopyableTextRowPress({shouldTrackTouchStart: true}); const {accessibilityLabel: rowAccessibilityLabel, accessibilityHint, registries: accessibilityRegistries} = useMenuItemAccessibility(accessibilityLabel); const {handler: registeredSecondaryInteraction, register: registerSecondaryInteraction} = useMenuItemSecondaryInteractionRegistry(); @@ -69,6 +101,10 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI return; } + if (shouldSuppressCopyableTextRowPress(shouldEnableTextSelection)) { + return; + } + // Prevent clicked menu items from retaining an unwanted focus outline on web, especially in Safari if (event?.type === 'click' && typeof HTMLElement !== 'undefined' && event.currentTarget instanceof HTMLElement) { event.currentTarget.blur(); @@ -82,19 +118,38 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI // Left undefined when no sub-component wants it, so the web keeps its native context menu on a plain row const onSecondaryInteractionAction = registeredSecondaryInteraction - ? (event: GestureResponderEvent | MouseEvent) => registeredSecondaryInteraction(event, pressableRef.current) + ? (event: GestureResponderEvent | MouseEvent) => { + if (shouldEnableTextSelection && (shouldSuppressCopyableTextRowLongPress() || isPressStartOnCopyableText(event))) { + return; + } + registeredSecondaryInteraction(event, pressableRef.current); + } : undefined; return ( - + {(isHovered) => ( !!onSecondaryInteractionAction && shouldUseNarrowLayout && canUseTouchScreen() && ControlSelection.block()} + onMouseDown={(event) => { + markMouseDownOnCopyableText(event?.target, shouldEnableTextSelection); + }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); + }} + shouldAllowTextSelection={shouldEnableTextSelection} + preventDefaultContextMenu={(event) => !shouldEnableTextSelection || !isPressStartOnCopyableText(event)} + onPressIn={() => + handleCopyableTextRowPressIn({ + shouldEnableTextSelection, + shouldBlockSelection: !!onSecondaryInteractionAction && shouldUseNarrowLayout && canUseTouchScreen(), + }) + } onPressOut={ControlSelection.unblock} - onSecondaryInteraction={onSecondaryInteractionAction} + // RN Web prevents the native context menu whenever an onLongPress handler is attached. + // Reset on the next pointer start, since press-out can precede the browser's selection menu. + onSecondaryInteraction={shouldEnableTextSelection && didTouchStartOnCopyableText ? undefined : onSecondaryInteractionAction} activeOpacity={!isInteractive ? 1 : variables.pressDimValue} opacityAnimationDuration={variables.instantAnimationDuration} style={({pressed}) => @@ -114,7 +169,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI accessibilityLabel={rowAccessibilityLabel} accessibilityHint={accessibilityHint} accessibilityState={accessibilityState} - accessible + accessible={shouldBeAccessible} tabIndex={isInteractive ? 0 : -1} sentryLabel={sentryLabel} testID={testID} diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx index fd203fcad6a0..d6968be987f1 100644 --- a/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx @@ -5,6 +5,8 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import convertToLTR from '@libs/convertToLTR'; +import getPlatform from '@libs/getPlatform'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import CONST from '@src/CONST'; @@ -13,9 +15,17 @@ import React from 'react'; import type {BaseMenuItemTextProps, MenuItemPrimaryTextProps} from './types'; /** Base of the full-contrast leaves */ -function BaseMenuItemPrimaryText({children, accessibilityLabel, numberOfLines = 1, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) { +function BaseMenuItemPrimaryText({children, accessibilityLabel, numberOfLines = 1, isSelectable = false, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) { const styles = useThemeStyles(); const {isDisabled, isInteractive} = useMenuItemConfig(); + const shouldHideTextFromSelection = isInteractive && isDisabled; + const shouldAllowTextSelection = isSelectable && getPlatform() === CONST.PLATFORM.WEB && !shouldHideTextFromSelection; + let selectionDataSet: React.ComponentProps['dataSet']; + if (shouldHideTextFromSelection) { + selectionDataSet = {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}; + } else if (shouldAllowTextSelection) { + selectionDataSet = COPYABLE_TEXT_DATA_SET; + } useMenuItemAccessibilityLabel(slot, accessibilityLabel ?? String(children)); @@ -25,13 +35,14 @@ function BaseMenuItemPrimaryText({children, accessibilityLabel, numberOfLines = styles.flexShrink1, styles.popoverMenuText, numberOfLines === 1 ? styles.pre : styles.preWrap, - isInteractive && isDisabled && styles.userSelectNone, + shouldHideTextFromSelection && styles.userSelectNone, + shouldAllowTextSelection && styles.userSelectText, styles.ltr, styles.mw100, style, ]} numberOfLines={numberOfLines} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: isInteractive && isDisabled}} + dataSet={selectionDataSet} > {typeof children === 'string' ? convertToLTR(children) : children} diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx index 7c7c5744a256..108e38060543 100644 --- a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx @@ -1,8 +1,11 @@ import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; +import {useMenuItemConfig} from '@components/MenuItem/MenuItemContext'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + import React from 'react'; import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types'; @@ -10,13 +13,15 @@ import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types'; /** Base of the muted leaves */ function BaseMenuItemSupportingText({children, numberOfLines = 2, slot, style}: MenuItemSupportingTextProps & BaseMenuItemTextProps) { const styles = useThemeStyles(); + const {shouldAllowTextSelection} = useMenuItemConfig(); useMenuItemAccessibilityLabel(slot, String(children)); return ( {children} diff --git a/src/components/MenuItem/leaves/content/base/types.ts b/src/components/MenuItem/leaves/content/base/types.ts index bb1604686404..305432c6c63d 100644 --- a/src/components/MenuItem/leaves/content/base/types.ts +++ b/src/components/MenuItem/leaves/content/base/types.ts @@ -21,6 +21,9 @@ type MenuItemPrimaryTextProps = ( ) & { /** Defaults to 1. Anything else lets the text wrap, and `0` lets it grow unbounded */ numberOfLines?: number; + + /** Whether this text can start native browser text selection inside an interactive row */ + isSelectable?: boolean; }; /** Props of the muted leaves */ diff --git a/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx b/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx index e7ad90213e78..77dad679bbde 100644 --- a/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx +++ b/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx @@ -5,13 +5,15 @@ import React from 'react'; import type {BaseMenuItemHelpTextProps} from './types'; /** Base of the help line leaves */ -function BaseMenuItemHelpText({message, children, isError = false, style}: BaseMenuItemHelpTextProps) { +function BaseMenuItemHelpText({message, children, isError = false, style, messageStyle, dataSet}: BaseMenuItemHelpTextProps) { return ( {children} diff --git a/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx index f8adf00ad2b3..9f50033e2377 100644 --- a/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx +++ b/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx @@ -1,5 +1,9 @@ +import {useMenuItemConfig} from '@components/MenuItem/MenuItemContext'; + import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + import React from 'react'; import type {MenuItemHelpTextProps} from './types'; @@ -9,12 +13,15 @@ import BaseMenuItemHelpText from './BaseMenuItemHelpText'; /** The help line of a `MenuItem.Root`, rendered under `MenuItem.Row` and inside the row's press target */ function MenuItemHelpText({message, isError = false}: MenuItemHelpTextProps) { const styles = useThemeStyles(); + const {shouldAllowTextSelection} = useMenuItemConfig(); return ( ); } diff --git a/src/components/MenuItem/leaves/helpText/types.ts b/src/components/MenuItem/leaves/helpText/types.ts index 29ef14a9be37..4798b5379f52 100644 --- a/src/components/MenuItem/leaves/helpText/types.ts +++ b/src/components/MenuItem/leaves/helpText/types.ts @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewProps, ViewStyle} from 'react-native'; /** Props shared by the help line leaves */ type MenuItemHelpTextSharedProps = { @@ -29,6 +29,12 @@ type BaseMenuItemHelpTextProps = MenuItemHelpTextSharedProps & { /** Spacing of the line, which depends on where the leaf sits */ style?: StyleProp; + + /** Style applied to the help message text */ + messageStyle?: StyleProp; + + /** Data attributes applied to the help text container */ + dataSet?: ViewProps['dataSet']; }; export type {BaseMenuItemHelpTextProps, MenuItemHelpTextHTMLProps, MenuItemHelpTextProps}; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx index 889eeb5c9d75..2b32fe7fd4ea 100644 --- a/src/components/MenuItem/presets/MenuItemField.tsx +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -25,6 +25,9 @@ type MenuItemFieldRowProps = PropsWithChildren<{ /** How many lines the value may take. Defaults to 1, and `0` lets it grow unbounded */ numberOfLinesValue?: number; + + /** Whether the field value can start native browser text selection */ + isValueSelectable?: boolean; }>; type MenuItemFieldProps = Omit & Omit; @@ -61,10 +64,19 @@ function MenuItemFieldContent({name, children}: MenuItemFieldContentProps) { * `MenuItemField` preset when the row needs siblings inside the same `Root` (an error or a hint * line under the row). */ -function MenuItemFieldRow({name, value, numberOfLinesValue, children}: MenuItemFieldRowProps) { +function MenuItemFieldRow({name, value, numberOfLinesValue, isValueSelectable = false, children}: MenuItemFieldRowProps) { return ( - {!!value && {value}} + + {!!value && ( + + {value} + + )} + {hasRenderableChildren(children) && {children}} ); @@ -74,18 +86,31 @@ function MenuItemFieldRow({name, value, numberOfLinesValue, children}: MenuItemF * Field preset: a field name plus its value. With no `value` the name takes over the row. * `children` land in the trailing cell, next to the chevron. */ -function MenuItemFieldPreset({name, value, numberOfLinesValue, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { +function MenuItemFieldPreset({ + name, + value, + numberOfLinesValue, + isValueSelectable = false, + children, + onPress, + isDisabled = false, + sentryLabel, + testID, + shouldAllowTextSelection = isValueSelectable, +}: MenuItemFieldProps) { return ( {children} {!!onPress && } diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 941fbf534f52..b81209f4e2e6 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -35,6 +35,7 @@ import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShareActions from '@hooks/useShareActions'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -156,7 +157,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); // While offline, keep pending-delete transactions so a queued-for-delete expense still counts as a real transaction until the delete syncs. const nonPendingDeleteTransactions = Object.values(reportTransactions).filter((t) => isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const allTransactions = Object.values(reportTransactions); + const moneyReportTransactions = Object.values(reportTransactions); const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -173,7 +174,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyReportTransactions); const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); const getChatReportActions = usePayChatReportActions(chatReport, existingB2BInvoiceReport); @@ -209,6 +210,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: moneyReportTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -234,6 +236,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: moneyReportTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', @@ -283,9 +286,10 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserLogin ?? '', accountID, undefined, true, isChatReportArchived, invoiceReceiverPolicy); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - const hasOnlyPendingTransactions = allTransactions.length > 0 && allTransactions.every((t) => isPending(t)); + const hasOnlyPendingTransactions = moneyReportTransactions.length > 0 && moneyReportTransactions.every((t) => isPending(t)); const shouldShowApproveButton = - (canApproveIOU(moneyRequestReport, policy, reportMetadata, currentUserPersonalDetails.accountID, allTransactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning; + (canApproveIOU(moneyRequestReport, policy, reportMetadata, currentUserPersonalDetails.accountID, moneyReportTransactions) && !hasOnlyPendingTransactions) || + isApprovedAnimationRunning; const isApproveDisabled = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); @@ -388,6 +392,8 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo onPDFModalOpen: openPDFDownload, }); + const shareActions = useShareActions({reportID, policy}); + // Compute list of applicable secondary action keys const secondaryActions = moneyRequestReport ? getSecondaryReportActions({ @@ -425,6 +431,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo }, }, ...exportActionEntries, + ...shareActions, ...lifecycleActionEntries, ...expenseActions, ...holdRejectActions, diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 8da49d2a3f78..d3486ca92b44 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -146,6 +146,7 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -171,6 +172,7 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx index 582240122314..552c476ad68f 100644 --- a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx @@ -1,7 +1,9 @@ +import {CATEGORY_PICKER_HEADER_HEIGHT} from '@components/CategoryPicker/CategoryPickerModal'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -12,6 +14,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import {canCreateCategoryInSitu} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; @@ -70,6 +73,7 @@ function CategoryField({ const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); + const currentUserLogin = useCurrentUserPersonalDetails().login; const dropdownRef = useRef(null); const isFocused = useIsFocused(); // Where the user is in the workspace step the row sent them to. Scoped to that one departure and return: it @@ -115,6 +119,7 @@ function CategoryField({ // The one condition both the row and the effect below open on, so the effect never asks the row to open a // list it would refuse and fall back to the full page for. const canOpenListInPlace = shouldOpenInDropdown && !isReadOnly && !didConfirm; + const canAddCategory = canCreateCategoryInSitu(policy, currentUserLogin); const canOpenInDropdownAfterWorkspaceStep = canUseAnchoredFieldDropdowns && shouldUseDropdownRows && isCreatingExpense; // Blurs move the step along: the first is the departure to the workspace step, the next after returning means @@ -225,6 +230,7 @@ function CategoryField({ errorText={shouldDisplayCategoryError ? translate(formError as TranslationPaths) : ''} onPress={openCategoryPage} shouldOpenInDropdown={canOpenListInPlace} + listHeaderHeight={canAddCategory ? CATEGORY_PICKER_HEADER_HEIGHT : 0} renderDropdown={(dropdownProps) => !!transactionID && ( ) } diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx index 064fcd2e6574..4907de7498a2 100644 --- a/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx @@ -6,8 +6,13 @@ import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; import useUpdateTransactionCategory from '@hooks/useUpdateTransactionCategory'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -25,6 +30,9 @@ type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & { /** Category the expense already holds, so the list can mark it */ selectedCategory: string; + + /** Whether the user can add a category to the workspace from the list */ + canAddCategory: boolean; }; /** @@ -32,8 +40,8 @@ type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & { * opened, keeping its Onyx subscriptions off the form's first render, and saves through the same hook the * full-page selector uses so both leave the expense in the same state. */ -function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) { - const {reportID, isEditingSplitBill, action} = useConfirmationFields(); +function CategoryFieldDropdown({transactionID, policy, selectedCategory, canAddCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) { + const {reportID, isEditingSplitBill, action, iouType} = useConfirmationFields(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); @@ -50,6 +58,14 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose isEditingSplit: isEditingSplitBill, }); + const openAddCategory = () => { + onClose(); + TransitionTracker.runAfterTransitions({ + callback: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute({action, iouType, transactionID, reportID}))), + waitForUpcomingTransition: true, + }); + }; + const handleSelected = (item: ListItem) => { // `CategoryPickerModal` hands back an empty item when the selected category is tapped again, clearing it. updateCategory(item.searchText ?? ''); @@ -63,6 +79,8 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose policyID={policy?.id} selectedCategory={selectedCategory} onSelected={handleSelected} + onAddCategory={canAddCategory ? openAddCategory : undefined} + addCategorySentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ADD_CATEGORY_BUTTON} /> ); } diff --git a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx index 3626c4fa9210..25b114b95ab4 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx @@ -87,6 +87,9 @@ type ExpenseFieldDropdownProps = Omit void; + /** Height of a header the list shows above its options, e.g. an add button, added to the container's limits so it still fits as many options */ + listHeaderHeight?: number; + /** Lets the field open its list without a press, e.g. once the user is back from a step the press sent them to */ ref?: Ref; }; @@ -103,7 +106,7 @@ type ExpenseFieldDropdownHandle = { * pop-over matches the row's width and opens below it, or above when there isn't room, capped so it is never * clipped. Knows nothing about any particular field: each passes its own list in through `renderDropdown`. */ -function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, ref, ...rowProps}: ExpenseFieldDropdownProps) { +function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, listHeaderHeight = 0, ref, ...rowProps}: ExpenseFieldDropdownProps) { const {windowHeight} = useWindowDimensions(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision, which is on isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -120,6 +123,9 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re shouldOpenAbove: false, }); + const maxContainerHeight = MAX_CONTAINER_HEIGHT + listHeaderHeight; + const minUsableHeight = MIN_USABLE_HEIGHT + listHeaderHeight; + const closeDropdown = () => setIsVisible(false); const openDropdown = () => { @@ -132,10 +138,10 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re anchorRef.current?.measureInWindow((x, y, width, height) => { const spaceBelow = windowHeight - (y + height + CONTAINER_GAP); const spaceAbove = y - CONTAINER_GAP - (safeAreaTop + contentHeaderHeight); - const shouldOpenAbove = spaceBelow < MAX_CONTAINER_HEIGHT && spaceAbove > spaceBelow; + const shouldOpenAbove = spaceBelow < maxContainerHeight && spaceAbove > spaceBelow; const availableHeight = (shouldOpenAbove ? spaceAbove : spaceBelow) - CONTAINER_BORDER; - if (availableHeight < MIN_USABLE_HEIGHT) { + if (availableHeight < minUsableHeight) { onPress(); return; } @@ -144,7 +150,7 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re horizontal: x, vertical: shouldOpenAbove ? y - CONTAINER_GAP : y + height + CONTAINER_GAP, width, - height: Math.min(MAX_CONTAINER_HEIGHT, availableHeight), + height: Math.min(maxContainerHeight, availableHeight), shouldOpenAbove, }); setHasEverOpened(true); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 5344b559e761..89754b4227cd 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -7,6 +7,7 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; @@ -93,6 +94,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon // Self-subscribe to report, policy, metadata, actions, transactions const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportIDFromRoute); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); @@ -253,6 +255,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} isLatestConciergeFeedbackAction={!!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} + reportsParentHierarchy={reportsParentHierarchy} /> diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 5e80f1dacc39..7b1845a2d003 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -5,6 +5,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import type {SearchColumnType, TableColumnSize} from '@components/Search/types'; import TransactionItemRow from '@components/TransactionItemRow'; +import useCopyableTextRowPress, {isPressStartOnCopyableText} from '@hooks/useCopyableTextRowPress'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; @@ -18,6 +19,7 @@ import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen'; import {getLatestErrorMessageField} from '@libs/ErrorUtils'; import {getTransactionRejectErrorKey, isSelectableReportTransaction} from '@libs/MoneyRequestReportUtils'; import {hasFlexColumn} from '@libs/SearchUIUtils'; +import {COPYABLE_ROW_DATA_SET} from '@libs/SelectionScraper'; import {getTransactionPendingAction} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -166,10 +168,11 @@ function MoneyRequestReportTransactionItemBody({ clearError(transaction.transactionID); }; - // On narrow layouts `inlineEdit` is undefined (the parent skips the hook). The fallback ref - // keeps the press handler shape identical without ever being mutated on narrow. - const fallbackEditingOnMouseDownRef = useRef(false); - const wasEditingOnMouseDownRef = inlineEdit?.wasEditingOnMouseDownRef ?? fallbackEditingOnMouseDownRef; + // Keep this ref local so React Compiler can prove the after-render event mutations are safe. + const wasEditingOnMouseDownRef = useRef(false); + const wasEditingOnPressInRef = useRef(false); + const wasTouchStartOnCopyableTextRef = useRef(false); + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowPress} = useCopyableTextRowPress(); useEffect(() => { if (!wasRecentlyEditingCell) { @@ -180,14 +183,69 @@ function MoneyRequestReportTransactionItemBody({ const handleMouseDown = (e?: React.MouseEvent) => { wasEditingOnMouseDownRef.current = isEditingCell; + const isCopyableTarget = markMouseDownOnCopyableText(e?.target); - if (!isEditingCell) { + if (!isEditingCell && !isCopyableTarget) { e?.preventDefault(); } }; const handleHoverIn = () => setShouldDisableHoverStyle(false); + const handlePress: React.ComponentProps['onPress'] = () => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + + // Prevent row press from firing while a cell is being inline-edited (e.g. pressing Space would otherwise open the expense) + // See https://github.com/Expensify/App/issues/88646 for more details + if (isEditingCell) { + return; + } + // If a cell was being edited when the user tapped the row, suppress navigation + // so the second tap doesn't immediately open the transaction detail. + if (wasEditingOnMouseDownRef.current || wasEditingOnPressInRef.current) { + wasEditingOnMouseDownRef.current = false; + wasEditingOnPressInRef.current = false; + return; + } + handleOnPress(transaction.transactionID); + }; + + const handleTouchStart: React.ComponentProps['onTouchStart'] = (event) => { + const isCopyableTextTouchStart = isPressStartOnCopyableText(event); + wasTouchStartOnCopyableTextRef.current = markTouchStartOnCopyableText(event, isCopyableTextTouchStart); + }; + + const handlePressIn: React.ComponentProps['onPressIn'] = () => { + wasEditingOnPressInRef.current = isEditingCell; + // Selection only needs to be blocked for touch interactions; desktop mouse selection is handled by onMouseDown. + if (!canUseTouchScreen()) { + return; + } + + // Let copyable values use native long-press/drag selection instead of applying the global selection blocker. + if (wasTouchStartOnCopyableTextRef.current) { + return; + } + + // Preserve the existing row behavior for non-copyable touch targets. + ControlSelection.block(); + }; + + const handlePressableLongPress = () => { + // Let touch-web long presses on copyable values complete native text selection without opening row actions. + if (wasTouchStartOnCopyableTextRef.current) { + return; + } + handleLongPress(transaction.transactionID); + }; + + const handlePressOut = () => { + wasTouchStartOnCopyableTextRef.current = false; + ControlSelection.unblock(); + }; + // The last row keeps its bottom border but hides it, so the row height stays the same when a row is added below it. // FlashList ignores size changes of 1px or less, so a border that appears later would be covered by the next row. const lastRowBorderStyle = isLastItem && styles.borderTransparent; @@ -201,24 +259,12 @@ function MoneyRequestReportTransactionItemBody({ > { - // Prevent row press from firing while a cell is being inline-edited (e.g. pressing Space would otherwise open the expense) - // See https://github.com/Expensify/App/issues/88646 for more details - if (isEditingCell) { - return; - } - // If a cell was being edited when the user tapped the row, suppress navigation - // so the second tap doesn't immediately open the transaction detail. - if (wasEditingOnMouseDownRef.current) { - wasEditingOnMouseDownRef.current = false; - return; - } - handleOnPress(transaction.transactionID); - }} + onPress={handlePress} accessibilityLabel={translate('iou.viewDetails')} sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_TRANSACTION_ITEM} role={CONST.ROLE.BUTTON} isNested + shouldAllowTextSelection id={transaction.transactionID} style={[ styles.transactionListItemStyle, @@ -227,19 +273,13 @@ function MoneyRequestReportTransactionItemBody({ : styles.noBorderRadius, ]} hoverStyle={[isSelectable && !shouldDisableHoverStyle && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + dataSet={COPYABLE_ROW_DATA_SET} onMouseDown={handleMouseDown} + onTouchStart={handleTouchStart} onHoverIn={handleHoverIn} - onPressIn={() => { - wasEditingOnMouseDownRef.current = wasEditingOnMouseDownRef.current || isEditingCell; - if (canUseTouchScreen()) { - ControlSelection.block(); - } - }} - onPressOut={() => ControlSelection.unblock()} - onLongPress={() => { - handleLongPress(transaction.transactionID); - }} + onPressIn={handlePressIn} + onPressOut={handlePressOut} + onLongPress={handlePressableLongPress} disabled={!isSelectable} wrapperStyle={[animatedHighlightStyle, styles.userSelectNone, shouldUseNarrowLayout && [StyleUtils.getSelectedBorderBottomStyle(isSelected), lastRowBorderStyle]]} > diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 39cfa7edcbef..928218180730 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,7 +1,7 @@ import LinkButton from '@components/Button/composed/LinkButton'; import type FlatListRefType from '@components/FlashList/types'; -import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; +import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; @@ -198,7 +198,7 @@ function MoneyRequestReportTransactionList({ isLoadingInitialActions, listFooterComponent, }: MoneyRequestReportTransactionListProps) { - useCopySelectionHelper(); + useCopySelectionEvent(); const styles = useThemeStyles(); const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 326535b4fdac..78bcf1fdfca0 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -1,4 +1,3 @@ -import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import HoldOrRejectEducationalModal from '@components/HoldOrRejectEducationalModal'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -10,6 +9,7 @@ import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDecisionModal from '@hooks/useDecisionModal'; import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -91,9 +91,24 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool const isMobileSelectionModeEnabled = useMobileSelectionMode(); const {showConfirmModal} = useConfirmModal(); + const {showDecisionModal} = useDecisionModal(); + + const showOfflineModal = () => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + const showDownloadErrorModal = () => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; - const [offlineModalVisible, setOfflineModalVisible] = useState(false); - const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const [rejectModalAction, setRejectModalAction] = useState | null>(null); const transactionsWithoutPendingDelete = transactions.filter((t) => !isTransactionPendingDelete(t)); @@ -102,7 +117,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], exportName: string) => { if (isOffline) { - setOfflineModalVisible(true); + showOfflineModal(); return; } @@ -164,8 +179,8 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool reportActions, allTransactionsLength: transactions.length, session, - onExportFailed: () => setIsDownloadErrorModalVisible(true), - onExportOffline: () => setOfflineModalVisible(true), + onExportFailed: showDownloadErrorModal, + onExportOffline: showOfflineModal, policy, beginExportWithTemplate: (templateName, templateType, transactionIDList, exportName) => beginExportWithTemplate(templateName, templateType, transactionIDList, exportName), onDeleteSelected, @@ -306,24 +321,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool onClearSelection={() => clearSelectedTransactions(true)} /> )} - setIsDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadErrorModalVisible} - onClose={() => setIsDownloadErrorModalVisible(false)} - /> - setOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={offlineModalVisible} - onClose={() => setOfflineModalVisible(false)} - /> {!!rejectModalAction && ( diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 19d2e6ecb5dd..0042091fab77 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -67,7 +67,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) role={CONST.ROLE.TAB} accessibilityLabel={translate('common.insights')} accessibilityState={{selected: isSelected}} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INSIGHTS} > diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 72308f304f1f..274dabf14b6e 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -8,8 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; -import {startSpan} from '@libs/telemetry/activeSpans'; -import {startNavigateToReportsSpans} from '@libs/telemetry/navigateToReportsSpans'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; import navigationRef from '@navigation/navigationRef'; @@ -44,12 +43,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { } clearSelectedText(); interceptAnonymousUser(() => { - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - forceTransaction: true, - }); - startNavigateToReportsSpans(); + startNavigateToReportsTabSpans(); Navigation.navigate(getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuery)); }); @@ -83,7 +77,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { role={CONST.ROLE.TAB} accessibilityLabel={translate('common.spend')} accessibilityState={searchAccessibilityState} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} > diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 6d75e37b5ea0..dcf891ed9d51 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -1,6 +1,7 @@ import Icon from '@components/Icon'; import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -33,15 +34,17 @@ function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType< function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2}: TabBarItemProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const iconSize = shouldUseNarrowLayout ? variables.iconFloatingTabBar : variables.iconBottomBar; return ( - <> + {!!statusIndicatorColor && ( @@ -49,11 +52,17 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator {label} - + ); } diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index f996c54f0522..507ec3a3239a 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -61,7 +61,7 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro role={CONST.ROLE.TAB} accessibilityLabel={`${translate('common.workspacesTabTitle')}${workspacesTabIndicatorStatus ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} accessibilityState={workspacesAccessibilityState} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} > diff --git a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts index e76c98b3398b..bfbc7bb71b85 100644 --- a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts @@ -14,6 +14,19 @@ import type {OnyxEntry} from 'react-native-onyx'; import getLastRoute from './getLastRoute'; +/** The latest Spend search kept in Onyx, for a Spend tab that has not shown a search yet. */ +function getStoredSearchTabParams(lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { + const lastQueryJSON = lastSearchParams?.queryJSON; + const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; + if (lastQueryFromOnyx) { + // The persisted search key belongs to the persisted query, so it only travels with it. + return {query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}; + } + + const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + return {query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}; +} + function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { const lastSearchRoute = getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT); @@ -28,15 +41,8 @@ function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEnt } } - const lastQueryJSON = lastSearchParams?.queryJSON; - const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; - if (lastQueryFromOnyx) { - // The persisted search key belongs to the persisted query, so it only travels with it. - return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}); - } - - const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); - return ROUTES.SEARCH_ROOT.getRoute({query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}); + return ROUTES.SEARCH_ROOT.getRoute(getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery)); } export default getSearchTabRoute; +export {getStoredSearchTabParams}; diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index f934146a96da..0f988d857280 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -161,7 +161,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig onPress={navigateToNewDotHome} role={CONST.ROLE.TAB} accessibilityLabel={translate('common.home')} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.HOME} > @@ -169,6 +169,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig icon={expensifyIcons.Home} label={translate('common.home')} isSelected={selectedTab === NAVIGATION_TABS.HOME} + numberOfLines={1} /> + + + ); } diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.android.ts b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.android.ts new file mode 100644 index 000000000000..d51c8db39122 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.android.ts @@ -0,0 +1,6 @@ +/** A tab root screen reserves the row of Material's bar below its content, so the bar covers none of it. */ +function useTabBarCoveredHeight(): number { + return 0; +} + +export default useTabBarCoveredHeight; diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ios.ts b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ios.ts new file mode 100644 index 000000000000..0293d6c6fd11 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ios.ts @@ -0,0 +1,14 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; + +import {getFloatingButtonsBottom} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions'; + +/** The translucent UITabBar and the home indicator inset under it cover the bottom of a narrow tab root screen. */ +function useTabBarCoveredHeight(): number { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {bottom: bottomInset} = useSafeAreaInsets(); + + return shouldUseNarrowLayout ? getFloatingButtonsBottom(bottomInset) : 0; +} + +export default useTabBarCoveredHeight; diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts new file mode 100644 index 000000000000..02febb16d947 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts @@ -0,0 +1,14 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; + +import variables from '@styles/variables'; + +/** The floating tab bar, the gap under it and the bottom safe area it sits on cover the bottom of a narrow tab root screen. */ +function useTabBarCoveredHeight(): number { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {paddingBottom: safeAreaPaddingBottom} = useSafeAreaPaddings(true); + + return shouldUseNarrowLayout ? variables.floatingTabBarHeight + variables.floatingTabBarBottomInset + safeAreaPaddingBottom : 0; +} + +export default useTabBarCoveredHeight; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx new file mode 100644 index 000000000000..0ecf905ca0d3 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -0,0 +1,29 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type TabRootScreenWrapperProps from './types'; + +/** + * Material's bar is drawn over the full-height tab screen, so a narrow tab root screen reserves the bar's row under its + * content, and the ScreenWrapper adds the gesture inset the bar sits on below it. Account drawn over the tabs hides the bar. + */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); + const styles = useThemeStyles(); + + if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + return {}; + } + + return {bottomContent: }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx new file mode 100644 index 000000000000..c7176d66e24b --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -0,0 +1,33 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; +import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; + +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type TabRootScreenWrapperProps from './types'; + +/** + * On narrow layouts the content of a tab root screen runs under the translucent UITabBar, down to the bottom edge of + * the screen, so the ScreenWrapper runs edge to edge and adds no bottom padding, to the screen or to the offline + * indicator. Each tab root's list lets UIKit inset its end past the bar, and the offline indicator sits on the bar with + * its background running down to the bottom edge. Account drawn over the tabs hides the bar. + */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); + const styles = useThemeStyles(); + const tabBarCoveredHeight = useTabBarCoveredHeight(); + + if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + return {}; + } + + return { + enableEdgeToEdgeBottomSafeAreaPadding: false, + offlineIndicatorStyle: styles.tabBarOfflineIndicator(tabBarCoveredHeight), + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx new file mode 100644 index 000000000000..7826ebaec323 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -0,0 +1,30 @@ +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; +import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type TabRootScreenWrapperProps from './types'; + +import TabBarBottomContent from '..'; + +/** + * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it + * down to the bottom edge of the screen. Each tab root's list ends with room for the bar, and the offline indicator sits + * on the bar with its background running down to the bottom edge. + */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + const tabBarCoveredHeight = useTabBarCoveredHeight(); + + return { + bottomContent: , + bottomContentStyle: shouldUseNarrowLayout ? [styles.stickToBottom, styles.overflowVisible] : styles.overflowVisible, + offlineIndicatorStyle: shouldUseNarrowLayout ? styles.tabBarOfflineIndicator(tabBarCoveredHeight) : undefined, + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts new file mode 100644 index 000000000000..44e8a56a8a2f --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -0,0 +1,7 @@ +import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; +import type {ScreenWrapperOfflineIndicatorsProps} from '@components/ScreenWrapper/ScreenWrapperOfflineIndicators'; + +type TabRootScreenWrapperProps = Pick & + Pick; + +export default TabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx new file mode 100644 index 000000000000..3a8b5817efe6 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx @@ -0,0 +1,13 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; + +/** + * A tab root screen reserves the row of Material's bar below its content, with the offline indicator laid out above + * it, so neither covers the list and the content container keeps its style. + */ +function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { + return {contentContainerStyle: style}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx new file mode 100644 index 000000000000..5e49f3363807 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx @@ -0,0 +1,35 @@ +import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; + +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; +import type {TabRootScrollOptions} from './types'; + +/** + * UIKit insets the end of a list past the translucent UITabBar that the content runs under, but not past the offline + * indicator laid over the content above the bar, so the list ends with room for it. UIKit's inset leaves the layout of + * the content alone, so content that fills the list and is laid out in it, like a centered empty state, would sit + * partly under the bar; it pads past the bar itself instead. + */ +function useTabRootScrollProps( + style?: StyleProp, + {hasBottomSafeAreaPadding = false, hasOfflineIndicatorPadding = false, fillsVisibleArea = false}: TabRootScrollOptions = {}, +): TabRootScrollProps { + const tabBarCoveredHeight = useTabBarCoveredHeight(); + const {paddingBottom: safeAreaPaddingBottom} = useSafeAreaPaddings(true); + const isCoveredByTabBar = tabBarCoveredHeight > 0; + const coveredHeightPadding = hasBottomSafeAreaPadding ? tabBarCoveredHeight - safeAreaPaddingBottom : tabBarCoveredHeight; + + const contentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ + style, + addOfflineIndicatorBottomSafeAreaPadding: isCoveredByTabBar && !hasOfflineIndicatorPadding, + additionalPaddingBottom: isCoveredByTabBar && fillsVisibleArea ? coveredHeightPadding : 0, + }); + + return {contentContainerStyle, contentInsetAdjustmentBehavior: fillsVisibleArea ? 'never' : 'automatic'}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx new file mode 100644 index 000000000000..48c6212dd36d --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -0,0 +1,28 @@ +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import variables from '@styles/variables'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; +import type {TabRootScrollOptions} from './types'; + +/** + * The content of a narrow tab root screen runs under the floating tab bar and the offline indicator laid over it, so a + * list's content container ends with room for both, letting its last row scroll out from under them. A list that + * already adds the bottom safe area or the offline indicator's room says so, so neither is added twice. + */ +function useTabRootScrollProps(style?: StyleProp, {hasBottomSafeAreaPadding = false, hasOfflineIndicatorPadding = false}: TabRootScrollOptions = {}): TabRootScrollProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const insetStyle = useBottomSafeSafeAreaPaddingStyle({ + style, + addBottomSafeAreaPadding: !hasBottomSafeAreaPadding, + addOfflineIndicatorBottomSafeAreaPadding: !hasOfflineIndicatorPadding, + additionalPaddingBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, + }); + + return {contentContainerStyle: shouldUseNarrowLayout ? insetStyle : style}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts new file mode 100644 index 000000000000..8dbd16a0f580 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -0,0 +1,17 @@ +import type {ScrollViewProps} from 'react-native'; + +type TabRootScrollProps = Pick; + +type TabRootScrollOptions = { + /** The list already ends with the bottom safe area */ + hasBottomSafeAreaPadding?: boolean; + + /** The list already ends with room for the offline indicator, as a ScrollView with `addBottomSafeAreaPadding` does */ + hasOfflineIndicatorPadding?: boolean; + + /** The content container grows to the height of the list and lays its content out in it, as a centered empty state does */ + fillsVisibleArea?: boolean; +}; + +export default TabRootScrollProps; +export type {TabRootScrollOptions}; diff --git a/src/components/Navigation/TopBar.tsx b/src/components/Navigation/TopBar.tsx index 16163c9ad1c2..90dff1001aab 100644 --- a/src/components/Navigation/TopBar.tsx +++ b/src/components/Navigation/TopBar.tsx @@ -1,3 +1,4 @@ +import Icon from '@components/Icon'; import LoadingBar from '@components/LoadingBar'; import {PressableWithoutFeedback} from '@components/Pressable'; import SearchButton from '@components/Search/SearchRouter/SearchButton'; @@ -7,8 +8,10 @@ import {useWideRHPState} from '@components/WideRHPContextProvider'; import useContentHeaderHeight from '@hooks/useContentHeaderHeight'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import SignInButton from '@pages/inbox/sidebar/SignInButton'; @@ -37,12 +40,25 @@ type TopBarProps = { cancelSearch?: () => void; children?: React.ReactNode; breadcrumbAnimatedStyle?: StyleProp>; + /** Shows a back arrow before the breadcrumb that calls this when pressed. */ + onBackButtonPress?: () => void; }; const authTokenTypeSelector = (session: OnyxEntry) => session && {authTokenType: session.authTokenType}; -function TopBar({breadcrumbLabel, shouldDisplaySearch = true, shouldDisplayHelpButton = false, cancelSearch, shouldShowLoadingBar, children, breadcrumbAnimatedStyle}: TopBarProps) { +function TopBar({ + breadcrumbLabel, + shouldDisplaySearch = true, + shouldDisplayHelpButton = false, + cancelSearch, + shouldShowLoadingBar, + children, + breadcrumbAnimatedStyle, + onBackButtonPress, +}: TopBarProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow']); const {translate} = useLocalize(); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: authTokenTypeSelector}); const isAnonymousUser = isAnonymousUserUtil(session); @@ -58,11 +74,25 @@ function TopBar({breadcrumbLabel, shouldDisplaySearch = true, shouldDisplayHelpB return ( Keyboard.dismiss() : undefined} > + {!!onBackButtonPress && ( + + + + )} ; * A thin wrapper around TopBar that encapsulates useLoadingBarVisibility(). * Use this in places where shouldShowLoadingBar is NOT passed explicitly by the parent. */ -function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, children}: TopBarWithLoadingBarProps) { +function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, onBackButtonPress, children}: TopBarWithLoadingBarProps) { const shouldShowLoadingBar = useLoadingBarVisibility(); return ( {children} diff --git a/src/components/OnboardingHelpDropdownButton.tsx b/src/components/OnboardingHelpDropdownButton.tsx index 3d388f9ba2ea..cc7408d8a5c2 100644 --- a/src/components/OnboardingHelpDropdownButton.tsx +++ b/src/components/OnboardingHelpDropdownButton.tsx @@ -13,8 +13,8 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ReportNameValuePairs} from '@src/types/onyx'; +import {latestCalendlyCallSelector} from '@selectors/ReportNameValuePairs'; import {accountIDSelector} from '@selectors/Session'; import {addMinutes} from 'date-fns'; import React from 'react'; @@ -37,8 +37,6 @@ type OnboardingHelpButtonProps = { hasActiveScheduledCall: boolean | undefined; }; -const reportNameValuePartsSelector = (reportNameValuePairs?: ReportNameValuePairs) => reportNameValuePairs?.calendlyCalls?.at(-1); - function OnboardingHelpDropdownButton({reportID, shouldUseNarrowLayout, shouldShowRegisterForWebinar, shouldShowGuideBooking, hasActiveScheduledCall}: OnboardingHelpButtonProps) { const {translate, dateFnsLocale} = useLocalize(); const StyleUtils = useStyleUtils(); @@ -47,7 +45,7 @@ function OnboardingHelpDropdownButton({reportID, shouldUseNarrowLayout, shouldSh }); const [latestScheduledCall] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { - selector: reportNameValuePartsSelector, + selector: latestCalendlyCallSelector, }); const styles = useThemeStyles(); diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 530d9d639627..0f7067f1488f 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -24,6 +24,8 @@ import SCREENS from '@src/SCREENS'; import type {ColorValue, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {useRoute} from '@react-navigation/native'; +import {getReportHasParentAccess} from '@selectors/Report'; +import {reportVisibleActionsSelector} from '@selectors/ReportAction'; import React from 'react'; import {View} from 'react-native'; @@ -98,12 +100,12 @@ function ParentNavigationSubtitle({ const {workspaceName, reportName} = parentNavigationSubtitleData; const {translate} = useLocalize(); - const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [hasParentAccess] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportHasParentAccess}); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: reportVisibleActionsSelector(parentReportID)}); const isReportArchived = useReportIsArchived(report?.reportID); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - const hasAccessToParentReport = currentReport?.hasParentAccess !== false; + const hasAccessToParentReport = hasParentAccess !== false; const {currentFullScreenRoute, currentFocusedNavigator} = useRootNavigationState((state) => { // Find the tab navigator, which wraps all full-screen navigators const tabNavigatorRoute = state?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 92a8a2597888..df30c895cb39 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -219,7 +219,7 @@ function GenericPressable({ (state.hovered || isHovered) && StyleUtils.parseStyleFromFunction(hoverStyle, state), state.pressed && StyleUtils.parseStyleFromFunction(pressStyle, state), isDisabled && [StyleUtils.parseStyleFromFunction(disabledStyle, state), styles.noSelect], - shouldSuppressSelection && styles.userSelectNone, + shouldAllowTextSelection ? styles.userSelectAuto : shouldSuppressSelection && styles.userSelectNone, ]} // accessibility props accessibilityState={{ diff --git a/src/components/Pressable/GenericPressable/implementation/index.tsx b/src/components/Pressable/GenericPressable/implementation/index.tsx index 34846d62b19a..0c4d22d8266c 100644 --- a/src/components/Pressable/GenericPressable/implementation/index.tsx +++ b/src/components/Pressable/GenericPressable/implementation/index.tsx @@ -2,6 +2,8 @@ import type PressableProps from '@components/Pressable/GenericPressable/types'; import mergeRefs from '@libs/mergeRefs'; +import CONST from '@src/CONST'; + import type {ComponentRef} from 'react'; import type {Role, View} from 'react-native'; @@ -11,6 +13,10 @@ import GenericPressable from './BaseGenericPressable'; function WebGenericPressable({focusable = true, ref, sentryLabel, ...props}: PressableProps) { const accessible = (props.accessible ?? props.accessible === undefined) ? true : props.accessible; + const role = (props.accessibilityRole ?? props.role) as Role; + // React Native Web maps role="button" to a native + ); + + // In landscape there isn't enough height to stack the inputs, number pad and Save button, so use the same layout as the + // amount page (NumberWithSymbolForm): inputs and AM/PM buttons next to the number pad, with the Save button pinned below. + if (isInLandscapeMode) { + return ( + + + + {renderedInputs} + {renderedAmPmButtons} + {isError && ( + - + )} + + {canUseTouchScreen && ( + + {numberPad()} + )} - + + {renderedSaveButton} + + ); + } + + return ( + + + {renderedInputs} {!canUseTouchScreen && renderedAmPmButtons} - {isError ? ( - - ) : ( - - )} + {renderedErrorMessage} {canUseTouchScreen && renderedAmPmButtons} {}, shou > {numberPad()} - + {renderedSaveButton} ); } diff --git a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx index c65078fd3fd4..5d1514dfbecb 100644 --- a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx +++ b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx @@ -52,6 +52,7 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem text={categoryForDisplay} numberOfLines={1} style={[styles.lineHeightLarge, styles.justifyContentCenter]} + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx b/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx index ebfbf5129ed6..9a5a9046a8f1 100644 --- a/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx @@ -14,6 +14,7 @@ import {isChatThread} from '@libs/ReportUtils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; @@ -61,7 +62,18 @@ function ChatBubbleCell({transaction, containerStyles, isInSingleTransactionRepo return ( threadMessages.count > 0 && ( - + ); diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx index 495137a77e81..72201799a907 100644 --- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx @@ -15,6 +15,7 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; import type {ComponentRef} from 'react'; @@ -87,8 +88,10 @@ function ReceiptCell({ StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusSmall), styles.overflowHidden, backgroundStyles, + styles.userSelectNone, style, ]} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} onMouseEnter={handleMouseEnter} onMouseLeave={bind.onMouseLeave} > diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx index 3ef5b0a0f8bf..5e9f20b65823 100644 --- a/src/components/TransactionItemRow/DataCells/TagCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx @@ -58,6 +58,7 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra text={tagForDisplay} numberOfLines={1} style={[styles.lineHeightLarge, styles.justifyContentCenter]} + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/TaxCell.tsx b/src/components/TransactionItemRow/DataCells/TaxCell.tsx index 36950112a47b..2133500677b8 100644 --- a/src/components/TransactionItemRow/DataCells/TaxCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TaxCell.tsx @@ -21,6 +21,7 @@ function TaxCell({transactionItem, shouldShowTooltip}: TransactionDataCellProps) shouldShowTooltip={shouldShowTooltip} text={convertToDisplayString(taxAmount, currency)} style={[styles.optionDisplayName, styles.lineHeightLarge, styles.pre, styles.justifyContentCenter, styles.textAlignRight]} + isSelectable /> ); } diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx index cc0fbcdca871..a123e6f23183 100644 --- a/src/components/TransactionItemRow/DataCells/TotalCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx @@ -147,6 +147,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report, shouldShowTooltip={shouldShowTooltip} text={amountToDisplay} style={[styles.optionDisplayName, styles.justifyContentCenter, styles.flexShrink1, styles.textAlignRight]} + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/TypeCell.tsx b/src/components/TransactionItemRow/DataCells/TypeCell.tsx index a2075a42ee8e..f1162f227e21 100644 --- a/src/components/TransactionItemRow/DataCells/TypeCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TypeCell.tsx @@ -102,6 +102,7 @@ function TypeCell({transactionItem, shouldUseNarrowLayout, shouldShowTooltip}: T shouldShowTooltip={shouldShowTooltip} text={translate(typeText)} style={[styles.mutedNormalTextLabel, styles.pre, styles.justifyContentCenter, styles.flexShrink0]} + isSelectable /> ) : ( diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 9438bd822f5d..54e5aa16097b 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -37,6 +37,7 @@ type TransactionItemRowNarrowProps = Pick< | 'onCheckboxPress' | 'shouldShowCheckbox' | 'style' + | 'dataSet' | 'isInSingleTransactionReport' | 'shouldShowRadioButton' | 'onRadioButtonPress' @@ -63,6 +64,7 @@ function TransactionItemRowNarrow({ onCheckboxPress = () => {}, shouldShowCheckbox = false, style, + dataSet, isInSingleTransactionReport = false, shouldShowRadioButton = false, onRadioButtonPress = () => {}, @@ -97,6 +99,7 @@ function TransactionItemRowNarrow({ <> diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx index b909a733f690..d70afea367ff 100644 --- a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -93,8 +93,9 @@ function TransactionItemRowRBRInner({transaction, violations, report, containerS return ( RBRMessages.length > 0 && ( {}, style, + dataSet, isReportItemChild = false, isActionLoading, isInSingleTransactionReport = false, @@ -341,7 +343,12 @@ function TransactionItemRowWide({ key={column} style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE)]} > - {getReimbursable(transactionItem) ? translate('common.yes') : translate('common.no')} + + {getReimbursable(transactionItem) ? translate('common.yes') : translate('common.no')} + ); case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: @@ -350,7 +357,12 @@ function TransactionItemRowWide({ key={column} style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.BILLABLE)]} > - {transactionItem.billable ? translate('common.yes') : translate('common.no')} + + {transactionItem.billable ? translate('common.yes') : translate('common.no')} + ); case CONST.SEARCH.TABLE_COLUMNS.ACTION: @@ -358,6 +370,7 @@ function TransactionItemRowWide({ {!!transactionItem.action && ( diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index a0ecd20d6f4f..1a7237ce969d 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -57,6 +57,7 @@ function TransactionItemRow({ columns, onButtonPress = () => {}, style, + dataSet, isReportItemChild = false, isActionLoading, isInSingleTransactionReport = false, @@ -156,6 +157,7 @@ function TransactionItemRow({ onCheckboxPress, shouldShowCheckbox, style, + dataSet, isInSingleTransactionReport, shouldShowRadioButton, onRadioButtonPress, @@ -208,6 +210,7 @@ function TransactionItemRow({ columns, onButtonPress, style, + dataSet, isReportItemChild, isActionLoading, isInSingleTransactionReport, diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 79da75aaf641..169db6102c95 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -85,6 +85,7 @@ type TransactionItemRowProps = { columns?: SearchColumnType[]; onButtonPress?: (event?: ModifiedMouseEvent) => void; style?: StyleProp; + dataSet?: Record; isReportItemChild?: boolean; isActionLoading?: boolean; isInSingleTransactionReport?: boolean; diff --git a/src/components/UserPill.tsx b/src/components/UserPill.tsx index f86d04d29ac3..df54fa5ebe63 100644 --- a/src/components/UserPill.tsx +++ b/src/components/UserPill.tsx @@ -2,6 +2,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; @@ -22,9 +23,10 @@ type UserPillProps = { accountID?: number; email?: string; style?: StyleProp; + isSelectable?: boolean; }; -function UserPill({avatar, displayName, accountID, email, style}: UserPillProps) { +function UserPill({avatar, displayName, accountID, email, style, isSelectable = false}: UserPillProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {formatPhoneNumber} = useLocalize(); @@ -42,14 +44,21 @@ function UserPill({avatar, displayName, accountID, email, style}: UserPillProps) }} > - + + + {formattedDisplayName} diff --git a/src/components/UserPills.tsx b/src/components/UserPills.tsx index 4ee882a37633..c3faa654e815 100644 --- a/src/components/UserPills.tsx +++ b/src/components/UserPills.tsx @@ -24,6 +24,7 @@ type UserPillData = { type UserPillsProps = { users: UserPillData[]; maxVisible?: number; + isSelectable?: boolean; } & ( | {onShowAllPress?: undefined; showAllSentryLabel?: undefined} | { @@ -36,7 +37,7 @@ type UserPillsProps = { const DEFAULT_MAX_VISIBLE = 6; -function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, showAllSentryLabel}: UserPillsProps) { +function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, showAllSentryLabel, isSelectable = false}: UserPillsProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); @@ -62,6 +63,7 @@ function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, sho displayName={user.displayName} accountID={user.accountID} email={user.email} + isSelectable={isSelectable} /> ); })} diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx new file mode 100644 index 000000000000..177712b6ec22 --- /dev/null +++ b/src/components/VacationDelegateForm.tsx @@ -0,0 +1,208 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useInitialSelection from '@hooks/useInitialSelection'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import DateUtils from '@libs/DateUtils'; +import { + formatVacationDelegateClearDateTime, + getActiveVacationDelegate, + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, + isVacationDelegateClearDatePassed, +} from '@libs/VacationDelegateUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {FormInputErrors, FormOnyxValues} from './Form/types'; + +import DatePicker from './DatePicker'; +import FormProvider from './Form/FormProvider'; +import InputWrapper from './Form/InputWrapper'; +import MenuItemAction from './MenuItem/presets/MenuItemAction'; +import OfflineWithFeedback from './OfflineWithFeedback'; +import Text from './Text'; +import TimeModalPicker from './TimeModalPicker'; +import VacationDelegateMenuItem from './VacationDelegateMenuItem'; + +type VacationDelegateFormProps = { + /** The saved vacation delegate */ + vacationDelegate?: BaseVacationDelegate; + + /** Text shown above the form */ + description?: string; + + /** + * Timezone the clear date and time are picked in. Defaults to the current user's Profile timezone. + * A domain admin passes the member's, so the delegate clears at the end of the vacationer's day, not the admin's. + */ + timezone?: SelectedTimezone; + + /** Opens the member selection screen. The picked member is stored in the form draft. */ + onChangeDelegate: () => void; + + /** + * Saves the picked delegate + * @param delegate - Login of the picked delegate + * @param clearAfter - UTC datetime (yyyy-MM-dd HH:mm:ss) when the delegate clears, or undefined when it never clears + */ + onSubmit: (delegate: string, clearAfter: string | undefined) => void; + + /** Removes the saved delegate */ + onRemove: () => void; + + /** Errors left by the last change to the saved delegate */ + errors?: Errors; + + /** Pending action of the last change to the saved delegate */ + pendingAction?: PendingAction; + + /** Dismisses `errors` */ + onCloseError?: () => void; + + /** Whether the save request is still running, which shows the spinner on the Save button */ + isLoading?: boolean; +}; + +function VacationDelegateForm({ + vacationDelegate, + description, + timezone: timezoneProp, + onChangeDelegate, + onSubmit, + onRemove, + errors, + pendingAction, + onCloseError, + isLoading = false, +}: VacationDelegateFormProps) { + const styles = useThemeStyles(); + const {translate, dateFnsLocale, getLocalDateFromDatetime} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); + const timezone = timezoneProp ?? currentUserTimezone?.selected; + const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + + const savedDelegate = getActiveVacationDelegate(vacationDelegate); + // Remove reflects the delegate saved when the form opened. Otherwise the optimistic write from this form's own Save + // would make the button appear while the request runs and the RHP closes. + const savedDelegateOnOpen = useInitialSelection(savedDelegate); + const savedClearDateTime = savedDelegate ? getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone) : ''; + const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone) : ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty draft means no member was picked yet + const delegate = draftValues?.[INPUT_IDS.DELEGATE] || savedDelegate; + const clearDate = draftValues?.[INPUT_IDS.CLEAR_AFTER_DATE] ?? savedClearDate; + const formattedClearDateTime = formatVacationDelegateClearDateTime( + getVacationDelegateLocalClearDateTime(clearDate, draftValues?.[INPUT_IDS.CLEAR_AFTER_TIME] ?? savedClearDateTime), + dateFnsLocale, + ); + + const validate = (values: FormOnyxValues): FormInputErrors => { + const formErrors: FormInputErrors = {}; + if (!values[INPUT_IDS.CLEAR_AFTER_DATE]) { + return formErrors; + } + + // Both checks read the picked date and time in the form's timezone, the same one clearAfter is built from, not the device's + if (isVacationDelegateClearDatePassed(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone)) { + formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = translate('common.error.dateInvalid'); + } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone))) { + formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); + } + return formErrors; + }; + + const submit = (values: FormOnyxValues) => { + if (!delegate) { + return; + } + + onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone)); + }; + + return ( + + {!!description && {description}} + + {/* The clear after date and time belong to the same pending save as the delegate, so they grey out with it */} + + + {/* Shows the calendar icon while empty and the clear button once a date is picked, so the delegate can go back to never clearing. + The earliest day is today in the form's timezone, so it matches the validation even when the device is in another timezone. */} + + + {/* The time only means something once a day is picked. Until it is changed, the delegate clears at the end of that day. */} + {!!clearDate && ( + + + + )} + {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} + {!!formattedClearDateTime && ( + {translate('statusPage.vacationDelegate.willClearOn', formattedClearDateTime)} + )} + + {!!savedDelegateOnOpen && !!savedDelegate && ( + + + + )} + + ); +} + +export default VacationDelegateForm; diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index ff3c58528440..f9a742fbf77e 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -1,25 +1,90 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {formatVacationDelegateClearDateTime, getActiveVacationDelegate, getVacationDelegateClearDateTime} from '@libs/VacationDelegateUtils'; -import CONST from '@src/CONST'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type CONST from '@src/CONST'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; -import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; import MenuItemField from './MenuItem/presets/MenuItemField'; -import MenuItemWithLabel from './MenuItem/presets/MenuItemWithLabel'; import OfflineWithFeedback from './OfflineWithFeedback'; +import UserPill from './UserPill'; + +type VacationDelegateMenuItemRowProps = { + /** Text above the delegate, naming what the row holds */ + label: string; + + /** Name shown in the delegate's pill */ + displayName: string; + + /** Avatar shown in the delegate's pill */ + avatar?: AvatarSource; + + /** Account ID of the delegate */ + accountID?: number; + + /** Login of the delegate */ + login?: string; + + /** Text under the pill saying when the delegate clears */ + untilText?: string; + + /** Indicator shown next to the chevron */ + brickRoadIndicator?: ValueOf; +}; + +/** + * The line of a set vacation delegate, without a `MenuItem.Root` of its own: the label, the delegate as a pill, and when it clears. + * The pill is not a text leaf, so the `Root` around it should get an `accessibilityLabel` that names the delegate. + */ +function VacationDelegateMenuItemRow({label, displayName, avatar, accountID, login, untilText, brickRoadIndicator}: VacationDelegateMenuItemRowProps) { + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + + return ( + + + {label} + + {!!untilText && {untilText}} + + + {!!brickRoadIndicator && } + + + + ); +} type VacationDelegateSectionProps = { vacationDelegate?: BaseVacationDelegate; + /** Text above the delegate. Defaults to "Vacation delegate" */ + label?: string; + + /** Timezone the clear date is shown in. Defaults to the current user's, so a domain admin can pass the member's instead */ + timezone?: SelectedTimezone; + /** Errors related to setting the vacation delegate */ errors?: Errors; @@ -29,7 +94,7 @@ type VacationDelegateSectionProps = { /** * Callback used to clear/reset errors related to the vacation delegate */ - onCloseError: () => void; + onCloseError?: () => void; /** * Callback triggered when the section is pressed. @@ -38,17 +103,21 @@ type VacationDelegateSectionProps = { onPress: () => void; }; -function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { +function VacationDelegateMenuItemPreset({vacationDelegate, label, timezone, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); - const hasVacationDelegate = !!vacationDelegate?.delegate; - const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(vacationDelegate?.delegate); + const rowLabel = label ?? translate('common.vacationDelegate'); + const activeVacationDelegate = getActiveVacationDelegate(vacationDelegate); + const hasVacationDelegate = !!activeVacationDelegate; + // Only looks up a delegate the row shows, so an expired one doesn't trigger a personal details request + const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(activeVacationDelegate); - const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate ?? ''; + const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? activeVacationDelegate ?? ''; const delegateDisplayName = getVacationDelegateDisplayName(rawDelegateLogin, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber); - const delegateDescription = formatPhoneNumber(rawDelegateLogin); + const clearDate = formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone ?? currentUserTimezone?.selected), dateFnsLocale); + const untilText = clearDate ? translate('statusPage.vacationDelegate.until', clearDate) : ''; return ( {hasVacationDelegate ? ( - - - - - - - {delegateDisplayName} - {!!delegateDescription && {delegateDescription}} - - - - - - + + ) : ( )} @@ -90,4 +151,6 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl ); } +const VacationDelegateMenuItem = Object.assign(VacationDelegateMenuItemPreset, {Row: VacationDelegateMenuItemRow}); + export default VacationDelegateMenuItem; diff --git a/src/components/WalletStatementModal/index.native.tsx b/src/components/WalletStatementModal/index.native.tsx index c4e45e77b8e9..ca2653220936 100644 --- a/src/components/WalletStatementModal/index.native.tsx +++ b/src/components/WalletStatementModal/index.native.tsx @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {WebViewMessageEvent, WebViewNavigation} from 'react-native-webview'; import type {ValueOf} from 'type-fest'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import {WebView} from 'react-native-webview'; @@ -28,7 +28,9 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { const [session] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const webViewRef = useRef(null); @@ -52,7 +54,16 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { if (!webViewRef.current || !parsedData) { return; } - handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, conciergePersonalDetails, parsedData.type, parsedData.url); + handleWalletStatementNavigation( + conciergeReportID, + introSelected, + session?.accountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + parsedData.type, + parsedData.url, + ); }; return ( diff --git a/src/components/WalletStatementModal/index.tsx b/src/components/WalletStatementModal/index.tsx index 6785c328a7ba..8c8aead941df 100644 --- a/src/components/WalletStatementModal/index.tsx +++ b/src/components/WalletStatementModal/index.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -23,7 +23,9 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const navigateRef = useRef<(event: MessageEvent) => void>(null); @@ -34,9 +36,9 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { (event: MessageEvent) => { const {data} = event; const {type, url} = data || {}; - handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, conciergePersonalDetails, type, url); + handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, type, url); }, - [conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, conciergePersonalDetails], + [conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails], ); useEffect(() => { diff --git a/src/components/WalletStatementModal/walletNavigationUtils.ts b/src/components/WalletStatementModal/walletNavigationUtils.ts index 3a18ac17458e..4799f8a17a9a 100644 --- a/src/components/WalletStatementModal/walletNavigationUtils.ts +++ b/src/components/WalletStatementModal/walletNavigationUtils.ts @@ -19,6 +19,7 @@ function handleWalletStatementNavigation( introSelected: OnyxEntry, currentUserAccountID: number | undefined, isSelfTourViewed: boolean | undefined, + hasCompletedGuidedSetupFlow: boolean | undefined, conciergePersonalDetails: OnyxEntry, type?: string, url?: string, @@ -28,7 +29,14 @@ function handleWalletStatementNavigation( } if (type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + }); return; } diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index 8e4d03b04fd5..a78cee210fcc 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -18,7 +18,7 @@ import {View} from 'react-native'; import {useDebugTabViewHeight} from './Navigation/DebugTabView'; import NAVIGATION_TABS from './Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from './Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from './Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from './Navigation/TopBarWithLoadingBar'; import OfflineIndicator from './OfflineIndicator'; import ScreenWrapper from './ScreenWrapper'; @@ -91,6 +91,7 @@ function WorkspaceListHeaderContent({activeTabKey, headerButton, shouldShowHeade function WorkspaceListLayout({children, activeTabKey, headerButton, headerComponent, scrollHeaderWithTable = false}: WorkspaceListLayoutProps) { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -122,8 +123,7 @@ function WorkspaceListLayout({children, activeTabKey, headerButton, headerCompon shouldEnableMaxHeight shouldEnablePickerAvoiding={false} enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContentStyle={styles.overflowVisible} - bottomContent={} + {...tabRootScreenWrapperProps} > diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts new file mode 100644 index 000000000000..ee322551ddf7 --- /dev/null +++ b/src/hooks/useAgents.ts @@ -0,0 +1,175 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import type {TableHandle} from '@components/Table'; +import type {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; + +import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@libs/actions/Agent'; +import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; +import {getLatestError} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +import {useEffect, useRef, useState} from 'react'; + +import useChatWithAgent from './useChatWithAgent'; +import useCleanupSelectedOptions from './useCleanupSelectedOptions'; +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; +import useRuleBotGuardModal from './useRuleBotGuardModal'; +import useSearchBackPress from './useSearchBackPress'; +import useSwitchToDelegator from './useSwitchToDelegator'; + +function handleErrorClose(pendingAction: PendingAction | null | undefined, accountID: number): void { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + clearAgentError(accountID); + } else if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + clearAgentDeleteError(accountID); + } else { + clearAgentUpdateError(accountID); + } +} + +/** + * Hook that derives the current user's manageable agent rows from Onyx and exposes selection and bulk-delete helpers for the Agents table. + */ +function useAgents() { + useEffect(() => { + openAgentsPage(); + }, []); + + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const [allAgents] = useOnyx(ONYXKEYS.COLLECTION.AGENT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const personalDetailsList = usePersonalDetails(); + const chatWithAgent = useChatWithAgent(); + const switchToDelegator = useSwitchToDelegator(); + const {showConfirmModal} = useConfirmModal(); + const showRuleBotGuardModal = useRuleBotGuardModal(); + const [selectedAgents, setSelectedAgents] = useState([]); + + const agents: AgentRowData[] = Object.entries(allAgents ?? {}).flatMap(([key, agent]) => { + if (!agent) { + return []; + } + const accountID = Number(key.slice(ONYXKEYS.COLLECTION.AGENT.length)); + const details = personalDetailsList?.[accountID]; + if (!details) { + return []; + } + const pendingAction = agent.pendingAction; + const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (!isOffline && isPendingDeletion) { + return []; + } + + const mergedErrors = { + ...getLatestError(agent.errors ?? undefined), + ...getLatestError(agent.nameErrors ?? undefined), + ...getLatestError(agent.promptErrors ?? undefined), + ...getLatestError(agent.avatarErrors ?? undefined), + }; + const rowErrors = getLatestError(mergedErrors); + + return [ + { + keyForList: String(accountID), + accountID, + displayName: details.displayName ?? details.login ?? '', + login: details.login ?? '', + ownerAccountID: agent.ownerAccountID, + ownerType: agent.ownerType, + pendingAction, + errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, + disabled: isPendingDeletion, + canManage: true, + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID))), + onChatPress: () => chatWithAgent(accountID), + onCopilotPress: () => switchToDelegator(details.login ?? ''), + dismissError: () => handleErrorClose(pendingAction, accountID), + }, + ]; + }); + + const tableRef = useRef>(null); + const agentKeys = agents.map((agent) => agent.keyForList); + const prevAgentKeys = usePrevious(agentKeys); + + // Highlight (and scroll to) a newly created agent's row once it appears in the table, mirroring + // the same pattern used for newly-invited workspace members (see WorkspaceMembersPage). Not + // gated on useIsFocused: on wide layouts this page is the central pane of a split navigator and + // stays visible (but unfocused per react-navigation) while the new agent's DM opens in the RHP. + useEffect(() => { + const newAgentKeys = agentKeys.filter((key) => !prevAgentKeys.includes(key)); + if (!newAgentKeys.length) { + return; + } + + const tableAgents = tableRef.current?.getProcessedData() ?? []; + const newAgentIndex = tableAgents.findIndex((agent) => newAgentKeys.includes(agent.keyForList)); + if (newAgentIndex !== -1) { + tableRef.current?.scrollToIndex({ + index: newAgentIndex, + animated: false, + viewPosition: 0.5, + }); + } + tableRef.current?.highlightItems(newAgentKeys); + }, [agentKeys, prevAgentKeys]); + + const agentsByAccountID = new Map(agents.map((agent) => [agent.keyForList, agent])); + const selectedAgentKeys = selectedAgents.filter((accountIDString) => { + const agent = agentsByAccountID.get(accountIDString); + return !!agent && agent.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); + + const clearSelectedAgents = () => setSelectedAgents((prevSelectedAgents) => (prevSelectedAgents.length > 0 ? [] : prevSelectedAgents)); + useCleanupSelectedOptions(clearSelectedAgents); + useSearchBackPress({ + onClearSelection: clearSelectedAgents, + onNavigationCallBack: () => Navigation.goBack(), + }); + + const removeSelectedAgents = () => { + for (const accountIDString of selectedAgentKeys) { + const accountID = Number(accountIDString); + const agentLogin = personalDetailsList?.[accountID]?.login; + deleteAgent(accountID, agentLogin, allPolicies, false); + } + clearSelectedAgents(); + }; + const askForConfirmationToDelete = async () => { + const ruleBotEnforcedPolicy = selectedAgentKeys.map((accountIDString) => getRuleBotEnforcedPolicy(Number(accountIDString), allPolicies)).find(Boolean); + if (ruleBotEnforcedPolicy) { + showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); + return; + } + const result = await showConfirmModal({ + title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), + prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldHandleNavigationBack: false, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + + removeSelectedAgents(); + }; + + return {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef}; +} + +export default useAgents; diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts index ce414d7a5831..cc5dc846b085 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -49,6 +49,7 @@ type UseArrowKeyFocusManager = [number, (index: number, shouldScrollHint?: boole * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused * @param [config.allowHorizontalArrowKeys] – Whether to enable the right/left keys + * @param [config.allowNegativeIndexes] – Whether ArrowUp from the first enabled item moves focus to -1 (for example, back to a search input), even when cyclic traversal is on * @param [config.isFocused] Whether navigation is focused */ export default function useArrowKeyFocusManager({ @@ -114,7 +115,8 @@ export default function useArrowKeyFocusManager({ if (maxIndex < 0 || !isFocused) { return; } - const nextIndex = disableCyclicTraversal ? -1 : maxIndex; + // With negative indexes allowed, ArrowUp from the first item moves to -1 even when ArrowDown still wraps + const nextIndex = disableCyclicTraversal || allowNegativeIndexes ? -1 : maxIndex; setHasKeyBeenPressed?.(); setFocusedIndexInternal((actualIndex) => { const currentFocusedIndex = actualIndex > 0 ? actualIndex - (itemsPerRow ?? 1) : nextIndex; @@ -123,12 +125,12 @@ export default function useArrowKeyFocusManager({ while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex -= itemsPerRow ?? 1; if (newFocusedIndex < 0) { - if (disableCyclicTraversal) { - if (!allowNegativeIndexes) { - return actualIndex; - } + if (allowNegativeIndexes) { break; } + if (disableCyclicTraversal) { + return actualIndex; + } newFocusedIndex = maxIndex; } if (newFocusedIndex === currentFocusedIndex) { diff --git a/src/hooks/useCopySelectionEvent.ts b/src/hooks/useCopySelectionEvent.ts new file mode 100644 index 000000000000..68945915fbd4 --- /dev/null +++ b/src/hooks/useCopySelectionEvent.ts @@ -0,0 +1,56 @@ +import getClipboardText from '@libs/Clipboard/getClipboardText'; +import SelectionScraper from '@libs/SelectionScraper'; + +import {useEffect} from 'react'; + +function hasSelectionWithinEditableTarget(target: EventTarget | null) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return target.selectionStart !== null && target.selectionEnd !== null && target.selectionStart !== target.selectionEnd; + } + + if (!(target instanceof HTMLElement) || !target.isContentEditable) { + return false; + } + + const selection = window.getSelection(); + return !!selection && (target.contains(selection.anchorNode) || target.contains(selection.focusNode)); +} + +function copySelectionToClipboard(event: ClipboardEvent) { + if (event.defaultPrevented || !event.clipboardData || hasSelectionWithinEditableTarget(event.target)) { + return; + } + + const selection = SelectionScraper.getCurrentSelection(); + if (!selection) { + return; + } + + const clipboardText = getClipboardText(selection); + event.preventDefault(); + event.clipboardData.clearData(); + event.clipboardData.setData('text/html', selection); + event.clipboardData.setData('text/plain', clipboardText); +} + +let copySelectionEventConsumerCount = 0; + +export default function useCopySelectionEvent() { + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + if (copySelectionEventConsumerCount === 0) { + document.addEventListener('copy', copySelectionToClipboard); + } + copySelectionEventConsumerCount += 1; + + return () => { + copySelectionEventConsumerCount -= 1; + if (copySelectionEventConsumerCount === 0) { + document.removeEventListener('copy', copySelectionToClipboard); + } + }; + }, []); +} diff --git a/src/hooks/useCopyableTextRowPress/index.native.ts b/src/hooks/useCopyableTextRowPress/index.native.ts new file mode 100644 index 000000000000..0ed3d818b772 --- /dev/null +++ b/src/hooks/useCopyableTextRowPress/index.native.ts @@ -0,0 +1,40 @@ +type MarkCopyableTextMouseDownOptions = { + shouldSuppressNextPress?: boolean; +}; + +type HandleCopyableTextRowPressOptions = { + shouldCheck?: boolean; +}; + +type HandleCopyableTextRowPressInOptions = { + shouldEnableTextSelection?: boolean; + shouldBlockSelection?: boolean; +}; + +const isPressStartOnCopyableText = () => false; + +function useCopyableTextRowPress() { + // Native does not expose browser selection APIs, so copyable text never suppresses row interactions here. + const markMouseDownOnCopyableText: (target: EventTarget | null | undefined, shouldCheck?: boolean, options?: MarkCopyableTextMouseDownOptions) => boolean = () => false; + const markTouchStartOnCopyableText: (event: unknown, shouldCheck?: boolean) => boolean = () => false; + const shouldSuppressCopyableTextRowPress: (shouldCheck?: boolean) => boolean = () => false; + const shouldSuppressCopyableTextRowLongPress: (shouldCheck?: boolean) => boolean = () => false; + const shouldSuppressCopyableTextRowFocus = () => false; + const handleCopyableTextRowPress: (onPress: () => void, options?: HandleCopyableTextRowPressOptions) => void = (onPress) => onPress(); + const handleCopyableTextRowPressIn: (options?: HandleCopyableTextRowPressInOptions) => void = () => {}; + + return { + didTouchStartOnCopyableText: false, + handleCopyableTextRowPress, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowFocus, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + }; +} + +export {isPressStartOnCopyableText}; +export default useCopyableTextRowPress; diff --git a/src/hooks/useCopyableTextRowPress/index.ts b/src/hooks/useCopyableTextRowPress/index.ts new file mode 100644 index 000000000000..d42d6a3762e8 --- /dev/null +++ b/src/hooks/useCopyableTextRowPress/index.ts @@ -0,0 +1,266 @@ +import ControlSelection from '@libs/ControlSelection'; + +import CONST from '@src/CONST'; + +import {useRef, useState} from 'react'; + +const COPYABLE_TEXT_SELECTOR = `[data-${CONST.COPYABLE_TEXT_ELEMENT}=true]`; + +type MarkCopyableTextMouseDownOptions = { + shouldSuppressNextPress?: boolean; +}; + +type HandleCopyableTextRowPressOptions = { + shouldCheck?: boolean; +}; + +type HandleCopyableTextRowPressInOptions = { + shouldEnableTextSelection?: boolean; + shouldBlockSelection?: boolean; +}; + +type UseCopyableTextRowPressOptions = { + shouldTrackTouchStart?: boolean; +}; + +type PressStartOnCopyableTextCache = { + clientX: number | undefined; + clientY: number | undefined; + result: boolean; + target: EventTarget | null; +}; + +function getCopyableTextElement(target: EventTarget | Node | null | undefined): HTMLElement | null { + if (typeof HTMLElement === 'undefined') { + return null; + } + + try { + if (target instanceof HTMLElement) { + return target.closest(COPYABLE_TEXT_SELECTOR); + } + + if (typeof Node !== 'undefined' && target instanceof Node) { + return target.parentElement?.closest(COPYABLE_TEXT_SELECTOR) ?? null; + } + } catch { + return null; + } + + return null; +} + +function isCopyableTextTarget(target: EventTarget | null | undefined): boolean { + return !!getCopyableTextElement(target); +} + +function getTouchPosition(touches: unknown): {clientX: number; clientY: number} | null { + if (typeof touches !== 'object' || touches === null || !('0' in touches)) { + return null; + } + + const touch = touches[0]; + if (typeof touch !== 'object' || touch === null || !('clientX' in touch) || !('clientY' in touch) || typeof touch.clientX !== 'number' || typeof touch.clientY !== 'number') { + return null; + } + + return {clientX: touch.clientX, clientY: touch.clientY}; +} + +function getPressStartPosition(event: unknown): {clientX: number; clientY: number} | null { + if (typeof event !== 'object' || event === null) { + return null; + } + + if ('clientX' in event && 'clientY' in event && typeof event.clientX === 'number' && typeof event.clientY === 'number') { + return {clientX: event.clientX, clientY: event.clientY}; + } + + if ('touches' in event) { + const touchPosition = getTouchPosition(event.touches); + if (touchPosition) { + return touchPosition; + } + } + + if (!('nativeEvent' in event) || typeof event.nativeEvent !== 'object' || event.nativeEvent === null) { + return null; + } + + const {nativeEvent} = event; + if ('clientX' in nativeEvent && 'clientY' in nativeEvent && typeof nativeEvent.clientX === 'number' && typeof nativeEvent.clientY === 'number') { + return {clientX: nativeEvent.clientX, clientY: nativeEvent.clientY}; + } + + if ('touches' in nativeEvent) { + return getTouchPosition(nativeEvent.touches); + } + + return null; +} + +function getPressStartTarget(event: unknown): EventTarget | null { + if (typeof EventTarget === 'undefined' || typeof event !== 'object' || event === null || !('target' in event) || !(event.target instanceof EventTarget)) { + return null; + } + + return event.target; +} + +function isPressStartOnCopyableText(event: unknown): boolean { + const position = getPressStartPosition(event); + const copyableElement = getCopyableTextElement(getPressStartTarget(event)); + if (!position || !copyableElement || typeof document === 'undefined' || typeof NodeFilter === 'undefined') { + return false; + } + + const walker = document.createTreeWalker(copyableElement, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + + while (textNode) { + if (textNode.textContent?.trim()) { + const range = document.createRange(); + range.selectNodeContents(textNode); + + const isInsideText = Array.from(range.getClientRects()).some( + (rect) => position.clientX >= rect.left && position.clientX <= rect.right && position.clientY >= rect.top && position.clientY <= rect.bottom, + ); + if (isInsideText) { + return true; + } + } + textNode = walker.nextNode(); + } + + return false; +} + +// Row press handlers use this after mouseup/click to suppress navigation only for the drag-select gesture that started on copyable text. +function shouldSuppressCopyableTextPress(didMouseDownStartOnCopyableText: boolean): boolean { + if (!didMouseDownStartOnCopyableText) { + return false; + } + + if (typeof window === 'undefined' || typeof window.getSelection !== 'function') { + return false; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.toString().length === 0) { + return false; + } + + const anchorCopyableElement = getCopyableTextElement(selection.anchorNode); + const focusCopyableElement = getCopyableTextElement(selection.focusNode); + return !!anchorCopyableElement || !!focusCopyableElement; +} + +function useCopyableTextRowPress({shouldTrackTouchStart = false}: UseCopyableTextRowPressOptions = {}) { + const wasMouseDownOnCopyableTextRef = useRef(false); + const wasTouchStartOnCopyableTextRef = useRef(false); + const didTouchStartOnCopyableTextRef = useRef(false); + const shouldSuppressNextPressRef = useRef(false); + const pressStartOnCopyableTextCacheRef = useRef(null); + const [didTouchStartOnCopyableText, setDidTouchStartOnCopyableText] = useState(false); + + const getCachedIsPressStartOnCopyableText = (event: unknown, shouldCheck = true): boolean => { + if (!shouldCheck) { + return false; + } + + const position = getPressStartPosition(event); + const target = getPressStartTarget(event); + const cachedPressStart = pressStartOnCopyableTextCacheRef.current; + + if (cachedPressStart && cachedPressStart.target === target && cachedPressStart.clientX === position?.clientX && cachedPressStart.clientY === position?.clientY) { + return cachedPressStart.result; + } + + const result = isPressStartOnCopyableText(event); + pressStartOnCopyableTextCacheRef.current = { + clientX: position?.clientX, + clientY: position?.clientY, + result, + target, + }; + return result; + }; + + const markMouseDownOnCopyableText = (target: EventTarget | null | undefined, shouldCheck = true, {shouldSuppressNextPress = false}: MarkCopyableTextMouseDownOptions = {}): boolean => { + pressStartOnCopyableTextCacheRef.current = null; + const isCopyableTarget = shouldCheck && isCopyableTextTarget(target); + wasMouseDownOnCopyableTextRef.current = isCopyableTarget; + didTouchStartOnCopyableTextRef.current = false; + shouldSuppressNextPressRef.current = isCopyableTarget && shouldSuppressNextPress; + if (shouldTrackTouchStart) { + setDidTouchStartOnCopyableText(false); + } + return isCopyableTarget; + }; + + const markTouchStartOnCopyableText = (event: unknown, shouldCheck = true): boolean => { + pressStartOnCopyableTextCacheRef.current = null; + const isCopyableTarget = shouldCheck && isCopyableTextTarget(getPressStartTarget(event)); + wasMouseDownOnCopyableTextRef.current = false; + wasTouchStartOnCopyableTextRef.current = isCopyableTarget; + didTouchStartOnCopyableTextRef.current = isCopyableTarget; + shouldSuppressNextPressRef.current = false; + if (shouldTrackTouchStart) { + setDidTouchStartOnCopyableText(isCopyableTarget); + } + return isCopyableTarget; + }; + + // RN Web press-in events omit client coordinates, so reuse the original touch hit-test before blocking selection. + const handleCopyableTextRowPressIn = ({shouldEnableTextSelection = false, shouldBlockSelection = false}: HandleCopyableTextRowPressInOptions = {}) => { + if (shouldEnableTextSelection && didTouchStartOnCopyableTextRef.current) { + return; + } + + if (shouldBlockSelection) { + ControlSelection.block(); + } + }; + + const shouldSuppressCopyableTextRowPress = (shouldCheck = true): boolean => { + const didPressStartOnCopyableText = wasMouseDownOnCopyableTextRef.current || wasTouchStartOnCopyableTextRef.current; + const shouldSuppressPress = shouldCheck && (shouldSuppressNextPressRef.current || shouldSuppressCopyableTextPress(didPressStartOnCopyableText)); + wasMouseDownOnCopyableTextRef.current = false; + wasTouchStartOnCopyableTextRef.current = false; + shouldSuppressNextPressRef.current = false; + pressStartOnCopyableTextCacheRef.current = null; + return shouldSuppressPress; + }; + + const shouldSuppressCopyableTextRowLongPress = (shouldCheck = true): boolean => { + const shouldSuppressLongPress = shouldCheck && wasTouchStartOnCopyableTextRef.current; + shouldSuppressNextPressRef.current = shouldSuppressLongPress; + return shouldSuppressLongPress; + }; + + const handleCopyableTextRowPress = (onPress: () => void, {shouldCheck = true}: HandleCopyableTextRowPressOptions = {}) => { + if (shouldSuppressCopyableTextRowPress(shouldCheck)) { + return; + } + + onPress(); + }; + + // Pointer focus from copyable text should not make virtualized lists scroll the row into view. + const shouldSuppressCopyableTextRowFocus = () => wasMouseDownOnCopyableTextRef.current; + + return { + didTouchStartOnCopyableText, + handleCopyableTextRowPress, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText: getCachedIsPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowFocus, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + }; +} + +export {isPressStartOnCopyableText}; +export default useCopyableTextRowPress; diff --git a/src/hooks/useHasTabBeenShown.ts b/src/hooks/useHasTabBeenShown.ts new file mode 100644 index 000000000000..f4ee1cf3de54 --- /dev/null +++ b/src/hooks/useHasTabBeenShown.ts @@ -0,0 +1,37 @@ +import {getTabNavigatorState} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import type NAVIGATORS from '@src/NAVIGATORS'; + +import type {NavigationState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +import {useState} from 'react'; + +import useRootNavigationState from './useRootNavigationState'; + +type TabNavigatorName = ValueOf; + +/** Whether the tab is shown or under a screen drawn over it, rather than a background tab of the tab navigator. */ +function isTabShown(rootState: NavigationState | undefined, tabName: TabNavigatorName) { + if (rootState?.routes.some((route) => route.name === tabName)) { + return true; + } + const tabState = getTabNavigatorState(rootState); + return !tabState || tabState.routes.at(tabState.index ?? 0)?.name === tabName; +} + +/** + * Whether the tab has been shown since the screen mounted. Native tab bars mount every tab at startup, so a tab root + * screen uses it to hold back the requests and writes it makes on mount until the user first opens the tab, as it does + * when it mounts with the tab. + */ +function useHasTabBeenShown(tabName: TabNavigatorName) { + const isShown = useRootNavigationState((rootState) => isTabShown(rootState, tabName)); + const [hasBeenShown, setHasBeenShown] = useState(isShown); + if (isShown && !hasBeenShown) { + setHasBeenShown(true); + } + return hasBeenShown; +} + +export default useHasTabBeenShown; diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index b21e8da14ec1..e9f51000950e 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -26,6 +26,7 @@ import usePayChatReportActions from './usePayChatReportActions'; import usePermissions from './usePermissions'; import {usePersonalDetail} from './usePersonalDetails'; import usePolicy from './usePolicy'; +import useReportTransactions from './useReportTransactions'; type ActionHandledType = DeepValueOf; @@ -47,6 +48,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const activePolicy = usePolicy(activePolicyID); const policy = usePolicy(moneyRequestReport?.policyID); const chatReportPolicy = usePolicy(chatReport?.policyID); + const moneyRequestReportTransactions = useReportTransactions(moneyRequestReport?.reportID); const getChatReportActions = usePayChatReportActions(chatReport, undefined); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); @@ -111,6 +113,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment paymentType, chatReport: currentChatReport, iouReport: currentMoneyRequestReport, + iouReportTransactions: moneyRequestReportTransactions, introSelected, currentUserAccountID: currentUserDetails.accountID, currentUserLogin: currentUserDetails.login ?? '', diff --git a/src/hooks/useInboxTabIndicatorStatus.ts b/src/hooks/useInboxTabIndicatorStatus.ts new file mode 100644 index 000000000000..0420652be03a --- /dev/null +++ b/src/hooks/useInboxTabIndicatorStatus.ts @@ -0,0 +1,26 @@ +import CONST from '@src/CONST'; + +import {useChatTabBrickRoad} from './useSidebarOrderedReports'; +import useTheme from './useTheme'; + +type InboxTabIndicatorStatusResult = { + /** The chat tab's brick road status, or undefined when no chat needs attention. */ + status: ReturnType; + + /** The indicator dot color: success for an info status, danger for anything else, none without a status. */ + indicatorColor: string | undefined; +}; + +function useInboxTabIndicatorStatus(): InboxTabIndicatorStatusResult { + const theme = useTheme(); + const status = useChatTabBrickRoad(); + + let indicatorColor: string | undefined; + if (status) { + indicatorColor = status === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; + } + + return {status, indicatorColor}; +} + +export default useInboxTabIndicatorStatus; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts new file mode 100644 index 000000000000..ba0c059aafa2 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -0,0 +1,19 @@ +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; + +/** + * When the native tab bar has no Account item, the Account tab is drawn over the other tabs + * as a full screen with the bar hidden, and it needs its own way back. + */ +function useIsSettingsDrawnOverTabs() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + return shouldUseNarrowLayout && getTabWithoutBarItem(isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.ts new file mode 100644 index 000000000000..7229ecc8f572 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.ts @@ -0,0 +1,6 @@ +/** On web the Account tab stays a tab root screen above the JS tab bar, whether or not the bar shows its item. */ +function useIsSettingsDrawnOverTabs() { + return false; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts index 51d4230c0875..50c1b2d00af5 100644 --- a/src/hooks/useMergeTransactions.ts +++ b/src/hooks/useMergeTransactions.ts @@ -56,7 +56,7 @@ function getTransaction( return transaction; } - return currentSearchResults?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; + return currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; } function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): UseMergeTransactionsReturn { diff --git a/src/hooks/useOpenConciergeAnywhere/index.native.tsx b/src/hooks/useOpenConciergeAnywhere/index.native.tsx index 89ed50dcb6cf..4072d3ccce86 100644 --- a/src/hooks/useOpenConciergeAnywhere/index.native.tsx +++ b/src/hooks/useOpenConciergeAnywhere/index.native.tsx @@ -7,7 +7,7 @@ import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; /** * Returns a callback that navigates to the Concierge chat on native (opens the side panel on web instead), @@ -17,12 +17,14 @@ function useOpenConciergeAnywhere() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const openConciergeAnywhere = (_options?: {forceConcierge?: boolean; reportID?: string}) => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }; return {openConciergeAnywhere, isInSidePanel: false}; diff --git a/src/hooks/useParentReportAction.ts b/src/hooks/useParentReportAction.ts index 5a183d00bab5..e3b1052f0e8b 100644 --- a/src/hooks/useParentReportAction.ts +++ b/src/hooks/useParentReportAction.ts @@ -1,19 +1,32 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type {ReportActionWithoutThreadReplyCounters} from '@selectors/ReportAction'; import type {OnyxEntry} from 'react-native-onyx'; -import {getParentReportActionSelector} from '@selectors/ReportAction'; +import {getParentReportActionSelector, getParentReportActionWithoutThreadRepliesSelector} from '@selectors/ReportAction'; import {useCallback} from 'react'; import useOnyx from './useOnyx'; -function useParentReportAction(report: OnyxEntry) { +type UseParentReportActionOptions = { + shouldIgnoreThreadReplies?: boolean; +}; + +/** The only report fields the hook reads, so callers can pass a narrowed report projection. */ +type ParentReportFields = Pick; + +function useParentReportAction(report: OnyxEntry): OnyxEntry; +function useParentReportAction(report: OnyxEntry, options: UseParentReportActionOptions): ReportActionWithoutThreadReplyCounters | undefined; +function useParentReportAction(report: OnyxEntry, {shouldIgnoreThreadReplies = false}: UseParentReportActionOptions = {}) { const getParentReportAction = useCallback( - (parentReportActions: OnyxEntry) => getParentReportActionSelector(parentReportActions, report?.parentReportActionID), - [report?.parentReportActionID], + (parentReportActions: OnyxEntry) => + shouldIgnoreThreadReplies + ? getParentReportActionWithoutThreadRepliesSelector(parentReportActions, report?.parentReportActionID) + : getParentReportActionSelector(parentReportActions, report?.parentReportActionID), + [report?.parentReportActionID, shouldIgnoreThreadReplies], ); const parentReportID = getNonEmptyStringOnyxID(report?.parentReportID); diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index be09beaa39f9..7cc5f96cd51b 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -2,12 +2,13 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canSubmitPerDiemExpenseFromWorkspace, getPolicyRole, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -21,14 +22,15 @@ function checkForUserPendingDelete(login: string, policy: OnyxEntry) { return isPolicyMemberWithoutPendingDelete(login, policy); } -function isPolicyMemberByRole(policy: OnyxEntry) { - return !!policy?.role && Object.values(CONST.POLICY.ROLE).includes(policy.role); +function isPolicyMemberByRole(policy: OnyxEntry, login: string) { + const role = getPolicyRole(policy, login); + return !!role && (Object.values(CONST.POLICY.ROLE) as string[]).includes(role); } function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string, isPerDiemRequest?: boolean, isTimeRequest?: boolean) { return ( checkForUserPendingDelete(login, policy) && - isPolicyMemberByRole(policy) && + isPolicyMemberByRole(policy, login) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. @@ -89,6 +91,11 @@ type PolicyForMovingExpenses = { policyForMovingExpenses: OnyxEntry; shouldSelectPolicy: boolean; shouldNavigateToUpgradePath: boolean; + /** + * False while policies are still loading from Onyx. Callers that act on the result (create report, move + * transactions) must no-op until true, as with `useCreateReport`. Picker-only callers can ignore it. + */ + arePoliciesLoaded: boolean; }; function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string, isUnreportedManagedCardTransaction?: boolean): PolicyForMovingExpenses { @@ -100,7 +107,7 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: // Contextual selector — captures login/flags from closure. // Returns only IDs + flags (stable output) to prevent re-renders when unrelated policies change. const policyQualificationSelector = (policies: OnyxCollection) => getPolicyQualificationResult(policies, login, isPerDiemRequest, isTimeRequest, expensePolicyID); - const [qualificationResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + const [qualificationResult, policiesLoadStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: policyQualificationSelector, }); @@ -110,40 +117,44 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: const resolvedPolicyID = validExpensePolicyID ?? singlePolicyID; const [resolvedPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`); - // User has no eligible policy - if (!resolvedPolicyID) { - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: true}; - } + // Gate the upgrade path on policy hydration. Without this, during Onyx cold-start the collection reads + // empty and we'd report that a member of a group workspace has none, sending them to MONEY_REQUEST_UPGRADE. + // `useCreateReport` guards the same way. + const arePoliciesLoaded = !isLoadingOnyxValue(policiesLoadStatus); // If this is an employee's card transaction that we manage, then we should report it to their default policy // which we don't know. Sending an empty `policyID` instructs the backend to auto-select the preferred policy. + // This never depends on a locally resolved policy, so it has to be answered before the upgrade path below. if (isUnreportedManagedCardTransaction) { - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; + } + + if (!resolvedPolicyID) { + if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { + return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; + } + + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: arePoliciesLoaded, arePoliciesLoaded}; } // If an expense policy ID is provided and valid, prefer it over the active policy if (validExpensePolicyID) { - return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } - if ( - activePolicy && - !isTeachersUnitePolicyID(activePolicy.id) && - (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && - (!isTimeRequest || isTimeTrackingEnabled(activePolicy)) - ) { - return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { + return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } if (singlePolicyID && !isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } if (isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: true, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: true, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: true}; + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: true, arePoliciesLoaded}; } export default usePolicyForMovingExpenses; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 09bf411c31b8..c8d05b3521d5 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -113,7 +113,7 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ + /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout and content size change */ flushPendingScrollToBottom: () => void; /** Whether the list should be pinned to the visual top (transaction thread / money request) */ @@ -171,6 +171,7 @@ function useReportActionsScroll({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${getNonEmptyStringOnyxID(reportID)}`); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const prevIsLoadingInitialReportActions = usePrevious(reportLoadingState?.isLoadingInitialReportActions); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); @@ -216,21 +217,41 @@ function useReportActionsScroll({ ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = - useReportUnreadMessageScrollTracking({ - reportID, - currentVerticalScrollingOffsetRef: scrollOffsetRef, - onUnreadActionVisible: completeSkippedMarkAsRead, - hasNewerActions, - unreadMarkerReportActionIndex, - isInverted: true, - shouldDisablePillTracking, - onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, - actionBadgeTargetIndex, - shouldBeAlignedToTop, - }); + const { + isFloatingMessageCounterVisible, + setIsFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + setIsActionBadgeAboveViewport, + trackVerticalScrolling, + onViewableItemsChanged, + updatePillVisibility, + } = useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: scrollOffsetRef, + onUnreadActionVisible: completeSkippedMarkAsRead, + hasNewerActions, + unreadMarkerReportActionIndex, + isInverted: true, + // The list is hidden behind the expanded composer, so scrolling it must not bring the Latest messages pill back + shouldDisablePillTracking: shouldDisablePillTracking || isComposerFullSize, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + actionBadgeTargetIndex, + shouldBeAlignedToTop, + }); + + const prevIsComposerFullSize = usePrevious(isComposerFullSize); + // Scroll events skipped pill tracking while expanded, so sync the Latest messages pill with the current offset + const syncPillAfterComposerCollapse = useEffectEvent(() => { + if (!prevIsComposerFullSize || isComposerFullSize || shouldDisablePillTracking) { + return; + } + updatePillVisibility(); + }); + useEffect(() => { + syncPillAfterComposerCollapse(); + }, [isComposerFullSize]); const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({ conciergeChat, @@ -399,6 +420,11 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } + // The expanded composer covers the list, so the target never becomes viewable to hide the pill + if (isComposerFullSize) { + setIsActionBadgeAboveViewport(false); + setIsFloatingMessageCounterVisible(false); + } // `animated` is explicit because native defaults to an instant jump, which would teleport the list and lose the user's place. reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; diff --git a/src/hooks/useReportsParentHierarchy.ts b/src/hooks/useReportsParentHierarchy.ts index 43dd826bd057..9b2291b4ef0a 100644 --- a/src/hooks/useReportsParentHierarchy.ts +++ b/src/hooks/useReportsParentHierarchy.ts @@ -1,20 +1,20 @@ import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import ONYXKEYS from '@src/ONYXKEYS'; -import {reportsParentHierarchySelector} from '@src/selectors/Report'; +import {reportParentChainSelector} from '@src/selectors/Report'; import type {OnyxCollection} from 'react-native-onyx'; import useOnyx from './useOnyx'; /** - * Subscribes to the parent hierarchy links (`parentReportID`/`parentReportActionID`) of every report so callers - * can resolve arbitrary ancestor chains (e.g. `clearAllRelatedReportActionErrors`) without subscribing to full - * report objects. Since these links are effectively immutable, the projected result only changes when reports - * are added or removed, so regular report updates don't re-render the subscriber. + * Subscribes to the parent hierarchy links (`parentReportID`/`parentReportActionID`) of the given report and its + * ancestors so callers can walk the ancestor chain (e.g. `clearAllRelatedReportActionErrors`) without subscribing to + * full report objects. The selector only looks up the reports on that chain, and since these links are effectively + * immutable, the result only changes when a report on the chain is added or removed. */ -function useReportsParentHierarchy(): OnyxCollection { - const [reportsParentHierarchy] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsParentHierarchySelector}); +function useReportsParentHierarchy(reportID: string | undefined): OnyxCollection { + const [reportsParentHierarchy] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportParentChainSelector(reportID)}); return reportsParentHierarchy; } diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 3e4cdabb9dbf..839abe84f706 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -7,7 +7,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import {getSearchGroupCountByKey} from '@components/Search/selectionBuilders'; -import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import {getAccountingIntegrationDisplayName, getExportLabelForConnection, isIntuitEnterpriseSuiteConnection} from '@libs/AccountingUtils'; import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards'; @@ -145,6 +145,7 @@ import useBulkPayOptions from './useBulkPayOptions'; import useConfirmModal from './useConfirmModal'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDecisionModal from './useDecisionModal'; import useDefaultExpensePolicy from './useDefaultExpensePolicy'; import useDelegateAccountID from './useDelegateAccountID'; import useDeleteTransactions from './useDeleteTransactions'; @@ -452,11 +453,18 @@ function getAllMatchingReportQuery(queryJSON: SearchQueryJSON, excludedTransacti return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters})); } -const MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - -function getGroupExportExactMatchFilterKeys(groupBy: SearchQueryJSON['groupBy']): ReadonlySet | undefined { - return groupBy === CONST.SEARCH.GROUP_BY.MERCHANT ? MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS : undefined; -} +/** + * The groupings whose selected groups `getSelectedGroupFilterEntry` can turn into a query filter, which a template export needs. + * Keep this in sync with that function. + */ +const TEMPLATE_EXPORTABLE_GROUP_BYS = new Set([ + CONST.SEARCH.GROUP_BY.FROM, + CONST.SEARCH.GROUP_BY.CARD, + CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + CONST.SEARCH.GROUP_BY.CATEGORY, + CONST.SEARCH.GROUP_BY.MERCHANT, + CONST.SEARCH.GROUP_BY.TAG, +]); type ShouldShowBulkDuplicateParams = { selectedTransactionsKeys: string[]; @@ -670,12 +678,46 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [currentSearchResults]); const searchResults = currentSearchResults?.data ? currentSearchResults : lastNonEmptySearchResultsRef.current; - const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); - const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const [isExpensifyCardStatementPDFModalVisible, setIsExpensifyCardStatementPDFModalVisible] = useState(false); const [expensifyCardStatementPDFParams, setExpensifyCardStatementPDFParams] = useState(undefined); - const [isExpensifyCardStatementMultiFeedAlertVisible, setIsExpensifyCardStatementMultiFeedAlertVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); + const {showDecisionModal} = useDecisionModal(); + + const showOfflineModal = useCallback(() => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }, [showDecisionModal, translate]); + + const showDownloadErrorModal = useCallback(() => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }, [showDecisionModal, translate]); + + const showEmptyReportsDownloadErrorModal = useCallback( + (count: number) => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedEmptyReportDescription', {count}), + secondOptionText: translate('common.buttonConfirm'), + }); + }, + [showDecisionModal, translate], + ); + + const showExpensifyCardStatementMultiFeedAlert = useCallback(() => { + showDecisionModal({ + title: translate('search.expensifyCardStatementPDF.title'), + prompt: translate('search.expensifyCardStatementPDF.oneFeedAtATime'), + secondOptionText: translate('common.buttonConfirm'), + }); + }, [showDecisionModal, translate]); + const openSearchReportSubmitToPopover = useOpenSearchReportSubmitToPopover(); const {showReportPDFDownloadModal} = useReportPDFDownloadModal(); const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); @@ -683,8 +725,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT > | null>(null); - const [emptyReportsCount, setEmptyReportsCount] = useState(0); - 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}); @@ -935,7 +975,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportExpensifyCardStatementPDF = useCallback(() => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -945,7 +985,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } if (selection.hasMultipleFeeds) { - setIsExpensifyCardStatementMultiFeedAlertVisible(true); + showExpensifyCardStatementMultiFeedAlert(); return; } @@ -965,7 +1005,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } setIsExpensifyCardStatementPDFModalVisible(false); setExpensifyCardStatementPDFParams(undefined); - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); }; setExpensifyCardStatementPDFParams(statementParams); @@ -989,7 +1029,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { setExpensifyCardStatementPDFParams((currentParams) => (currentParams ? {...currentParams, statementKey} : currentParams)); }) .catch(showStatementError); - }, [isOffline]); + }, [isOffline, showOfflineModal, showExpensifyCardStatementMultiFeedAlert, showDownloadErrorModal]); const firstTransactionID = selectedTransactionsKeys.at(0); const firstTransaction = (firstTransactionID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`] : undefined) ?? @@ -1037,18 +1077,17 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, policyID: string | undefined, exportName: string) => { if (hasOnlyEmptyReports) { - setEmptyReportsCount(emptyReports.length); - setIsDownloadErrorModalVisible(true); + showEmptyReportsDownloadErrorModal(emptyReports.length); return; } if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } if (areAllMatchingItemsSelected) { const allMatchingQueryJSON = queryJSON ? getUngroupedTemplateExportQuery(queryJSON) : undefined; if (queryJSON && !allMatchingQueryJSON) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } queueExportSearchWithTemplate( @@ -1069,7 +1108,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions); const groupExportQueryJSON = isGroupExport && queryJSON ? getTemplateGroupExportQuery(queryJSON, selectedTransactions, currentSearchResults?.data) : undefined; if (isGroupExport && !groupExportQueryJSON) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } queueExportSearchWithTemplate( @@ -1078,9 +1117,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { templateType, // searchKey changes what the backend query matches (e.g. reconciliation includes Expensify Card cash back), // so the export must send it exactly as search() does or the exported set differs from the viewed set. - jsonQuery: groupExportQueryJSON - ? serializeQueryJSONForBackend({...groupExportQueryJSON, searchKey: currentSearchKey}, getGroupExportExactMatchFilterKeys(queryJSON?.groupBy)) - : '{}', + jsonQuery: groupExportQueryJSON ? serializeQueryJSONForBackend({...groupExportQueryJSON, searchKey: currentSearchKey}) : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList, policyID, @@ -1099,6 +1136,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { emptyReports.length, selectedTransactions, isOffline, + showOfflineModal, + showDownloadErrorModal, + showEmptyReportsDownloadErrorModal, areAllMatchingItemsSelected, currentSearchResults?.data, currentSearchKey, @@ -1126,7 +1166,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportSearchType = searchResults?.search.type ?? queryJSON?.type; const getCSVExportParameters = useCallback( - (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined, exactMatchFilterKeys?: ReadonlySet) => { + (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined) => { const groupBy = getValidGroupBy(queryJSON?.groupBy); let columnsToExport: SearchColumnType[]; let groupColumnsToExport: SearchColumnType[] = []; @@ -1186,7 +1226,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { searchKey: currentSearchKey, } : undefined; - const jsonQuery = queryToExport ? serializeQueryJSONForBackend(queryToExport, exactMatchFilterKeys) : (JSON.stringify(queryJSONToExport) ?? ''); + const jsonQuery = queryToExport ? serializeQueryJSONForBackend(queryToExport) : (JSON.stringify(queryJSONToExport) ?? ''); return { jsonQuery, @@ -1200,12 +1240,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const handleCSVExport = useCallback( async (isBasicExport: boolean) => { if (hasOnlyEmptyReports) { - setEmptyReportsCount(emptyReports.length); - setIsDownloadErrorModalVisible(true); + showEmptyReportsDownloadErrorModal(emptyReports.length); return; } if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -1219,7 +1258,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const allMatchingExportData = isExpenseType && queryJSON ? getAllMatchingExportQueryAndExclusions(queryJSON, excludedTransactions, currentSearchResults?.data) : undefined; const allMatchingReportExportQuery = isExpenseReportType && queryJSON ? getAllMatchingReportQuery(queryJSON, excludedTransactions) : undefined; if ((isExpenseType && !allMatchingExportData) || (isExpenseReportType && !allMatchingReportExportQuery)) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? []; @@ -1244,8 +1283,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { let didFail = false; const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; - const exactMatchFilterKeys = isGroupExport ? getGroupExportExactMatchFilterKeys(queryJSON?.groupBy) : undefined; - const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport, exactMatchFilterKeys); + const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport); await exportSearchItemsToCSV( { jsonQuery: exportParameters.jsonQuery, @@ -1258,8 +1296,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, () => { didFail = true; - setEmptyReportsCount(0); - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); }, translate, allReportsTransactionsAndViolations, @@ -1272,6 +1309,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { hasOnlyEmptyReports, emptyReports.length, isOffline, + showOfflineModal, + showDownloadErrorModal, + showEmptyReportsDownloadErrorModal, areAllMatchingItemsSelected, queryJSON, selectedReports, @@ -1297,7 +1337,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const handleApproveWithDEWCheck = useCallback(async () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -1376,6 +1416,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } }, [ isOffline, + showOfflineModal, isDelegateAccessRestricted, showDelegateNoAccessModal, selectedReports, @@ -1567,7 +1608,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (isOffline) { // On iOS, presenting the offline modal while the payment popover is still dismissing freezes the app, // so defer it until the popover transition completes (matching the delegate/locked-account guards). - deferModalPresentationAfterPopoverDismiss(() => setIsOfflineModalVisible(true)); + deferModalPresentationAfterPopoverDismiss(() => showOfflineModal()); return; } @@ -1713,6 +1754,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const isItemInvoice = isInvoiceReport(iouReport); + const iouReportTransactions = Object.values(allReportsTransactionsAndViolations?.[item.reportID]?.transactions ?? {}); const fallbackChatReportID = item.chatReportID ?? iouReport.chatReportID ?? iouReport.parentReportID; const fallbackPolicyID = iouReport.policyID ?? item.policyID; const {chatReport, isFallbackChatReport} = getChatReportWithFallback( @@ -1779,6 +1821,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paymentMethodType: paymentItem.paymentType as PaymentMethodType, chatReport, invoiceReport: iouReport, + invoiceReportTransactions: iouReportTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -1808,6 +1851,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paymentType: paymentItem.paymentType as PaymentMethodType, chatReport, iouReport, + iouReportTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', @@ -1844,6 +1888,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [ hash, + showOfflineModal, areAllMatchingItemsSelected, queryJSON, excludedTransactions, @@ -1864,6 +1909,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { personalPolicyID, lastPaymentMethods, allTransactions, + allReportsTransactionsAndViolations, policyIDsWithVBBA, clearSelectedTransactions, accountID, @@ -2111,14 +2157,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const isReportsTab = isExpenseReportType; const includesGroupExport = isGroupedSearch && Object.entries(selectedTransactions).some(([key, selectedTransaction]) => isGroupSelection(key, selectedTransaction)); - // Group selections are sent as query filters, not report or transaction IDs, so template exports are usually unavailable. - // Reconciliation - All Expenses is supported for card-grouped searches because selected card groups become a `cardID:` - // filter, which template reports can use to scope card spend. - const includesCardGroupExport = includesGroupExport && groupBy === CONST.SEARCH.GROUP_BY.CARD; - const customTemplatesToShow = includesCardGroupExport ? [] : availableCustomTemplates; - const defaultTemplatesToShow = includesCardGroupExport - ? availableDefaultTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES) - : availableDefaultTemplates; + // A template export sends a group selection as a filter on the ungrouped query (see `getTemplateGroupExportQuery`), alongside any individually picked expenses. + // Groupings with no such filter (the time-based ones) would fail that export, so they keep only "Current view". + const canExportGroupWithTemplates = !includesGroupExport || (!!groupBy && TEMPLATE_EXPORTABLE_GROUP_BYS.has(groupBy)); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -2355,7 +2396,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } clearSelectedTransactions(); @@ -2406,7 +2447,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { addSeparatorBefore: true, }); - if (!allSelectedAreDeleted && (!includesGroupExport || includesCardGroupExport)) { + if (!allSelectedAreDeleted && canExportGroupWithTemplates) { // Builds a single export sub-menu item for a template. `isDefaultTemplate` picks the icon and `addSeparatorBefore` draws the divider at the top of each group. const buildExportOption = (template: ExportTemplate, isDefaultTemplate: boolean, addSeparatorBefore: boolean): ExportMenuItem => { // The basic export is a plain CSV download, so it uses its own handler rather than the template export flow @@ -2430,10 +2471,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; // Add each group's templates separately so the icon and the group-boundary divider come from the group itself, not from an index into a combined list. - for (const [index, template] of customTemplatesToShow.entries()) { + for (const [index, template] of availableCustomTemplates.entries()) { exportOptions.push(buildExportOption(template, false, index === 0)); } - for (const [index, template] of defaultTemplatesToShow.entries()) { + for (const [index, template] of availableDefaultTemplates.entries()) { exportOptions.push(buildExportOption(template, true, index === 0)); } } else if (!isGroupedSearch) { @@ -2535,7 +2576,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { onSelected: () => { // A queued all-matching move would send a stale query, so ask the user to reconnect like export does if (areAllMatchingItemsSelected && isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.getRoute()); @@ -2567,7 +2608,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: async () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } // In "Select all" mode the matching reports aren't enumerated on the client (results are paged), @@ -2576,7 +2617,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (areAllMatchingItemsSelected) { const allMatchingReportQuery = queryJSON ? getAllMatchingReportQuery(queryJSON, excludedTransactions) : undefined; if (!allMatchingReportQuery) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } const serializedQuery = serializeQueryJSONForBackend({...allMatchingReportQuery, searchKey: currentSearchKey}); @@ -2729,7 +2770,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -2789,7 +2830,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -3029,7 +3070,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } exportReceiptsToZip({reportIDs: selectedReportIDs}); @@ -3059,7 +3100,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } exportReceiptsToZip({transactionIDs}); @@ -3094,7 +3135,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -3166,19 +3207,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, - onSelected: () => - setupMergeTransactionDataAndNavigate( - transactionID, - searchedTransactions, - localeCompare, - getCurrencyDecimals, - reports, - false, - true, - undefined, - accountID, - personalDetails, - ), + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, undefined), }); } } @@ -3290,6 +3319,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return buildResult(options); }, [ selectedTransactionsKeys, + showOfflineModal, + showDownloadErrorModal, cardList, hash, selectedTransactions, @@ -3398,14 +3429,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { dropdownButtonsOptions.length > 0 && dropdownButtonsOptions.every((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT && !option.subMenuItems); const bulkActionsMenuHeaderText = isShowingExportOptionsDirectly ? translate('common.export') : undefined; - const handleOfflineModalClose = useCallback(() => { - setIsOfflineModalVisible(false); - }, [setIsOfflineModalVisible]); - - const handleDownloadErrorModalClose = useCallback(() => { - setIsDownloadErrorModalVisible(false); - }, [setIsDownloadErrorModalVisible]); - const handleExpensifyCardStatementPDFModalHide = useCallback(() => { setExpensifyCardStatementPDFParams(undefined); // Clear the selection when the statement modal closes (after download or failure), like the other bulk @@ -3414,10 +3437,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { clearSelectedTransactions(); }, [clearSelectedTransactions]); - const handleExpensifyCardStatementMultiFeedAlertClose = useCallback(() => { - setIsExpensifyCardStatementMultiFeedAlertVisible(false); - }, []); - const dismissModalAndUpdateUseHold = useCallback(() => { setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !isOffline); @@ -3448,20 +3467,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedReportIDs, businessBankAccountOptions, confirmPayment: stableOnBulkPaySelected, - isOfflineModalVisible, - isDownloadErrorModalVisible, isHoldEducationalModalVisible, areAllTransactionsFromDMReports, rejectModalAction, - emptyReportsCount, - handleOfflineModalClose, - handleDownloadErrorModalClose, isExpensifyCardStatementPDFModalVisible, setIsExpensifyCardStatementPDFModalVisible, expensifyCardStatementPDFParams, handleExpensifyCardStatementPDFModalHide, - isExpensifyCardStatementMultiFeedAlertVisible, - handleExpensifyCardStatementMultiFeedAlertClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, isDuplicateOptionVisible, diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index bd88e40df03f..4aa12c46e0e6 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -466,6 +466,7 @@ function useSelectedTransactionsActions({ clearSelectedTransactions(true); } : () => beginExportWithTemplate(template.templateName, template.type, selectedTransactionIDs, template.name, template.policyID), + shouldCallAfterModalHide: true, addSeparatorBefore, }; }; @@ -529,8 +530,6 @@ function useSelectedTransactionsActions({ false, isOnSearch, selectedTransactionsList.length > 1 ? [policy, policy] : undefined, - currentUserAccountID, - personalDetails, ), }); } diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 60aaa6b22843..6afa44b92463 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -193,6 +193,7 @@ function useSelectionModePayment({ paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -217,6 +218,7 @@ function useSelectionModePayment({ paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/hooks/useShareActions.ts b/src/hooks/useShareActions.ts new file mode 100644 index 000000000000..1781f7c85b8f --- /dev/null +++ b/src/hooks/useShareActions.ts @@ -0,0 +1,91 @@ +/** + * Builds the Share option of the report More menu and the options under it. + */ +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; + +import Clipboard from '@libs/Clipboard'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {canShareReport} from '@libs/ReportUtils'; +import addTrailingForwardSlash from '@libs/UrlUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useEnvironment from './useEnvironment'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +type UseShareActionsParams = { + reportID: string | undefined; + policy?: OnyxEntry; +}; + +type UseShareActionsReturn = Pick, SecondaryActionEntry>, typeof CONST.REPORT.SECONDARY_ACTIONS.SHARE>; + +function useShareActions({reportID, policy}: UseShareActionsParams): UseShareActionsReturn { + const {translate} = useLocalize(); + const {environmentURL} = useEnvironment(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Mail', 'QrCode', 'Cash', 'Copy', 'ArrowRight'] as const); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + + const shareSubMenuItems: PopoverMenuItem[] = [ + { + text: translate('common.shareCode'), + icon: expensifyIcons.QrCode, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_CODE, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.getRoute(true))), + }, + { + text: translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.header`), + icon: expensifyIcons.Cash, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REFER_FRIEND, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND))), + }, + { + text: translate('qrCodes.copy'), + icon: expensifyIcons.Copy, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.COPY_URL, + onSelected: () => { + if (!moneyRequestReport?.reportID) { + return; + } + Clipboard.setString(`${addTrailingForwardSlash(environmentURL)}${ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport.reportID)}`); + }, + }, + ]; + + if (canShareReport(moneyRequestReport, policy, currentUserAccountID)) { + shareSubMenuItems.unshift({ + text: translate('common.shareReport'), + icon: expensifyIcons.Mail, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_REPORT, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path)), + }); + } + + return { + [CONST.REPORT.SECONDARY_ACTIONS.SHARE]: { + value: CONST.REPORT.SECONDARY_ACTIONS.SHARE, + text: translate('common.share'), + backButtonText: translate('common.share'), + icon: expensifyIcons.Mail, + rightIcon: expensifyIcons.ArrowRight, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE, + subMenuItems: shareSubMenuItems, + }, + }; +} + +export default useShareActions; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 80dd9483a600..c544c8ed72ea 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -72,6 +72,9 @@ const SidebarOrderedReportsStateContext = createContext(undefined); + const SidebarOrderedReportsActionsContext = createContext({ clearLHNCache: () => {}, setActiveTab: () => {}, @@ -474,7 +477,9 @@ function SidebarOrderedReportsContextProvider({ return ( - {children} + + {children} + ); } @@ -483,6 +488,10 @@ function useSidebarOrderedReportsState() { return useContext(SidebarOrderedReportsStateContext); } +function useChatTabBrickRoad() { + return useContext(SidebarChatTabBrickRoadContext); +} + function useSidebarOrderedReportsActions() { return useContext(SidebarOrderedReportsActionsContext); } @@ -495,5 +504,5 @@ function useSidebarOrderedReports() { return useMemo(() => ({...state, ...actions}), [state, actions]); } -export {SidebarOrderedReportsContextProvider, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; +export {SidebarOrderedReportsContextProvider, useChatTabBrickRoad, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; export type {ReportsToDisplayInLHN}; diff --git a/src/hooks/useWorkspaceAgents.ts b/src/hooks/useWorkspaceAgents.ts new file mode 100644 index 000000000000..c1499f969b9f --- /dev/null +++ b/src/hooks/useWorkspaceAgents.ts @@ -0,0 +1,71 @@ +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useAgents from './useAgents'; +import useNetwork from './useNetwork'; +import useWorkspaceAccountID from './useWorkspaceAccountID'; +import useWorkspaceMembers from './useWorkspaceMembers'; + +/** + * Hook that returns the agent rows for a workspace: agents the user can manage joined with their workspace role, plus read-only rows for custom-agent members the user cannot manage. + */ +function useWorkspaceAgents(policy: OnyxEntry) { + const {isOffline} = useNetwork(); + const policyID = policy?.id; + const workspaceAccountID = useWorkspaceAccountID(policyID); + const workspaceMembers = useWorkspaceMembers(policy); + const policyRoles = Object.values(CONST.POLICY.ROLE); + + const {agents, ...rest} = useAgents(); + const workspaceAgents = agents.flatMap((agent) => { + const agentMembership = workspaceMembers.find((member) => member.accountID === agent.accountID); + const isAgentOwnedByWorkspace = agent.ownerAccountID === workspaceAccountID; + const isAgentMemberOfWorkspace = !!agentMembership; + if (!isAgentOwnedByWorkspace && !isAgentMemberOfWorkspace) { + return []; + } + // Attach the workspace role to the agent row; the agent object is local to this flatMap and not shared + // eslint-disable-next-line no-param-reassign + agent.role = policyRoles.find((policyRole) => policyRole === agentMembership?.policyEmployee.role); + return [agent]; + }); + + // The useAgents hook only includes agents that the user can manage + // We need to include other agents in the workspace too + const manageableAgentAccountIDs = new Set(workspaceAgents.map(({accountID}) => accountID)); + for (const workspaceMember of workspaceMembers) { + const {policyEmployee, accountID, details} = workspaceMember; + if (!details.isCustomAgent) { + continue; + } + if (manageableAgentAccountIDs.has(accountID)) { + continue; + } + const pendingAction = policyEmployee.pendingAction; + const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + if (!isOffline && isPendingDeletion) { + continue; + } + workspaceAgents.push({ + keyForList: String(accountID), + accountID, + displayName: details.displayName ?? details.login ?? '', + login: details.login ?? '', + role: policyRoles.find((policyRole) => policyRole === policyEmployee.role), + pendingAction, + disabled: true, + isSelectionDisabled: true, + canManage: false, + action: () => {}, + onChatPress: () => {}, + onCopilotPress: () => {}, + dismissError: () => {}, + }); + } + + return {agents: workspaceAgents, ...rest}; +} + +export default useWorkspaceAgents; diff --git a/src/hooks/useWorkspaceMembers.ts b/src/hooks/useWorkspaceMembers.ts new file mode 100644 index 000000000000..3e7e1d5a7147 --- /dev/null +++ b/src/hooks/useWorkspaceMembers.ts @@ -0,0 +1,78 @@ +import {openWorkspaceMembersPage} from '@libs/actions/Policy/Member'; +import {shouldFilterExpensifyTeam, isDeletedPolicyEmployee, isExpensifyTeam, canMemberRead, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {generateAccountID} from '@libs/UserUtils'; + +import CONST from '@src/CONST'; +import type {PersonalDetails, Policy, PolicyEmployee} from '@src/types/onyx'; +import {isEmptyValueObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useEffectEvent, useEffect} from 'react'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import {usePersonalDetailsByLogins} from './usePersonalDetailByLogin'; +import {useAllPersonalDetails} from './usePersonalDetails'; + +function useWorkspaceMembers(policy: OnyxEntry) { + const policyID = policy?.id; + const policyOwner = policy?.owner; + + const {formatPhoneNumber} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserLogin = currentUserPersonalDetails.login; + const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true); + const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); + const [personalDetails] = useAllPersonalDetails(); + + const getWorkspaceMembers = () => { + if (!policyID) { + return; + } + if (!canMemberRead(policy, currentUserPersonalDetails.login ?? '', CONST.POLICY.POLICY_FEATURE.MEMBERS)) { + return; + } + const clientMemberEmails = Object.keys(getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails)); + openWorkspaceMembersPage(policyID, clientMemberEmails); + }; + const {isOffline} = useNetwork({onReconnect: getWorkspaceMembers}); + const getWorkspaceMembersEvent = useEffectEvent(() => getWorkspaceMembers()); + useEffect(() => getWorkspaceMembersEvent(), []); + + const result: Array<{email: string; policyEmployee: PolicyEmployee; accountID: number; details: PersonalDetails}> = []; + for (const [email, policyEmployee] of Object.entries(policy?.employeeList ?? {})) { + // Inviting a secondary login leaves an empty employeeList entry: the backend nulls that key, then + // successData merges {pendingAction: null} back onto it. Skip it so it doesn't render as a second + // member. A real member whose personal details haven't loaded still has a role and stays visible. + if (isEmptyValueObject(policyEmployee) || isDeletedPolicyEmployee(policyEmployee, isOffline)) { + continue; + } + + // The accountID normally comes from the personal-details join. When a member's personal details + // haven't loaded (e.g. the backend under-returns them), that join is empty, so we fall back to a + // generated accountID. This keeps the rendered count in sync with employeeList and matches OldDot, + // which shows every member rather than silently dropping the ones without loaded details. + const accountID = policyMemberEmailsToAccountIDs[email] ? Number(policyMemberEmailsToAccountIDs[email]) : generateAccountID(email); + + // Render a fallback identity (email as display name) when personal details are missing so the member + // is still shown instead of being dropped from the list. + const fallbackDetails: PersonalDetails = {accountID, login: email, displayName: formatPhoneNumber(email)}; + const details = personalDetails?.[accountID] ?? fallbackDetails; + + // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails + // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they + // see random people added to their policy, but guides having access to the policies help set them up. + if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { + continue; + } + + result.push({email, policyEmployee, accountID, details}); + } + + return result; +} + +export default useWorkspaceMembers; diff --git a/src/languages/de.ts b/src/languages/de.ts index 9443b7ef5276..0cea64457ada 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -532,6 +532,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Das Diagramm konnte nicht geladen werden. Bitte aktualisieren Sie die Seite und versuchen Sie es erneut.', apiKey: 'API-Schlüssel', exportsTo: 'Exportiert nach', + shareReport: 'Bericht teilen', + reportShared: 'Bericht geteilt', }, socials: { podcast: 'Folgen Sie uns auf Podcast', @@ -3954,6 +3956,11 @@ ${amount} für ${merchant} – ${date}`, `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', + chooseDelegate: 'Vertretung auswählen', + clearAfterRecommended: 'Löschen nach (empfohlen)', + willClearOn: (date: string) => `Die Urlaubsvertretung wird am ${date} entfernt`, + until: (date: string) => `Bis ${date}`, + removeDelegate: 'Urlaubsvertretung entfernen', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4046,6 +4053,10 @@ ${amount} für ${merchant} – ${date}`, unlockAlreadyRequestedTitle: 'Anfrage bereits eingereicht', unlockAlreadyRequestedDescription: 'Ihre Anfrage zur Entsperrung dieses Bankkontos wurde bereits gesendet. Concierge meldet sich bei Ihnen, falls noch etwas benötigt wird.', }, + listField: { + editItem: ({name}: {name: string}) => `${name} bearbeiten`, + removeItem: ({name}: {name: string}) => `${name} entfernen`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Beispiel: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4057,7 +4068,13 @@ ${amount} für ${merchant} – ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Ungültiges Format. Beispiel: ${example}` : 'Ungültiges Format'), invalidOption: 'Wähle eine der verfügbaren Optionen', outOfRange: ({min, max}: {min: number; max: number}) => `Gib einen Wert zwischen ${min} und ${max} ein`, + tooFewItems: ({min}: {min: number}) => `Füge mindestens ${min} hinzu`, + tooManyItems: ({max}: {max: number}) => `Füge höchstens ${max} hinzu`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `${item} hinzufügen`, + removeItemTitle: ({item}: {item: string}) => `${item} entfernen?`, + removeItemPrompt: ({name}: {name: string}) => `Möchtest du ${name} wirklich entfernen?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Der SWIFT/BIC muss 8 oder 11 Zeichen lang sein, mit 6 Buchstaben gefolgt von 2 oder 5 Buchstaben oder Ziffern.', @@ -5102,7 +5119,10 @@ ${amount} für ${merchant} – ${date}`, }, requestEmptyList: { title: 'Noch keine Pauschalbeträge', - subtitle: 'In diesem Workspace sind keine Pauschalen hinterlegt, bitten Sie Ihre Admin, welche hinzuzufügen.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.` + : 'Dieser Workspace hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.', adminSubtitle: 'Fügen Sie Pauschalen hinzu, um Ihre Ausgaben zu organisieren.', }, importPerDiemRates: 'Tagespauschalen importieren', @@ -6652,6 +6672,8 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU distanceRates: { title: 'Kilometersätze', subtitle: 'Sätze Tarife fest, aktualisiere sie und setze sie durch.', + commuterExclusionsOnTitle: 'Pendlerfahrten-Ausschlüsse sind aktiviert', + disableLockedByCommuterExclusionsPrompt: 'Um Entfernungsraten zu deaktivieren, stellen Sie zuerst „Arbeitswege ausschließen“ auf „Arbeitswege nicht ausschließen“.', }, perDiem: { title: 'Tagegeld', @@ -7890,6 +7912,21 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, addressContext: 'Für die Aktivierung von Expensify Travel ist eine Workspace-Adresse erforderlich. Bitte gib eine Adresse ein, die deinem Unternehmen zugeordnet ist.', policy: 'Spesenrichtlinie', }, + officeLocations: { + title: 'Bürostandorte', + subtitle: 'Fügen Sie Bürostandorte hinzu, die Sie Mitgliedern für Dinge wie die Entfernungserfassung zuweisen können.', + addOfficeLocation: 'Bürostandort hinzufügen', + editOfficeLocation: 'Standort des Büros bearbeiten', + locationName: 'Standortname', + locationNameHint: 'Wählen Sie einen Standort-Spitznamen, wenn Sie mehrere Bürostandorte hinzufügen', + defaultName: ({officeNumber}: {officeNumber: number}) => `Bürostandort ${officeNumber}`, + setAsPrimary: 'Als primär festlegen', + primary: 'Primär', + deleteOfficeLocation: 'Bürostandort löschen?', + deleteOfficeLocationConfirmation: 'Möchten Sie diesen Bürostandort wirklich löschen? Mitglieder werden dem Hauptbüro neu zugewiesen.', + existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen existiert bereits', + setCompanyAddressAsPrimaryConfirmation: 'Unternehmensadresse als Hauptbüro festlegen?', + }, bankAccount: { continueWithSetup: 'Einrichtung fortsetzen', youAreAlmostDone: @@ -9018,7 +9055,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc toggleTitle: 'Büros', allSelected: 'Alle Büros', }, - enableJobStagesOrTags: 'Aktivieren Sie Jobphasen oder Tags, um fortzufahren', + enableJobStagesOrTags: 'Wählen Sie mindestens eine Jobphase oder ein Tag aus, um fortzufahren', }, subtitle: 'Verknüpfen Sie Recruiting-Tools und halten Sie Kandidatengenehmigungen synchron.', syncResults: { @@ -9717,6 +9754,15 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ zu verbotenen Ausgaben hinzugefügt`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ aus verbotenen Ausgaben entfernt`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `hat den Bürostandort „${name}“ (${address}) hinzugefügt`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `hat den Bürostandort „${oldName}“ in „${newName}“ umbenannt`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `hat die Adresse des Bürostandorts „${name}“ in „${newAddress}“ geändert (zuvor „${oldAddress}“)`, + setAsPrimary: ({name}: {name: string}) => `Legen Sie den Bürositz „${name}“ als primär fest`, + setCompanyAddressAsPrimary: 'Firmenadresse als primär festlegen', + removed: ({name}: {name: string}) => `hat den Bürostandort „${name}“ entfernt`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `Standard-Arbeitsmodell auf ${arrangement} festlegen`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9994,6 +10040,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc fallbackTitle: 'Support-Ticket', resolved: 'Dieses Support-Ticket ist gelöst.', surveyPrompt: 'Wie war Ihre Erfahrung mit dem Support?', + great: 'Großartig', + poor: 'Schlecht', reopenTicket: 'Ticket erneut öffnen', }, statementPage: { @@ -10216,6 +10264,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Export', }, filterType: {label: 'Filtertyp', has: {positive: 'hat', negative: 'hat nicht'}, is: {positive: 'ist', negative: 'ist nicht'}}, + merchant: { + matchType: 'Übereinstimmungstyp', + contains: (merchant?: string) => (merchant ? `Enthält "${merchant}"` : 'Enthält'), + equalTo: (merchant?: string) => (merchant ? `Gleich "${merchant}"` : 'Gleich'), + }, created: 'Erstellt', createdDate: 'Erstellungsdatum', transactionStatus: { @@ -11197,6 +11250,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, systemMessage: { mergedWithCashTransaction: 'hat eine Quittung mit dieser Transaktion abgeglichen', + mergedExpenseDeleted: 'hat Ausgaben zusammengeführt und eine in Gelöscht verschoben', }, subscription: { authenticatePaymentCard: 'Zahlungskarte authentifizieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index 45c848e2985e..8ae8d6e7d983 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -532,6 +532,8 @@ const translations: TranslationDeepObject = { other: 'Λήψη αποδείξεων', }), commuter: 'επιβάτης καθημερινών μετακινήσεων', + shareReport: 'Κοινοποιήστε την αναφορά', + reportShared: 'Η αναφορά κοινοποιήθηκε', }, socials: { podcast: 'Ακολουθήστε μας στο Podcast', @@ -4001,6 +4003,11 @@ ${amount} για ${merchant} - ${date}`, `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + chooseDelegate: 'Επιλέξτε εκπρόσωπο', + clearAfterRecommended: 'Εκκαθάριση μετά από (συνιστάται)', + willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}`, + until: (date: string) => `Έως ${date}`, + removeDelegate: 'Κατάργηση εκπροσώπου διακοπών', }, }, stepCounter: (step, total, text) => { @@ -4094,6 +4101,10 @@ ${amount} για ${merchant} - ${date}`, unlockAlreadyRequestedDescription: 'Το αίτημά σας για ξεκλείδωμα αυτού του τραπεζικού λογαριασμού έχει ήδη αποσταλεί. Το Concierge θα επικοινωνήσει μαζί σας αν χρειαστεί οτιδήποτε άλλο.', }, + listField: { + editItem: ({name}: {name: string}) => `Επεξεργασία ${name}`, + removeItem: ({name}: {name: string}) => `Αφαίρεση ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Παράδειγμα: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4105,7 +4116,13 @@ ${amount} για ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Μη έγκυρη μορφή. Παράδειγμα: ${example}` : 'Μη έγκυρη μορφή'), invalidOption: 'Επιλέξτε μία από τις διαθέσιμες επιλογές', outOfRange: ({min, max}: {min: number; max: number}) => `Εισαγάγετε μια τιμή μεταξύ ${min} και ${max}`, + tooFewItems: ({min}: {min: number}) => `Προσθέστε τουλάχιστον ${min}`, + tooManyItems: ({max}: {max: number}) => `Προσθέστε το πολύ ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Προσθήκη ${item}`, + removeItemTitle: ({item}: {item: string}) => `Αφαίρεση ${item};`, + removeItemPrompt: ({name}: {name: string}) => `Θέλετε σίγουρα να αφαιρέσετε ${name};`, }, addPersonalBankAccount: { swiftBicFormatError: 'Το SWIFT/BIC πρέπει να έχει μήκος 8 ή 11 χαρακτήρες, με 6 γράμματα ακολουθούμενα από 2 ή 5 γράμματα ή αριθμούς.', @@ -5178,7 +5195,10 @@ ${amount} για ${merchant} - ${date}`, editCurrencySubtitle: (destination: string) => `Η ενημέρωση αυτού του νομίσματος θα το αλλάξει για όλες τις υποτιμές ημερήσιας αποζημίωσης ${destination}.`, requestEmptyList: { title: 'Δεν υπάρχουν ακόμη ημερήσια επιδόματα', - subtitle: 'Αυτός ο χώρος εργασίας δεν έχει ημερήσια επιδόματα· ζητήστε από τον διαχειριστή σας να προσθέσει κάποια.', + subtitle: (workspaceName?: string) => + workspaceName + ? `Το ${workspaceName} δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.` + : 'Αυτός ο χώρος εργασίας δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.', adminSubtitle: 'Προσθέστε ημερήσια επιδόματα για να οργανώσετε τις δαπάνες σας.', }, }, @@ -6760,6 +6780,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ distanceRates: { title: 'Τιμές χιλιομετρικής αποζημίωσης', subtitle: 'Προσθέστε, ενημερώστε και εφαρμόστε τιμές.', + commuterExclusionsOnTitle: 'Οι εξαιρέσεις μετακίνησης είναι ενεργές', + disableLockedByCommuterExclusionsPrompt: + 'Για να απενεργοποιήσετε τις χρεώσεις απόστασης, πρώτα ορίστε την επιλογή «Εξαίρεση μετακινήσεων από και προς την εργασία» σε «Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία».', }, perDiem: { title: 'Ημερήσια αποζημίωση', @@ -7943,7 +7966,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ toggleTitle: 'Γραφεία', allSelected: 'Όλα τα γραφεία', }, - enableJobStagesOrTags: 'Ενεργοποιήστε τα στάδια εργασίας ή τις ετικέτες για να συνεχίσετε', + enableJobStagesOrTags: 'Επιλέξτε τουλάχιστον ένα στάδιο εργασίας ή ετικέτα για να συνεχίσετε', }, subtitle: 'Συνδέστε τα εργαλεία προσλήψεων και διατηρήστε τις εγκρίσεις υποψηφίων συγχρονισμένες.', syncResults: { @@ -8190,6 +8213,21 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ 'Απαιτείται μια διεύθυνση χώρου εργασίας για να ενεργοποιήσετε το Expensify Travel. Παρακαλούμε εισαγάγετε μια διεύθυνση που να σχετίζεται με την επιχείρησή σας.', policy: 'Πολιτική εξόδων', }, + officeLocations: { + title: 'Τοποθεσίες γραφείων', + subtitle: 'Προσθέστε τοποθεσίες γραφείων για να τις αντιστοιχίσετε στα μέλη για λειτουργίες όπως η παρακολούθηση αποστάσεων.', + addOfficeLocation: 'Προσθήκη τοποθεσίας γραφείου', + editOfficeLocation: 'Επεξεργασία τοποθεσίας γραφείου', + locationName: 'Όνομα τοποθεσίας', + locationNameHint: 'Επιλέξτε ένα ψευδώνυμο τοποθεσίας αν προσθέτετε πολλές τοποθεσίες γραφείου', + defaultName: ({officeNumber}: {officeNumber: number}) => `Τοποθεσία γραφείου ${officeNumber}`, + setAsPrimary: 'Ορισμός ως κύριο', + primary: 'Κύριο', + deleteOfficeLocation: 'Να διαγραφεί η τοποθεσία γραφείου;', + deleteOfficeLocationConfirmation: 'Θέλετε σίγουρα να διαγράψετε αυτήν την τοποθεσία γραφείου; Τα μέλη θα επαναχωριστούν στο κύριο γραφείο.', + existingOfficeLocationError: 'Υπάρχει ήδη τοποθεσία γραφείου με αυτό το όνομα', + setCompanyAddressAsPrimaryConfirmation: 'Να οριστεί η διεύθυνση της εταιρείας ως κύριο γραφείο;', + }, bankAccount: { continueWithSetup: 'Συνεχίστε τη ρύθμιση', youAreAlmostDone: @@ -9839,6 +9877,15 @@ ${reportName}`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `προστέθηκε το "${prohibitedExpense}" στις απαγορευμένες δαπάνες`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `αφαιρέσατε το «${prohibitedExpense}» από τις απαγορευμένες δαπάνες`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `πρόσθεσε την τοποθεσία γραφείου «${name}» (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `μετονομάστηκε η τοποθεσία γραφείου «${oldName}» σε «${newName}»`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `άλλαξε τη διεύθυνση της τοποθεσίας γραφείου «${name}» σε «${newAddress}» (προηγουμένως «${oldAddress}»)`, + setAsPrimary: ({name}: {name: string}) => `ορίστε την τοποθεσία γραφείου «${name}» ως κύρια`, + setCompanyAddressAsPrimary: 'ορίστε τη διεύθυνση της εταιρείας ως κύρια', + removed: ({name}: {name: string}) => `αφαίρεσε την τοποθεσία γραφείου «${name}»`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ορίστε την προεπιλεγμένη εργασιακή ρύθμιση σε ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -10175,6 +10222,8 @@ ${reportName}`, fallbackTitle: 'Αίτημα υποστήριξης', resolved: 'Αυτό το αίτημα υποστήριξης επιλύθηκε.', surveyPrompt: 'Πώς ήταν η εμπειρία σας με την υποστήριξη;', + great: 'Εξαιρετική', + poor: 'Κακή', reopenTicket: 'Επανέναρξη αιτήματος', }, statementPage: { @@ -10422,6 +10471,11 @@ ${reportName}`, has: {positive: 'έχει', negative: 'δεν έχει'}, is: {positive: 'είναι', negative: 'δεν είναι'}, }, + merchant: { + matchType: 'Τύπος αντιστοίχισης', + contains: (merchant?: string) => (merchant ? `Περιέχει "${merchant}"` : 'Περιέχει'), + equalTo: (merchant?: string) => (merchant ? `Ίσο με "${merchant}"` : 'Ίσο με'), + }, created: 'Δημιουργήθηκε', createdDate: 'Ημερομηνία δημιουργίας', transactionStatus: { @@ -11409,6 +11463,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'έγινε αντιστοίχιση μιας απόδειξης με αυτή τη συναλλαγή', + mergedExpenseDeleted: 'συγχώνευσε έξοδα και μετέφερε ένα στα Διαγραμμένα', }, subscription: { authenticatePaymentCard: 'Πιστοποιήστε την κάρτα πληρωμής', diff --git a/src/languages/en.ts b/src/languages/en.ts index 082a4aed7b8d..38aa27019c37 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -356,6 +356,8 @@ const translations = { assignee: 'Assignee', with: 'with', shareCode: 'Share code', + shareReport: 'Share report', + reportShared: 'Report shared', share: 'Share', per: 'per', // @context Unit label for “mile.” Should be treated as a measurement unit and may require capitalization depending on locale conventions. @@ -4118,6 +4120,11 @@ const translations = { `${delegate} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, youAreAMemberOf: 'You are a member of these workspaces:', youAreAnAdminOf: 'You are an admin of these workspaces:', + chooseDelegate: 'Choose delegate', + clearAfterRecommended: 'Clear after (recommended)', + willClearOn: (date: string) => `Vacation delegate will clear on ${date}`, + until: (date: string) => `Until ${date}`, + removeDelegate: 'Remove vacation delegate', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4210,6 +4217,10 @@ const translations = { sameDepositAndWithdrawalAccount: 'The deposit and withdrawal accounts are the same.', }, }, + listField: { + editItem: ({name}: {name: string}) => `Edit ${name}`, + removeItem: ({name}: {name: string}) => `Remove ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Example: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4221,7 +4232,13 @@ const translations = { invalidFormat: ({example}: {example?: string}) => (example ? `Invalid format. Example: ${example}` : 'Invalid format'), invalidOption: 'Choose one of the available options', outOfRange: ({min, max}: {min: number; max: number}) => `Enter a value between ${min} and ${max}`, + tooFewItems: ({min}: {min: number}) => `Add at least ${min}`, + tooManyItems: ({max}: {max: number}) => `Add at most ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Add ${item}`, + removeItemTitle: ({item}: {item: string}) => `Remove ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Are you sure you want to remove ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC must be 8 or 11 characters long, with 6 letters followed by 2 or 5 letters or numbers.', @@ -5283,7 +5300,10 @@ const translations = { }, requestEmptyList: { title: 'No per diem rates yet', - subtitle: "This workspace doesn't have any per diem rates, ask your Admin to add some.", + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} doesn't have any per diem rates. Ask an admin to add some.` + : "This workspace doesn't have any per diem rates. Ask an admin to add some.", adminSubtitle: 'Add per diem rates to organize your spend.', }, importPerDiemRates: 'Import per diem rates', @@ -6984,6 +7004,8 @@ const translations = { distanceRates: { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', + commuterExclusionsOnTitle: 'Commute exclusions are on', + disableLockedByCommuterExclusionsPrompt: 'To turn off Distance rates, first set Exclude commutes to “Do not exclude commutes.”', }, perDiem: { title: 'Per diem', @@ -8165,7 +8187,7 @@ const translations = { }, filters: { description: (providerName: string) => `Select which members get imported from ${providerName}. You can choose from job stages, tags, and offices.`, - enableJobStagesOrTags: 'Enable Job stages or Tags to continue', + enableJobStagesOrTags: 'Select at least one Job stage or Tag to continue', stages: { title: 'Job stage', toggleTitle: 'Job stages', @@ -8406,6 +8428,21 @@ const translations = { addressContext: 'A Workspace Address is required to enable Expensify Travel. Please enter an address associated with your business.', policy: 'Expense policy', }, + officeLocations: { + title: 'Office locations', + subtitle: 'Add office locations to assign to members for things like distance tracking.', + addOfficeLocation: 'Add office location', + editOfficeLocation: 'Edit office location', + locationName: 'Location name', + locationNameHint: 'Choose a location nickname if you are adding multiple office locations', + defaultName: ({officeNumber}: {officeNumber: number}) => `Office location ${officeNumber}`, + setAsPrimary: 'Set as primary', + primary: 'Primary', + deleteOfficeLocation: 'Delete office location?', + deleteOfficeLocationConfirmation: 'Do you really want to delete this office location? Members will be reassigned to the primary office.', + existingOfficeLocationError: 'An office location with this name already exists', + setCompanyAddressAsPrimaryConfirmation: 'Set the company address as primary office?', + }, bankAccount: { continueWithSetup: 'Continue setup', youAreAlmostDone: "You're almost done setting up your bank account, which will let you issue corporate cards, reimburse expenses, collect invoices, and pay bills.", @@ -9856,6 +9893,15 @@ const translations = { }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `added "${prohibitedExpense}" to prohibited expenses`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removed "${prohibitedExpense}" from prohibited expenses`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `added the office location "${name}" (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `renamed the office location "${oldName}" to "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `changed the address of the office location "${name}" to "${newAddress}" (previously "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `set the office location "${name}" as primary`, + setCompanyAddressAsPrimary: 'set the company address as primary', + removed: ({name}: {name: string}) => `removed the office location "${name}"`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `set the default work arrangement to ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -10191,6 +10237,8 @@ const translations = { fallbackTitle: 'Support ticket', resolved: 'This support ticket is resolved.', surveyPrompt: 'How was your support experience?', + great: 'Great', + poor: 'Poor', reopenTicket: 'Reopen ticket', }, statementPage: { @@ -10390,6 +10438,11 @@ const translations = { }, equalTo: (amount?: string) => `Equal to ${amount ?? ''}`, }, + merchant: { + matchType: 'Match type', + contains: (merchant?: string) => (merchant ? `Contains "${merchant}"` : 'Contains'), + equalTo: (merchant?: string) => (merchant ? `Equal to "${merchant}"` : 'Equal to'), + }, card: { expensify: 'Expensify', travelInvoicing: 'Consolidated Travel Billing', @@ -11450,6 +11503,7 @@ const translations = { }, systemMessage: { mergedWithCashTransaction: 'matched a receipt to this transaction', + mergedExpenseDeleted: 'merged expenses and moved one to Deleted', }, subscription: { authenticatePaymentCard: 'Authenticate payment card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5a4f6962d2ca..23fc4be3b766 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -519,6 +519,8 @@ const translations: TranslationDeepObject = { commuter: 'viajero diario', noResultsFoundSubtitle: 'Sin resultados. Intenta ajustar tus filtros o la búsqueda.', exportsTo: 'Exporta a', + shareReport: 'Compartir informe', + reportShared: 'Informe compartido', }, socials: { podcast: 'Síguenos en Podcast', @@ -3866,6 +3868,11 @@ ${amount} para ${merchant} - ${date}`, `${delegate} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', + chooseDelegate: 'Elegir delegado', + clearAfterRecommended: 'Borrar después de (recomendado)', + willClearOn: (date: string) => `El delegado de vacaciones se eliminará el ${date}`, + until: (date: string) => `Hasta el ${date}`, + removeDelegate: 'Eliminar delegado de vacaciones', }, }, stepCounter: (step, total, text) => { @@ -3957,6 +3964,10 @@ ${amount} para ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Solicitud ya enviada', unlockAlreadyRequestedDescription: 'Tu solicitud para desbloquear esta cuenta bancaria ya ha sido enviada. Concierge se pondrá en contacto contigo si se necesita algo más.', }, + listField: { + editItem: ({name}: {name: string}) => `Editar ${name}`, + removeItem: ({name}: {name: string}) => `Eliminar ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Ejemplo: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3968,7 +3979,13 @@ ${amount} para ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato inválido. Ejemplo: ${example}` : 'Formato inválido'), invalidOption: 'Elige una de las opciones disponibles', outOfRange: ({min, max}: {min: number; max: number}) => `Introduce un valor entre ${min} y ${max}`, + tooFewItems: ({min}: {min: number}) => `Añade al menos ${min}`, + tooManyItems: ({max}: {max: number}) => `Añade como máximo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Añadir ${item}`, + removeItemTitle: ({item}: {item: string}) => `¿Eliminar ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `¿Seguro que quieres eliminar a ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'El SWIFT/BIC debe tener 8 u 11 caracteres, con 6 letras seguidas de 2 o 5 letras o números.', @@ -5024,7 +5041,10 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aún no hay tasas de Per diem', - subtitle: 'Este espacio de trabajo no tiene ninguna tasa de per diem, pide a tu administrador que añada algunas.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.` + : 'Este espacio de trabajo no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.', adminSubtitle: 'Añade tasas de per diem para organizar tus gastos.', }, importPerDiemRates: 'Importar tasas de per diem', @@ -6507,6 +6527,8 @@ ${amount} para ${merchant} - ${date}`, distanceRates: { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', + commuterExclusionsOnTitle: 'Las exclusiones de desplazamiento están activadas', + disableLockedByCommuterExclusionsPrompt: 'Para desactivar las tarifas de distancia, primero establece Excluir desplazamientos a “No excluir desplazamientos”.', }, perDiem: { title: 'Per diem', @@ -7675,7 +7697,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, toggleTitle: 'Oficinas', allSelected: 'Todas las oficinas', }, - enableJobStagesOrTags: 'Habilita las etapas de trabajo o las etiquetas para continuar', + enableJobStagesOrTags: 'Selecciona al menos una etapa de trabajo o etiqueta para continuar', }, subtitle: 'Conecta herramientas de selección y mantén sincronizadas las aprobaciones de candidatos.', syncResults: { @@ -7912,6 +7934,21 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, addressContext: 'Se requiere una dirección para habilitar Expensify Travel. Por favor, introduce una dirección asociada con tu negocio.', policy: 'Política de gastos', }, + officeLocations: { + title: 'Ubicaciones de oficinas', + subtitle: 'Añade ubicaciones de oficina para asignarlas a miembros para cosas como el seguimiento de distancias.', + addOfficeLocation: 'Añadir ubicación de la oficina', + editOfficeLocation: 'Editar ubicación de la oficina', + locationName: 'Nombre de la ubicación', + locationNameHint: 'Elige un nombre abreviado para la ubicación si estás añadiendo varias oficinas', + defaultName: ({officeNumber}: {officeNumber: number}) => `Ubicación de la oficina ${officeNumber}`, + setAsPrimary: 'Establecer como principal', + primary: 'Principal', + deleteOfficeLocation: '¿Eliminar la ubicación de la oficina?', + deleteOfficeLocationConfirmation: '¿Seguro que quieres eliminar esta ubicación de oficina? Los miembros serán reasignados a la oficina principal.', + existingOfficeLocationError: 'Ya existe una ubicación de oficina con este nombre', + setCompanyAddressAsPrimaryConfirmation: '¿Establecer la dirección de la empresa como oficina principal?', + }, bankAccount: { continueWithSetup: 'Continuar con la configuración', youAreAlmostDone: 'Casi has acabado de configurar tu cuenta bancaria, que te permitirá emitir tarjetas corporativas, reembolsar gastos y cobrar pagar facturas.', @@ -9486,6 +9523,15 @@ ${reportName}`, updatedTimeRate: (newRate, oldRate) => `cambió la tarifa por hora a "${newRate}" (anteriormente "${oldRate}")`, addedProhibitedExpense: ({prohibitedExpense}) => `añadió "${prohibitedExpense}" a los gastos prohibidos`, removedProhibitedExpense: ({prohibitedExpense}) => `eliminó "${prohibitedExpense}" de los gastos prohibidos`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `añadió la ubicación de la oficina «${name}» (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `cambió el nombre de la ubicación de la oficina de "${oldName}" a "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `cambió la dirección de la oficina "${name}" a "${newAddress}" (previamente "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `establecer la ubicación de la oficina «${name}» como principal`, + setCompanyAddressAsPrimary: 'establecer la dirección de la empresa como principal', + removed: ({name}: {name: string}) => `eliminó la ubicación de la oficina «${name}»`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `establecer la modalidad de trabajo predeterminada en ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9813,6 +9859,8 @@ ${reportName}`, fallbackTitle: 'Ticket de soporte', resolved: 'Este ticket de soporte está resuelto.', surveyPrompt: '¿Cómo fue tu experiencia con el soporte?', + great: 'Excelente', + poor: 'Mala', reopenTicket: 'Reabrir ticket', }, statementPage: { @@ -10062,6 +10110,11 @@ ${reportName}`, }, reportField: (name, value) => `${name} es ${value}`, filterType: {label: 'Tipo de filtro', has: {positive: 'tiene', negative: 'no tiene'}, is: {positive: 'es', negative: 'no es'}}, + merchant: { + matchType: 'Tipo de coincidencia', + contains: (merchant) => (merchant ? `Contiene "${merchant}"` : 'Contiene'), + equalTo: (merchant) => (merchant ? `Igual a "${merchant}"` : 'Igual a'), + }, created: 'Creado', createdDate: 'Fecha de creación', transactionStatus: { @@ -11390,6 +11443,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'encontró un recibo para esta transacción', + mergedExpenseDeleted: 'combinó gastos y movió uno a Eliminados', }, subscription: { authenticatePaymentCard: 'Autenticar tarjeta de pago', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1397788a7159..7f37b163f1af 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -531,6 +531,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Le graphique n’a pas pu être chargé. Veuillez actualiser la page et réessayer.', apiKey: 'Clé API', exportsTo: 'Exports vers', + shareReport: 'Partager la note de frais', + reportShared: 'Note de frais partagée', }, socials: { podcast: 'Suivez-nous sur Podcast', @@ -3961,6 +3963,11 @@ ${amount} pour ${merchant} - ${date}`, `${delegate} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', + chooseDelegate: 'Choisir un délégué', + clearAfterRecommended: 'Effacer après (recommandé)', + willClearOn: (date: string) => `Le délégué de vacances sera supprimé le ${date}`, + until: (date: string) => `Jusqu’au ${date}`, + removeDelegate: 'Supprimer le délégué de vacances', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4053,6 +4060,10 @@ ${amount} pour ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Demande déjà soumise', unlockAlreadyRequestedDescription: 'Votre demande de déverrouillage de ce compte bancaire a déjà été envoyée. Concierge vous contactera si autre chose est nécessaire.', }, + listField: { + editItem: ({name}: {name: string}) => `Modifier ${name}`, + removeItem: ({name}: {name: string}) => `Supprimer ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Exemple : ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4064,7 +4075,13 @@ ${amount} pour ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Format invalide. Exemple : ${example}` : 'Format invalide'), invalidOption: "Choisissez l'une des options disponibles", outOfRange: ({min, max}: {min: number; max: number}) => `Saisissez une valeur entre ${min} et ${max}`, + tooFewItems: ({min}: {min: number}) => `Ajoutez au moins ${min}`, + tooManyItems: ({max}: {max: number}) => `Ajoutez au plus ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item} : ${error}`, }, + addItem: ({item}: {item: string}) => `Ajouter ${item}`, + removeItemTitle: ({item}: {item: string}) => `Supprimer ${item} ?`, + removeItemPrompt: ({name}: {name: string}) => `Voulez-vous vraiment supprimer ${name} ?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Le code SWIFT/BIC doit comporter 8 ou 11 caractères, avec 6 lettres suivies de 2 ou 5 lettres ou chiffres.', @@ -5108,7 +5125,10 @@ ${amount} pour ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aucun taux de per diem pour le moment', - subtitle: 'Cet espace de travail n’a aucun taux de per diem, demandez à votre Administrateur d’en ajouter.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.` + : 'Cet espace de travail n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.', adminSubtitle: 'Ajoutez des taux de per diem pour organiser vos dépenses.', }, importPerDiemRates: 'Importer des taux de per diem', @@ -6659,6 +6679,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. distanceRates: { title: 'Taux de distance', subtitle: 'Ajoutez, mettez à jour et appliquez les taux.', + commuterExclusionsOnTitle: 'Les exclusions de trajet domicile-travail sont activées', + disableLockedByCommuterExclusionsPrompt: + 'Pour désactiver les taux de distance, commencez par définir Exclure les trajets domicile-travail sur « Ne pas exclure les trajets domicile-travail ».', }, perDiem: { title: 'Indemnité journalière', @@ -7911,6 +7934,21 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, addressContext: 'Une adresse d’espace de travail est requise pour activer Expensify Travel. Veuillez saisir une adresse associée à votre entreprise.', policy: 'Politique de dépenses', }, + officeLocations: { + title: 'Sites de bureau', + subtitle: 'Ajoutez des bureaux pour les assigner aux membres, par exemple pour le suivi des distances.', + addOfficeLocation: 'Ajouter un lieu de travail', + editOfficeLocation: 'Modifier le lieu de travail', + locationName: 'Nom du lieu', + locationNameHint: 'Choisissez un surnom de lieu si vous ajoutez plusieurs sites de bureau', + defaultName: ({officeNumber}: {officeNumber: number}) => `Bureau ${officeNumber}`, + setAsPrimary: 'Définir comme principal', + primary: 'Principal', + deleteOfficeLocation: 'Supprimer le site ?', + deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce bureau ? Les membres seront réassignés au bureau principal.', + existingOfficeLocationError: 'Un bureau portant ce nom existe déjà', + setCompanyAddressAsPrimaryConfirmation: 'Définir l’adresse de l’entreprise comme bureau principal ?', + }, bankAccount: { continueWithSetup: 'Continuer la configuration', youAreAlmostDone: @@ -9037,7 +9075,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e toggleTitle: 'Bureaux', allSelected: 'Tous les bureaux', }, - enableJobStagesOrTags: 'Activez les étapes de mission ou les Tags pour continuer', + enableJobStagesOrTags: 'Sélectionnez au moins une étape de poste ou un tag pour continuer', }, subtitle: 'Connectez les outils de recrutement et synchronisez les validations de candidats.', syncResults: { @@ -9739,6 +9777,15 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `a ajouté « ${prohibitedExpense} » aux dépenses interdites`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `a supprimé « ${prohibitedExpense} » des dépenses interdites`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `a ajouté le lieu de bureau « ${name} » (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `a renommé le lieu de bureau « ${oldName} » en « ${newName} »`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `a modifié l’adresse du bureau « ${name} » en « ${newAddress} » (auparavant « ${oldAddress} »)`, + setAsPrimary: ({name}: {name: string}) => `définir le lieu de travail « ${name} » comme principal`, + setCompanyAddressAsPrimary: 'définir l’adresse de l’entreprise comme principale', + removed: ({name}: {name: string}) => `a supprimé le lieu de bureau « ${name} »`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `définir l’organisation de travail par défaut sur ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -10020,6 +10067,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e fallbackTitle: 'Ticket d’assistance', resolved: 'Ce ticket d’assistance est résolu.', surveyPrompt: 'Comment s’est passée votre expérience avec l’assistance ?', + great: 'Excellente', + poor: 'Mauvaise', reopenTicket: 'Rouvrir le ticket', }, statementPage: { @@ -10241,6 +10290,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporter', }, filterType: {label: 'Type de filtre', has: {positive: 'a', negative: 'n’a pas'}, is: {positive: 'est', negative: 'n’est pas'}}, + merchant: { + matchType: 'Type de correspondance', + contains: (merchant?: string) => (merchant ? `Contient "${merchant}"` : 'Contient'), + equalTo: (merchant?: string) => (merchant ? `Égal à "${merchant}"` : 'Égal à'), + }, created: 'Créé', createdDate: 'Date de création', transactionStatus: { @@ -11221,6 +11275,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, systemMessage: { mergedWithCashTransaction: 'a fait correspondre un reçu à cette transaction', + mergedExpenseDeleted: 'a fusionné des dépenses et en a déplacé une vers Supprimées', }, subscription: { authenticatePaymentCard: 'Authentifier la carte de paiement', diff --git a/src/languages/it.ts b/src/languages/it.ts index 9b5a9461ab45..bd8eb402c6a5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -531,6 +531,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Impossibile caricare il grafico. Aggiorna la pagina e riprova.', apiKey: 'Chiave API', exportsTo: 'Esporta in', + shareReport: 'Condividi report', + reportShared: 'Report condiviso', }, socials: { podcast: 'Seguici su Podcast', @@ -3934,6 +3936,11 @@ ${amount} per ${merchant} - ${date}`, `${delegate} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', + chooseDelegate: 'Scegli delegato', + clearAfterRecommended: 'Cancella dopo (consigliato)', + willClearOn: (date: string) => `La delega ferie verrà rimossa il ${date}`, + until: (date: string) => `Fino al ${date}`, + removeDelegate: 'Rimuovi delega ferie', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4024,6 +4031,10 @@ ${amount} per ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Richiesta già inviata', unlockAlreadyRequestedDescription: 'La tua richiesta di sblocco di questo conto bancario è già stata inviata. Concierge ti contatterà se servirà altro.', }, + listField: { + editItem: ({name}: {name: string}) => `Modifica ${name}`, + removeItem: ({name}: {name: string}) => `Rimuovi ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Esempio: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4035,7 +4046,13 @@ ${amount} per ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato non valido. Esempio: ${example}` : 'Formato non valido'), invalidOption: 'Scegli una delle opzioni disponibili', outOfRange: ({min, max}: {min: number; max: number}) => `Inserisci un valore tra ${min} e ${max}`, + tooFewItems: ({min}: {min: number}) => `Aggiungi almeno ${min}`, + tooManyItems: ({max}: {max: number}) => `Aggiungi al massimo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Aggiungi ${item}`, + removeItemTitle: ({item}: {item: string}) => `Rimuovere ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Vuoi davvero rimuovere ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Lo SWIFT/BIC deve essere lungo 8 o 11 caratteri, con 6 lettere seguite da 2 oppure 5 lettere o numeri.', @@ -5070,7 +5087,10 @@ ${amount} per ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ancuna nessuna tariffa di diaria', - subtitle: 'Questo spazio di lavoro non ha nessuna diaria, chiedi al tuo amministratore di aggiungerne.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.` + : 'Questo spazio di lavoro non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.', adminSubtitle: 'Aggiungi le tariffe giornaliere per organizzare le tue spese.', }, importPerDiemRates: 'Importa tariffe di diaria', @@ -6604,6 +6624,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. distanceRates: { title: 'Tariffe chilometriche', subtitle: 'Aggiungi, aggiorna e applica le tariffe.', + commuterExclusionsOnTitle: 'Le esclusioni del tragitto casa-lavoro sono attive', + disableLockedByCommuterExclusionsPrompt: 'Per disattivare le tariffe distanza, prima imposta Escludi spostamenti casa-lavoro su “Non escludere spostamenti casa-lavoro”.', }, perDiem: { title: 'Diaria', @@ -7845,6 +7867,21 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, addressContext: 'Per abilitare Expensify Travel è necessario un indirizzo dell’area di lavoro. Inserisci un indirizzo associato alla tua azienda.', policy: 'Politica di spesa', }, + officeLocations: { + title: 'Sedi degli uffici', + subtitle: 'Aggiungi sedi dell’ufficio da assegnare ai membri per attività come il monitoraggio delle distanze.', + addOfficeLocation: 'Aggiungi sede dell’ufficio', + editOfficeLocation: 'Modifica sede ufficio', + locationName: 'Nome località', + locationNameHint: 'Scegli un nome abbreviato per la sede se stai aggiungendo più sedi di ufficio', + defaultName: ({officeNumber}: {officeNumber: number}) => `Sede ${officeNumber}`, + setAsPrimary: 'Imposta come principale', + primary: 'Principale', + deleteOfficeLocation: 'Eliminare la sede?', + deleteOfficeLocationConfirmation: 'Vuoi davvero eliminare questa sede? I membri verranno riassegnati alla sede principale.', + existingOfficeLocationError: 'Esiste già una sede con questo nome', + setCompanyAddressAsPrimaryConfirmation: 'Impostare l’indirizzo dell’azienda come sede principale?', + }, bankAccount: { continueWithSetup: 'Continua configurazione', youAreAlmostDone: 'Hai quasi finito di configurare il tuo conto bancario, che ti permetterà di emettere carte aziendali, rimborsare spese, incassare fatture e pagare bollette.', @@ -8961,7 +8998,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, toggleTitle: 'Uffici', allSelected: 'Tutti gli uffici', }, - enableJobStagesOrTags: 'Abilita le fasi di lavoro o i tag per continuare', + enableJobStagesOrTags: 'Seleziona almeno una fase del lavoro o un tag per continuare', }, subtitle: 'Collega gli strumenti di recruiting e mantieni sincronizzate le approvazioni dei candidati.', syncResults: { @@ -9664,6 +9701,15 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha aggiunto "${prohibitedExpense}" alle spese vietate`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha rimosso "${prohibitedExpense}" dalle spese vietate`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `ha aggiunto la sede "${name}" (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `ha rinominato la sede "${oldName}" in "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `ha modificato l’indirizzo della sede "${name}" in "${newAddress}" (in precedenza "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `imposta la sede "${name}" come principale`, + setCompanyAddressAsPrimary: 'imposta l’indirizzo aziendale come principale', + removed: ({name}: {name: string}) => `ha rimosso la sede "${name}"`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `imposta la modalità di lavoro predefinita su ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9947,6 +9993,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, fallbackTitle: 'Ticket di supporto', resolved: 'Questo ticket di assistenza è risolto.', surveyPrompt: 'Com’è stata la tua esperienza con l’assistenza?', + great: 'Ottima', + poor: 'Scarsa', reopenTicket: 'Riapri ticket', }, statementPage: { @@ -10168,6 +10216,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Esporta', }, filterType: {label: 'Tipo di filtro', has: {positive: 'ha', negative: 'non ha'}, is: {positive: 'è', negative: 'non è'}}, + merchant: { + matchType: 'Tipo di corrispondenza', + contains: (merchant?: string) => (merchant ? `Contiene "${merchant}"` : 'Contiene'), + equalTo: (merchant?: string) => (merchant ? `Uguale a "${merchant}"` : 'Uguale a'), + }, created: 'Creato', createdDate: 'Data di creazione', transactionStatus: { @@ -11149,6 +11202,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, systemMessage: { mergedWithCashTransaction: 'ha associato una ricevuta a questa transazione', + mergedExpenseDeleted: 'ha unito delle spese e ne ha spostata una in Eliminate', }, subscription: { authenticatePaymentCard: 'Autentica carta di pagamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d4b6395a03d0..7da59e4d68dc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -530,6 +530,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'グラフを読み込めませんでした。ページを再読み込みして、もう一度お試しください。', apiKey: 'API キー', exportsTo: 'エクスポート先', + shareReport: 'レポートを共有', + reportShared: 'レポートを共有しました', }, socials: { podcast: 'ポッドキャストでフォロー', @@ -3903,6 +3905,11 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + chooseDelegate: '代理人を選択', + clearAfterRecommended: 'クリア日(推奨)', + willClearOn: (date: string) => `休暇代理人は${date}に解除されます`, + until: (date: string) => `${date}まで`, + removeDelegate: '休暇代理人を削除', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -3992,6 +3999,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' unlockAlreadyRequestedTitle: 'リクエストは既に送信されています', unlockAlreadyRequestedDescription: 'この銀行口座のロック解除リクエストは既に送信されています。追加で必要なことがある場合は、Concierge からご連絡します。', }, + listField: { + editItem: ({name}: {name: string}) => `${name}を編集`, + removeItem: ({name}: {name: string}) => `${name}を削除`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `例:${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4003,7 +4014,13 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' invalidFormat: ({example}: {example?: string}) => (example ? `形式が正しくありません。例:${example}` : '形式が正しくありません'), invalidOption: '利用可能な選択肢から選んでください', outOfRange: ({min, max}: {min: number; max: number}) => `${min}から${max}の間の値を入力してください`, + tooFewItems: ({min}: {min: number}) => `${min}件以上追加してください`, + tooManyItems: ({max}: {max: number}) => `${max}件以内で追加してください`, + itemError: ({item, error}: {item: string; error: string}) => `${item}:${error}`, }, + addItem: ({item}: {item: string}) => `${item}を追加`, + removeItemTitle: ({item}: {item: string}) => `${item}を削除しますか?`, + removeItemPrompt: ({name}: {name: string}) => `本当に${name}を削除しますか?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC は 8 文字または 11 文字で、最初の 6 文字はアルファベット、続く 2 文字または 5 文字はアルファベットまたは数字である必要があります。', @@ -5033,7 +5050,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, requestEmptyList: { title: '日当レートがまだありません', - subtitle: 'このワークスペースには日当レートがありません。管理者に追加してもらってください。', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} には日当レートがありません。管理者に追加してもらってください。` + : 'このワークスペースには日当レートがありません。管理者に追加してもらってください。', adminSubtitle: '日当額を追加して、支出を整理しましょう。', }, importPerDiemRates: '日当レートをインポート', @@ -6528,6 +6548,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO distanceRates: { title: '距離単価', subtitle: 'レートを追加、更新し、適用します。', + commuterExclusionsOnTitle: '通勤除外が有効になっています', + disableLockedByCommuterExclusionsPrompt: '距離レートをオフにするには、まず「通勤を除外しない」に通勤の除外設定を変更してください。', }, perDiem: { title: '日当', @@ -7749,6 +7771,21 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から addressContext: 'Expensify Travel を有効にするには、ワークスペースの住所が必要です。お客様のビジネスに関連付けられた住所を入力してください。', policy: '経費ポリシー', }, + officeLocations: { + title: 'オフィス所在地', + subtitle: '距離の追跡などのためにメンバーに割り当てられるオフィス所在地を追加します。', + addOfficeLocation: 'オフィス所在地を追加', + editOfficeLocation: 'オフィス所在地を編集', + locationName: '場所名', + locationNameHint: '複数のオフィス所在地を追加する場合は、所在地のニックネームを選択してください', + defaultName: ({officeNumber}: {officeNumber: number}) => `オフィス所在地 ${officeNumber}`, + setAsPrimary: '優先に設定', + primary: 'メイン', + deleteOfficeLocation: 'オフィス所在地を削除しますか?', + deleteOfficeLocationConfirmation: 'このオフィス所在地を本当に削除しますか?メンバーはプライマリのオフィスに再割り当てされます。', + existingOfficeLocationError: 'この名前のオフィス所在地はすでに存在します', + setCompanyAddressAsPrimaryConfirmation: '会社住所を主なオフィス所在地として設定しますか?', + }, bankAccount: { continueWithSetup: 'セットアップを続行', youAreAlmostDone: '銀行口座の設定はほぼ完了です。これにより、コーポレートカードの発行、経費精算、請求書の回収、請求書の支払いが行えるようになります。', @@ -8842,7 +8879,7 @@ ${reportName}`, allSelected: 'すべてのタグ', }, offices: {title: 'オフィス', description: 'このワークスペースと同期したい候補者のオフィスを選択してください', toggleTitle: 'オフィス', allSelected: 'すべてのオフィス'}, - enableJobStagesOrTags: '続行するにはジョブステージまたはタグを有効にしてください', + enableJobStagesOrTags: '続行するには、少なくとも 1 つのジョブのステージまたはタグを選択してください', }, subtitle: '採用ツールを連携して、候補者の承認を常に同期させます。', syncResults: { @@ -9528,6 +9565,15 @@ ${reportName}`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費に「${prohibitedExpense}」を追加しました`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費から「${prohibitedExpense}」を削除しました`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `オフィス所在地「${name}」(${address})を追加しました`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `オフィス所在地名を「${oldName}」から「${newName}」に変更しました`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `オフィス所在地「${name}」の住所を「${newAddress}」(以前は「${oldAddress}」)に変更しました`, + setAsPrimary: ({name}: {name: string}) => `オフィス所在地「${name}」を主勤務地に設定します`, + setCompanyAddressAsPrimary: '会社の住所を主要に設定', + removed: ({name}: {name: string}) => `オフィス所在地「${name}」を削除しました`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `デフォルトの勤務形態を${arrangement}に設定します`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9804,6 +9850,8 @@ ${reportName}`, fallbackTitle: 'サポートチケット', resolved: 'このサポートチケットは解決済みです。', surveyPrompt: 'サポートの対応はいかがでしたか?', + great: '素晴らしい', + poor: '悪い', reopenTicket: 'チケットを再開', }, statementPage: { @@ -10025,6 +10073,11 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'エクスポート', }, filterType: {label: 'フィルタータイプ', has: {positive: '持っています', negative: '持っていません'}, is: {positive: 'は', negative: 'ではありません'}}, + merchant: { + matchType: 'マッチタイプ', + contains: (merchant?: string) => (merchant ? `"${merchant}"を含む` : '含む'), + equalTo: (merchant?: string) => (merchant ? `"${merchant}"に等しい` : '等しい'), + }, created: '作成しました', createdDate: '作成日', transactionStatus: { @@ -10986,6 +11039,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'この取引にレシートを照合しました', + mergedExpenseDeleted: '経費を統合し、1件を削除済みに移動しました', }, subscription: { authenticatePaymentCard: '支払カードを認証', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4fd0023754ec..b96c6121e753 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -531,6 +531,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'De grafiek kon niet worden geladen. Vernieuw de pagina en probeer het opnieuw.', apiKey: 'API-sleutel', exportsTo: 'Exporteert naar', + shareReport: 'Rapport delen', + reportShared: 'Rapport gedeeld', }, socials: { podcast: 'Volg ons op Podcast', @@ -3936,6 +3938,11 @@ ${amount} voor ${merchant} - ${date}`, `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', + chooseDelegate: 'Vertegenwoordiger kiezen', + clearAfterRecommended: 'Wissen na (aanbevolen)', + willClearOn: (date: string) => `De vertegenwoordiger tijdens vakantie wordt gewist op ${date}`, + until: (date: string) => `Tot ${date}`, + removeDelegate: 'Vertegenwoordiger tijdens vakantie verwijderen', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4026,6 +4033,10 @@ ${amount} voor ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Verzoek al ingediend', unlockAlreadyRequestedDescription: 'Je verzoek om deze bankrekening te deblokkeren is al verzonden. Concierge neemt contact met je op als er nog iets anders nodig is.', }, + listField: { + editItem: ({name}: {name: string}) => `${name} bewerken`, + removeItem: ({name}: {name: string}) => `${name} verwijderen`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Voorbeeld: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4037,7 +4048,13 @@ ${amount} voor ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Ongeldige indeling. Voorbeeld: ${example}` : 'Ongeldige indeling'), invalidOption: 'Kies een van de beschikbare opties', outOfRange: ({min, max}: {min: number; max: number}) => `Voer een waarde in tussen ${min} en ${max}`, + tooFewItems: ({min}: {min: number}) => `Voeg er minimaal ${min} toe`, + tooManyItems: ({max}: {max: number}) => `Voeg er maximaal ${max} toe`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `${item} toevoegen`, + removeItemTitle: ({item}: {item: string}) => `${item} verwijderen?`, + removeItemPrompt: ({name}: {name: string}) => `Weet je zeker dat je ${name} wilt verwijderen?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC moet 8 of 11 tekens lang zijn, met 6 letters gevolgd door 2 of 5 letters of cijfers.', @@ -5070,7 +5087,10 @@ ${amount} voor ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nog geen dagvergoedingen', - subtitle: 'Deze workspace heeft geen dagvergoedingen, vraag je admin om er een paar toe te voegen.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.` + : 'Deze workspace heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.', adminSubtitle: 'Voeg dagvergoedingen toe om je uitgaven te organiseren.', }, importPerDiemRates: 'Daggeldtarieven importeren', @@ -6596,6 +6616,8 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ distanceRates: { title: 'Kilometertarieven', subtitle: 'Tarieven toevoegen, bijwerken en afdwingen.', + commuterExclusionsOnTitle: 'Woon-werkuitzonderingen zijn ingeschakeld', + disableLockedByCommuterExclusionsPrompt: 'Om afstandstarieven uit te schakelen, stel je eerst Woon-werkverkeer uitsluiten in op ‘Woon-werkverkeer niet uitsluiten’.', }, perDiem: { title: 'Dagvergoeding', @@ -7825,6 +7847,21 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, addressContext: 'Een werkruimte-adres is vereist om Expensify Travel in te schakelen. Voer een adres in dat bij uw bedrijf hoort.', policy: 'Declaratiebeleid', }, + officeLocations: { + title: 'Kantoorlokaties', + subtitle: 'Voeg kantoorlocaties toe om aan leden toe te wijzen voor zaken als afstandsregistratie.', + addOfficeLocation: 'Kantoorlokatie toevoegen', + editOfficeLocation: 'Kantoorlocatie bewerken', + locationName: 'Locatienaam', + locationNameHint: 'Kies een bijnaam voor de locatie als je meerdere kantoorlocaties toevoegt', + defaultName: ({officeNumber}: {officeNumber: number}) => `Kantoorlocatie ${officeNumber}`, + setAsPrimary: 'Als primair instellen', + primary: 'Primair', + deleteOfficeLocation: 'Kantoorlocatie verwijderen?', + deleteOfficeLocationConfirmation: 'Wil je deze kantoorlocatie echt verwijderen? Leden worden opnieuw toegewezen aan het hoofdkantoor.', + existingOfficeLocationError: 'Er bestaat al een kantoorlocatie met deze naam', + setCompanyAddressAsPrimaryConfirmation: 'Het bedrijfsadres instellen als hoofdvestiging?', + }, bankAccount: { continueWithSetup: 'Setup voortzetten', youAreAlmostDone: @@ -8939,7 +8976,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, toggleTitle: 'Kantoren', allSelected: 'Alle kantoren', }, - enableJobStagesOrTags: 'Schakel functiestadia of labels in om door te gaan', + enableJobStagesOrTags: 'Selecteer minstens één functiestap of tag om door te gaan', }, subtitle: 'Koppel wervingstools en houd kandidaategoedkeuringen gesynchroniseerd.', syncResults: { @@ -9635,6 +9672,15 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft ‘${prohibitedExpense}’ toegevoegd aan verboden uitgaven`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft „${prohibitedExpense}” verwijderd uit verboden uitgaven`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `heeft de kantoorlocatie "${name}" (${address}) toegevoegd`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `heeft de kantoorlokatie “${oldName}” gewijzigd in “${newName}”`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `heeft het adres van kantoorlocatie "${name}" gewijzigd naar "${newAddress}" (voorheen "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `stel de kantoorlocatie "${name}" in als primair`, + setCompanyAddressAsPrimary: 'stel het bedrijfsadres in als primair', + removed: ({name}: {name: string}) => `heeft de kantoorlocatie "${name}" verwijderd`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `stel de standaard werkregeling in op ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9913,6 +9959,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, fallbackTitle: 'Supportticket', resolved: 'Dit supportticket is opgelost.', surveyPrompt: 'Hoe was je ervaring met de ondersteuning?', + great: 'Geweldig', + poor: 'Slecht', reopenTicket: 'Ticket opnieuw openen', }, statementPage: { @@ -10134,6 +10182,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporteren', }, filterType: {label: 'Filtertype', has: {positive: 'heeft', negative: 'heeft niet'}, is: {positive: 'is', negative: 'is niet'}}, + merchant: { + matchType: 'Overeenkomsttype', + contains: (merchant?: string) => (merchant ? `Bevat "${merchant}"` : 'Bevat'), + equalTo: (merchant?: string) => (merchant ? `Gelijk aan "${merchant}"` : 'Gelijk aan'), + }, created: 'Aangemaakt', createdDate: 'Aanmaakdatum', transactionStatus: { @@ -11112,6 +11165,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, systemMessage: { mergedWithCashTransaction: 'heeft een bonnetje aan deze transactie gekoppeld', + mergedExpenseDeleted: 'heeft uitgaven samengevoegd en er één naar Verwijderd verplaatst', }, subscription: { authenticatePaymentCard: 'Betalingskaart verifiëren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 829cfa260527..95e7b90a2f26 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -534,6 +534,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Nie udało się załadować wykresu. Odśwież stronę i spróbuj ponownie.', apiKey: 'Klucz API', exportsTo: 'Eksporty do', + shareReport: 'Udostępnij raport', + reportShared: 'Udostępniono raport', }, socials: { podcast: 'Śledź nas na Podcast', @@ -3966,6 +3968,11 @@ ${amount} dla ${merchant} - ${date}`, `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', + chooseDelegate: 'Wybierz zastępcę', + clearAfterRecommended: 'Wyczyść po (zalecane)', + willClearOn: (date: string) => `Zastępca urlopowy zostanie usunięty ${date}`, + until: (date: string) => `Do ${date}`, + removeDelegate: 'Usuń zastępcę urlopowego', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4056,6 +4063,10 @@ ${amount} dla ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Wniosek został już złożony', unlockAlreadyRequestedDescription: 'Twoja prośba o odblokowanie tego konta bankowego została już wysłana. Concierge skontaktuje się z tobą, jeśli będzie potrzebne coś jeszcze.', }, + listField: { + editItem: ({name}: {name: string}) => `Edytuj ${name}`, + removeItem: ({name}: {name: string}) => `Usuń ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Przykład: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4069,7 +4080,13 @@ ${amount} dla ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Nieprawidłowy format. Przykład: ${example}` : 'Nieprawidłowy format'), invalidOption: 'Wybierz jedną z dostępnych opcji', outOfRange: ({min, max}: {min: number; max: number}) => `Wprowadź wartość od ${min} do ${max}`, + tooFewItems: ({min}: {min: number}) => `Dodaj co najmniej ${min}`, + tooManyItems: ({max}: {max: number}) => `Dodaj maksymalnie ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Dodaj ${item}`, + removeItemTitle: ({item}: {item: string}) => `Usunąć ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Czy na pewno chcesz usunąć ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Kod SWIFT/BIC musi mieć długość 8 lub 11 znaków, z 6 literami, po których następują 2 lub 5 liter lub cyfr.', @@ -5102,7 +5119,10 @@ ${amount} dla ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nie ma jeszcze stawek diet', - subtitle: 'W tym obszarze roboczym nie ma żadnych stawek diet, poproś swojego administratora o dodanie kilku.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.` + : 'Ten obszar roboczy nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.', adminSubtitle: 'Dodaj stawki diet, żeby uporządkować swoje wydatki.', }, importPerDiemRates: 'Zaimportuj stawki diet', @@ -6620,6 +6640,8 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy distanceRates: { title: 'Stawki za dystans', subtitle: 'Dodawaj, aktualizuj i egzekwuj stawki.', + commuterExclusionsOnTitle: 'Wykluczenia dojazdów są włączone', + disableLockedByCommuterExclusionsPrompt: 'Aby wyłączyć stawki za dystans, najpierw ustaw Wykluczanie dojazdów na „Nie wykluczaj dojazdów”.', }, perDiem: { title: 'Dieta', @@ -7849,6 +7871,21 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, addressContext: 'Adres miejsca pracy jest wymagany, aby włączyć Expensify Travel. Wprowadź adres powiązany z Twoją firmą.', policy: 'Polityka wydatków', }, + officeLocations: { + title: 'Lokalizacje biur', + subtitle: 'Dodaj lokalizacje biur, aby przypisywać je członkom do rzeczy takich jak śledzenie dystansu.', + addOfficeLocation: 'Dodaj lokalizację biura', + editOfficeLocation: 'Edytuj lokalizację biura', + locationName: 'Nazwa lokalizacji', + locationNameHint: 'Wybierz nazwę lokalizacji, jeśli dodajesz wiele biur', + defaultName: ({officeNumber}: {officeNumber: number}) => `Lokalizacja biura ${officeNumber}`, + setAsPrimary: 'Ustaw jako główny', + primary: 'Główny', + deleteOfficeLocation: 'Usunąć lokalizację biura?', + deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura? Członkowie zostaną przypisani do głównego biura.', + existingOfficeLocationError: 'Lokalizacja biura o tej nazwie już istnieje', + setCompanyAddressAsPrimaryConfirmation: 'Ustawić adres firmy jako główne biuro?', + }, bankAccount: { continueWithSetup: 'Kontynuuj konfigurację', youAreAlmostDone: 'Prawie zakończyłeś konfigurację konta bankowego, które pozwoli Ci wydawać firmowe karty, zwracać wydatki, pobierać faktury i opłacać rachunki.', @@ -8955,7 +8992,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, allSelected: 'Wszystkie tagi', }, offices: {title: 'Biuro', description: 'Wybierz biura kandydatów, które chcesz zsynchronizować z tym miejscem pracy', toggleTitle: 'Biura', allSelected: 'Wszystkie biura'}, - enableJobStagesOrTags: 'Włącz etapy zleceń lub tagi, aby kontynuować', + enableJobStagesOrTags: 'Wybierz co najmniej jeden etap zadania lub znacznik, aby kontynuować', }, subtitle: 'Połącz narzędzia rekrutacyjne i utrzymuj zgody kandydatów w synchronizacji.', syncResults: { @@ -9647,6 +9684,15 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `dodano „${prohibitedExpense}” do zabronionych wydatków`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `usunięto „${prohibitedExpense}” z wydatków zabronionych`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `dodał(a) lokalizację biura „${name}” (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `zmienił(a) nazwę lokalizacji biura z „${oldName}” na „${newName}”`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `zmienił(a) adres lokalizacji biura „${name}” na „${newAddress}” (wcześniej „${oldAddress}”)`, + setAsPrimary: ({name}: {name: string}) => `ustaw lokalizację biura „${name}” jako główną`, + setCompanyAddressAsPrimary: 'ustaw firmowy adres jako główny', + removed: ({name}: {name: string}) => `usunięto lokalizację biura „${name}”`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ustaw domyślny tryb pracy na ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9924,6 +9970,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, fallbackTitle: 'Zgłoszenie do pomocy technicznej', resolved: 'To zgłoszenie do pomocy technicznej zostało rozwiązane.', surveyPrompt: 'Jak oceniasz swoje doświadczenie ze wsparciem?', + great: 'Świetne', + poor: 'Słabe', reopenTicket: 'Otwórz zgłoszenie ponownie', }, statementPage: { @@ -10151,6 +10199,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Eksportuj', }, filterType: {label: 'Typ filtra', has: {positive: 'ma', negative: 'nie ma'}, is: {positive: 'jest', negative: 'nie jest'}}, + merchant: { + matchType: 'Typ dopasowania', + contains: (merchant?: string) => (merchant ? `Zawiera "${merchant}"` : 'Zawiera'), + equalTo: (merchant?: string) => (merchant ? `Równe "${merchant}"` : 'Równe'), + }, created: 'Utworzono', createdDate: 'Data utworzenia', transactionStatus: { @@ -11126,6 +11179,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, systemMessage: { mergedWithCashTransaction: 'dopasowano paragon do tej transakcji', + mergedExpenseDeleted: 'scalono wydatki i przeniesiono jeden do Usuniętych', }, subscription: { authenticatePaymentCard: 'Uwierzytelnij kartę płatniczą', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c48f53b822ab..0a6753623215 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -530,6 +530,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Não foi possível carregar o gráfico. Atualize a página e tente novamente.', apiKey: 'Chave de API', exportsTo: 'Exportações para', + shareReport: 'Compartilhar relatório', + reportShared: 'Relatório compartilhado', }, socials: { podcast: 'Siga-nos no Podcast', @@ -3923,6 +3925,11 @@ ${amount} para ${merchant} - ${date}`, `${delegate} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, youAreAMemberOf: 'Você é membro destes workspaces:', youAreAnAdminOf: 'Você é admin destes workspaces:', + chooseDelegate: 'Escolher delegado', + clearAfterRecommended: 'Limpar após (recomendado)', + willClearOn: (date: string) => `O delegado de férias será removido em ${date}`, + until: (date: string) => `Até ${date}`, + removeDelegate: 'Remover delegado de férias', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4013,6 +4020,10 @@ ${amount} para ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Solicitação já enviada', unlockAlreadyRequestedDescription: 'Sua solicitação para desbloquear esta conta bancária já foi enviada. O Concierge vai entrar em contato se for necessário mais alguma coisa.', }, + listField: { + editItem: ({name}: {name: string}) => `Editar ${name}`, + removeItem: ({name}: {name: string}) => `Remover ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Exemplo: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4024,7 +4035,13 @@ ${amount} para ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato inválido. Exemplo: ${example}` : 'Formato inválido'), invalidOption: 'Escolha uma das opções disponíveis', outOfRange: ({min, max}: {min: number; max: number}) => `Insira um valor entre ${min} e ${max}`, + tooFewItems: ({min}: {min: number}) => `Adicione pelo menos ${min}`, + tooManyItems: ({max}: {max: number}) => `Adicione no máximo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Adicionar ${item}`, + removeItemTitle: ({item}: {item: string}) => `Remover ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Tem certeza de que deseja remover ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'O SWIFT/BIC deve ter 8 ou 11 caracteres, com 6 letras seguidas de 2 ou 5 letras ou números.', @@ -5062,7 +5079,10 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ainda não há diárias', - subtitle: 'Este espaço de trabalho não tem nenhuma diária definida, peça para o seu administrador adicionar algumas.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.` + : 'Este espaço de trabalho não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.', adminSubtitle: 'Adicione diárias para organizar seus gastos.', }, importPerDiemRates: 'Importar tarifas de diária', @@ -6594,6 +6614,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS distanceRates: { title: 'Taxas de distância', subtitle: 'Adicione, atualize e aplique tarifas.', + commuterExclusionsOnTitle: 'Exclusões de deslocamento estão ativadas', + disableLockedByCommuterExclusionsPrompt: 'Para desativar as tarifas de distância, primeiro defina Excluir deslocamentos como “Não excluir deslocamentos”.', }, perDiem: { title: 'Diária', @@ -7830,6 +7852,21 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, addressContext: 'É necessário um Endereço do Espaço de Trabalho para ativar o Expensify Travel. Insira um endereço associado à sua empresa.', policy: 'Política de despesas', }, + officeLocations: { + title: 'Locais do escritório', + subtitle: 'Adicione locais de escritório para atribuir aos membros, por exemplo, para rastreamento de distância.', + addOfficeLocation: 'Adicionar local do escritório', + editOfficeLocation: 'Editar localização do escritório', + locationName: 'Nome do local', + locationNameHint: 'Escolha um apelido para o local se você estiver adicionando vários escritórios', + defaultName: ({officeNumber}: {officeNumber: number}) => `Local do escritório ${officeNumber}`, + setAsPrimary: 'Definir como principal', + primary: 'Principal', + deleteOfficeLocation: 'Excluir local do escritório?', + deleteOfficeLocationConfirmation: 'Você realmente quer excluir este escritório? Os membros serão realocados para o escritório principal.', + existingOfficeLocationError: 'Já existe um local de escritório com esse nome', + setCompanyAddressAsPrimaryConfirmation: 'Definir o endereço da empresa como escritório principal?', + }, bankAccount: { continueWithSetup: 'Continuar configuração', youAreAlmostDone: @@ -8942,7 +8979,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, toggleTitle: 'Escritórios', allSelected: 'Todos os escritórios', }, - enableJobStagesOrTags: 'Ative Estágios de Trabalho ou Tags para continuar', + enableJobStagesOrTags: 'Selecione pelo menos um estágio de trabalho ou etiqueta para continuar', }, subtitle: 'Conecte ferramentas de recrutamento e mantenha as aprovações de candidatos em sincronia.', syncResults: { @@ -9633,6 +9670,15 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `adicionou "${prohibitedExpense}" às despesas proibidas`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removeu "${prohibitedExpense}" das despesas proibidas`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `adicionou a localização do escritório "${name}" (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `renomeou o local do escritório de "${oldName}" para "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `alterou o endereço da localização do escritório "${name}" para "${newAddress}" (anteriormente "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `definir o local de escritório "${name}" como principal`, + setCompanyAddressAsPrimary: 'definir o endereço da empresa como principal', + removed: ({name}: {name: string}) => `removeu o local do escritório "${name}"`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `definir o arranjo de trabalho padrão como ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9911,6 +9957,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, fallbackTitle: 'Chamado de suporte', resolved: 'Este chamado de suporte foi resolvido.', surveyPrompt: 'Como foi sua experiência com o suporte?', + great: 'Ótima', + poor: 'Ruim', reopenTicket: 'Reabrir chamado', }, statementPage: { @@ -10132,6 +10180,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exportar', }, filterType: {label: 'Tipo de filtro', has: {positive: 'tem', negative: 'não tem'}, is: {positive: 'é', negative: 'não é'}}, + merchant: { + matchType: 'Tipo de correspondência', + contains: (merchant?: string) => (merchant ? `Contém "${merchant}"` : 'Contém'), + equalTo: (merchant?: string) => (merchant ? `Igual a "${merchant}"` : 'Igual a'), + }, created: 'Criado', createdDate: 'Data de criação', transactionStatus: { @@ -11107,6 +11160,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, systemMessage: { mergedWithCashTransaction: 'correspondeu um recibo a esta transação', + mergedExpenseDeleted: 'mesclou despesas e moveu uma para Excluídas', }, subscription: { authenticatePaymentCard: 'Autenticar cartão de pagamento', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80c14fa5170f..3b4ae9ffc316 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -526,6 +526,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: '无法加载图表。请刷新页面后重试。', apiKey: 'API 密钥', exportsTo: '导出到', + shareReport: '分享报表', + reportShared: '报表已分享', }, socials: { podcast: '在播客上关注我们', @@ -3812,6 +3814,11 @@ ${amount},商户:${merchant} - 日期:${date}`, `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', + chooseDelegate: '选择代理人', + clearAfterRecommended: '清除日期(推荐)', + willClearOn: (date: string) => `休假代理将于 ${date} 清除`, + until: (date: string) => `直到 ${date}`, + removeDelegate: '移除休假代理', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -3900,6 +3907,10 @@ ${amount},商户:${merchant} - 日期:${date}`, unlockAlreadyRequestedTitle: '请求已提交', unlockAlreadyRequestedDescription: '您解锁此银行账户的请求已发送。如需其他信息,Concierge 会与您联系。', }, + listField: { + editItem: ({name}: {name: string}) => `编辑${name}`, + removeItem: ({name}: {name: string}) => `移除${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `示例:${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3911,7 +3922,13 @@ ${amount},商户:${merchant} - 日期:${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `格式无效。示例:${example}` : '格式无效'), invalidOption: '请从可用选项中选择', outOfRange: ({min, max}: {min: number; max: number}) => `请输入 ${min} 到 ${max} 之间的值`, + tooFewItems: ({min}: {min: number}) => `至少添加 ${min} 项`, + tooManyItems: ({max}: {max: number}) => `最多添加 ${max} 项`, + itemError: ({item, error}: {item: string; error: string}) => `${item}:${error}`, }, + addItem: ({item}: {item: string}) => `添加${item}`, + removeItemTitle: ({item}: {item: string}) => `移除${item}?`, + removeItemPrompt: ({name}: {name: string}) => `确定要移除${name}吗?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC 必须为 8 或 11 个字符长度,由 6 个字母后接 2 或 5 个字母或数字组成。', @@ -4917,7 +4934,12 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '每日津贴', subtitle: '设置每日津贴标准以控制员工每日支出。可从电子表格导入标准以开始使用。', }, - requestEmptyList: {title: '尚未设置每日津贴费率', subtitle: '此工作区尚未设置任何每日津贴费率,请联系管理员添加。', adminSubtitle: '添加每日津贴标准,以整理您的支出。'}, + requestEmptyList: { + title: '尚未设置每日津贴费率', + subtitle: (workspaceName?: string) => + workspaceName ? `${workspaceName} 没有任何每日津贴标准。请联系管理员添加一些。` : '此工作区没有任何每日津贴标准。请联系管理员添加一些。', + adminSubtitle: '添加每日津贴标准,以整理您的支出。', + }, importPerDiemRates: '导入每日津贴费率', editPerDiemRate: '编辑每日补贴费率', editPerDiemRates: '编辑每日津贴费率', @@ -6379,6 +6401,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM distanceRates: { title: '距离费率', subtitle: '添加、更新并执行费率。', + commuterExclusionsOnTitle: '通勤排除已开启', + disableLockedByCommuterExclusionsPrompt: '若要关闭距离费率,首先将“排除通勤”设置为“不要排除通勤”。', }, perDiem: { title: '每日津贴', @@ -7557,6 +7581,21 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM addressContext: '要启用 Expensify Travel,需要填写一个工作区地址。请输入与您企业关联的地址。', policy: '报销政策', }, + officeLocations: { + title: '办公地点', + subtitle: '添加办公地点,以便为成员分配用于里程跟踪等用途。', + addOfficeLocation: '添加办公地点', + editOfficeLocation: '编辑办公室位置', + locationName: '位置名称', + locationNameHint: '如果你要添加多个办公地点,请为每个地点选择一个昵称', + defaultName: ({officeNumber}: {officeNumber: number}) => `办公室位置 ${officeNumber}`, + setAsPrimary: '设为主要', + primary: '主要', + deleteOfficeLocation: '删除办公地点?', + deleteOfficeLocationConfirmation: '你确定要删除此办公地点吗?成员将被重新分配到主办公地点。', + existingOfficeLocationError: '已存在同名的办公地点', + setCompanyAddressAsPrimaryConfirmation: '将公司地址设为主要办公室?', + }, bankAccount: { continueWithSetup: '继续设置', youAreAlmostDone: '您即将完成企业银行账户的设置,这将使您能够发放公司卡、报销费用、收取发票和支付账单。', @@ -8608,7 +8647,7 @@ ${reportName}`, }, tags: {title: '标签', description: '选择要与此工作区同步的候选人标签', toggleTitle: '标签', allSelected: '所有标签'}, offices: {title: '办公室', description: '选择你想与此工作区同步的候选人办公地点', toggleTitle: '办公室', allSelected: '所有办公室'}, - enableJobStagesOrTags: '启用职位阶段或标签以继续', + enableJobStagesOrTags: '请选择至少一个工作阶段或标签以继续', }, subtitle: '连接招聘工具并保持候选人审批同步。', syncResults: { @@ -9276,6 +9315,15 @@ ${reportName}`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已将“${prohibitedExpense}”添加到禁止报销的费用中`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已从禁用报销类别中移除“${prohibitedExpense}”`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `已添加办公地点“${name}”(${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `已将办公地点名称从“${oldName}”重命名为“${newName}”`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `已将办公地点“${name}”的地址更改为“${newAddress}”(原为“${oldAddress}”)`, + setAsPrimary: ({name}: {name: string}) => `将办公地点“${name}”设为主要地点`, + setCompanyAddressAsPrimary: '将公司地址设为主要地址', + removed: ({name}: {name: string}) => `已移除办公室地点“${name}”`, + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `将默认工作安排设置为 ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => `将默认工作安排更改为 ${arrangement}(之前为 ${previousArrangement})`, @@ -9543,6 +9591,8 @@ ${reportName}`, fallbackTitle: '支持工单', resolved: '此支持工单已解决。', surveyPrompt: '您对支持服务的体验如何?', + great: '很好', + poor: '很差', reopenTicket: '重新打开工单', }, statementPage: { @@ -9754,6 +9804,11 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: '导出', }, filterType: {label: '筛选类型', has: {positive: '有', negative: '没有'}, is: {positive: '是', negative: '不是'}}, + merchant: { + matchType: '匹配类型', + contains: (merchant?: string) => (merchant ? `包含 "${merchant}"` : '包含'), + equalTo: (merchant?: string) => (merchant ? `等于 "${merchant}"` : '等于'), + }, created: '已创建', createdDate: '创建日期', transactionStatus: { @@ -10698,6 +10753,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: '已将一张收据匹配到此交易', + mergedExpenseDeleted: '已合并报销并将其中一笔移至已删除', }, subscription: { authenticatePaymentCard: '验证支付卡', diff --git a/src/libs/API/parameters/AddOfficeLocationParams.ts b/src/libs/API/parameters/AddOfficeLocationParams.ts new file mode 100644 index 000000000000..d436044b5fbd --- /dev/null +++ b/src/libs/API/parameters/AddOfficeLocationParams.ts @@ -0,0 +1,17 @@ +type AddOfficeLocationParams = { + policyID: string; + + /** Client-generated ID of the new office */ + officeID: string; + + /** JSON-encoded address with addressStreet, city, state, zipCode and country, the same shape as the workspace address */ + address: string; + + /** Nickname of the office. Offices added without one are named "Office location N" */ + officeName?: string; + + /** Whether the office becomes the workspace default */ + isDefault?: boolean; +}; + +export default AddOfficeLocationParams; diff --git a/src/libs/API/parameters/DeleteOfficeLocationParams.ts b/src/libs/API/parameters/DeleteOfficeLocationParams.ts new file mode 100644 index 000000000000..2e825bc8d5ae --- /dev/null +++ b/src/libs/API/parameters/DeleteOfficeLocationParams.ts @@ -0,0 +1,6 @@ +type DeleteOfficeLocationParams = { + policyID: string; + officeID: string; +}; + +export default DeleteOfficeLocationParams; diff --git a/src/libs/API/parameters/SetVacationDelegateParams.ts b/src/libs/API/parameters/SetVacationDelegateParams.ts index d149f370d5c5..eb5abb05c555 100644 --- a/src/libs/API/parameters/SetVacationDelegateParams.ts +++ b/src/libs/API/parameters/SetVacationDelegateParams.ts @@ -2,6 +2,9 @@ type SetVacationDelegateParams = { creator: string; vacationerEmail?: string; vacationDelegateEmail: string; + + /** UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the vacation delegate */ + clearAfter?: string; overridePolicyDiffWarning: boolean; domainAccountID?: number; }; diff --git a/src/libs/API/parameters/ShareReportParams.ts b/src/libs/API/parameters/ShareReportParams.ts new file mode 100644 index 000000000000..33338659745c --- /dev/null +++ b/src/libs/API/parameters/ShareReportParams.ts @@ -0,0 +1,7 @@ +type ShareReportParams = { + reportID: string; + shareToEmail: string; + permissions: string; +}; + +export default ShareReportParams; diff --git a/src/libs/API/parameters/UpdateOfficeLocationParams.ts b/src/libs/API/parameters/UpdateOfficeLocationParams.ts new file mode 100644 index 000000000000..f0668f7b8ec4 --- /dev/null +++ b/src/libs/API/parameters/UpdateOfficeLocationParams.ts @@ -0,0 +1,15 @@ +type UpdateOfficeLocationParams = { + policyID: string; + officeID: string; + + /** New nickname of the office */ + officeName?: string; + + /** JSON-encoded address with addressStreet, city, state, zipCode and country, the same shape as the workspace address */ + address?: string; + + /** Makes the office the workspace default */ + isDefault?: boolean; +}; + +export default UpdateOfficeLocationParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ef78d3086b6e..ac0fe97aa346 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -597,6 +597,7 @@ export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesPara export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; export type {default as ExportReportPDFParams} from './ExportReportPDFParams'; export type {default as ExportReportsToPDFParams} from './ExportReportsToPDFParams'; +export type {default as ShareReportParams} from './ShareReportParams'; export type {default as ExportReceiptsToZipParams} from './ExportReceiptsToZipParams'; export type {default as UpdateWorkspaceCustomUnitParams} from './UpdateWorkspaceCustomUnitParams'; export type {default as DismissProductTrainingParams} from './DismissProductTraining'; @@ -626,6 +627,9 @@ export type {default as SetPolicyCommuterExclusionsParams} from './SetPolicyComm export type {default as SetPolicyWorkArrangementParams} from './SetPolicyWorkArrangementParams'; export type {default as SetEmployeeWorkArrangementParams} from './SetEmployeeWorkArrangementParams'; export type {default as DisablePolicyCommuterExclusionsParams} from './DisablePolicyCommuterExclusionsParams'; +export type {default as AddOfficeLocationParams} from './AddOfficeLocationParams'; +export type {default as UpdateOfficeLocationParams} from './UpdateOfficeLocationParams'; +export type {default as DeleteOfficeLocationParams} from './DeleteOfficeLocationParams'; export type {default as SetPolicyRequireMapOrGPSParams} from './SetPolicyRequireMapOrGPSParams'; export type {default as CreateDigitalWalletParams} from './CreateDigitalWalletParams'; export type {default as GetGuideCallAvailabilityScheduleParams} from './GetGuideCallAvailabilitySchedule'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bbf7ebdb96e1..e8ba37f9c9fb 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -191,6 +191,7 @@ const WRITE_COMMANDS = { MOVE_IOU_REPORT_TO_EXISTING_POLICY: 'MoveIOUReportToExistingPolicy', EXPORT_REPORT_TO_PDF: 'ExportReportToPDF', EXPORT_REPORTS_TO_PDF: 'ExportReportsToPDF', + SHARE_REPORT: 'ShareReport', EXPORT_RECEIPTS_TO_ZIP: 'ExportReceiptsToZip', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', @@ -303,6 +304,9 @@ const WRITE_COMMANDS = { SET_POLICY_WORK_ARRANGEMENT: 'SetPolicyWorkArrangement', SET_EMPLOYEE_WORK_ARRANGEMENT: 'SetEmployeeWorkArrangement', DISABLE_POLICY_COMMUTER_EXCLUSIONS: 'DisablePolicyCommuterExclusions', + ADD_OFFICE_LOCATION: 'AddOfficeLocation', + UPDATE_OFFICE_LOCATION: 'UpdateOfficeLocation', + DELETE_OFFICE_LOCATION: 'DeleteOfficeLocation', SET_POLICY_REQUIRE_MAP_OR_GPS: 'SetPolicyRequireMapOrGPS', SET_POLICY_EXPENSE_MAX_AGE: 'SetPolicyExpenseMaxAge', ADD_POLICY_AGENT_RULE: 'AddPolicyAgentRule', @@ -959,6 +963,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.EXPORT_PER_DIEM_CSV]: Parameters.ExportPerDiemCSVParams; [WRITE_COMMANDS.EXPORT_REPORT_TO_PDF]: Parameters.ExportReportPDFParams; [WRITE_COMMANDS.EXPORT_REPORTS_TO_PDF]: Parameters.ExportReportsToPDFParams; + [WRITE_COMMANDS.SHARE_REPORT]: Parameters.ShareReportParams; [WRITE_COMMANDS.EXPORT_RECEIPTS_TO_ZIP]: Parameters.ExportReceiptsToZipParams; [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; @@ -1237,6 +1242,9 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_WORK_ARRANGEMENT]: Parameters.SetPolicyWorkArrangementParams; [WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT]: Parameters.SetEmployeeWorkArrangementParams; [WRITE_COMMANDS.DISABLE_POLICY_COMMUTER_EXCLUSIONS]: Parameters.DisablePolicyCommuterExclusionsParams; + [WRITE_COMMANDS.ADD_OFFICE_LOCATION]: Parameters.AddOfficeLocationParams; + [WRITE_COMMANDS.UPDATE_OFFICE_LOCATION]: Parameters.UpdateOfficeLocationParams; + [WRITE_COMMANDS.DELETE_OFFICE_LOCATION]: Parameters.DeleteOfficeLocationParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_MAP_OR_GPS]: Parameters.SetPolicyRequireMapOrGPSParams; [WRITE_COMMANDS.RETRACT_REPORT]: Parameters.RetractReportParams; [WRITE_COMMANDS.FINISH_CORPAY_BANK_ACCOUNT_ONBOARDING]: Parameters.FinishCorpayBankAccountOnboardingParams; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 9f9639231ea1..a76717e108dc 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1095,7 +1095,7 @@ const formatInTimeZoneWithFallback: typeof formatInTimeZone = (date, timeZone, f * @param timeZone - Target timezone to display the date in * @returns Date string in yyyy-MM-dd format, or empty string if invalid */ -function formatUTCDateTimeToDateInTimezone(utcDateTime: string, timeZone: SelectedTimezone | undefined, formatStr = CONST.DATE.FNS_FORMAT_STRING): string { +function formatUTCDateTimeToDateInTimezone(utcDateTime: string, timeZone: SelectedTimezone | undefined, formatStr: string = CONST.DATE.FNS_FORMAT_STRING): string { if (!utcDateTime || !timeZone) { return ''; } @@ -1196,7 +1196,7 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b return datePart >= monthStart && datePart <= monthEnd; } -/** Returns a compact day label, e.g. "Sep 15, ’26". */ +/** Returns a compact date label, e.g. "Sep 15, ’26", or "Sep 15" when the year is left out. */ function getShortFormattedDayForSearch(day: string, dateFnsLocale: DateFnsLocale | undefined, shouldShowYear = true): string { return format(parse(day, 'yyyy-MM-dd', new Date()), shouldShowYear ? 'MMM d, ’yy' : 'MMM d', {locale: dateFnsLocale}); } @@ -1367,6 +1367,7 @@ const DateUtils = { formatViolationSnapshotStartedAtDate, normalizeDateToStartOfDay, normalizeDateToEndOfDay, + formatDBTimeWithoutMilliseconds, getMonthDateRange, getWeekDateRange, isDateStringInMonth, diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 71e5804d8e5a..19414a189282 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -25,6 +25,8 @@ const onyxKeysToRemove = new Set | ValueOf([ ONYXKEYS.ONYX_UPDATES_FROM_SERVER, ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, ONYXKEYS.PENDING_CONTACT_ACTION, + ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, ONYXKEYS.PERSISTED_ONGOING_REQUESTS, ONYXKEYS.PERSISTED_REQUESTS, ONYXKEYS.PERSONAL_BANK_ACCOUNT, diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index d4ad52b448b9..254a41a14e44 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -84,12 +84,14 @@ function processHTTPRequest( method: RequestType = 'get', body: FormData | null = null, abortSignal: AbortSignal | undefined = undefined, + shouldSkipPrefetch = false, ): Promise> { const startTime = new Date().valueOf(); const command = url.match(APICommandRegex)?.[1]; - const {prefetchKey, prefetchHeaders} = preparePrefetchRequest(command); + const {prefetchKey, prefetchHeaders} = shouldSkipPrefetch ? {prefetchKey: undefined, prefetchHeaders: undefined} : preparePrefetchRequest(command); + let isPrefetchedResponse = false; const fetchParams: NonNullable[1]> = { // We hook requests to the same Controller signal, so we can cancel them all at once @@ -125,6 +127,7 @@ function processHTTPRequest( } if (response.headers) { setLoadTestParameters(response.headers.get('X-Load-Test')); + isPrefetchedResponse = response.headers.get('nitroPrefetched') === 'true'; } // We are calculating the skew to minimize the delay when posting the messages @@ -240,6 +243,13 @@ function processHTTPRequest( alert('Too many auth writes', message); } if (response.jsonCode === CONST.JSON_CODE.UPDATE_REQUIRED) { + // A prefetched response can come from a body saved by the previous build, so it carries that build's appversion. + // https://github.com/Expensify/App/pull/103842 leaves appversion out of saved bodies, but bodies saved by older builds still have it. + // Retry without the prefetchKey, because nitro-fetch can serve the same cached response again for a few seconds. + if (isPrefetchedResponse) { + return processHTTPRequest(url, method, body, abortSignal, true); + } + // Trigger a modal and disable the app as the user needs to upgrade to the latest minimum version to continue alertUser(); } diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index fbad088f740a..9456ddbcdc63 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -232,19 +232,16 @@ function getMergeableDataAndConflictFields( searchReports: Array> = [], targetTransactionPolicy?: OnyxEntry, sourceTransactionPolicy?: OnyxEntry, - targetReportOwnerAsAttendee?: Attendee, - sourceReportOwnerAsAttendee?: Attendee, ) { const conflictFields: string[] = []; const mergeableData: Record = {}; // The same object, typed for the field builders that read the values merged so far const mergedSoFar = mergeableData as MergeTransaction; - // Resolve the report-owner fallback the same way the display path (buildMergeFieldsData) does, so an expense - // with no stored attendee is compared as [owner] instead of [] and doesn't produce a false attendee conflict - // against an expense whose attendee is that same owner. - const targetTransactionDetails = getTransactionDetails(targetTransaction, undefined, undefined, undefined, undefined, targetReportOwnerAsAttendee); - const sourceTransactionDetails = getTransactionDetails(sourceTransaction, undefined, undefined, undefined, undefined, sourceReportOwnerAsAttendee); + // Attendees are compared as stored, without the report-owner display fallback. The owner can only be resolved for + // reports loaded locally, so applying it would give the two expenses different values when they sit on different reports. + const targetTransactionDetails = getTransactionDetails(targetTransaction); + const sourceTransactionDetails = getTransactionDetails(sourceTransaction); for (const field of getMergeFields(targetTransaction)) { const targetValue = getMergeFieldValue(targetTransactionDetails, targetTransaction, field); @@ -319,7 +316,8 @@ function getMergeableDataAndConflictFields( const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); - if (deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { + // An expense with no attendees has nothing to choose between, so the other side's attendees are kept + if (isTargetValueEmpty || isSourceValueEmpty || deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue; } else { conflictFields.push(field); diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 1a423540de20..fc5b2f96e884 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -1,5 +1,7 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import {reconnectApp} from '@userActions/App'; import * as OnyxUpdates from '@userActions/OnyxUpdates'; import CONST from '@src/CONST'; @@ -21,6 +23,15 @@ const requestsToIgnoreLastUpdateID = new Set([ SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES, ]); +// These requests already bring the client up to date (or end the session), so they never need a full ReconnectApp to cover a gap in update IDs. +// This is separate from requestsToIgnoreLastUpdateID because requests like DeleteMoneyRequest skip the OnyxUpdates flow but still move lastUpdateID forward. +const requestsExemptFromFullReconnect = new Set([ + WRITE_COMMANDS.OPEN_APP, + SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP, + SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES, + WRITE_COMMANDS.CLOSE_ACCOUNT, +]); + // A request belongs here when its successData/finallyData is what unblocks authentication, because parking that leaves the client unable to reauthenticate. const requestsToApplyWithoutAdvancingLastUpdateID = new Set([READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]); @@ -44,6 +55,31 @@ const SaveResponseInOnyx: Middleware = (requestResponse: P const shouldApplyWithoutAdvancingLastUpdateID = requestsToApplyWithoutAdvancingLastUpdateID.has(request.command); + // The server only keeps recent update IDs. When a client comes back after a long time, the server no longer knows its + // lastUpdateIDAppliedToClient and answers a request with a lastUpdateID but no previousUpdateID. Applying that response moves + // the client's lastUpdateID forward past updates it never received, so an incremental ReconnectApp can't recover them. + // Run a full ReconnectApp after applying so the client gets everything in the gap. + const clientLastUpdateID = OnyxUpdates.getPersistedLastUpdateID(); + const responseLastUpdateID = Number(response?.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID); + const shouldFullReconnectAfterApply = + !shouldApplyWithoutAdvancingLastUpdateID && + !requestsExemptFromFullReconnect.has(request.command) && + !!clientLastUpdateID && + responseLastUpdateID > clientLastUpdateID && + !Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID); + + if (shouldFullReconnectAfterApply) { + Log.info('[SaveResponseInOnyx] Response has a lastUpdateID but no previousUpdateID, running a full ReconnectApp after applying it', false, { + command: request.command, + clientLastUpdateID, + responseLastUpdateID, + }); + return OnyxUpdates.apply(responseToApply).then((appliedResponse) => { + reconnectApp(); + return appliedResponse; + }); + } + if ( shouldApplyWithoutAdvancingLastUpdateID || requestsToIgnoreLastUpdateID.has(request.command) || diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx index 50672780db45..a794924c3020 100644 --- a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx @@ -1,7 +1,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// On native, tab-level freezing is handled by the TabNavigator's `freezeOnBlur: true` option, -// which uses react-native-screens to natively freeze inactive tabs. +// On native, background tabs are not frozen: the native tab bar shows a tab before JS can unfreeze it, so a frozen +// tab would flash an empty screen on every switch. // On web, react-native-screens only sets `display: none` without actually freezing the React tree, // so the web FreezeWrapper uses `react-freeze` to suspend rendering of background tabs. function FreezeWrapper({children}: ChildrenProps) { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index edd39e8d49e7..201a11234ea6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -285,6 +285,7 @@ const ReportDetailsModalStackNavigator = createModalStackNavigator require('../../../../pages/DynamicReportDetailsPage').default, [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: () => require('../../../../pages/inbox/report/DynamicReportDetailsShareCodePage').default, [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: () => require('../../../../pages/inbox/report/DynamicReportDetailsExportPage').default, + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: () => require('../../../../pages/inbox/report/DynamicReportDetailsShareReportPage').default, }); const ReportCardActivateStackNavigator = createModalStackNavigator({ @@ -515,13 +516,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/UpdatePersonalBankAccountPage').default, [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: () => require('../../../../pages/settings/Wallet/BankAccountPurposePage').default, [SCREENS.SETTINGS.RULES.ROOT]: () => require('../../../../pages/settings/Rules/ExpenseRulesPage').default, - [SCREENS.SETTINGS.AGENTS.NEW]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/NewAgentPage').default), - [SCREENS.SETTINGS.AGENTS.ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/AddAgentPage').default), - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/AddAgentAvatarPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/EditAgentPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditNamePage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditPromptPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditAgentAvatarPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicNewAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicAddAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicAddAgentAvatarPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicEditAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditNamePage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditPromptPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditAgentAvatarPage').default), [SCREENS.SETTINGS.RULES.ADD]: () => require('../../../../pages/settings/Rules/AddRulePage').default, [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddMerchantPage').default, [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddRenameMerchantPage').default, @@ -546,7 +547,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default, - [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateFormPage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage').default, [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default, @@ -581,6 +583,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceInviteMessageApproverPage').default, [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPayerPage').default, [SCREENS.WORKSPACE.NAME]: () => require('../../../../pages/workspace/WorkspaceNamePage').default, + [SCREENS.WORKSPACE.OFFICE_LOCATION_ADD]: () => require('../../../../pages/workspace/WorkspaceOfficeLocationAddPage').default, + [SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT]: () => require('../../../../pages/workspace/WorkspaceOfficeLocationEditPage').default, [SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../../pages/workspace/WorkspaceOverviewDescriptionPage').default, [SCREENS.WORKSPACE.CLIENT_ID]: () => require('../../../../pages/workspace/WorkspaceOverviewClientIDPage').default, [SCREENS.WORKSPACE.SHARE]: () => require('../../../../pages/workspace/WorkspaceOverviewSharePage').default, @@ -1315,7 +1319,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/Admins/DomainAddAdminPage').default, [SCREENS.DOMAIN.MEMBER_DETAILS]: () => require('../../../../pages/domain/Members/DomainMemberDetailsPage').default, [SCREENS.DOMAIN.RESET_DOMAIN]: () => require('../../../../pages/domain/DomainResetPage').default, - [SCREENS.DOMAIN.VACATION_DELEGATE]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegatePage').default, + [SCREENS.DOMAIN.VACATION_DELEGATE]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegateFormPage').default, + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegatePage').default, [SCREENS.DOMAIN.ADD_MEMBER]: () => require('../../../../pages/domain/Members/DomainAddMemberPage').default, [SCREENS.DOMAIN.MEMBERS_SETTINGS]: () => require('../../../../pages/domain/Members/DomainMembersSettingsPage').default, [SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: () => require('../../../../pages/domain/Members/DomainRequireTwoFactorAuthPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts new file mode 100644 index 000000000000..495f8dfcdf42 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts @@ -0,0 +1,84 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +type NativeTabGlyphPath = { + d: string; + isEvenOdd?: boolean; +}; + +type NativeTabGlyph = { + /** Side of the square viewBox the paths are drawn in. */ + viewBoxSize: number; + paths: NativeTabGlyphPath[]; +}; + +/** + * Vector copies of the tab bar SVGs in `assets/images`, so Skia draws each glyph at the bar's size and the device's + * density on both platforms. They have to be kept in sync with those files. + */ +const NATIVE_TAB_GLYPHS = { + // home.svg + [SCREENS.HOME]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.386 1.477a1 1 0 0 1 1.229 0l7.613 5.922c.487.379.772.962.772 1.579V18a1 1 0 0 1-1 1h-5v-6a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v6H2a1 1 0 0 1-1-1V8.978c0-.617.285-1.2.772-1.579z', + }, + ], + }, + // inbox.svg + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M4.5 1.5a3 3 0 0 0-3 3v11a3 3 0 0 0 3 3h11a3 3 0 0 0 3-3v-11a3 3 0 0 0-3-3zm-1 3a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1V12H13c-.276 0-.495.226-.549.497a2.5 2.5 0 0 1-4.902 0C7.494 12.226 7.276 12 7 12H3.5z', + isEvenOdd: true, + }, + ], + }, + // receipt-multiple.svg + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.72 1.24c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.54-.83a.35.35 0 0 1 .64.14l2.18 15.49c.04.32-.33.52-.58.31l-.75-.65a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.15-1a.35.35 0 0 0-.52.07l-.83 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.54.83a.35.35 0 0 1-.64-.14L.97 3.11c-.04-.32.33-.52.58-.31l.75.65c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28Zm1.37 10.99-5.2.73a.87.87 0 0 0-.74.99c.07.48.51.81.99.75l5.2-.73c.48-.07.81-.51.75-.99a.886.886 0 0 0-.99-.75ZM8.87 9.01 5.4 9.5c-.48.07-.81.51-.75.99.07.48.51.81.99.75l3.47-.49c.48-.07.81-.51.75-.99a.87.87 0 0 0-.99-.74Zm1.24-3.71-5.2.73c-.48.07-.81.51-.75.99.07.48.51.81.99.75l5.2-.73a.87.87 0 0 0 .74-.99.886.886 0 0 0-.99-.75Z', + isEvenOdd: true, + }, + { + d: 'M15.15 3.09c.1.16.32.19.46.07l1.03-.89c.14-.12.36-.09.46.06l.74 1.14c.1.16.32.19.46.07l.67-.58c.22-.19.55 0 .51.28l-1.94 13.79c-.04.28-.41.37-.57.13l-.49-.74s-.02-.03-.04-.04L14.42 2.03l.7 1.07Z', + }, + ], + }, + // pie-chart.svg + [SCREENS.INSIGHTS]: { + viewBoxSize: 24, + paths: [ + {d: 'M15.598.074C14.94-.008 14.4.537 14.4 1.2v8.4h8.4c.663 0 1.208-.54 1.126-1.198A9.605 9.605 0 0 0 15.598.074'}, + {d: 'M22.734 13.198C22.138 18.6 17.56 22.8 12 22.8 6.035 22.8 1.2 17.965 1.2 12c0-5.56 4.2-10.138 9.602-10.734C11.46 1.193 12 1.737 12 2.4V12h9.6c.663 0 1.207.54 1.134 1.198'}, + ], + }, + // buildings.svg + [NAVIGATORS.WORKSPACE_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M1 3a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8v-3a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v3H3a2 2 0 0 1-2-2zm2 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m0 4a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3-7a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m5-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m-3-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0', + isEvenOdd: true, + }, + ], + }, + // profile.svg, with its circle written as a path + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + {d: 'M13 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'}, + {d: 'M10 1c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9m5.9 12.7C14.6 12.1 12.4 11 10 11s-4.6 1.1-5.9 2.7C3.4 12.6 3 11.4 3 10c0-3.9 3.1-7 7-7s7 3.1 7 7c0 1.4-.4 2.6-1.1 3.7'}, + ], + }, +} as const satisfies Record; + +/** A tab of the native tab bar, by its route name. */ +type NativeTabName = keyof typeof NATIVE_TAB_GLYPHS; + +export default NATIVE_TAB_GLYPHS; +export type {NativeTabGlyph, NativeTabName}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx new file mode 100644 index 000000000000..1328b8270df7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx @@ -0,0 +1,74 @@ +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useOnyx from '@hooks/useOnyx'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; + +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React, {useEffect} from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +import NativeTabBarShadow from './NativeTabBarShadow'; +import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; + +/** What a narrow layout draws over the tabs along with the native bar, kept apart so wide layouts skip its hooks. */ +function NativeTabBarOverlay({state, descriptors}: Pick) { + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. The new tab's span is started at + // the tap, before navigation, so it is kept. On wide layouts the JS side bar does this. + useEffect(() => { + cancelTabNavigationSpans(NAVIGATION_TAB_TO_SPANS[selectedTab]); + }, [selectedTab]); + + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + return ( + <> + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. + // They leave faster than they come in, so they stop covering a bar that is still sliding out. + + + + + + + + + + + )} + + ); +} + +export default NativeTabBarOverlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx new file mode 100644 index 000000000000..d3f248535c5b --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx @@ -0,0 +1,6 @@ +/** UITabBar draws its own glass edge over the content. */ +function NativeTabBarShadow() { + return null; +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx new file mode 100644 index 000000000000..2ad21697e685 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx @@ -0,0 +1,20 @@ +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +/** Material's bar shares the screen's background, so a shadow along its top edge marks where the content ends. */ +function NativeTabBarShadow() { + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + + return ( + + ); +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx new file mode 100644 index 000000000000..777c74986afd --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -0,0 +1,40 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; + +import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; + +import React from 'react'; +import {View} from 'react-native'; + +import NativeTabBarOverlay from './NativeTabBarOverlay'; +import TabPressListeners from './TabPressListeners'; + +type NativeTabLayoutProps = Parameters>[0]; + +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + + // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. + return ( + + {!shouldUseNarrowLayout && } + {children} + + {shouldUseNarrowLayout && ( + + )} + + ); +} + +export default NativeTabLayout; +export type {NativeTabLayoutProps}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx new file mode 100644 index 000000000000..7e61362931a5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx @@ -0,0 +1,62 @@ +import {getStoredSearchTabParams} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; + +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useOnyx from '@hooks/useOnyx'; +import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; + +import navigationRef from '@libs/Navigation/navigationRef'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; + +import {CommonActions} from '@react-navigation/native'; +import {useEffect} from 'react'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +/** + * The native bar's Spend and Workspaces handling that needs Onyx data, so the data re-renders only this component. + * Spend's first visit opens the latest search kept in Onyx, so the tab mounted at startup with the default search gets + * it before it is first shown, both for the native bar and for the side bar of a wide layout. Workspaces restores the + * last workspace, or the list for one that is gone, whenever the tab does not already show it. + */ +function TabPressListeners({state, descriptors}: Pick) { + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + const getTabRoute = (routeName: string) => state.routes.find((tabRoute) => tabRoute.name === routeName); + const spendNavigatorState = getTabRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.state; + const spendNavigatorKey = spendNavigatorState?.key; + const spendRootRoute = spendNavigatorState?.routes.at(0); + const spendRootRouteKey = spendRootRoute?.key; + const spendRootQuery = spendRootRoute?.params && 'q' in spendRootRoute.params ? spendRootRoute.params.q : undefined; + const {query: storedQuery, searchKey: storedSearchKey} = getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery); + const workspacesDescriptor = descriptors[getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)?.key ?? '']; + const workspacesNavigation = workspacesDescriptor?.navigation; + const isWorkspacesSwitchedByJS = workspacesDescriptor?.options.tabBarSelectionEnabled === false; + + useEffect(() => { + if (hasSpendBeenShown || !spendNavigatorKey || !spendRootRouteKey || spendRootQuery === storedQuery) { + return; + } + navigationRef.dispatch({...CommonActions.setParams({q: storedQuery, searchKey: storedSearchKey}), source: spendRootRouteKey, target: spendNavigatorKey}); + }, [hasSpendBeenShown, spendNavigatorKey, spendRootRouteKey, spendRootQuery, storedQuery, storedSearchKey]); + + // Runs on the focused tab too, where it returns from a workspace to the list. + useEffect( + () => + workspacesNavigation?.addListener('tabPress', () => { + if (!workspacesNavigation.isFocused() && !isWorkspacesSwitchedByJS) { + return; + } + navigateToWorkspaces(); + }), + [workspacesNavigation, isWorkspacesSwitchedByJS, navigateToWorkspaces], + ); + + return null; +} + +export default TabPressListeners; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts new file mode 100644 index 000000000000..502b8506ea4e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts @@ -0,0 +1,12 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +/** + * The native bar shows at most five of the six tabs. With the Insights beta, Insights takes the Account tab's place + * and Account moves to the top bar; without it, Insights has no item. + */ +function getTabWithoutBarItem(isInsightsBetaEnabled: boolean) { + return isInsightsBetaEnabled ? NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : SCREENS.INSIGHTS; +} + +export default getTabWithoutBarItem; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts new file mode 100644 index 000000000000..0eb625077854 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -0,0 +1,252 @@ +import FontUtils from '@styles/utils/FontUtils'; +import variables from '@styles/variables'; + +import type {NativeBottomTabIcon, NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; +import type {SkCanvas, SkImage, SkParagraph, SkPath} from '@shopify/react-native-skia'; + +import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia, TextAlign} from '@shopify/react-native-skia'; +import {PixelRatio} from 'react-native'; + +import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; +import type {TabAvatarImage} from './useTabAvatarImage'; + +import NATIVE_TAB_GLYPHS from './NATIVE_TAB_GLYPHS'; + +type TabIconLayout = { + /** Side of a glyph's box. */ + glyphSize: number; + /** Side of the account avatar's box. */ + avatarSize: number; + /** Whether the label is drawn under the glyph or beside it, as UIKit lays out a tab in landscape. */ + direction: 'stacked' | 'inline'; +}; + +type TabIconLabel = { + text: string; + color: string; + isBold: boolean; + fontSize: number; + /** Gap between the glyph box and the label. */ + gap: number; + /** Width of the tab's slot in the bar, past which the label is truncated with an ellipsis. */ + maxWidth: number; +}; + +type TabIconParams = { + /** The glyph drawn unless an avatar is given. */ + name: NativeTabName; + color: string; + avatar?: TabAvatarImage; + dotColor?: string; + label?: TabIconLabel; +}; + +/** Upper bound on cached icons, enough for every tab in both selection states across a few status and theme changes. */ +const MAX_CACHED_ICONS = 64; + +const iconCache = new Map(); +const glyphPathCache = new Map(); + +function getGlyphPaths(name: NativeTabName): SkPath[] { + const cachedPaths = glyphPathCache.get(name); + if (cachedPaths) { + return cachedPaths; + } + const paths: SkPath[] = []; + const glyph: NativeTabGlyph = NATIVE_TAB_GLYPHS[name]; + for (const {d, isEvenOdd} of glyph.paths) { + const path = Skia.Path.MakeFromSVGString(d); + if (!path) { + continue; + } + if (isEvenOdd) { + path.setFillType(FillType.EvenOdd); + } + paths.push(path); + } + glyphPathCache.set(name, paths); + return paths; +} + +/** A paragraph rather than a single font, because only a paragraph falls back to a system font for scripts Expensify Neue lacks. */ +function makeLabelParagraph({text, color, isBold, fontSize, maxWidth}: TabIconLabel, scale: number): SkParagraph { + const paragraph = Skia.ParagraphBuilder.Make({maxLines: 1, ellipsis: '…', textAlign: TextAlign.Left}) + .pushStyle({ + color: Skia.Color(color), + fontFamilies: [FontUtils.fontFamily.single.EXP_NEUE.fontFamily], + fontSize: fontSize * scale, + fontStyle: {weight: isBold ? FontWeight.Bold : FontWeight.Normal}, + }) + .addText(text) + .build(); + paragraph.layout(maxWidth * scale); + return paragraph; +} + +function drawGlyph(canvas: SkCanvas, name: NativeTabName, left: number, top: number, size: number, color: string) { + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + const viewBoxScale = size / NATIVE_TAB_GLYPHS[name].viewBoxSize; + + canvas.save(); + canvas.translate(left, top); + canvas.scale(viewBoxScale, viewBoxScale); + for (const path of getGlyphPaths(name)) { + canvas.drawPath(path, paint); + } + canvas.restore(); + paint.dispose(); +} + +function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: number, size: number) { + const sourceSize = Math.min(image.width(), image.height()); + const circle = Skia.Path.Make(); + circle.addCircle(left + size / 2, top + size / 2, size / 2); + const paint = Skia.Paint(); + + canvas.save(); + canvas.clipPath(circle, ClipOp.Intersect, true); + canvas.drawImageRectOptions( + image, + Skia.XYWHRect((image.width() - sourceSize) / 2, (image.height() - sourceSize) / 2, sourceSize, sourceSize), + Skia.XYWHRect(left, top, size, size), + FilterMode.Linear, + MipmapMode.Linear, + paint, + ); + canvas.restore(); + paint.dispose(); + circle.dispose(); +} + +/** A cutout rather than a border in the bar's color, because the iOS 26 bar is glass and has no single color. */ +function drawStatusDot(canvas: SkCanvas, right: number, top: number, scale: number, color: string) { + const radius = variables.nativeTabIconDotRadius * scale; + const centerX = right - radius; + const centerY = top + radius; + const paint = Skia.Paint(); + paint.setAntiAlias(true); + paint.setBlendMode(BlendMode.Clear); + canvas.drawCircle(centerX, centerY, radius + variables.nativeTabIconDotCutout * scale, paint); + paint.setBlendMode(BlendMode.SrcOver); + paint.setColor(Skia.Color(color)); + canvas.drawCircle(centerX, centerY, radius, paint); + paint.dispose(); +} + +type TabIconFrame = { + canvasWidth: number; + canvasHeight: number; + contentLeft: number; + contentTop: number; + labelLeft: number; + labelTop: number; +}; + +/** + * With a label, every tab gives its glyph or avatar a box as big as the avatar, so glyphs and the avatar share a centre + * and every label starts at the same place: under that box, or beside it in landscape. + */ +function getTabIconFrame(layout: TabIconLayout, contentSize: number, labelGap: number, labelWidth: number, labelHeight: number, hasLabel: boolean, scale: number): TabIconFrame { + const boxSize = hasLabel ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; + + if (layout.direction === 'inline') { + const canvasHeight = Math.ceil(Math.max(boxSize, labelHeight)); + return { + canvasWidth: Math.ceil(boxSize + (hasLabel ? labelGap + labelWidth : 0)), + canvasHeight, + contentLeft: (boxSize - contentSize) / 2, + contentTop: (canvasHeight - contentSize) / 2, + labelLeft: boxSize + labelGap, + labelTop: (canvasHeight - labelHeight) / 2, + }; + } + + const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); + const labelTop = (boxSize + layout.glyphSize * scale) / 2 + labelGap; + return { + canvasWidth, + canvasHeight: Math.ceil(hasLabel ? labelTop + labelHeight : boxSize), + contentLeft: (canvasWidth - contentSize) / 2, + contentTop: (boxSize - contentSize) / 2, + labelLeft: (canvasWidth - labelWidth) / 2, + labelTop, + }; +} + +function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, label}: TabIconParams): NativeBottomTabIcon | undefined { + const scale = PixelRatio.get(); + const contentSize = (avatar ? layout.avatarSize : layout.glyphSize) * scale; + const paragraph = label ? makeLabelParagraph(label, scale) : undefined; + const {canvasWidth, canvasHeight, contentLeft, contentTop, labelLeft, labelTop} = getTabIconFrame( + layout, + contentSize, + (label?.gap ?? 0) * scale, + paragraph?.getLongestLine() ?? 0, + paragraph?.getHeight() ?? 0, + !!label, + scale, + ); + + const surface = Skia.Surface.Make(canvasWidth, canvasHeight); + if (!surface) { + paragraph?.dispose(); + return undefined; + } + + const canvas = surface.getCanvas(); + if (avatar) { + drawCircularImage(canvas, avatar.image, contentLeft, contentTop, contentSize); + } else { + drawGlyph(canvas, name, contentLeft, contentTop, contentSize, color); + } + if (dotColor) { + drawStatusDot(canvas, contentLeft + contentSize, contentTop, scale, dotColor); + } + if (paragraph) { + paragraph.paint(canvas, labelLeft, labelTop); + paragraph.dispose(); + } + + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + snapshot.dispose(); + surface.dispose(); + + return { + type: 'image', + source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}, + tinted: false, + }; +} + +/** + * Draws a tab icon synchronously, so the bar never waits for it, and reuses it for as long as everything drawn into it + * stays the same. A status, theme or language change redraws only the icons it reaches. + */ +function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { + const {name, color, avatar, dotColor, label} = params; + // The label's font is fixed per platform, so it is left out of the key. + const key = [layout.direction, name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, label?.maxWidth].join('|'); + if (iconCache.has(key)) { + return iconCache.get(key); + } + if (iconCache.size >= MAX_CACHED_ICONS) { + iconCache.clear(); + } + const icon = drawTabIcon(layout, params); + iconCache.set(key, icon); + return icon; +} + +/** Both selection states as images, given as a function, because React Navigation derives the selected icon only from a function. */ +function getTabBarIcon(layout: TabIconLayout, getParams: (isSelected: boolean) => TabIconParams): NativeBottomTabNavigationOptions['tabBarIcon'] { + const inactiveIcon = getTabIcon(layout, getParams(false)); + const activeIcon = getTabIcon(layout, getParams(true)); + return inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined; +} + +export default getTabBarIcon; +export type {TabIconLayout}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts new file mode 100644 index 000000000000..50b556171740 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -0,0 +1,87 @@ +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +type NavigationTab = ValueOf; + +/** Tabs the JS tab buttons keep from anonymous users, who get the sign-in modal instead. */ +const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.WORKSPACES, NAVIGATION_TABS.SETTINGS]); + +type TabRoute = { + name: string; + state?: NavigationState | PartialState; +}; + +/** Whether Inbox shows its chat list, which is where its JS tab button opens it. */ +function isInboxTabAtChatList(inboxRoute: TabRoute | undefined) { + return getFocusedLeafScreenName(inboxRoute?.state) === SCREENS.INBOX; +} + +/** + * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. + * Workspaces is handled by TabPressListeners, which needs Onyx data. + */ +const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { + [NAVIGATION_TABS.INBOX]: (route) => (isInboxTabAtChatList(route) ? startNavigateToInboxTabSpan({isWideLayout: false}) : navigateToInboxTab()), + [NAVIGATION_TABS.SEARCH]: () => interceptAnonymousUser(startNavigateToReportsTabSpans), + [NAVIGATION_TABS.INSIGHTS]: () => interceptAnonymousUser(() => {}), + [NAVIGATION_TABS.SETTINGS]: () => interceptAnonymousUser(() => {}), +}; + +type NativeTabSelectionParams = { + isAnonymousUser: boolean; + isInboxAtChatList: boolean; + /** Whether Workspaces shows what its JS tab button restores, see useIsWorkspacesTabRestored. */ + isWorkspacesTabRestored: boolean; +}; + +/** + * Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. The native switch + * is kept whenever the tab already shows what its JS tab button opens, because a prevented selection makes the iOS bar + * slide back to the current tab before JS moves it to the new one. + */ +function isNativeTabSelectionEnabled(routeName: string, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}: NativeTabSelectionParams) { + const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; + if (isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)) { + return false; + } + if (tab === NAVIGATION_TABS.INBOX) { + return isInboxAtChatList; + } + if (tab === NAVIGATION_TABS.WORKSPACES) { + return isWorkspacesTabRestored; + } + return true; +} + +type TabScreenListenerProps = { + route: TabRoute; + navigation: {isFocused: () => boolean}; +}; + +/** + * Native tabs emit `tabPress` when a user tap reaches JS, both for a tab the bar switched to and for one whose + * selection is disabled. A tap on the already focused tab does nothing, the same as a JS tab button pressed on its own tab. + */ +function tabScreenListeners({route, navigation}: TabScreenListenerProps) { + return { + tabPress: () => { + if (navigation.isFocused()) { + return; + } + NAVIGATION_TAB_PRESS_HANDLERS[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(route); + }, + }; +} + +export default tabScreenListeners; +export {isInboxTabAtChatList, isNativeTabSelectionEnabled}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts new file mode 100644 index 000000000000..928dd7accded --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts @@ -0,0 +1,50 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {isPendingDeletePolicy, shouldShowPolicy} from '@libs/PolicyUtils'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Domain, Policy} from '@src/types/onyx'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; + +type WorkspacesTabRoute = { + state?: NavigationState | PartialState; +}; + +const domainExistsSelector = (domain: OnyxEntry) => !!domain?.accountID; + +/** + * Whether the Workspaces tab shows what its JS tab button restores on narrow layouts, so a plain switch to the tab + * lands on the same page: the workspaces list, the initial page of a workspace that is still shown, or a domain that + * still exists. Any other page is replaced by the tab button, see navigateToWorkspacesPage. + */ +function useIsWorkspacesTabRestored(workspacesTabRoute: WorkspacesTabRoute | undefined) { + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const navigatorState = workspacesTabRoute?.state; + const topRoute = navigatorState?.routes.at(navigatorState.index ?? -1); + const splitRootParams = topRoute?.state?.routes.at(0)?.params; + const policyID = topRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR && splitRootParams && 'policyID' in splitRootParams ? String(splitRootParams.policyID) : undefined; + const domainAccountID = + topRoute?.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR && splitRootParams && 'domainAccountID' in splitRootParams ? String(splitRootParams.domainAccountID) : undefined; + const isPolicyShownSelector = (policy: OnyxEntry) => shouldShowPolicy(policy, false, currentUserLogin) && !isPendingDeletePolicy(policy); + const [isPolicyShown = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isPolicyShownSelector}); + const [doesDomainExist = false] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: domainExistsSelector}); + + switch (topRoute?.name) { + case SCREENS.WORKSPACES_LIST: + return true; + case NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR: + return !!policyID && isPolicyShown && getFocusedLeafScreenName(topRoute.state) === SCREENS.WORKSPACE.INITIAL; + case NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR: + return !!domainAccountID && doesDomainExist; + default: + return false; + } +} + +export default useIsWorkspacesTabRestored; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts new file mode 100644 index 000000000000..d439553f17ae --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -0,0 +1,61 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabBarIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; + +/** + * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather + * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. + */ +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconBottomBar, + avatarSize: variables.iconBottomBar, + direction: 'stacked', +}; + +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const styles = useThemeStyles(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + + // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 004). + const screenOptions: NativeBottomTabNavigationOptions = { + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled', + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ + tabBarLabel: tabLabels[name], + // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. + tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + })), + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts new file mode 100644 index 000000000000..81627df46fd6 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -0,0 +1,89 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabBarIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import {fontScale} from '@styles/typography'; +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** UITabBar sits above the home indicator inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.iosNativeTabBarFloatingButtonsBottom + bottomInset; + +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOS, + // A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. + avatarSize: variables.avatarNativeTabBarIOS, + direction: 'stacked', +}; + +/** A phone in landscape gives the bar too little height for a stacked tab, so UIKit lays its own tabs out this way. */ +const LANDSCAPE_TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOSLandscape, + avatarSize: variables.avatarNativeTabBarIOSLandscape, + direction: 'inline', +}; + +/** + * iOS 26 never applies the inactive icon color or the title color from `UITabBarItemAppearance`, and paints every badge + * in the color of the selected tab, so each icon is drawn with its own colors, its status dot and its label. Both + * selection states are images, because RNScreens rejects a tab whose icon and selectedIcon differ in type. + */ +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + const {windowWidth} = useWindowDimensions(); + const {isInLandscapeMode} = useResponsiveLayout(); + const tabIconLayout = isInLandscapeMode ? LANDSCAPE_TAB_ICON_LAYOUT : TAB_ICON_LAYOUT; + // One of the tabs never has a bar item, so the bar splits its width between the others. + const barItemCount = Object.keys(tabLabels).length - 1; + const itemWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; + // In landscape the label shares the item's width with the glyph's box beside it. + const labelMaxWidth = isInLandscapeMode ? itemWidth - tabIconLayout.avatarSize - variables.nativeTabIconLabelGap : itemWidth; + + const screenOptions: NativeBottomTabNavigationOptions = { + // The labels are drawn into the icons. + tabBarLabel: '', + tabBarActiveTintColor: theme.iconMenu, + tabBarInactiveTintColor: theme.icon, + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + tabBarControllerMode: 'tabBar', + // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. + tabBarMinimizeBehavior: 'none', + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ + tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + label: { + text: tabLabels[name], + color: isSelected ? theme.text : theme.textSupporting, + isBold: isSelected, + fontSize: fontScale.micro, + gap: variables.nativeTabIconLabelGap, + maxWidth: labelMaxWidth, + }, + })), + // The label is drawn into the icon, so VoiceOver reads it from here. + tabBarAccessibilityLabel: tabLabels[name], + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts new file mode 100644 index 000000000000..2401d88a0b31 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -0,0 +1,18 @@ +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +type UseNativeTabBarOptions = (params: NativeTabBarOptionsParams) => { + screenOptions: NativeBottomTabNavigationOptions; + getTabOptions: (name: NativeTabName) => NativeBottomTabNavigationOptions; +}; + +/** Only iOS and Android have a native tab bar, so other platforms get no options. */ +const useNativeTabBarOptions: UseNativeTabBarOptions = () => ({screenOptions: {}, getTabOptions: () => ({})}); + +const getFloatingButtonsBottom = (bottomInset: number) => bottomInset; + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts new file mode 100644 index 000000000000..4091ff2f6522 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts @@ -0,0 +1,9 @@ +type NativeTabBarOptionsParams = { + shouldShowNativeTabBar: boolean; + /** Whether the account tab has an item in a narrow layout's bar, so its avatar is drawn. */ + isAccountAvatarShown: boolean; + dotColors: Record; + tabLabels: Record; +}; + +export default NativeTabBarOptionsParams; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts new file mode 100644 index 000000000000..4d2de8177d63 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -0,0 +1,130 @@ +import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; + +import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useInboxTabIndicatorStatus from '@hooks/useInboxTabIndicatorStatus'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; + +import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; + +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import {isAnonymousSessionSelector} from '@selectors/Session'; +import {useEffect} from 'react'; + +import getTabWithoutBarItem from './getTabWithoutBarItem'; +import {isInboxTabAtChatList, isNativeTabSelectionEnabled} from './tabScreenListeners'; +import useIsWorkspacesTabRestored from './useIsWorkspacesTabRestored'; + +/** + * Root-level tab screens where the swipe-back gesture should be disabled. + * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. + * WORKSPACE.INITIAL is intentionally excluded, since swiping back from it returns to the workspace list. + */ +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); + +/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ +function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { + return state?.stale === false; +} + +function useNativeTabNavigator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBlockingViewVisible} = useFullScreenBlockingViewState(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const {translate} = useLocalize(); + const [isAnonymousUser = false] = useOnyx(ONYXKEYS.SESSION, {selector: isAnonymousSessionSelector}); + const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); + const tabWithoutBarItem = getTabWithoutBarItem(isInsightsTabVisible); + const {indicatorColor: inboxDotColor} = useInboxTabIndicatorStatus(); + const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); + const navigation = useNavigation(); + const parentNavigation = navigation.getParent(); + const isFocusedRouteTabRoot = useNavigationState((state) => TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(findFocusedRoute(state)?.name ?? '')); + const route = useRoute(); + // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via + // `useNavigationState((s) => s)` here because the navigator's body runs before + // mounts, so the nearest navigation listener context is still the parent stack's. + const tabState = useNavigationState((parentState) => parentState.routes.find((parentRoute) => parentRoute.key === route.key)?.state); + const activeTabRoute = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; + // A tab with no item in the bar is drawn over the other tabs as a full screen, so the bar hides while it is focused. + const isActiveTabWithoutBarItem = activeTabRoute?.name === tabWithoutBarItem; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isActiveTabWithoutBarItem; + const getTabRoute = (tabName: string) => (isRealizedNavigationState(tabState) ? tabState.routes.find((tabRoute) => tabRoute.name === tabName) : undefined); + const isInboxAtChatList = isInboxTabAtChatList(getTabRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); + const isWorkspacesTabRestored = useIsWorkspacesTabRestored(getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)); + + const dotColors: Record = { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, + [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesIndicatorStatus ? workspacesIndicatorColor : undefined, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountIndicatorStatus ? accountIndicatorColor : undefined, + }; + const tabLabels: Record = { + [SCREENS.HOME]: translate('common.home'), + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [SCREENS.INSIGHTS]: translate('common.insights'), + [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), + }; + + useEffect(() => { + if (!shouldUseNarrowLayout || !parentNavigation) { + return; + } + parentNavigation.setOptions({gestureEnabled: !isFocusedRouteTabRoot}); + }, [isFocusedRouteTabRoot, shouldUseNarrowLayout, parentNavigation]); + + useEffect(() => { + if (!isRealizedNavigationState(tabState)) { + return; + } + setPreservedNavigatorState(route.key, tabState); + }, [tabState, route.key]); + + // Without the beta, Insights has no bar item and its page is not found, so a restored or deep-linked Insights tab + // would be a full screen with no way out. Home replaces it, so going back does not return to it. Betas that are + // still loading are not a missing beta. + const isInsightsTabFocusedWithoutBeta = isBetaEnabledOrUnknown(CONST.BETAS.INSIGHTS_PAGE) === false && activeTabRoute?.name === SCREENS.INSIGHTS; + useEffect(() => { + if (!isInsightsTabFocusedWithoutBeta) { + return; + } + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); + }, [isInsightsTabFocusedWithoutBeta]); + + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount + // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved + // state by overriding the bottom-tab router's getInitialState, the same pattern SplitRouter + // uses for its split navigators. + const tabRouterOverride = ( + originalRouter: Router, Action>, + ): Partial, Action>> => ({ + getInitialState: (configOptions) => { + const preserved = getPreservedNavigatorState>(route.key); + return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); + }, + }); + + const isAccountAvatarShown = shouldUseNarrowLayout && tabWithoutBarItem !== NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; + + const isTabSelectionEnabled = (routeName: string) => isNativeTabSelectionEnabled(routeName, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}); + + return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; +} + +export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts new file mode 100644 index 000000000000..b38d210f3cd0 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts @@ -0,0 +1,54 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; + +import {getAvatarURL} from '@libs/UserAvatarUtils'; + +import type {SkImage} from '@shopify/react-native-skia'; + +import {Skia} from '@shopify/react-native-skia'; +import {useEffect, useState} from 'react'; + +type TabAvatarImage = {uri: string; image: SkImage}; + +/** Held outside any component, so a status or theme redraw and a remounted navigator reuse the decoded avatar. */ +let lastDecodedAvatar: TabAvatarImage | undefined; + +/** + * The current user's avatar, decoded once per URI for the account tab icon while that icon is shown. Until it is + * decoded, and for avatars that are not a remote image, the account glyph stands in. + */ +function useTabAvatarImage(isShown: boolean): TabAvatarImage | undefined { + const {avatar, accountID} = useCurrentUserPersonalDetails(); + const avatarSource = getAvatarURL({avatarSource: avatar, accountID}); + const uri = typeof avatarSource === 'string' ? avatarSource : undefined; + const [decodedAvatar, setDecodedAvatar] = useState(lastDecodedAvatar); + const decodedUri = decodedAvatar?.uri; + + useEffect(() => { + if (!isShown || !uri || decodedUri === uri) { + return; + } + let isActive = true; + Skia.Data.fromURI(uri) + .then((data) => { + const image = Skia.Image.MakeImageFromEncoded(data); + data.dispose(); + if (!image) { + return; + } + lastDecodedAvatar = {uri, image}; + if (isActive) { + setDecodedAvatar(lastDecodedAvatar); + } + }) + // A failed decode keeps the account glyph. + .catch(() => undefined); + return () => { + isActive = false; + }; + }, [isShown, uri, decodedUri]); + + return decodedAvatar?.uri === uri ? decodedAvatar : undefined; +} + +export default useTabAvatarImage; +export type {TabAvatarImage}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts index 84825c7babaa..cbb02a5c88d8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts @@ -1,6 +1,7 @@ /** * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.tsx. + * This must match the Tab.Screen order in TabNavigator.tsx and TabNavigator.native.tsx, where it is also the order + * the native tab bar draws its items in. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ import NAVIGATORS from '@src/NAVIGATORS'; @@ -11,8 +12,8 @@ const TAB_SCREENS = [ NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ] as const; export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 8b0b7c7e1856..6c4b97d1fedc 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -1,104 +1,60 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; - -import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; import InsightsPage from '@pages/Insights/InsightsPage'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -/** - * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. - */ -import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {NavigationAction, NavigationState, Router, TabNavigationState} from '@react-navigation/native'; +import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; +import React from 'react'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import type {NativeTabName} from './NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; +import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; +import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; +import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import SettingsSplitNavigator from './SettingsSplitNavigator'; -import TabNavigatorBar from './TabNavigatorBar'; import WorkspaceNavigator from './WorkspaceNavigator'; -const renderTabBar = ({state}: BottomTabBarProps) => ; - -const Tab = createBottomTabNavigator(); - /** - * Root-level tab screens where the swipe-back gesture should be disabled. - * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. - * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. + * Tab Navigator backed by the platform's own tab bar: UITabBar with the liquid glass material on iOS 26, and + * Material's BottomNavigationView on Android. Wide layouts keep the JS side bar, since neither native bar can be + * moved to the side of the screen. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); - -const TAB_SCREEN_OPTIONS_BASE = { - headerShown: false, - lazy: true, - animation: 'none' as const, - freezeOnBlur: true, - tabBarPosition: 'bottom' as const, -} as const; - -function TabNavigator() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const theme = useTheme(); - const navigation = useNavigation(); - const parentNavigation = navigation.getParent(); - const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const route = useRoute(); - // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via - // `useNavigationState((s) => s)` here because TabNavigator's body runs before - // mounts, so the nearest navigation listener context is still the parent stack's. - const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state as NavigationState | undefined); - - useEffect(() => { - if (!shouldUseNarrowLayout || !parentNavigation) { - return; - } - const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); - }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); +const Tab = createNativeBottomTabNavigator(); - useEffect(() => { - // stale === false distinguishes a fully realized NavigationState from a PartialState. - if (!tabState || tabState.stale !== false) { - return; - } - setPreservedNavigatorState(route.key, tabState); - }, [tabState, route.key]); +const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; - // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount - // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved - // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter - // uses for its split navigators. - const tabRouterOverride = ( - originalRouter: Router, Action>, - ): Partial, Action>> => ({ - getInitialState: (configOptions) => { - const preserved = getPreservedNavigatorState>(route.key); - return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); - }, - }); +const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; - const screenOptions = { - ...TAB_SCREEN_OPTIONS_BASE, - sceneStyle: {flex: 1, backgroundColor: theme.appBG}, - }; +function TabNavigator() { + const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); + // A tab with no bar item draws no icon. + const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => + route.name === tabWithoutBarItem + ? {...screenOptions, ...HIDDEN_TAB_OPTIONS} + : { + ...screenOptions, + ...getTabOptions(route.name), + tabBarSelectionEnabled: isTabSelectionEnabled(route.name), + }; return ( - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index d77a12be149d..6bf2ea7f9df1 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -133,14 +133,14 @@ function TabNavigator() { name={SCREENS.INSIGHTS} component={InsightsPageScreen} /> - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 9b46ffd6e246..327776eaa50b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -10,21 +10,15 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; -import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; import SCREENS from '@src/SCREENS'; import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {ValueOf} from 'type-fest'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; -const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { - [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, - [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, -}; - /** * Custom tab bar rendered by the BottomTabNavigator. Only receives `state` (not the * full BottomTabBarProps) to avoid `descriptors` thrashing memoization. @@ -40,7 +34,7 @@ function TabNavigatorBar({state}: Pick) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const isAtRoot = isTabRouteAtRoot(activeRoute); // --- Narrow-only animation logic (hooks must run unconditionally per Rules of Hooks) --- - // On native, screens also render the tab bar via bottomContent for swipe-back animations. + // Screens also render the tab bar via bottomContent for swipe-back animations. // Delay showing this navigator's tab bar only when navigating back from a deeper screen // (where the tab bar was hidden). Keep it visible during tab switches so it doesn't flash. // Guard with shouldUseNarrowLayout so prevShouldHide stays false in wide layout, diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index c2cc3fe466ff..c6acf30a7184 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -23,6 +23,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPageRevamp').default, [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, + [SCREENS.WORKSPACE.AGENTS]: () => require('../../../../pages/workspace/WorkspaceAgentsPage').default, [SCREENS.WORKSPACE.ROOMS]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index 30a79116a813..b9f2385d3be7 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -1,3 +1,4 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; @@ -5,6 +6,7 @@ import buildTabNavigatorNestedState from '@libs/Navigation/helpers/buildTabNavig import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import hasNativeSwipeBackGesture from '@libs/Navigation/helpers/hasNativeSwipeBackGesture'; import {isFullScreenName, isPreMountBufferHostName} from '@libs/Navigation/helpers/isNavigatorName'; +import {isStaleWideTabPreMountPreloadedRouteKey, isStaleWideTabPreMountRouteKey} from '@libs/Navigation/helpers/wideTabPreMountRouteKey'; import {SIDEBAR_TO_SPLIT, SPLIT_TO_SIDEBAR} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {isRecord} from '@libs/ObjectUtils'; @@ -23,6 +25,7 @@ import type { RemoveFullscreenUnderRHPActionType, ReplaceActionType, ReplaceFullscreenUnderRHPActionType, + RootStackNavigatorAction, ToggleMfaModalNavigatorWithHistoryActionType, ToggleModalWithHistoryActionType, ToggleSidePanelWithHistoryActionType, @@ -119,10 +122,10 @@ type TabNavigatorPushPayloadParams = { }; type TabRouteForReplacement = NavigationState['routes'][number] | NavigationPartialRoute; -type TabStateForReplacement = Omit & {routes: TabRouteForReplacement[]; stale?: true | false}; +type TabStateForReplacement = Omit & {routes: TabRouteForReplacement[]; stale?: true | false; preloadedRouteKeys?: string[]}; type StaleTabStateOverrides = {routes: TabRouteForReplacement[]; index: number; routeNames?: string[]}; -function toStaleTabState(existingTabState: NavigationState | undefined, overrides: StaleTabStateOverrides): TabStateForReplacement { +function toStaleTabState(existingTabState: NavigationState | PartialState | undefined, overrides: StaleTabStateOverrides): TabStateForReplacement { return { type: existingTabState?.type ?? 'tab', key: existingTabState?.key ?? '', @@ -208,6 +211,10 @@ function isNavigationPartialRoute(route: unknown): route is NavigationPartialRou return typeof route === 'object' && route !== null && 'name' in route && typeof route.name === 'string'; } +function isRealizedNavigationState(state: unknown): state is NavigationState { + return isRecord(state) && state.stale === false && typeof state.key === 'string' && Array.isArray(state.routes); +} + function isNavigationStateWithRoutes(state: unknown): state is PartialState { return typeof state === 'object' && state !== null && 'routes' in state && Array.isArray(state.routes); } @@ -353,6 +360,14 @@ function getTabStateWithFocusedTarget(existingTabState: NavigationState | undefi } } + return getTabStateWithFreshTarget(existingTabState, focusedTargetTab); +} + +/** Builds the target tab with fresh keys, keeping the other tabs and tab history from `existingTabState`. */ +function getTabStateWithFreshTarget( + existingTabState: NavigationState | PartialState | undefined, + focusedTargetTab: NavigationPartialRoute, +): TabStateForReplacement | undefined { const completeTabState = buildTabNavigatorNestedState(focusedTargetTab); const completeTargetTabIndex = completeTabState.routes.findIndex((route) => route.name === focusedTargetTab.name); if (completeTargetTabIndex < 0) { @@ -363,6 +378,7 @@ function getTabStateWithFocusedTarget(existingTabState: NavigationState | undefi if (route.name === focusedTargetTab.name) { return getTargetTabRoute(undefined, focusedTargetTab); } + // Keys are kept on purpose: tab history references them, so fresh keys would drop it after the reveal. return existingTabState?.routes.find((r) => r.name === route.name) ?? route; }); @@ -464,6 +480,68 @@ function markFocusedTabRouteForRemount(tabState: TabStateForReplacement, existin }); } +/** + * Adds the focused tab to the end of the tab history the way the tab router does when switching tabs (fullHistory). + * A realized state is not rehydrated, so without this the history would still end on the tab the user came from. + */ +function withFocusedTabInHistory(tabState: TabStateForReplacement): TabStateForReplacement { + const focusedRoute = tabState.routes[tabState.index]; + if (tabState.stale === true || !tabState.history || !focusedRoute || !('key' in focusedRoute) || typeof focusedRoute.key !== 'string') { + return tabState; + } + const lastRouteEntry = tabState.history.findLast((entry) => isRecord(entry) && entry.type === 'route'); + if (isRecord(lastRouteEntry) && lastRouteEntry.key === focusedRoute.key) { + return tabState; + } + return {...tabState, history: [...tabState.history, {type: 'route', key: focusedRoute.key, params: focusedRoute.params}]}; +} + +/** + * Puts the wide pre-mounted destination screen in place of the freshly built one, so the reveal shows the mounted instance. + * Everything else comes from the regular replace, so the stack, tab history and back navigation end up the same as without it. + */ +function withPreMountedDestination(builtTabState: TabStateForReplacement, existingTabState: NavigationState | undefined, preMountedRouteKey: string): TabStateForReplacement { + const targetTabRoute = builtTabState.routes[builtTabState.index]; + const existingTargetTabRoute = existingTabState?.routes.find((route) => route.name === targetTabRoute?.name); + const existingNestedState = isNavigationStateWithRoutes(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; + const preMountedRoute = existingNestedState?.routes.find((route) => route.key === preMountedRouteKey); + const builtNestedState = isNavigationStateWithRoutes(targetTabRoute?.state) ? targetTabRoute.state : undefined; + const builtDestination = builtNestedState?.routes.at(-1); + if (!targetTabRoute || !preMountedRoute || !builtNestedState || builtDestination?.name !== preMountedRoute.name) { + Log.hmmm('[Navigation] Wide pre-mount does not match the revealed destination, replacing without it', {preMountedRouteKey}); + return withoutFocusedTabPreload(builtTabState); + } + + // Native keeps the focused report's history, which holds the pre-mount under it, so that copy is dropped to keep keys unique. + const backRoutes = builtNestedState.routes.slice(0, -1).filter((route) => !('key' in route) || route.key !== preMountedRouteKey); + const nestedRoutes = [...backRoutes, {...builtDestination, key: preMountedRouteKey}]; + // When every screen of the built stack is already mounted, the mounted stack state is kept with only its routes changed. + // A partial state would be rehydrated under a new key, which makes the navigator re-render all of its screens. + const mountedNestedState = isRealizedNavigationState(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; + // Params still come from the built routes, so both branches reveal the destination the action asked for. + const mountedRoutes = nestedRoutes.map((route) => { + const existing = mountedNestedState?.routes.find((mountedRoute) => 'key' in route && mountedRoute.key === route.key); + return existing ? {...existing, params: route.params} : undefined; + }); + const routes = [...builtTabState.routes]; + if (mountedNestedState && mountedRoutes.every((route): route is NonNullable => !!route) && 'key' in targetTabRoute && typeof targetTabRoute.key === 'string') { + // Route snapshots added by addPushParamsRouterExtension are rebuilt from the routes, the same as a rehydrated stack gets them. + const history = mountedNestedState.history ? {history: mountedRoutes.map((route) => ({...route}))} : {}; + routes[builtTabState.index] = {...targetTabRoute, key: targetTabRoute.key, state: {...mountedNestedState, routes: mountedRoutes, index: mountedRoutes.length - 1, ...history}}; + } else { + routes[builtTabState.index] = {...targetTabRoute, state: {...builtNestedState, routes: nestedRoutes, index: nestedRoutes.length - 1}}; + } + return withoutFocusedTabPreload({...builtTabState, routes}); +} + +/** The pre-mount kept a covered tab unfrozen. It is focused from here, so it no longer needs that. */ +function withoutFocusedTabPreload(tabState: TabStateForReplacement): TabStateForReplacement { + const {preloadedRouteKeys} = tabState; + const focusedTabRoute = tabState.routes[tabState.index]; + const focusedTabRouteKey = focusedTabRoute && 'key' in focusedTabRoute ? focusedTabRoute.key : undefined; + return {...tabState, ...(preloadedRouteKeys ? {preloadedRouteKeys: preloadedRouteKeys.filter((key) => key !== focusedTabRouteKey)} : {})}; +} + /** * Handles the REPLACE_FULLSCREEN_UNDER_RHP action. * @@ -530,14 +608,24 @@ function handleReplaceFullscreenUnderRHP( if (!focusedTargetTab) { return null; } - const updatedTabState = getTabStateWithFocusedTarget(existingTabState, focusedTargetTab); - if (!updatedTabState) { + + const builtTabState = getTabStateWithFocusedTarget(existingTabState, focusedTargetTab); + if (!builtTabState) { return null; } - const staleTabState = existingTabState ? markFocusedTabRouteForRemount(updatedTabState, existingTabState) : updatedTabState; + const {preMountedRouteKey} = action.payload; + const updatedTabState = preMountedRouteKey ? withPreMountedDestination(builtTabState, existingTabState, preMountedRouteKey) : builtTabState; + // The remount guards against a push-transition flash on narrow layout (#90985). A wide pre-mount reveal keeps the key, + // because a remount would drop the pre-mounted screen. Wide renders split screens without a push animation. + let tabStateForReplace = updatedTabState; + if (preMountedRouteKey && !getIsNarrowLayout()) { + tabStateForReplace = withFocusedTabInHistory(updatedTabState); + } else if (existingTabState) { + tabStateForReplace = markFocusedTabRouteForRemount(updatedTabState, existingTabState); + } // Drop consumed deep-link hints before remounting, or React Navigation can replay the old target over the new state. - const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: staleTabState} as StackNavigationState['routes'][number]; + const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: tabStateForReplace} as StackNavigationState['routes'][number]; // Save original route so handleRemoveFullscreenUnderRHP can fully restore it on cancel. // In the cold-start fallback the tab navigator has no nested state yet, so saving the raw // route would leave it stateless and the dismiss-restore path (removePreInsertedFullscreenIfNeeded) @@ -755,12 +843,56 @@ function handleToggleModalWithHistoryAction(state: StackNavigationState; index?: number; preloadedRouteKeys?: string[]}; + +function isResetPayloadState(state: unknown): state is ResetPayloadState { + return isRecord(state) && Array.isArray(state.routes); +} + +/** Drops stale wide pre-mounts from a (nested) state, returning the same object when there is none. */ +function removeStalePreMountsFromState(state: S): S { + let hasChangedNestedState = false; + const routes = state.routes.map((route) => { + if (!isResetPayloadState(route.state)) { + return route; + } + const nestedState = removeStalePreMountsFromState(route.state); + if (nestedState === route.state) { + return route; + } + hasChangedNestedState = true; + // A never-visited tab can hold only the pre-mount. Without a state, the navigator builds its initial route again. + return {...route, state: nestedState.routes.length ? nestedState : undefined}; + }); + const hasStalePreload = !!state.preloadedRouteKeys?.some(isStaleWideTabPreMountPreloadedRouteKey); + const preloads = hasStalePreload ? {preloadedRouteKeys: state.preloadedRouteKeys?.filter((key) => !isStaleWideTabPreMountPreloadedRouteKey(key))} : {}; + const isStale = (route: {key?: string}) => isStaleWideTabPreMountRouteKey(route.key); + if (!routes.some(isStale)) { + return hasChangedNestedState || hasStalePreload ? {...state, routes, ...preloads} : state; + } + const keptRoutes = routes.filter((route) => !isStale(route)); + const focusedIndex = state.index ?? routes.length - 1; + // A stale pre-mount on top of a covered stack was its focused route, so focus falls back to the route under it. + const index = Math.max(0, Math.min(focusedIndex - routes.slice(0, focusedIndex).filter(isStale).length, keptRoutes.length - 1)); + return {...state, routes: keptRoutes, index, ...preloads}; +} + +/** Drops pre-mounts nobody owns from a RESET, e.g. restored by browser back/forward from a saved history entry. */ +function removeStalePreMountsFromResetAction(action: RootStackNavigatorAction): RootStackNavigatorAction { + if (action.type !== CONST.NAVIGATION.ACTION_TYPE.RESET || !action.payload || !isResetPayloadState(action.payload)) { + return action; + } + const payload = removeStalePreMountsFromState(action.payload); + return payload === action.payload ? action : ({...action, payload} as RootStackNavigatorAction); +} + export { handleDismissModalAction, handleNavigatingToModalFromModal, handlePushFullscreenAction, handleReplaceFullscreenUnderRHP, handleRemoveFullscreenUnderRHP, + removeStalePreMountsFromResetAction, handleReplaceReportsSplitNavigatorAction, screensWithEnteringAnimation, handleToggleSidePanelWithHistoryAction, @@ -770,6 +902,7 @@ export { clearPreInsertedOriginalTabRoute, MODAL_ROUTES_TO_DISMISS, // Exported for unit-test access; not used outside of testing. + getTabStateWithFreshTarget, withSanitizedDeepLinkParams, getTabStateWithFocusedTarget, markFocusedTabRouteForRemount, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts index b59a0154e6e9..2d70b852def2 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts @@ -38,6 +38,7 @@ import { handleToggleModalWithHistoryAction, handleToggleSidePanelWithHistoryAction, MODAL_ROUTES_TO_DISMISS, + removeStalePreMountsFromResetAction, } from './GetStateForActionHandlers'; import syncBrowserHistory from './syncBrowserHistory'; @@ -189,7 +190,9 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return { ...stackRouter, - getStateForAction(state: StackNavigationState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) { + getStateForAction(state: StackNavigationState, rawAction: RootStackNavigatorAction, configOptions: RouterConfigOptions) { + const action = removeStalePreMountsFromResetAction(rawAction); + // Evaluate navigation guards FIRST const guardState = handleNavigationGuards(state, action, configOptions, stackRouter); if (guardState) { diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts index 2371a29b3680..a40348629bfd 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts @@ -28,7 +28,7 @@ type RootStackNavigatorActionType = } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; - payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean; preMountedRouteKey?: string}; } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; @@ -69,7 +69,7 @@ type DismissModalActionType = RootStackNavigatorActionType & { type ReplaceFullscreenUnderRHPActionType = RootStackNavigatorActionType & { type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; - payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean; preMountedRouteKey?: string}; }; type RemoveFullscreenUnderRHPActionType = RootStackNavigatorActionType & { diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 3af6e3f40087..0af832696e4d 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -168,11 +168,13 @@ function SplitRouter(options: SplitNavigatorRouterOptions) { return result ? getRehydratedState(result, configOptions) : result; } if (isPushingSidebarOnCentralPane(state, action, options)) { - if (getIsNarrowLayout()) { + if (getIsNarrowLayout() && state.index > 0) { const newAction = StackActions.popToTop(); - return stackRouter.getStateForAction(state, newAction, configOptions); + // popToTop returns null when the sidebar is already the only screen, so there is nothing to do. + return stackRouter.getStateForAction(state, newAction, configOptions) ?? state; } // On wide screen do nothing as we want to keep the central pane screen and the sidebar is visible. + // On narrow screen the sidebar is already on top, and the stack router reports popToTop with nothing to pop as unhandled. return state; } diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 91c270ac7e56..b8d057f4969e 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -63,7 +63,7 @@ import { } from './helpers/preMountBuffer'; import replaceWithSplitNavigator from './helpers/replaceWithSplitNavigator'; import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionToMicrotaskQueue'; -import {linkingConfig} from './linkingConfig'; +import {config as linkingConfig} from './linkingConfig/config'; import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS'; import navigationRef from './navigationRef'; import TransitionTracker from './TransitionTracker'; @@ -515,7 +515,7 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { const stateFromPath = getStateFromPath(backToRoute); - const action = getActionFromState(stateFromPath, linkingConfig.config); + const action = getActionFromState(stateFromPath, linkingConfig); if (!action) { Log.hmmm(`[Navigation] Unable to go up. Action is undefined.`); diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index da6ab94cb1af..543b69655ea0 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -144,7 +144,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N const {updateCurrentReportID} = useCurrentReportIDActions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); @@ -166,10 +165,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N return getAdaptedStateFromPath(lastVisitedPath); } - if (!account || account.isFromPublicDomain) { - return; - } - const isTransitioning = path?.includes(ROUTES.TRANSITION_BETWEEN_APPS); // If we have a transition URL, don't restore last visited path - let React Navigation handle it diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 821007eb556a..c50b7a92b33a 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,7 +1,8 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; -import type {BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; +import type {BottomTabNavigationOptions} from '@react-navigation/bottom-tabs'; +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; import React, {useLayoutEffect, useRef} from 'react'; @@ -26,18 +27,18 @@ function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs>) { - return ( - - ); +// Same as screenLayoutWrapper above, but for bottom-tab navigators, JS and native. No cast needed here - their +// `navigation` props' `addListener` structurally satisfies TransitionAwareNavigation. +function bottomTabScreenLayoutWrapper(props: ScreenLayoutProps) { + return ; } -type ScreenLayoutProps = ScreenLayoutArgs; +type ScreenLayoutProps = ScreenLayoutArgs< + ParamListBase, + string, + PlatformSpecificNavigationOptions | PlatformStackNavigationOptions | BottomTabNavigationOptions | NativeBottomTabNavigationOptions, + TransitionAwareNavigation +>; function ScreenLayout({children, navigation}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx index 386d78d00c47..74bb12405f96 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx @@ -51,6 +51,7 @@ function wrapDescriptorsWithNonTopScreensBehavior( const NonTopScreenWrapper = WRAPPER_FOR_BEHAVIOR[behavior]; // The state always carries a top route, but a missing key must leave every screen visible instead of blurring the whole stack. const isCovered = topRouteKey !== undefined && key !== topRouteKey; + // A wide submit pre-mount keeps its wrapper, so the reveal does not remount it, but must render while covered. const isLivePreMount = isLiveWideTabPreMountRouteKey(key); const isScreenBlurred = isCovered && !isLivePreMount; diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index 2c77dccd00b0..a82e4d144761 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,38 +1,20 @@ import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; -import { - RHP_TO_DOMAIN, - RHP_TO_HOME, - RHP_TO_SEARCH, - RHP_TO_SEARCH_DEEPLINK, - RHP_TO_SETTINGS, - RHP_TO_SIDEBAR, - RHP_TO_WORKSPACE, - RHP_TO_WORKSPACES_LIST, -} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, NavigationRoute, RootNavigatorParamList} from '@libs/Navigation/types'; import {getReportOrDraftReport} from '@libs/ReportUtils'; -import {getSearchParamFromPath} from '@libs/Url'; import NAVIGATORS from '@src/NAVIGATORS'; import type {Route as RoutePath} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Screen} from '@src/SCREENS'; import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; -import pick from 'lodash/pick'; - -import buildTabNavigatorNestedState from './buildTabNavigatorNestedState'; -import findAllMatchingDynamicSuffixes from './dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; import getDynamicRouteAdaptedState from './dynamicRoutesUtils/getDynamicRouteAdaptedState'; -import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen'; import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; -import getDefaultDeeplinkSearchQuery from './getDefaultDeeplinkSearchQuery'; +import getMatchingFullScreenRoute, {getRoutesWithIndex, getTabNavigatorState} from './getMatchingFullScreenRoute'; import getMatchingNewRoute from './getMatchingNewRoute'; -import getParamsFromRoute from './getParamsFromRoute'; import getStateFromPath from './getStateFromPath'; import {isFullScreenName} from './isNavigatorName'; import normalizePath from './normalizePath'; @@ -42,9 +24,6 @@ type GetAdaptedStateReturnType = ReturnType; type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; -// The function getPathFromState that we are using in some places isn't working correctly without defined index. -const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); - /** * Standalone full-screen public pages registered in PublicScreens (unauthenticated navigator) that * should NOT have TabNavigator prepended — they render on their own, with no tab navigator underneath. @@ -61,221 +40,10 @@ const PUBLIC_SCREENS = new Set([ SCREENS.SAML_SIGN_IN, ]); -/** - * Builds TabNavigator state with all tabs and the correct selected tab. - * Tab navigators require all routes in the state for proper rendering. - */ -function getTabNavigatorState(selectedTabRoute: NavigationPartialRoute): NavigationPartialRoute { - return { - name: NAVIGATORS.TAB_NAVIGATOR, - state: buildTabNavigatorNestedState(selectedTabRoute), - }; -} - -function isRouteWithBackToParam(route: NavigationRoute): route is Route { - return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string'; -} - function isRouteWithReportID(route: NavigationRoute): route is Route { return route.params !== undefined && 'reportID' in route.params && typeof route.params.reportID === 'string'; } -/** - * @param route - The (focused) route to find a full screen route for. - * @param isDeeplink - Whether the state is being built from a path (deeplink / browser refresh / cold - * load) as opposed to in-app navigation. When true, deeplink-only relations (e.g. RHP_TO_SEARCH_DEEPLINK) - * are also considered so an RHP can get a sensible default fullscreen underneath it. In-app callers - * (linkTo, swapBackgroundTabForRHPTarget) leave this false so the same RHP can open over any fullscreen - * without changing the page currently underneath. - */ -function getMatchingFullScreenRoute(route: NavigationRoute, isDeeplink = false) { - const isDynamicScreen = isDynamicRouteScreen(route.name as Screen); - - // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. - // Dynamic screens are skipped here because they never carry their own backTo - they only - // inherit it from the screen underneath. Letting backTo dictate the full-screen route for - // a dynamic screen would resolve the wrong page. - if (isRouteWithBackToParam(route) && !isDynamicScreen) { - const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath); - - // This may happen if the backTo url is invalid. - const lastRoute = stateForBackTo?.routes.at(-1); - if (!stateForBackTo || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { - return undefined; - } - - const isLastRouteFullScreen = isFullScreenName(lastRoute.name); - - // If the state for back to last route is a full screen route, we can use it - if (isLastRouteFullScreen) { - return lastRoute; - } - - const focusedStateForBackToRoute = findFocusedRouteWithOnyxTabGuard(stateForBackTo); - - if (!focusedStateForBackToRoute) { - return undefined; - } - // If not, get the matching full screen route for the back to state. - return getMatchingFullScreenRoute(focusedStateForBackToRoute, isDeeplink); - } - - // Deeplink-only relations provide a default search screen underneath the RHP when the state is - // built from a path. They are ignored for in-app navigation so the RHP can open over any fullscreen. - const matchingSearchScreen = RHP_TO_SEARCH[route.name]; - const matchingDeeplinkSearchScreen = isDeeplink ? RHP_TO_SEARCH_DEEPLINK[route.name] : undefined; - const resolvedSearchScreen = matchingSearchScreen ?? matchingDeeplinkSearchScreen; - if (resolvedSearchScreen) { - const paramsFromRoute = getParamsFromRoute(resolvedSearchScreen); - const copiedParams = paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : {}; - let queryParam: Record = {}; - if (route.path) { - const query = getSearchParamFromPath(route.path, 'q'); - if (query) { - queryParam = {q: query}; - } - } - - // Deeplink-only defaults (e.g. create flows) land on SCREENS.SEARCH.ROOT, which carries no path - // params and no `q`. Without an explicit query, SearchQueryProvider falls back to the previous - // defined query and can reveal a stale search when the RHP closes. Seed the canned expenses query - // so these deeplinks resolve to Spend > Expenses. - if (!queryParam.q && !matchingSearchScreen && matchingDeeplinkSearchScreen) { - queryParam = {q: getDefaultDeeplinkSearchQuery()}; - } - - const searchRoute = { - name: resolvedSearchScreen, - params: Object.keys({...copiedParams, ...queryParam}).length > 0 ? {...copiedParams, ...queryParam} : undefined, - }; - const searchState = getRoutesWithIndex([searchRoute]); - return getTabNavigatorState({name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, state: searchState}); - } - - if (RHP_TO_HOME[route.name]) { - return { - ...getTabNavigatorState({name: SCREENS.HOME}), - path: normalizePath(ROUTES.HOME), - }; - } - - if (RHP_TO_WORKSPACES_LIST[route.name]) { - return getTabNavigatorState({ - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: getRoutesWithIndex([ - { - name: SCREENS.WORKSPACES_LIST, - // prepending a slash to ensure closing the RHP after refreshing the page - // replaces the whole path with "/workspaces", instead of just replacing the last url segment ("/x/y/workspaces") - path: normalizePath(ROUTES.WORKSPACES_LIST.route), - }, - ]), - }); - } - - if (RHP_TO_WORKSPACE[route.name]) { - const paramsFromRoute = getParamsFromRoute(RHP_TO_WORKSPACE[route.name]); - - const workspaceSplitRoute = getInitialSplitNavigatorState( - { - name: SCREENS.WORKSPACE.INITIAL, - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - { - name: RHP_TO_WORKSPACE[route.name], - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - ); - - return getTabNavigatorState({ - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, workspaceSplitRoute]), - }); - } - - if (RHP_TO_SETTINGS[route.name]) { - const paramsFromRoute = getParamsFromRoute(RHP_TO_SETTINGS[route.name]); - - const settingsState = getInitialSplitNavigatorState( - {name: SCREENS.SETTINGS.ROOT}, - { - name: RHP_TO_SETTINGS[route.name], - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - ); - return getTabNavigatorState(settingsState); - } - - if (RHP_TO_DOMAIN[route.name]) { - const paramsFromRoute = getParamsFromRoute(RHP_TO_DOMAIN[route.name]); - - const domainSplitRoute = getInitialSplitNavigatorState( - { - name: SCREENS.DOMAIN.INITIAL, - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - { - name: RHP_TO_DOMAIN[route.name], - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - ); - - return getTabNavigatorState({ - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, domainSplitRoute]), - }); - } - - // Fallback: if no specific central screen RELATION matched, check if the RHP screen - // maps to a sidebar. This shows the split navigator with just the sidebar (no central screen). - if (RHP_TO_SIDEBAR[route.name]) { - const splitState = getInitialSplitNavigatorState({ - name: RHP_TO_SIDEBAR[route.name], - }); - return getTabNavigatorState(splitState); - } - - // Handle dynamic routes: find the appropriate full screen route. - // Iterate all candidates so that a false-positive first match (e.g. a tag named "gl-code" - // colliding with the registered static suffix) does not produce a NOT_FOUND state. - if (route.path) { - const allSuffixMatches = findAllMatchingDynamicSuffixes(route.path); - for (const suffixMatch of allSuffixMatches) { - const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(suffixMatch.pathUsedForMatching, suffixMatch.actualSuffix, suffixMatch.pattern); - - if (!pathWithoutDynamicSuffix) { - continue; - } - - // Parse the base path (without dynamic suffix) into a navigation state - // to determine which full-screen route should be visible underneath the overlay. - const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix); - const lastRoute = stateUnderDynamicRoute?.routes.at(-1); - - if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { - continue; - } - - const isLastRouteFullScreen = isFullScreenName(lastRoute.name); - - if (isLastRouteFullScreen) { - return lastRoute; - } - - const focusedRouteUnderDynamicRoute = findFocusedRouteWithOnyxTabGuard(stateUnderDynamicRoute); - - if (!focusedRouteUnderDynamicRoute) { - continue; - } - - // Recursively find the matching full screen route for the focused dynamic route - return getMatchingFullScreenRoute(focusedRouteUnderDynamicRoute, isDeeplink); - } - } - - return undefined; -} - // If there is no particular matching route defined, we want to get the default route. // It is the reports split navigator with report. If the reportID is defined in the focused route, we want to use it for the default report. // This is separated from getMatchingFullScreenRoute because we want to use it only for the initial state. @@ -445,4 +213,3 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR }; export default getAdaptedStateFromPath; -export {getMatchingFullScreenRoute, isFullScreenName}; diff --git a/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts new file mode 100644 index 000000000000..10ee6caa4cad --- /dev/null +++ b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts @@ -0,0 +1,258 @@ +/** + * Finds the full screen route to render under a given route. + * Split from getAdaptedStateFromPath so linkTo and Navigation can import it without pulling in ReportUtils. + * Do not import ReportUtils or anything that imports Navigation here, or Navigation rejoins the import/no-cycle graph. + */ +import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import { + RHP_TO_DOMAIN, + RHP_TO_HOME, + RHP_TO_SEARCH, + RHP_TO_SEARCH_DEEPLINK, + RHP_TO_SETTINGS, + RHP_TO_SIDEBAR, + RHP_TO_WORKSPACE, + RHP_TO_WORKSPACES_LIST, +} from '@libs/Navigation/linkingConfig/RELATIONS'; +import type {NavigationPartialRoute, NavigationRoute} from '@libs/Navigation/types'; +import {getSearchParamFromPath} from '@libs/Url'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import type {Route as RoutePath} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Screen} from '@src/SCREENS'; + +import type {NavigationState, PartialState, Route} from '@react-navigation/native'; + +import pick from 'lodash/pick'; + +import buildTabNavigatorNestedState from './buildTabNavigatorNestedState'; +import findAllMatchingDynamicSuffixes from './dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; +import getDefaultDeeplinkSearchQuery from './getDefaultDeeplinkSearchQuery'; +import getParamsFromRoute from './getParamsFromRoute'; +import getStateFromPath from './getStateFromPath'; +import {isFullScreenName} from './isNavigatorName'; +import normalizePath from './normalizePath'; + +// The function getPathFromState that we are using in some places isn't working correctly without defined index. +const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); + +/** + * Builds TabNavigator state with all tabs and the correct selected tab. + * Tab navigators require all routes in the state for proper rendering. + */ +function getTabNavigatorState(selectedTabRoute: NavigationPartialRoute): NavigationPartialRoute { + return { + name: NAVIGATORS.TAB_NAVIGATOR, + state: buildTabNavigatorNestedState(selectedTabRoute), + }; +} + +function isRouteWithBackToParam(route: NavigationRoute): route is Route { + return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string'; +} + +/** + * @param route - The (focused) route to find a full screen route for. + * @param isDeeplink - Whether the state is being built from a path (deeplink / browser refresh / cold + * load) as opposed to in-app navigation. When true, deeplink-only relations (e.g. RHP_TO_SEARCH_DEEPLINK) + * are also considered so an RHP can get a sensible default fullscreen underneath it. In-app callers + * (linkTo, swapBackgroundTabForRHPTarget) leave this false so the same RHP can open over any fullscreen + * without changing the page currently underneath. + */ +function getMatchingFullScreenRoute(route: NavigationRoute, isDeeplink = false) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- route names in navigation state are typed as string + const isDynamicScreen = isDynamicRouteScreen(route.name as Screen); + + // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. + // Dynamic screens are skipped here because they never carry their own backTo - they only + // inherit it from the screen underneath. Letting backTo dictate the full-screen route for + // a dynamic screen would resolve the wrong page. + if (isRouteWithBackToParam(route) && !isDynamicScreen) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- backTo is a path string read from route params + const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath); + + // This may happen if the backTo url is invalid. + const lastRoute = stateForBackTo?.routes.at(-1); + if (!stateForBackTo || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + return undefined; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + // If the state for back to last route is a full screen route, we can use it + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedStateForBackToRoute = findFocusedRouteWithOnyxTabGuard(stateForBackTo); + + if (!focusedStateForBackToRoute) { + return undefined; + } + // If not, get the matching full screen route for the back to state. + return getMatchingFullScreenRoute(focusedStateForBackToRoute, isDeeplink); + } + + // Deeplink-only relations provide a default search screen underneath the RHP when the state is + // built from a path. They are ignored for in-app navigation so the RHP can open over any fullscreen. + const matchingSearchScreen = RHP_TO_SEARCH[route.name]; + const matchingDeeplinkSearchScreen = isDeeplink ? RHP_TO_SEARCH_DEEPLINK[route.name] : undefined; + const resolvedSearchScreen = matchingSearchScreen ?? matchingDeeplinkSearchScreen; + if (resolvedSearchScreen) { + const paramsFromRoute = getParamsFromRoute(resolvedSearchScreen); + const copiedParams = paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : {}; + let queryParam: Record = {}; + if (route.path) { + const query = getSearchParamFromPath(route.path, 'q'); + if (query) { + queryParam = {q: query}; + } + } + + // Deeplink-only defaults (e.g. create flows) land on SCREENS.SEARCH.ROOT, which carries no path + // params and no `q`. Without an explicit query, SearchQueryProvider falls back to the previous + // defined query and can reveal a stale search when the RHP closes. Seed the canned expenses query + // so these deeplinks resolve to Spend > Expenses. + if (!queryParam.q && !matchingSearchScreen && matchingDeeplinkSearchScreen) { + queryParam = {q: getDefaultDeeplinkSearchQuery()}; + } + + const searchRoute = { + name: resolvedSearchScreen, + params: Object.keys({...copiedParams, ...queryParam}).length > 0 ? {...copiedParams, ...queryParam} : undefined, + }; + const searchState = getRoutesWithIndex([searchRoute]); + return getTabNavigatorState({name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, state: searchState}); + } + + if (RHP_TO_HOME[route.name]) { + return { + ...getTabNavigatorState({name: SCREENS.HOME}), + path: normalizePath(ROUTES.HOME), + }; + } + + if (RHP_TO_WORKSPACES_LIST[route.name]) { + return getTabNavigatorState({ + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: getRoutesWithIndex([ + { + name: SCREENS.WORKSPACES_LIST, + // prepending a slash to ensure closing the RHP after refreshing the page + // replaces the whole path with "/workspaces", instead of just replacing the last url segment ("/x/y/workspaces") + path: normalizePath(ROUTES.WORKSPACES_LIST.route), + }, + ]), + }); + } + + if (RHP_TO_WORKSPACE[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_WORKSPACE[route.name]); + + const workspaceSplitRoute = getInitialSplitNavigatorState( + { + name: SCREENS.WORKSPACE.INITIAL, + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + { + name: RHP_TO_WORKSPACE[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + + return getTabNavigatorState({ + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, workspaceSplitRoute]), + }); + } + + if (RHP_TO_SETTINGS[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_SETTINGS[route.name]); + + const settingsState = getInitialSplitNavigatorState( + {name: SCREENS.SETTINGS.ROOT}, + { + name: RHP_TO_SETTINGS[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + return getTabNavigatorState(settingsState); + } + + if (RHP_TO_DOMAIN[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_DOMAIN[route.name]); + + const domainSplitRoute = getInitialSplitNavigatorState( + { + name: SCREENS.DOMAIN.INITIAL, + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + { + name: RHP_TO_DOMAIN[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + + return getTabNavigatorState({ + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, domainSplitRoute]), + }); + } + + // Fallback: if no specific central screen RELATION matched, check if the RHP screen + // maps to a sidebar. This shows the split navigator with just the sidebar (no central screen). + if (RHP_TO_SIDEBAR[route.name]) { + const splitState = getInitialSplitNavigatorState({ + name: RHP_TO_SIDEBAR[route.name], + }); + return getTabNavigatorState(splitState); + } + + // Handle dynamic routes: find the appropriate full screen route. + // Iterate all candidates so that a false-positive first match (e.g. a tag named "gl-code" + // colliding with the registered static suffix) does not produce a NOT_FOUND state. + if (route.path) { + const allSuffixMatches = findAllMatchingDynamicSuffixes(route.path); + for (const suffixMatch of allSuffixMatches) { + const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(suffixMatch.pathUsedForMatching, suffixMatch.actualSuffix, suffixMatch.pattern); + + if (!pathWithoutDynamicSuffix) { + continue; + } + + // Parse the base path (without dynamic suffix) into a navigation state + // to determine which full-screen route should be visible underneath the overlay. + const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix); + const lastRoute = stateUnderDynamicRoute?.routes.at(-1); + + if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + continue; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedRouteUnderDynamicRoute = findFocusedRouteWithOnyxTabGuard(stateUnderDynamicRoute); + + if (!focusedRouteUnderDynamicRoute) { + continue; + } + + // Recursively find the matching full screen route for the focused dynamic route + return getMatchingFullScreenRoute(focusedRouteUnderDynamicRoute, isDeeplink); + } + } + + return undefined; +} + +export default getMatchingFullScreenRoute; +export {getRoutesWithIndex, getTabNavigatorState}; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index 7bafdef8d723..398a68898e57 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -1,8 +1,9 @@ -import {getMatchingFullScreenRoute, isFullScreenName} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getMatchingFullScreenRoute from '@libs/Navigation/helpers/getMatchingFullScreenRoute'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import {getTabState} from '@libs/Navigation/helpers/tabNavigatorUtils'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {config as linkingConfig} from '@libs/Navigation/linkingConfig/config'; import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; import {shallowCompare} from '@libs/ObjectUtils'; @@ -204,7 +205,7 @@ export default function linkTo(navigation: NavigationContainerRef(); + +// Tab route keys a pre-mount marked preloaded, so a saved browser history entry cannot keep a covered tab unfrozen forever. +const preloadedRouteKeysAddedByPreMount = new Set(); +let livePreloadedRouteKey: string | undefined; + function setLiveWideTabPreMountRouteKey(key: string | undefined) { liveRouteKey = key; } @@ -21,4 +30,26 @@ function isLiveWideTabPreMountTabRouteKey(key: string | undefined): boolean { return !!key && key === liveTabRouteKey; } -export {setLiveWideTabPreMountRouteKey, isLiveWideTabPreMountRouteKey, setLiveWideTabPreMountTabRouteKey, isLiveWideTabPreMountTabRouteKey}; +function markWideTabPreMountRouteKeyRevealed(key: string) { + revealedRouteKeys.add(key); +} + +/** True for a pre-mount that was neither revealed nor still live, e.g. restored from a saved browser history entry. */ +function isStaleWideTabPreMountRouteKey(key: string | undefined): boolean { + return !!key && key.includes(ROUTE_KEY_INFIX) && key !== liveRouteKey && !revealedRouteKeys.has(key); +} + +/** True for a tab preload that an earlier pre-mount added and the live one does not own. */ +function isStaleWideTabPreMountPreloadedRouteKey(key: string): boolean { + return preloadedRouteKeysAddedByPreMount.has(key) && key !== livePreloadedRouteKey; +} + +export { + setLiveWideTabPreMountRouteKey, + isLiveWideTabPreMountRouteKey, + setLiveWideTabPreMountTabRouteKey, + isLiveWideTabPreMountTabRouteKey, + markWideTabPreMountRouteKeyRevealed, + isStaleWideTabPreMountRouteKey, + isStaleWideTabPreMountPreloadedRouteKey, +}; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts index 572d7444556e..17f92ba79351 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts @@ -14,6 +14,7 @@ const DOMAIN_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE, exact: true, }, - [SCREENS.SETTINGS.AGENTS.NEW]: { - path: ROUTES.SETTINGS_AGENTS_NEW.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: { + path: DYNAMIC_ROUTES.AGENT_NEW.path, exact: true, }, - [SCREENS.SETTINGS.AGENTS.ADD]: { - path: ROUTES.SETTINGS_AGENTS_ADD.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: { + path: DYNAMIC_ROUTES.AGENT_ADD.path, }, - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: { - path: ROUTES.SETTINGS_AGENTS_ADD_AVATAR, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: { + path: DYNAMIC_ROUTES.AGENT_ADD_AVATAR.path, exact: true, }, - [SCREENS.SETTINGS.AGENTS.EDIT]: { - path: ROUTES.SETTINGS_AGENTS_EDIT.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { + path: DYNAMIC_ROUTES.AGENT_EDIT.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_NAME.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_NAME.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.path, parse: { accountID: Number, }, @@ -541,6 +541,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_VACATION_DELEGATE, exact: true, }, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: { + path: ROUTES.SETTINGS_VACATION_DELEGATE_SELECT, + exact: true, + }, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: { path: ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES, exact: true, @@ -1322,6 +1326,8 @@ const config: LinkingOptions['config'] = { [SCREENS.SETTINGS.DYNAMIC_KEYBOARD_SHORTCUTS]: DYNAMIC_ROUTES.KEYBOARD_SHORTCUTS.path, [SCREENS.SETTINGS.DYNAMIC_BETA_OVERRIDES]: DYNAMIC_ROUTES.BETA_OVERRIDES.path, [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_OVERVIEW_NAME.route, + [SCREENS.WORKSPACE.OFFICE_LOCATION_ADD]: ROUTES.WORKSPACE_OVERVIEW_OFFICE_LOCATION_ADD.route, + [SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT]: ROUTES.WORKSPACE_OVERVIEW_OFFICE_LOCATION_EDIT.route, [SCREENS.SETTINGS.SHARE_CODE]: { path: ROUTES.SETTINGS_SHARE_CODE, }, @@ -1618,6 +1624,9 @@ const config: LinkingOptions['config'] = { [SCREENS.DOMAIN.VACATION_DELEGATE]: { path: ROUTES.DOMAIN_VACATION_DELEGATE.route, }, + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + path: ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.route, + }, [SCREENS.DOMAIN.ADD_MEMBER]: { path: ROUTES.DOMAIN_ADD_MEMBER.route, }, @@ -1707,6 +1716,7 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_DETAILS.DYNAMIC_ROOT]: DYNAMIC_ROUTES.REPORT_DETAILS.path, [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path, [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: DYNAMIC_ROUTES.REPORT_DETAILS_EXPORT.path, + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path, }, }, [SCREENS.RIGHT_MODAL.REPORT_CARD_ACTIVATE]: { @@ -2514,6 +2524,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MEMBERS]: { path: ROUTES.WORKSPACE_MEMBERS.route, }, + [SCREENS.WORKSPACE.AGENTS]: { + path: ROUTES.WORKSPACE_AGENTS.route, + }, [SCREENS.WORKSPACE.ROOMS]: { path: ROUTES.WORKSPACE_ROOMS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d48e1c39b4fd..616cb31b8868 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + import type {MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types'; import type {SearchQueryString} from '@components/Search/types'; @@ -285,23 +287,23 @@ type SettingsNavigatorParamList = { subPage?: string; }; [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: undefined; - [SCREENS.SETTINGS.AGENTS.NEW]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: { policyID?: string; }; - [SCREENS.SETTINGS.AGENTS.ADD]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: { policyID?: string; }; - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: undefined; - [SCREENS.SETTINGS.AGENTS.EDIT]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: undefined; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { accountID: number; }; - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: { accountID: number; }; - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: { accountID: number; }; - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: { accountID: number; }; [SCREENS.SETTINGS.RULES.ADD]: undefined; @@ -353,6 +355,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: undefined; + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: undefined; [SCREENS.WORKSPACE.CURRENCY]: { isForcedToChangeCurrency?: boolean; @@ -369,6 +372,15 @@ type SettingsNavigatorParamList = { country?: Country | ''; }; [SCREENS.WORKSPACE.NAME]: undefined; + [SCREENS.WORKSPACE.OFFICE_LOCATION_ADD]: { + policyID: string; + country?: Country | ''; + }; + [SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT]: { + policyID: string; + officeID: string; + country?: Country | ''; + }; [SCREENS.WORKSPACE.DESCRIPTION]: undefined; [SCREENS.WORKSPACE.CLIENT_ID]: undefined; [SCREENS.WORKSPACE.SHARE]: undefined; @@ -2214,6 +2226,10 @@ type SettingsNavigatorParamList = { domainAccountID: number; accountID: number; }; + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + domainAccountID: number; + accountID: number; + }; [SCREENS.DOMAIN.MEMBER_FORCE_TWO_FACTOR_AUTH]: { domainAccountID: number; accountID: number; @@ -2314,12 +2330,16 @@ type ReportDetailsNavigatorParamList = { }; [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: { reportID: string; + shouldShowQRCodeOnly?: string; }; [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: { reportID: string; policyID: string; connectionName: ConnectionName; }; + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: { + reportID: string; + }; }; type ReportCardActivateNavigatorParamList = { @@ -3337,6 +3357,9 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.MEMBERS]: { policyID: string; }; + [SCREENS.WORKSPACE.AGENTS]: { + policyID: string; + }; [SCREENS.WORKSPACE.ROOMS]: { policyID: string; }; diff --git a/src/libs/NumberFormatUtils/index.ts b/src/libs/NumberFormatUtils/index.ts index 989f267bd9d1..ddedab49a798 100644 --- a/src/libs/NumberFormatUtils/index.ts +++ b/src/libs/NumberFormatUtils/index.ts @@ -4,6 +4,7 @@ import memoize from '@libs/memoize'; import CONST from '@src/CONST'; import type Locale from '@src/types/onyx/Locale'; +import type {Unit} from '@src/types/onyx/Policy'; // Polyfill the Intl API if locale data is not as expected intlPolyfill(); @@ -44,6 +45,20 @@ function formatToParts(locale: Locale | undefined, number: number, options?: Int return createFormatter(locale, options).formatToParts(number); } +/** + * Formats a distance that is already expressed in the given unit (mi/km) into localized parts, so the caller can + * render the number and the unit in the order, spacing, and spelling the locale uses (e.g. "12.50 mi", "12,50 km", "12.50英里"). + */ +function formatDistanceToParts(locale: Locale | undefined, distanceInUnits: number, unit: Unit): Intl.NumberFormatPart[] { + return formatToParts(locale, distanceInUnits, { + style: 'unit', + unit: unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? 'mile' : 'kilometer', + unitDisplay: 'short', + minimumFractionDigits: CONST.DISTANCE_DECIMAL_PLACES, + maximumFractionDigits: CONST.DISTANCE_DECIMAL_PLACES, + }); +} + /** * Test-only: clears the malformed-currency deduplication so warn-assertion tests don't pollute each other. */ @@ -51,4 +66,4 @@ function resetMalformedCurrenciesForTesting() { warnedMalformedCurrencies.clear(); } -export {format, formatToParts, resetMalformedCurrenciesForTesting}; +export {format, formatToParts, formatDistanceToParts, resetMalformedCurrenciesForTesting}; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 55e9cd44de57..b03f54e78e86 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -230,9 +230,8 @@ type GetAlternateTextConfig = { lastActions?: Record; isTrackIntentUser?: boolean; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + currentUserAccountID: number; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; - // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 - currentUserAccountID?: number; currentUserLogin?: string; localeCompare?: LocaleContextProps['localeCompare']; formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; @@ -395,10 +394,9 @@ type CreateOptionParams = { conciergeReportID: string | undefined; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; + currentUserAccountID: number; transactionThreadIDs?: Record; lastActions?: Record; - // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 - currentUserAccountID?: number; // TODO: Remove optional (?) once all callers pass pendingDeleteMemberAccountIDs. Refactor issue: https://github.com/Expensify/App/issues/66421 pendingDeleteMemberAccountIDs?: string[]; currentUserLogin?: string; @@ -2127,7 +2125,7 @@ function prepareReportOptionsForDisplay( convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; dateFnsLocale: DateFnsLocale | undefined; - currentUserAccountID?: number; + currentUserAccountID: number; currentUserLogin?: string; localeCompare?: LocaleContextProps['localeCompare']; formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; diff --git a/src/libs/PolicyUtils/approvals.ts b/src/libs/PolicyUtils/approvals.ts new file mode 100644 index 000000000000..28fcccc0cb17 --- /dev/null +++ b/src/libs/PolicyUtils/approvals.ts @@ -0,0 +1,455 @@ +/** + * Approval mode, the default approver, approval workflow rules, and who a report submits or forwards to. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + +import {getCategoryApproverRule} from '@libs/CategoryUtils'; +import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; +import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; +import {isApprovalWorkflowRule, isRuleFilterComparison} from '@libs/RuleUtils'; +import {getAllSortedTransactions, getCategory, getTag} from '@libs/TransactionUtils'; +import {isValidAccountRoute} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy, Report} from '@src/types/onyx'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type {ApprovalRule} from '@src/types/onyx/Policy'; +import type Rule from '@src/types/onyx/Rule'; +import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {isPolicyApprover} from './permissions'; +import {isGroupPolicy} from './policyType'; +import {getTagApproverRule} from './rules'; +import {isSubmitAndClose} from './workflows'; + +/** The report facts an approval workflow rule is matched against. */ +type ApprovalWorkflowContext = { + /** Email of the member who submitted the report. */ + submitterEmail: string; + + /** Email of the approver the report is currently with. Empty when the report has not been approved yet. */ + currentApproverEmail?: string; + + /** Total of the report, in the backend (cents) amount the rules' `amount` filters are written in. */ + reportTotal: number; +}; + +/** The outcome of matching a report against the policy's approval workflow rules. */ +type ApprovalWorkflowRuleMatch = { + /** Email the matched rule forwards the report to. Undefined when the rule finalizes the report instead. */ + forwardsTo?: string; +}; + +/** + * Returns true when the user is both a submitter and an approver, mirroring the Submit/Approve suggested-search eligibility in + * `getSuggestedSearchesVisibility` (SearchUIUtils): a submitter is a member of any group workspace, and an approver is a member of a + * group workspace with a non-optional approval flow whom `isPolicyApprover` recognizes (named approver or someone reports submit/forward to). + */ +function isSubmitterAndApprover(policies: OnyxCollection | null | undefined, currentUserEmail: string | undefined): boolean { + if (!policies || !currentUserEmail) { + return false; + } + let isSubmitter = false; + let isApprover = false; + for (const policy of Object.values(policies)) { + if (!policy) { + continue; + } + isSubmitter = isSubmitter || isGroupPolicy(policy); + if (!isApprover) { + const hasApprovalFlow = isGroupPolicy(policy) && !!policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; + isApprover = hasApprovalFlow && isPolicyApprover(policy, currentUserEmail); + } + if (isSubmitter && isApprover) { + return true; + } + } + return false; +} + +function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + +function getApprovalWorkflow(policy: OnyxEntry): ValueOf { + if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { + return CONST.POLICY.APPROVAL_MODE.OPTIONAL; + } + + return policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + +function getDefaultApprover(policy: OnyxEntry): string { + // We intentionally use || here so an empty-string approver falls back to the owner. ?? would keep the empty string. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return policy?.approver || policy?.owner || ''; +} + +/** + * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: + * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or + * - a new workflow was created (a member submits to an approver other than the default approver). + */ +function hasCustomApprovalWorkflow(policy: OnyxEntry): boolean { + if (!policy) { + return false; + } + + const defaultApprover = getDefaultApprover(policy); + + // The default workflow's first approver was changed away from the workspace owner. + if (!!policy.approver && !!policy.owner && policy.approver !== policy.owner) { + return true; + } + + const employees = policy.employeeList ?? {}; + + // The default approver forwards approvals to someone, i.e. an "Approves to" user was added, + // either unconditionally (forwardsTo) or above an approval limit (overLimitForwardsTo). + const defaultApproverEmployee = employees[defaultApprover]; + if (!!defaultApprover && (!!defaultApproverEmployee?.forwardsTo || !!defaultApproverEmployee?.overLimitForwardsTo)) { + return true; + } + + // A new workflow exists when a member submits to an approver other than the default approver. + return Object.values(employees).some( + (employee) => employee?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!employee?.submitsTo && employee.submitsTo !== defaultApprover, + ); +} + +function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { + const categoryApprovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); + + // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. + // Category approvers are prioritized, then tag approvers. + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const tag = getTag(transaction); + const category = getCategory(transaction); + const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = getTagApproverRule(policy, tag)?.approver; + if (categoryApprover) { + categoryApprovers.push(categoryApprover); + } + + if (tagApprover) { + tagApprovers.push(tagApprover); + } + } + + // Return the unique approvers list + return [...new Set([...categoryApprovers, ...tagApprovers])]; +} + +function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry, ownerLogin: string | undefined) { + // Pre-build a lookup map of { category: { value → approver }, tag: { value → approver } } + // from the policy's approval rules so that each transaction's category/tag can be resolved in O(1). + const rulesMap: Record<'category' | 'tag', Record> = { + category: {}, + tag: {}, + }; + + for (let i = 0; i < approvalRules.length; i++) { + const rule = approvalRules.at(i); + if (!rule) { + continue; + } + for (let j = 0; j < rule.applyWhen.length; j++) { + const applyWhen = rule.applyWhen.at(j); + if (applyWhen?.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) { + continue; + } + if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) { + rulesMap[applyWhen.field][applyWhen.value] = rule.approver; + } + } + } + + if (isEmptyObject(rulesMap.category) && isEmptyObject(rulesMap.tag)) { + return ''; + } + + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); + + if (!allReportTransactions.length) { + return ''; + } + + let firstCategoryApprover = ''; + let firstTagApprover = ''; + + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const category = getCategory(transaction); + const categoryApprover = rulesMap.category[category]; + + // Category approvers take strict priority over tag approvers. + // Break immediately on the first match so we don't keep scanning transactions unnecessarily. + if (categoryApprover && categoryApprover !== ownerLogin) { + firstCategoryApprover = categoryApprover; + break; + } + + // Only look for a tag approver if we haven't found one yet — no need to re-check on subsequent transactions. + if (!firstTagApprover) { + const tag = getTag(transaction); + const tagApprover = rulesMap.tag[tag]; + + if (tagApprover && tagApprover !== ownerLogin) { + firstTagApprover = tagApprover; + } + } + } + + return firstCategoryApprover || firstTagApprover; +} + +function matchesApprovalWorkflowEmailComparison(node: RuleFilterComparison, email: string | undefined): boolean { + const expectedEmails = (Array.isArray(node.right) ? node.right : [node.right]).map((value) => String(value).toLowerCase()); + const isMatch = !!email && expectedEmails.includes(email.toLowerCase()); + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO ? !isMatch : isMatch; +} + +function matchesApprovalWorkflowAmountComparison(node: RuleFilterComparison, amount: number): boolean { + const expectedAmount = typeof node.right === 'number' ? node.right : Number(node.right); + if (Number.isNaN(expectedAmount)) { + return false; + } + + switch (node.operator) { + case CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO: + return amount === expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO: + return amount !== expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN: + return amount > expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO: + return amount >= expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN: + return amount < expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO: + return amount <= expectedAmount; + default: + return false; + } +} + +function evaluateApprovalWorkflowFilter(node: RuleFilterNode, context: ApprovalWorkflowContext): boolean { + if (!isRuleFilterComparison(node)) { + const left = evaluateApprovalWorkflowFilter(node.left, context); + const right = evaluateApprovalWorkflowFilter(node.right, context); + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR ? left || right : left && right; + } + + switch (node.left) { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: + return matchesApprovalWorkflowEmailComparison(node, context.submitterEmail); + + // A report with no current approver has not been approved by anybody yet, so a rule based on who + // approved it last cannot apply. + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: + return !!context.currentApproverEmail && matchesApprovalWorkflowEmailComparison(node, context.currentApproverEmail); + case CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT: + return matchesApprovalWorkflowAmountComparison(node, Math.abs(context.reportTotal)); + + default: + return false; + } +} + +/** + * True when every condition in the rule's filter tree is true for `context`. + */ +function evaluateApprovalWorkflowRule(rule: ApprovalWorkflowRule, context: ApprovalWorkflowContext): boolean { + return evaluateApprovalWorkflowFilter(rule.filters, context); +} + +/** + * Check the policy's approval workflow rules to determine where the report goes next. + */ +function getForwardsToFromRules(policy: OnyxEntry, context: ApprovalWorkflowContext, rules: OnyxCollection): ApprovalWorkflowRuleMatch | undefined { + if (!policy?.id || !context.submitterEmail) { + return undefined; + } + + const trigger = context.currentApproverEmail ? CONST.RULES.TRIGGERS.REPORT_APPROVE : CONST.RULES.TRIGGERS.REPORT_SUBMIT; + + // Sort by Onyx key so the rule picked stays the same across evaluations when more than one matches (which should not happen). + const ruleKeys = Object.keys(rules ?? {}).sort(); + for (const ruleKey of ruleKeys) { + const rule = rules?.[ruleKey]; + if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policy.id || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (!isApprovalWorkflowRule(rule) || !Object.values(rule.triggers ?? {}).includes(trigger)) { + continue; + } + if (!evaluateApprovalWorkflowRule(rule, context)) { + continue; + } + + return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.ACTIONS.FORWARD_TO)?.approver}; + } + + return undefined; +} + +function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0): string { + const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal}, rules); + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + const defaultApprover = getDefaultApprover(policy); + + // For policy using the optional or basic workflow, the manager is the policy default approver. + if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { + return defaultApprover; + } + + const employee = policy?.employeeList?.[ownerLogin ?? '']; + if (!employee && !defaultApprover) { + return ''; + } + + return employee?.submitsTo ?? defaultApprover ?? ''; +} + +function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0) { + const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, reportTotal); + return managerEmail ? (getAccountIDsByLogins([managerEmail]).at(0) ?? -1) : -1; +} + +/** + * Returns the email the expense report should submit to per workspace approval config + * (approval rules, employee submitsTo, or default approver for basic/optional workflows). + */ +function getSubmitToEmail( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): string { + const approvalRules = policy?.rules?.approvalRules; + + if (!isSubmitAndClose(policy) && approvalRules?.length) { + const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport, ownerLogin); + if (ruleApprover) { + return ruleApprover; + } + } + + const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal: expenseReport?.total ?? 0}, rules); + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, expenseReport?.total ?? 0); + + // Falls back to the default approver when the manager is unavailable. + if (shouldFallBackWhenManagerIsNotMember && managerEmail && !policy?.employeeList?.[managerEmail] && !getHRAdvancedModeFinalApprover(policy)) { + return getDefaultApprover(policy); + } + + return managerEmail; +} + +/** + * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. + */ +function getSubmitToAccountID( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): number { + const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, rules, shouldFallBackWhenManagerIsNotMember); + return submitToEmail ? (getAccountIDsByLogins([submitToEmail]).at(0) ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; +} + +function getSubmitReportManagerAccountID( + policy: OnyxEntry, + expenseReport: OnyxEntry, + submitterLogin: string | undefined, + rules: OnyxCollection, + personalDetailsByLogins: PersonalDetailsByLogin, +): number | undefined { + const approvalRules = policy?.rules?.approvalRules; + const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; + const hasReliablePolicyRoute = + ([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy)) || + !!ruleApprover || + !!policy?.employeeList?.[submitterLogin ?? '']; + + if (!hasReliablePolicyRoute) { + return undefined; + } + + const submitToEmail = getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true); + const submitToAccountID = submitToEmail ? personalDetailsByLogins[submitToEmail]?.accountID : undefined; + if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { + return undefined; + } + + return submitToAccountID; +} + +/** + * Returns the email of the account to forward the report to depending on the approver's approval limit. + * Used for advanced approval mode only. + * + * `submitterEmail` is needed to consult the workspace's approval workflow rules, which describe each hop in + * terms of who submitted the report as well as who is approving it. Without it only the legacy employeeList + * forwardsTo/overLimitForwardsTo is considered. + */ +function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, reportTotal: number, rules: OnyxCollection, submitterEmail?: string): string { + const ruleMatch = submitterEmail ? getForwardsToFromRules(policy, {submitterEmail, currentApproverEmail: employeeEmail, reportTotal}, rules) : undefined; + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + if (!isControlOnAdvancedApprovalMode(policy)) { + return ''; + } + + const employee = policy?.employeeList?.[employeeEmail]; + if (!employee) { + return ''; + } + + const positiveReportTotal = Math.abs(reportTotal); + if (employee.approvalLimit && employee.overLimitForwardsTo && positiveReportTotal > employee.approvalLimit) { + return employee.overLimitForwardsTo; + } + return employee.forwardsTo ?? ''; +} + +function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; +} + +export { + isSubmitterAndApprover, + getApprovalWorkflow, + getDefaultApprover, + hasCustomApprovalWorkflow, + getRuleApprovers, + evaluateApprovalWorkflowRule, + getForwardsToFromRules, + getManagerAccountEmail, + getManagerAccountID, + getSubmitToEmail, + getSubmitToAccountID, + getSubmitReportManagerAccountID, + getForwardsToAccount, + getWorkflowApprovalsUnavailable, +}; diff --git a/src/libs/PolicyUtils/errors.ts b/src/libs/PolicyUtils/errors.ts new file mode 100644 index 000000000000..19eb0be7e415 --- /dev/null +++ b/src/libs/PolicyUtils/errors.ts @@ -0,0 +1,144 @@ +/** + * Policy error flags, the brick-road indicator, and settings error and pending-action helpers. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {isQBORefreshTokenExpiringSoon} from '@libs/AccountingUtils'; +import {hasSynchronizationErrorMessage} from '@libs/actions/connections'; +import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; +import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {getAccountingConnectionNames} from './connections'; +import {isPolicyAdmin} from './permissions'; + +/** + * Checks if we have any errors stored within the policy?.employeeList. Determines whether we should show a red brick road error or not. + */ +function shouldShowEmployeeListError(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.values(policy?.employeeList ?? {}).some((employee) => Object.keys(employee?.errors ?? {}).length > 0); +} + +/** + * Check if the policy has any tax rate errors. + */ +function shouldShowTaxRateError(policy: OnyxEntry): boolean { + return ( + isPolicyAdmin(policy) && + Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)) + ); +} + +/** + * Check if the policy has any errors within the categories. + */ +function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { + return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); +} + +/** + * Check if the policy has any errors within the rules. + */ +function hasPolicyRulesError(policy: OnyxEntry, hasMerchantRuleErrors: boolean): boolean { + const agentRules = Object.values(policy?.rules?.agentRules ?? {}); + + return hasMerchantRuleErrors || agentRules.some((rule) => rule && Object.keys(rule.errors ?? {}).length > 0); +} + +/** + * Checks if the policy had a sync error. + */ +function shouldShowSyncError(policy: OnyxEntry, isSyncInProgress: boolean, connectionNames?: readonly ConnectionName[]): boolean { + const connectionsToCheck = connectionNames ?? (Object.keys(policy?.connections ?? {}) as ConnectionName[]); + return isPolicyAdmin(policy) && connectionsToCheck.some((connection) => !!hasSynchronizationErrorMessage(policy, connection, isSyncInProgress)); +} + +/** + * Check if the policy has any error fields. + */ +function shouldShowPolicyErrorFields(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0); +} + +/** + * Check if the policy has any errors, and if it doesn't, then check if it has any error fields. + */ +function shouldShowPolicyError(policy: OnyxEntry): boolean { + return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); +} + +/** + * Checks if we have any errors stored within the policy custom units. + */ +function shouldShowCustomUnitsError(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.keys(policy?.customUnits?.errors ?? {}).length > 0; +} + +/** + * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. + */ +function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnectionInProgress: boolean): ValueOf | undefined { + if ( + shouldShowEmployeeListError(policy) || + shouldShowCustomUnitsError(policy) || + shouldShowPolicyErrorFields(policy) || + shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)) + ) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + return undefined; +} + +/** + * Returns whether the Merge HR setup still needs to be completed for a policy. + */ +const isMergeHRCompleteSetupNeededSelector = (policy: OnyxEntry) => isMergeHRCompleteSetupNeeded(policy); + +/** + * Returns whether an admin should be warned that the workspace's QuickBooks Online connection is about to expire. + */ +const isQBORefreshTokenExpiringSoonSelector = (policy: OnyxEntry) => isPolicyAdmin(policy) && isQBORefreshTokenExpiringSoon(policy); + +function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { + if (settings === undefined || errorFields === undefined) { + return false; + } + + const keys = Object.keys(errorFields); + return settings.some((setting) => keys.includes(setting)); +} + +function settingsPendingAction(settings?: string[], pendingFields?: PendingFields): PendingAction | undefined { + if (settings === undefined || pendingFields === undefined) { + return null; + } + + const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); + if (!key) { + return; + } + return pendingFields[key]; +} + +export { + shouldShowEmployeeListError, + shouldShowTaxRateError, + hasPolicyCategoriesError, + hasPolicyRulesError, + shouldShowSyncError, + shouldShowPolicyError, + shouldShowCustomUnitsError, + getPolicyBrickRoadIndicatorStatus, + isMergeHRCompleteSetupNeededSelector, + isQBORefreshTokenExpiringSoonSelector, + areSettingsInErrorFields, + settingsPendingAction, +}; diff --git a/src/libs/PolicyUtils/features.ts b/src/libs/PolicyUtils/features.ts new file mode 100644 index 000000000000..ef57eeeaa58c --- /dev/null +++ b/src/libs/PolicyUtils/features.ts @@ -0,0 +1,106 @@ +/** + * Whether a workspace feature is enabled, including rules, invoice fields, attendee tracking, time tracking, and MCP. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {hasAnyCategoryRules} from '@libs/CategoryUtils'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import {isAnyHRConnected} from '@libs/merge/HRUtils'; +import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {hasAccountingFeatureConnection} from './connections'; +import {canPolicyAccessFeature, isControlPolicy, isPaidGroupPolicy} from './policyType'; + +/** + * For backwards compatibility with Expensify Classic, attendee tracking defaults to enabled + * on Control policies when the property is undefined. + */ +function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { + return (isControlPolicy(policy) && policy?.isAttendeeTrackingEnabled) ?? true; +} + +function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { + if (!isPaidGroupPolicy(policy)) { + return false; + } + if (policy?.areRulesEnabled === true) { + return true; + } + if (policy?.areRulesEnabled === false) { + return false; + } + // areRulesEnabled is undefined - this can happen in case of migrated old Control policies, in such case users might have set up category rules in Classic and we should show Rules as enabled + if (!isControlPolicy(policy)) { + return false; + } + return hasAnyCategoryRules(policyCategories ?? undefined); +} + +/** + * Whether Invoice Fields is enabled for the policy. + * Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only). + */ +function isInvoiceFieldsEnabled(policy: OnyxEntry | null): boolean { + return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED); +} + +function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { + return arePolicyRulesEnabled(policy, policyCategories); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) { + return isInvoiceFieldsEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { + return !!policy?.tax?.trackingEnabled; + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { + return policy?.[featureName] ? !!policy?.[featureName] : hasAccountingFeatureConnection(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED) { + return isMCPEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED) { + return policy?.isHREnabled === true || isAnyHRConnected(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED) { + return policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED) { + return policy?.receiptPartners?.enabled ?? false; + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED) { + return isTimeTrackingEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED) { + return isAttendeeTrackingEnabled(policy); + } + + return !!policy?.[featureName]; +} + +/** + * Returns if the policy has the Time Tracking feature enabled. + */ +function isTimeTrackingEnabled(policy: OnyxEntry): boolean { + return !!policy?.units?.time?.enabled; +} + +/** MCP is on for every workspace unless an admin has explicitly turned it off, so an absent flag reads as enabled. */ +function isMCPEnabled(policy: OnyxEntry): boolean { + return policy?.isMCPEnabled ?? true; +} + +/** + * Returns the policy's default hourly rate for the Time Tracking feature. + */ +function getDefaultTimeTrackingRate(policy: Partial>): number | undefined { + return policy?.units?.time?.rate !== undefined ? convertToBackendAmount(policy.units.time.rate) : undefined; +} + +export {isAttendeeTrackingEnabled, arePolicyRulesEnabled, isInvoiceFieldsEnabled, isPolicyFeatureEnabled, isTimeTrackingEnabled, isMCPEnabled, getDefaultTimeTrackingRate}; diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index c1d8e3fecc80..7242705ff5eb 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1,49 +1,35 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; -import {isQBORefreshTokenExpiringSoon} from '@libs/AccountingUtils'; import {getBankAccountFromID} from '@libs/actions/BankAccounts'; -import {hasSynchronizationErrorMessage} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import {getApiRoot} from '@libs/ApiUtils'; -import {getCategoryApproverRule, hasAnyCategoryRules} from '@libs/CategoryUtils'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {getHRAdvancedModeFinalApprover, isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; -import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; +import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getIsOffline} from '@libs/NetworkState'; -import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; -import {isApprovalWorkflowRule, isRuleFilterComparison} from '@libs/RuleUtils'; -import {getAllSortedTransactions, getCategory, getTag} from '@libs/TransactionUtils'; import {generateAccountID} from '@libs/UserUtils'; -import {isPublicDomain, isValidAccountRoute} from '@libs/ValidationUtils'; +import {isPublicDomain} from '@libs/ValidationUtils'; import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; -import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyCategories, PolicyEmployeeList, Report, TravelSettings} from '@src/types/onyx'; -import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; -import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; -import type {ApprovalRule, CommuterExclusions, ConnectionName, PolicyFeatureName} from '@src/types/onyx/Policy'; +import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyEmployeeList, TravelSettings} from '@src/types/onyx'; +import type {CommuterExclusions} from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; -import type Rule from '@src/types/onyx/Rule'; -import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; -import {getAccountingConnectionNames, hasAccountingFeatureConnection} from './connections'; +import {hasAccountingConnections} from './connections'; import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; -import {canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; -import {canPolicyAccessFeature, isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; +import {isTimeTrackingEnabled} from './features'; +import {canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; +import {isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; type MemberEmailsToAccountIDs = Record; @@ -54,24 +40,6 @@ type WorkspaceDetails = { name: string; }; -/** The report facts an approval workflow rule is matched against. */ -type ApprovalWorkflowContext = { - /** Email of the member who submitted the report. */ - submitterEmail: string; - - /** Email of the approver the report is currently with. Empty when the report has not been approved yet. */ - currentApproverEmail?: string; - - /** Total of the report, in the backend (cents) amount the rules' `amount` filters are written in. */ - reportTotal: number; -}; - -/** The outcome of matching a report against the policy's approval workflow rules. */ -type ApprovalWorkflowRuleMatch = { - /** Email the matched rule forwards the report to. Undefined when the rule finalizes the report instead. */ - forwardsTo?: string; -}; - /** * Returns true if the policy has no fieldList or its fieldList is empty. */ @@ -132,65 +100,10 @@ function getActivePoliciesWithExpenseChatAndTimeEnabled(policies: OnyxCollection } /** - * Checks if we have any errors stored within the policy?.employeeList. Determines whether we should show a red brick road error or not. - */ -function shouldShowEmployeeListError(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.values(policy?.employeeList ?? {}).some((employee) => Object.keys(employee?.errors ?? {}).length > 0); -} - -/** - * Check if the policy has any tax rate errors. - */ -function shouldShowTaxRateError(policy: OnyxEntry): boolean { - return ( - isPolicyAdmin(policy) && - Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)) - ); -} - -/** - * Check if the policy has any errors within the categories. - */ -function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { - return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); -} - -/** - * Check if the policy has any errors within the rules. - */ -function hasPolicyRulesError(policy: OnyxEntry, hasMerchantRuleErrors: boolean): boolean { - const agentRules = Object.values(policy?.rules?.agentRules ?? {}); - - return hasMerchantRuleErrors || agentRules.some((rule) => rule && Object.keys(rule.errors ?? {}).length > 0); -} - -/** - * Checks if the policy had a sync error. - */ -function shouldShowSyncError(policy: OnyxEntry, isSyncInProgress: boolean, connectionNames?: readonly ConnectionName[]): boolean { - const connectionsToCheck = connectionNames ?? (Object.keys(policy?.connections ?? {}) as ConnectionName[]); - return isPolicyAdmin(policy) && connectionsToCheck.some((connection) => !!hasSynchronizationErrorMessage(policy, connection, isSyncInProgress)); -} - -/** - * Check if the policy has any error fields. - */ -function shouldShowPolicyErrorFields(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0); -} - -/** - * Check if the policy has any errors, and if it doesn't, then check if it has any error fields. + * Whether the workspace has a company address */ -function shouldShowPolicyError(policy: OnyxEntry): boolean { - return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); -} - -/** - * Checks if we have any errors stored within the policy custom units. - */ -function shouldShowCustomUnitsError(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.keys(policy?.customUnits?.errors ?? {}).length > 0; +function hasCompanyAddress(policy: OnyxEntry): boolean { + return !!policy?.address?.addressStreet?.trim(); } /** @@ -247,33 +160,6 @@ function hasEligibleBankAccountShareRecipient(policies: OnyxCollection | return getEligibleBankAccountShareRecipientEmails(policies, currentUserLogin, bankAccountID).length > 0; } -/** - * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. - */ -function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnectionInProgress: boolean): ValueOf | undefined { - if ( - shouldShowEmployeeListError(policy) || - shouldShowCustomUnitsError(policy) || - shouldShowPolicyErrorFields(policy) || - shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) || - shouldShowQBOReimbursableExportDestinationAccountError(policy) || - shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)) - ) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - return undefined; -} - -/** - * Returns whether the Merge HR setup still needs to be completed for a policy. - */ -const isMergeHRCompleteSetupNeededSelector = (policy: OnyxEntry) => isMergeHRCompleteSetupNeeded(policy); - -/** - * Returns whether an admin should be warned that the workspace's QuickBooks Online connection is about to expire. - */ -const isQBORefreshTokenExpiringSoonSelector = (policy: OnyxEntry) => isPolicyAdmin(policy) && isQBORefreshTokenExpiringSoon(policy); - /** * Check if the policy can be displayed * If shouldShowPendingDeletePolicy is true, show the policy pending deletion. @@ -315,69 +201,6 @@ function isPolicyMemberWithoutPendingDelete(currentUserLogin: string | undefined return !!policyEmployee && policyEmployee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } -/** - * Resolve the email of the workspace's Authorized Payer (reimburser), matching the canonical resolution used across the app. - * Returns undefined when reimbursement is disabled or no payer can be resolved. - */ -function getReimburserEmail(policy: OnyxEntry): string | undefined { - if (!policy) { - return undefined; - } - - const reimbursementChoice = getReimbursementChoice(policy); - const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - - // Reimbursement is disabled for this workspace. - if (!isAutoReimbursement && !isManualReimbursement) { - return undefined; - } - - return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); -} - -/** - * Payer fields to merge into the successData of an ownership transfer. The backend keeps the former payer when the - * workspace has a bank account, so only reassign when there is none and the outgoing owner is the resolved payer. - */ -function getOwnerChangePayerSuccessData(policy: OnyxEntry, newOwnerLogin: string): NullishDeep { - if (!policy || policy.achAccount?.bankAccountID || getReimburserEmail(policy) !== policy.owner) { - return {}; - } - - return { - ...(policy.reimburser ? {reimburser: newOwnerLogin} : {}), - ...(policy.achAccount?.reimburser ? {achAccount: {reimburser: newOwnerLogin}} : {}), - }; -} - -function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { - if (!policy) { - return false; - } - - const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; - const reimbursementChoice = getReimbursementChoice(policy); - const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - - // Reimbursement is disabled for this workspace. - if (!isAutoReimbursement && !isManualReimbursement) { - return false; - } - - const reimburserEmail = getReimburserEmail(policy); - - // No designated reimburser means any workspace admin can pay. - if (!reimburserEmail) { - return isAdmin; - } - - const canPayOnPolicy = isAdmin || (!!currentUserLogin && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); - - return canPayOnPolicy && currentUserLogin === reimburserEmail; -} - /** Check if the passed employee holds an active Expensify Card on the policy, as reported by the backend in the policy's employeeList */ function hasActiveExpensifyCard(policy: OnyxEntry, employeeLogin: string) { const primaryLogin = policy?.primaryLoginsInvited?.[employeeLogin]; @@ -687,100 +510,6 @@ function filterGuideAndAccountManager): boolean { - if (!policyCategories) { - return false; - } - - const defaultCategoryNames = new Set(Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); - - return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); -} - -/** - * Checks whether a policy max expense amount (e.g. `maxExpenseAmountNoReceipt`) is actually configured. - * - * `0` is a valid limit meaning "always required", so a falsy check would wrongly treat an explicit $0.00 as unset. - * Only `undefined` and `CONST.DISABLED_MAX_EXPENSE_VALUE` mean the amount is not set. - */ -function isMaxExpenseAmountSet(value: number | undefined): value is number { - return value !== undefined && value !== CONST.DISABLED_MAX_EXPENSE_VALUE; -} - -/** - * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). - */ -function hasConfiguredRules(policy: OnyxEntry, policyCategories: PolicyCategories | null | undefined, hasExpenseDefaultRules: boolean): boolean { - if (!policy) { - return false; - } - - if (hasExpenseDefaultRules) { - return true; - } - - if (!!policy.customRules && policy.customRules.trim().length > 0) { - return true; - } - - const {rules: policyRules} = policy; - if (!!policyRules?.approvalRules && policyRules.approvalRules.length > 0) { - return true; - } - if (!!policyRules?.expenseRules && policyRules.expenseRules.length > 0) { - return true; - } - if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { - return true; - } - if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { - return true; - } - if ( - !!policy.maxExpenseAmountNoReceipt && - policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && - policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT - ) { - return true; - } - if ( - !!policy.maxExpenseAmountNoItemizedReceipt && - policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && - policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT - ) { - return true; - } - - if (policy.defaultBillable) { - return true; - } - if (policy.defaultReimbursable === false) { - return true; - } - if (policy.eReceipts) { - return true; - } - if (policy.requireCompanyCardsEnabled) { - return true; - } - - const {prohibitedExpenses} = policy; - if ( - !!prohibitedExpenses && - Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { - const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; - return value !== undefined && value !== defaultValue; - }) - ) { - return true; - } - - return hasAnyCategoryRules(policyCategories ?? undefined); -} - function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); } @@ -800,14 +529,6 @@ function tryNavigateToSubmitWorkspaceUpgrade(policy: OnyxEntry, isEnabli return true; } -/** - * For backwards compatibility with Expensify Classic, attendee tracking defaults to enabled - * on Control policies when the property is undefined. - */ -function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { - return (isControlPolicy(policy) && policy?.isAttendeeTrackingEnabled) ?? true; -} - /** * Collect workspaces can access a limited subset of Rules features. When a Collect admin tries to * access a Control-only Rules feature, navigate to the upgrade flow and return true. @@ -825,201 +546,15 @@ function tryNavigateToControlPolicyUpgrade(policy: OnyxEntry, upgradeFea return true; } -/** - * Checks if policy's scheduled submit / auto reporting frequency is "instant". - * Note: Free policies have "instant" submit always enabled. - */ -function isInstantSubmitEnabled(policy: OnyxInputOrEntry): boolean { - return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; -} - -/** - * Checks if policy's scheduled submit / auto reporting frequency is not "instant". - */ -function isDelayedSubmissionEnabled(policy: OnyxInputOrEntry): boolean { - return policy?.autoReporting === true && policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; -} - -/** - * This gets a "corrected" value for autoReportingFrequency. The purpose of this function is to encapsulate some logic around the "immediate" frequency. - * - * - "immediate" is actually not immediate. For that you want "instant". - * - (immediate && harvesting.enabled) === daily - * - (immediate && !harvesting.enabled) === manual - * - * Note that "daily" and "manual" only exist as options for the API, not in the database or Onyx. - */ -function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): ValueOf | undefined { - if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE) { - return policy?.autoReportingFrequency; - } - - if (policy?.harvesting?.enabled) { - // This is actually not really "immediate". It's "daily". Surprise! - return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; - } - - // "manual" is really just "immediate" (aka "daily") with harvesting disabled - return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; -} - -/** - * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" - */ -function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; -} - -/** - * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. - * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement - * disabled, which hides Pay on its approved reports. - */ -function getReimbursementChoice(policy: OnyxInputOrEntry): ValueOf | undefined { - switch (policy?.reimbursementChoice) { - case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO: - return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL: - return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - default: - return policy?.reimbursementChoice; - } -} - -function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { - return getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; -} - -/** Whether the workspace has everything auto-pay approved reports needs: workflows, direct reimbursements, and a business bank account. */ -function isAutoPayApprovedReportsAvailable(policy: OnyxInputOrEntry): boolean { - return !!policy?.areWorkflowsEnabled && getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !!policy?.achAccount?.bankAccountID; -} - -/** - * Returns true when the user is both a submitter and an approver, mirroring the Submit/Approve suggested-search eligibility in - * `getSuggestedSearchesVisibility` (SearchUIUtils): a submitter is a member of any group workspace, and an approver is a member of a - * group workspace with a non-optional approval flow whom `isPolicyApprover` recognizes (named approver or someone reports submit/forward to). - */ -function isSubmitterAndApprover(policies: OnyxCollection | null | undefined, currentUserEmail: string | undefined): boolean { - if (!policies || !currentUserEmail) { - return false; - } - let isSubmitter = false; - let isApprover = false; - for (const policy of Object.values(policies)) { - if (!policy) { - continue; - } - isSubmitter = isSubmitter || isGroupPolicy(policy); - if (!isApprover) { - const hasApprovalFlow = isGroupPolicy(policy) && !!policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; - isApprover = hasApprovalFlow && isPolicyApprover(policy, currentUserEmail); - } - if (isSubmitter && isApprover) { - return true; - } - } - return false; -} - -function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { - return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; -} - -/** - * Checks if policy has Dynamic External Workflow enabled - */ -function hasDynamicExternalWorkflow(policy: OnyxEntry): boolean { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; -} - -/** - * Checks if the approval workflow configuration must stay hidden because the workspace's Dynamic External Workflow has - * "Hide People Table Columns" set. The backend only returns `dynamicExternalWorkflowHidePeople` when it is `true`, so an - * absent flag means "show". The approval mode is checked too, so a stale flag can't hide workflows on a policy that no - * longer uses a Dynamic External Workflow. - */ -function shouldHideDynamicExternalWorkflowPeople(policy: OnyxEntry): boolean { - return hasDynamicExternalWorkflow(policy) && !!policy?.dynamicExternalWorkflowHidePeople; +/** Whether the user can add a category to the workspace straight from an expense's category picker */ +function canCreateCategoryInSitu(policy: OnyxEntry, login?: string) { + return canEditWorkspaceSettings(policy, login) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; } function goBackFromInvalidPolicy() { Navigation.goBack(ROUTES.WORKSPACES_LIST.route); } -function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { - if (!isPaidGroupPolicy(policy)) { - return false; - } - if (policy?.areRulesEnabled === true) { - return true; - } - if (policy?.areRulesEnabled === false) { - return false; - } - // areRulesEnabled is undefined - this can happen in case of migrated old Control policies, in such case users might have set up category rules in Classic and we should show Rules as enabled - if (!isControlPolicy(policy)) { - return false; - } - return hasAnyCategoryRules(policyCategories ?? undefined); -} - -/** - * Whether Invoice Fields is enabled for the policy. - * Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only). - */ -function isInvoiceFieldsEnabled(policy: OnyxEntry | null): boolean { - return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED); -} - -function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { - return arePolicyRulesEnabled(policy, policyCategories); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) { - return isInvoiceFieldsEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { - return !!policy?.tax?.trackingEnabled; - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { - return policy?.[featureName] ? !!policy?.[featureName] : hasAccountingFeatureConnection(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED) { - return isMCPEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED) { - return policy?.isHREnabled === true || isAnyHRConnected(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED) { - return policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED) { - return policy?.receiptPartners?.enabled ?? false; - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED) { - return isTimeTrackingEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED) { - return isAttendeeTrackingEnabled(policy); - } - - return !!policy?.[featureName]; -} - -function getApprovalWorkflow(policy: OnyxEntry): ValueOf { - if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { - return CONST.POLICY.APPROVAL_MODE.OPTIONAL; - } - - return policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.ADVANCED; -} - -function getDefaultApprover(policy: OnyxEntry): string { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return policy?.approver || policy?.owner || ''; -} - /** * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. @@ -1028,357 +563,6 @@ function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); } -/** - * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: - * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or - * - a new workflow was created (a member submits to an approver other than the default approver). - */ -function hasCustomApprovalWorkflow(policy: OnyxEntry): boolean { - if (!policy) { - return false; - } - - const defaultApprover = getDefaultApprover(policy); - - // The default workflow's first approver was changed away from the workspace owner. - if (!!policy.approver && !!policy.owner && policy.approver !== policy.owner) { - return true; - } - - const employees = policy.employeeList ?? {}; - - // The default approver forwards approvals to someone, i.e. an "Approves to" user was added, - // either unconditionally (forwardsTo) or above an approval limit (overLimitForwardsTo). - const defaultApproverEmployee = employees[defaultApprover]; - if (!!defaultApprover && (!!defaultApproverEmployee?.forwardsTo || !!defaultApproverEmployee?.overLimitForwardsTo)) { - return true; - } - - // A new workflow exists when a member submits to an approver other than the default approver. - return Object.values(employees).some( - (employee) => employee?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!employee?.submitsTo && employee.submitsTo !== defaultApprover, - ); -} - -function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { - const categoryApprovers: string[] = []; - const tagApprovers: string[] = []; - const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); - - // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. - // Category approvers are prioritized, then tag approvers. - for (let i = 0; i < allReportTransactions.length; i++) { - const transaction = allReportTransactions.at(i); - const tag = getTag(transaction); - const category = getCategory(transaction); - const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; - const tagApprover = getTagApproverRule(policy, tag)?.approver; - if (categoryApprover) { - categoryApprovers.push(categoryApprover); - } - - if (tagApprover) { - tagApprovers.push(tagApprover); - } - } - - // Return the unique approvers list - return [...new Set([...categoryApprovers, ...tagApprovers])]; -} - -function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry, ownerLogin: string | undefined) { - // Pre-build a lookup map of { category: { value → approver }, tag: { value → approver } } - // from the policy's approval rules so that each transaction's category/tag can be resolved in O(1). - const rulesMap: Record<'category' | 'tag', Record> = { - category: {}, - tag: {}, - }; - - for (let i = 0; i < approvalRules.length; i++) { - const rule = approvalRules.at(i); - if (!rule) { - continue; - } - for (let j = 0; j < rule.applyWhen.length; j++) { - const applyWhen = rule.applyWhen.at(j); - if (applyWhen?.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) { - continue; - } - if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) { - rulesMap[applyWhen.field][applyWhen.value] = rule.approver; - } - } - } - - if (isEmptyObject(rulesMap.category) && isEmptyObject(rulesMap.tag)) { - return ''; - } - - const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); - - if (!allReportTransactions.length) { - return ''; - } - - let firstCategoryApprover = ''; - let firstTagApprover = ''; - - for (let i = 0; i < allReportTransactions.length; i++) { - const transaction = allReportTransactions.at(i); - const category = getCategory(transaction); - const categoryApprover = rulesMap.category[category]; - - // Category approvers take strict priority over tag approvers. - // Break immediately on the first match so we don't keep scanning transactions unnecessarily. - if (categoryApprover && categoryApprover !== ownerLogin) { - firstCategoryApprover = categoryApprover; - break; - } - - // Only look for a tag approver if we haven't found one yet — no need to re-check on subsequent transactions. - if (!firstTagApprover) { - const tag = getTag(transaction); - const tagApprover = rulesMap.tag[tag]; - - if (tagApprover && tagApprover !== ownerLogin) { - firstTagApprover = tagApprover; - } - } - } - - return firstCategoryApprover || firstTagApprover; -} - -function matchesApprovalWorkflowEmailComparison(node: RuleFilterComparison, email: string | undefined): boolean { - const expectedEmails = (Array.isArray(node.right) ? node.right : [node.right]).map((value) => String(value).toLowerCase()); - const isMatch = !!email && expectedEmails.includes(email.toLowerCase()); - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO ? !isMatch : isMatch; -} - -function matchesApprovalWorkflowAmountComparison(node: RuleFilterComparison, amount: number): boolean { - const expectedAmount = typeof node.right === 'number' ? node.right : Number(node.right); - if (Number.isNaN(expectedAmount)) { - return false; - } - - switch (node.operator) { - case CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO: - return amount === expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO: - return amount !== expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN: - return amount > expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO: - return amount >= expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN: - return amount < expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO: - return amount <= expectedAmount; - default: - return false; - } -} - -function evaluateApprovalWorkflowFilter(node: RuleFilterNode, context: ApprovalWorkflowContext): boolean { - if (!isRuleFilterComparison(node)) { - const left = evaluateApprovalWorkflowFilter(node.left, context); - const right = evaluateApprovalWorkflowFilter(node.right, context); - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR ? left || right : left && right; - } - - switch (node.left) { - case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: - return matchesApprovalWorkflowEmailComparison(node, context.submitterEmail); - - // A report with no current approver has not been approved by anybody yet, so a rule based on who - // approved it last cannot apply. - case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: - return !!context.currentApproverEmail && matchesApprovalWorkflowEmailComparison(node, context.currentApproverEmail); - case CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT: - return matchesApprovalWorkflowAmountComparison(node, Math.abs(context.reportTotal)); - - default: - return false; - } -} - -/** - * True when every condition in the rule's filter tree is true for `context`. - */ -function evaluateApprovalWorkflowRule(rule: ApprovalWorkflowRule, context: ApprovalWorkflowContext): boolean { - return evaluateApprovalWorkflowFilter(rule.filters, context); -} - -/** - * Check the policy's approval workflow rules to determine where the report goes next. - */ -function getForwardsToFromRules(policy: OnyxEntry, context: ApprovalWorkflowContext, rules: OnyxCollection): ApprovalWorkflowRuleMatch | undefined { - if (!policy?.id || !context.submitterEmail) { - return undefined; - } - - const trigger = context.currentApproverEmail ? CONST.RULES.TRIGGERS.REPORT_APPROVE : CONST.RULES.TRIGGERS.REPORT_SUBMIT; - - // Sort by Onyx key so the rule picked stays the same across evaluations when more than one matches (which should not happen). - const ruleKeys = Object.keys(rules ?? {}).sort(); - for (const ruleKey of ruleKeys) { - const rule = rules?.[ruleKey]; - if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policy.id || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (!isApprovalWorkflowRule(rule) || !Object.values(rule.triggers ?? {}).includes(trigger)) { - continue; - } - if (!evaluateApprovalWorkflowRule(rule, context)) { - continue; - } - - return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.ACTIONS.FORWARD_TO)?.approver}; - } - - return undefined; -} - -function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0): string { - const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal}, rules); - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - const defaultApprover = getDefaultApprover(policy); - - // For policy using the optional or basic workflow, the manager is the policy default approver. - if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { - return defaultApprover; - } - - const employee = policy?.employeeList?.[ownerLogin ?? '']; - if (!employee && !defaultApprover) { - return ''; - } - - return employee?.submitsTo ?? defaultApprover ?? ''; -} - -function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0) { - const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, reportTotal); - return managerEmail ? (getAccountIDsByLogins([managerEmail]).at(0) ?? -1) : -1; -} - -/** - * Returns the email the expense report should submit to per workspace approval config - * (approval rules, employee submitsTo, or default approver for basic/optional workflows). - */ -function getSubmitToEmail( - policy: OnyxEntry, - expenseReport: OnyxEntry, - ownerLogin: string | undefined, - rules: OnyxCollection, - shouldFallBackWhenManagerIsNotMember = false, -): string { - const approvalRules = policy?.rules?.approvalRules; - - if (!isSubmitAndClose(policy) && approvalRules?.length) { - const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport, ownerLogin); - if (ruleApprover) { - return ruleApprover; - } - } - - const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal: expenseReport?.total ?? 0}, rules); - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, expenseReport?.total ?? 0); - - // Falls back to the default approver when the manager is unavailable. - if (shouldFallBackWhenManagerIsNotMember && managerEmail && !policy?.employeeList?.[managerEmail] && !getHRAdvancedModeFinalApprover(policy)) { - return getDefaultApprover(policy); - } - - return managerEmail; -} - -/** - * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. - */ -function getSubmitToAccountID( - policy: OnyxEntry, - expenseReport: OnyxEntry, - ownerLogin: string | undefined, - rules: OnyxCollection, - shouldFallBackWhenManagerIsNotMember = false, -): number { - const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, rules, shouldFallBackWhenManagerIsNotMember); - return submitToEmail ? (getAccountIDsByLogins([submitToEmail]).at(0) ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; -} - -function getSubmitReportManagerAccountID( - policy: OnyxEntry, - expenseReport: OnyxEntry, - submitterLogin: string | undefined, - rules: OnyxCollection, - personalDetailsByLogins: PersonalDetailsByLogin, -): number | undefined { - const approvalRules = policy?.rules?.approvalRules; - const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; - const hasReliablePolicyRoute = - ([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy)) || - !!ruleApprover || - !!policy?.employeeList?.[submitterLogin ?? '']; - - if (!hasReliablePolicyRoute) { - return undefined; - } - - const submitToEmail = getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true); - const submitToAccountID = submitToEmail ? personalDetailsByLogins[submitToEmail]?.accountID : undefined; - if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { - return undefined; - } - - return submitToAccountID; -} - -/** - * Returns the email of the account to forward the report to depending on the approver's approval limit. - * Used for advanced approval mode only. - * - * `submitterEmail` is needed to consult the workspace's approval workflow rules, which describe each hop in - * terms of who submitted the report as well as who is approving it. Without it only the legacy employeeList - * forwardsTo/overLimitForwardsTo is considered. - */ -function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, reportTotal: number, rules: OnyxCollection, submitterEmail?: string): string { - const ruleMatch = submitterEmail ? getForwardsToFromRules(policy, {submitterEmail, currentApproverEmail: employeeEmail, reportTotal}, rules) : undefined; - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - if (!isControlOnAdvancedApprovalMode(policy)) { - return ''; - } - - const employee = policy?.employeeList?.[employeeEmail]; - if (!employee) { - return ''; - } - - const positiveReportTotal = Math.abs(reportTotal); - if (employee.approvalLimit && employee.overLimitForwardsTo && positiveReportTotal > employee.approvalLimit) { - return employee.overLimitForwardsTo; - } - return employee.forwardsTo ?? ''; -} - -/** - * Returns the accountID of the policy reimburser, if not available returns -1. - */ -function getReimburserAccountID(policy: OnyxEntry): number { - const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; - return reimburserEmail ? (getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1) : -1; -} - function getAdminEmployees(policy: OnyxEntry): PolicyEmployee[] { if (!policy?.employeeList) { return []; @@ -1423,27 +607,6 @@ function canSendInvoice(policies: OnyxCollection | null, currentUserLogi return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy)); } -function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { - if (settings === undefined || errorFields === undefined) { - return false; - } - - const keys = Object.keys(errorFields); - return settings.some((setting) => keys.includes(setting)); -} - -function settingsPendingAction(settings?: string[], pendingFields?: PendingFields): PendingAction | undefined { - if (settings === undefined || pendingFields === undefined) { - return null; - } - - const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); - if (!key) { - return; - } - return pendingFields[key]; -} - /** * Sort the workspaces by their name, while keeping the selected one at the beginning. * @param workspace1 Details of the first workspace to be compared. @@ -1491,19 +654,6 @@ function hasOnlyPersonalPolicies(policies: OnyxCollection) { return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } -function getTagApproverRule(policy: OnyxEntry, tagName: string) { - if (!policy) { - return; - } - - const approvalRules = policy.rules?.approvalRules ?? []; - const approverRule = approvalRules.find((rule) => - rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.TAG && value === tagName), - ); - - return approverRule; -} - function getDomainNameForPolicy(policyID?: string): string { if (!policyID) { return ''; @@ -1512,10 +662,6 @@ function getDomainNameForPolicy(policyID?: string): string { return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID.toLowerCase()}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; } -function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; -} - function getUserFriendlyWorkspaceType(workspaceType: ValueOf, translate: LocalizedTranslate) { switch (workspaceType) { case CONST.POLICY.TYPE.CORPORATE: @@ -1805,56 +951,11 @@ function getTravelStep( return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; } -/** - * Returns if the policy has the Time Tracking feature enabled. - */ -function isTimeTrackingEnabled(policy: OnyxEntry): boolean { - return !!policy?.units?.time?.enabled; -} - -/** MCP is on for every workspace unless an admin has explicitly turned it off, so an absent flag reads as enabled. */ -function isMCPEnabled(policy: OnyxEntry): boolean { - return policy?.isMCPEnabled ?? true; -} - -/** - * Returns the policy's default hourly rate for the Time Tracking feature. - */ -function getDefaultTimeTrackingRate(policy: Partial>): number | undefined { - return policy?.units?.time?.rate !== undefined ? convertToBackendAmount(policy.units.time.rate) : undefined; -} - function sortPoliciesByName(policies: Policy[], localeCompare: (a: string, b: string) => number): Policy[] { // copy first: callers pass memoized selector output, sorting it in place makes the next deepEqual report a change return [...policies].sort((a, b) => localeCompare(a.name || '', b.name || '')); } -/** - * Builds a source URL for rendering a policy document PDF. - * Local blob/file URIs (from optimistic uploads) are returned directly. - * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. - * The stored URL (which contains a unique timestamp per upload) is appended as a version - * parameter so the browser treats each replacement as a distinct resource. - */ -function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { - if (!rulesDocumentURL || !policyID) { - return ''; - } - - const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); - if (isLocalFile) { - return rulesDocumentURL; - } - - return addEncryptedAuthTokenToURL( - // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. - // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. - `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, - encryptedAuthToken, - true, - ); -} - export { getActivePolicies, getActivePoliciesWithExpenseChat, @@ -1867,41 +968,18 @@ export { getSoftExclusionsForGuideAndAccountManager, getExpensifyTeamExclusions, filterGuideAndAccountManager, - getPolicyBrickRoadIndicatorStatus, - hasCustomCategories, - hasConfiguredRules, - isMaxExpenseAmountSet, goBackFromInvalidPolicy, - shouldShowSyncError, - shouldShowCustomUnitsError, - shouldShowEmployeeListError, - hasPolicyCategoriesError, - hasPolicyRulesError, - shouldShowPolicyError, - shouldShowTaxRateError, + canCreateCategoryInSitu, isExpensifyTeam, shouldFilterExpensifyTeam, createFilteredMemberCountSelector, createInvoiceConfigurationTextSelector, isDeletedPolicyEmployee, - isInstantSubmitEnabled, - isDelayedSubmissionEnabled, - getCorrectedAutoReportingFrequency, hasEligibleBankAccountShareRecipient, - arePolicyRulesEnabled, - isPolicyFeatureEnabled, isPolicyFieldListEmpty, canUnarchivePolicy, isPolicyMember, isMemberInHomeAndOfficeWorkspace, - isPolicyPayer, - getReimburserEmail, - getOwnerChangePayerSuccessData, - arePaymentsEnabled, - isAutoPayApprovedReportsAvailable, - getReimbursementChoice, - isSubmitterAndApprover, - isSubmitAndClose, shouldShowPolicy, getActiveAdminWorkspaces, hasActiveAdminWorkspaces, @@ -1910,70 +988,44 @@ export { canSubmitPerDiemExpenseFromWorkspace, canSendInvoice, getEligibleBankAccountShareRecipientEmails, + hasCompanyAddress, hasOfficeWorkArrangement, getPolicyIDOrDefault, sortWorkspacesBySelected, goBackWhenEnableFeature, navigateToExpensifyCardPage, - getDefaultApprover, isNonMemberApprover, - hasCustomApprovalWorkflow, - getApprovalWorkflow, - getReimburserAccountID, - isAttendeeTrackingEnabled, hasOnlyPersonalPolicies, - areSettingsInErrorFields, - settingsPendingAction, getGroupPaidPolicies, getGroupPoliciesWhereReportCanBeCreated, getDefaultChatEnabledPolicy, getDefaultChatEnabledPolicySelection, - getForwardsToAccount, - getForwardsToFromRules, - evaluateApprovalWorkflowRule, - getSubmitToAccountID, - getSubmitReportManagerAccountID, - getTagApproverRule, getDomainNameForPolicy, - getWorkflowApprovalsUnavailable, getUserFriendlyWorkspaceType, getDefaultWorkspacePlanType, isPolicyAccessible, hasOtherControlWorkspaces, shouldBlockWorkspaceDeletionForInvoicifyUser, - getManagerAccountEmail, - getSubmitToEmail, - getRuleApprovers, canModifyPlan, getAdminsPrivateEmailDomains, getMostFrequentEmailDomain, getPolicyIDFromDomainName, getPolicyForAssignedCard, getDescriptionForPolicyDomainCard, - getManagerAccountID, getActiveEmployeeWorkspaces, isPolicyMemberWithoutPendingDelete, - hasDynamicExternalWorkflow, - shouldHideDynamicExternalWorkflowPeople, getActivePoliciesWithExpenseChatAndPerDiemEnabled, getPoliciesForRoomCreation, - isInvoiceFieldsEnabled, getTravelStep, isWorkspaceProvisionedForTravel, hasAcceptedTravelTerms, isNonUSDPolicy, - isTimeTrackingEnabled, - isMCPEnabled, - getDefaultTimeTrackingRate, getActivePoliciesWithExpenseChatAndTimeEnabled, sortPoliciesByName, hasActiveExpensifyCard, tryNavigateToSubmitWorkspaceUpgrade, tryNavigateToControlPolicyUpgrade, - getRulesDocumentSourceURL, hasAnyPaidPolicy, - isMergeHRCompleteSetupNeededSelector, - isQBORefreshTokenExpiringSoonSelector, }; // Re-exported with `export *` rather than through the block above: a named re-export becomes a getter that throws @@ -1988,5 +1040,11 @@ export * from './xero'; export * from './sageIntacct'; export * from './customUnits'; export * from './tax'; +export * from './rules'; +export * from './features'; +export * from './workflows'; +export * from './errors'; +export * from './approvals'; +export * from './payments'; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/PolicyUtils/payments.ts b/src/libs/PolicyUtils/payments.ts new file mode 100644 index 000000000000..866a3a4098fb --- /dev/null +++ b/src/libs/PolicyUtils/payments.ts @@ -0,0 +1,111 @@ +/** + * The workspace reimburser and payer, the reimbursement choice, and auto-pay availability. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; + +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; + +import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {canMemberWrite} from './permissions'; + +/** + * Resolve the email of the workspace's Authorized Payer (reimburser), matching the canonical resolution used across the app. + * Returns undefined when reimbursement is disabled or no payer can be resolved. + */ +function getReimburserEmail(policy: OnyxEntry): string | undefined { + if (!policy) { + return undefined; + } + + const reimbursementChoice = getReimbursementChoice(policy); + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return undefined; + } + + return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); +} + +/** + * Payer fields to merge into the successData of an ownership transfer. The backend keeps the former payer when the + * workspace has a bank account, so only reassign when there is none and the outgoing owner is the resolved payer. + */ +function getOwnerChangePayerSuccessData(policy: OnyxEntry, newOwnerLogin: string): NullishDeep { + if (!policy || policy.achAccount?.bankAccountID || getReimburserEmail(policy) !== policy.owner) { + return {}; + } + + return { + ...(policy.reimburser ? {reimburser: newOwnerLogin} : {}), + ...(policy.achAccount?.reimburser ? {achAccount: {reimburser: newOwnerLogin}} : {}), + }; +} + +function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { + if (!policy) { + return false; + } + + const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; + const reimbursementChoice = getReimbursementChoice(policy); + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return false; + } + + const reimburserEmail = getReimburserEmail(policy); + + // No designated reimburser means any workspace admin can pay. + if (!reimburserEmail) { + return isAdmin; + } + + const canPayOnPolicy = isAdmin || (!!currentUserLogin && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + + return canPayOnPolicy && currentUserLogin === reimburserEmail; +} + +/** + * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. + * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement + * disabled, which hides Pay on its approved reports. + */ +function getReimbursementChoice(policy: OnyxInputOrEntry): ValueOf | undefined { + switch (policy?.reimbursementChoice) { + case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO: + return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL: + return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + default: + return policy?.reimbursementChoice; + } +} + +function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { + return getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; +} + +/** Whether the workspace has everything auto-pay approved reports needs: workflows, direct reimbursements, and a business bank account. */ +function isAutoPayApprovedReportsAvailable(policy: OnyxInputOrEntry): boolean { + return !!policy?.areWorkflowsEnabled && getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !!policy?.achAccount?.bankAccountID; +} + +/** + * Returns the accountID of the policy reimburser, if not available returns -1. + */ +function getReimburserAccountID(policy: OnyxEntry): number { + const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; + return reimburserEmail ? (getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1) : -1; +} + +export {getReimburserEmail, getOwnerChangePayerSuccessData, isPolicyPayer, getReimbursementChoice, arePaymentsEnabled, isAutoPayApprovedReportsAvailable, getReimburserAccountID}; diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 92465cc8b71a..1a27d68b6f83 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -96,6 +96,7 @@ const ROLE_PERMISSION_BUNDLES: Record): boolean { + if (!policyCategories) { + return false; + } + + const defaultCategoryNames = new Set(Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); + + return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); +} + +/** + * Checks whether a policy max expense amount (e.g. `maxExpenseAmountNoReceipt`) is actually configured. + * + * `0` is a valid limit meaning "always required", so a falsy check would wrongly treat an explicit $0.00 as unset. + * Only `undefined` and `CONST.DISABLED_MAX_EXPENSE_VALUE` mean the amount is not set. + */ +function isMaxExpenseAmountSet(value: number | undefined): value is number { + return value !== undefined && value !== CONST.DISABLED_MAX_EXPENSE_VALUE; +} + +/** + * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). + */ +function hasConfiguredRules(policy: OnyxEntry, policyCategories: PolicyCategories | null | undefined, hasExpenseDefaultRules: boolean): boolean { + if (!policy) { + return false; + } + + if (hasExpenseDefaultRules) { + return true; + } + + if (!!policy.customRules && policy.customRules.trim().length > 0) { + return true; + } + + const {rules: policyRules} = policy; + if (!!policyRules?.approvalRules && policyRules.approvalRules.length > 0) { + return true; + } + if (!!policyRules?.expenseRules && policyRules.expenseRules.length > 0) { + return true; + } + if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { + return true; + } + if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { + return true; + } + if ( + !!policy.maxExpenseAmountNoReceipt && + policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT + ) { + return true; + } + if ( + !!policy.maxExpenseAmountNoItemizedReceipt && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT + ) { + return true; + } + + if (policy.defaultBillable) { + return true; + } + if (policy.defaultReimbursable === false) { + return true; + } + if (policy.eReceipts) { + return true; + } + if (policy.requireCompanyCardsEnabled) { + return true; + } + + const {prohibitedExpenses} = policy; + if ( + !!prohibitedExpenses && + Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { + const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; + return value !== undefined && value !== defaultValue; + }) + ) { + return true; + } + + return hasAnyCategoryRules(policyCategories ?? undefined); +} + +function getTagApproverRule(policy: OnyxEntry, tagName: string) { + if (!policy) { + return; + } + + const approvalRules = policy.rules?.approvalRules ?? []; + const approverRule = approvalRules.find((rule) => + rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.TAG && value === tagName), + ); + + return approverRule; +} + +/** + * Builds a source URL for rendering a policy document PDF. + * Local blob/file URIs (from optimistic uploads) are returned directly. + * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. + * The stored URL (which contains a unique timestamp per upload) is appended as a version + * parameter so the browser treats each replacement as a distinct resource. + */ +function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { + if (!rulesDocumentURL || !policyID) { + return ''; + } + + const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); + if (isLocalFile) { + return rulesDocumentURL; + } + + return addEncryptedAuthTokenToURL( + // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. + // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. + `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, + encryptedAuthToken, + true, + ); +} + +export {hasCustomCategories, isMaxExpenseAmountSet, hasConfiguredRules, getTagApproverRule, getRulesDocumentSourceURL}; diff --git a/src/libs/PolicyUtils/workflows.ts b/src/libs/PolicyUtils/workflows.ts new file mode 100644 index 000000000000..c144e0bf5388 --- /dev/null +++ b/src/libs/PolicyUtils/workflows.ts @@ -0,0 +1,73 @@ +/** + * Auto-reporting (scheduled submit) frequency, Submit & Close, and Dynamic External Workflow. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +/** + * Checks if policy's scheduled submit / auto reporting frequency is "instant". + * Note: Free policies have "instant" submit always enabled. + */ +function isInstantSubmitEnabled(policy: OnyxInputOrEntry): boolean { + return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; +} + +/** + * Checks if policy's scheduled submit / auto reporting frequency is not "instant". + */ +function isDelayedSubmissionEnabled(policy: OnyxInputOrEntry): boolean { + return policy?.autoReporting === true && policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; +} + +/** + * This gets a "corrected" value for autoReportingFrequency. The purpose of this function is to encapsulate some logic around the "immediate" frequency. + * + * - "immediate" is actually not immediate. For that you want "instant". + * - (immediate && harvesting.enabled) === daily + * - (immediate && !harvesting.enabled) === manual + * + * Note that "daily" and "manual" only exist as options for the API, not in the database or Onyx. + */ +function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): ValueOf | undefined { + if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE) { + return policy?.autoReportingFrequency; + } + + if (policy?.harvesting?.enabled) { + // This is actually not really "immediate". It's "daily". Surprise! + return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; + } + + // "manual" is really just "immediate" (aka "daily") with harvesting disabled + return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; +} + +/** + * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" + */ +function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; +} + +/** + * Checks if policy has Dynamic External Workflow enabled + */ +function hasDynamicExternalWorkflow(policy: OnyxEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; +} + +/** + * Checks if the approval workflow configuration must stay hidden because the workspace's Dynamic External Workflow has + * "Hide People Table Columns" set. The backend only returns `dynamicExternalWorkflowHidePeople` when it is `true`, so an + * absent flag means "show". The approval mode is checked too, so a stale flag can't hide workflows on a policy that no + * longer uses a Dynamic External Workflow. + */ +function shouldHideDynamicExternalWorkflowPeople(policy: OnyxEntry): boolean { + return hasDynamicExternalWorkflow(policy) && !!policy?.dynamicExternalWorkflowHidePeople; +} + +export {isInstantSubmitEnabled, isDelayedSubmissionEnabled, getCorrectedAutoReportingFrequency, isSubmitAndClose, hasDynamicExternalWorkflow, shouldHideDynamicExternalWorkflowPeople}; diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 1baddf312db4..41d4ec38e1f4 100644 --- a/src/libs/PopoverMenuSections.ts +++ b/src/libs/PopoverMenuSections.ts @@ -23,7 +23,7 @@ const REPORT_MORE_MENU_SECTIONS = [ ], [REPORT_SECONDARY_ACTIONS.SPLIT, REPORT_SECONDARY_ACTIONS.MERGE, REPORT_SECONDARY_ACTIONS.DUPLICATE_EXPENSE, REPORT_SECONDARY_ACTIONS.MOVE_EXPENSE], [REPORT_SECONDARY_ACTIONS.DUPLICATE_REPORT, REPORT_SECONDARY_ACTIONS.CHANGE_WORKSPACE, REPORT_SECONDARY_ACTIONS.CHANGE_APPROVER], - [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.PRINT], + [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.SHARE, REPORT_SECONDARY_ACTIONS.PRINT], [REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], ]; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index 8a18ea9ef288..2857ee0ca1d7 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -19,6 +19,10 @@ import type RegisterPrefetchOnAppStart from './types'; const NITRO_FETCH_TARGET = 'fetch'; const CONTENT_TYPE_HEADER = 'Content-Type'; +type PrefetchFormData = { + getParts: () => Array<{fieldName: string; string: string}>; +}; + function buildAuthenticateBody(credentials: Credentials): string { // NitroFetch token refresh runs outside the JS request pipeline, so we must serialize the // Authenticate body here instead of going through Request.post() and enhanceParameters(). @@ -47,6 +51,29 @@ function buildAuthenticateBody(credentials: Credentials): string { return body.toString(); } +function isPrefetchFormData(body: unknown): body is PrefetchFormData { + return typeof body === 'object' && body !== null && 'getParts' in body && typeof body.getParts === 'function'; +} + +/** + * The saved body can be replayed natively on the next app start, which may already be running a newer version after an update. + * Sending the old `appversion` there would get a 426 and show the Update Required screen, so it is omitted. + */ +function removeAppVersionFromPrefetchBody(body: RequestInit['body']): RequestInit['body'] { + if (!isPrefetchFormData(body)) { + return body; + } + + const prefetchBody = new FormData(); + for (const part of body.getParts()) { + if (part.fieldName === 'appversion') { + continue; + } + prefetchBody.append(part.fieldName, part.string); + } + return prefetchBody; +} + function registerPrefetchTokenRefresh(): void { const credentials = getCredentials(); @@ -80,7 +107,7 @@ const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = ({prefetchKey, fe } registerPrefetchTokenRefresh(); - prefetchOnAppStart(url, fetchParams).catch((error) => { + prefetchOnAppStart(url, {...fetchParams, body: removeAppVersionFromPrefetchBody(fetchParams.body)}).catch((error) => { Log.warn(`[NitroFetch] prefetchOnAppStart failed for ${command}`, {error, fetchParams, url}); }); }; diff --git a/src/libs/Pusher/EventType.ts b/src/libs/Pusher/EventType.ts index 50dad3dffcd0..e744f89eeb98 100644 --- a/src/libs/Pusher/EventType.ts +++ b/src/libs/Pusher/EventType.ts @@ -8,6 +8,7 @@ export default { USER_IS_LEAVING_ROOM: 'client-userIsLeavingRoom', USER_IS_TYPING: 'client-userIsTyping', MULTIPLE_EVENTS: 'multipleEvents', + AGENT_ZERO_PROCESSING_STATUS: 'agentZeroProcessingStatus', CONCIERGE_REASONING: 'conciergeReasoning', CONCIERGE_DRAFT_EVENTS: 'conciergeDraftEvents', CONCIERGE_DRAFT_STARTED: 'conciergeDraftStarted', diff --git a/src/libs/Pusher/types.ts b/src/libs/Pusher/types.ts index 028fbe3b4d9a..fb9fabde86dc 100644 --- a/src/libs/Pusher/types.ts +++ b/src/libs/Pusher/types.ts @@ -1,4 +1,5 @@ import type CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; import type {AnyOnyxUpdatesFromServer, ReportUserIsTyping} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -29,6 +30,16 @@ type UserIsLeavingRoomEvent = Record & { userLogin?: string; }; +type AgentZeroProcessingStatusEvent = { + updates: Array<{ + onyxMethod: 'merge'; + key: `${typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${string}`; + value: { + agentZeroProcessingRequestIndicator: Record; + }; + }>; +}; + type ConciergeReasoningEvent = { reasoning: string; agentZeroRequestID: string; @@ -68,6 +79,7 @@ type ConciergeDraftEventsEvent = { type PusherEventMap = { [TYPE.USER_IS_TYPING]: UserIsTypingEvent; [TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent; + [TYPE.AGENT_ZERO_PROCESSING_STATUS]: AgentZeroProcessingStatusEvent; [TYPE.CONCIERGE_REASONING]: ConciergeReasoningEvent; [TYPE.CONCIERGE_DRAFT_EVENTS]: ConciergeDraftEventsEvent; [TYPE.CONCIERGE_DRAFT_STARTED]: ConciergeDraftEvent; diff --git a/src/libs/PusherUtils.ts b/src/libs/PusherUtils.ts index d311abf03649..e89daaf488b3 100644 --- a/src/libs/PusherUtils.ts +++ b/src/libs/PusherUtils.ts @@ -1,9 +1,11 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import type {AnyOnyxUpdatesFromServer, OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; +import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type {OnyxKey} from 'react-native-onyx'; +import type {EventData, PusherEventName} from './Pusher/types'; + import {reconnect} from './actions/Reconnect'; import Log from './Log'; import Pusher from './Pusher'; @@ -35,14 +37,14 @@ function triggerMultiEventHandler(eventType: string, data: /** * Abstraction around subscribing to private user channel events. Handles all logs and errors automatically. */ -function subscribeToPrivateUserChannelEvent(eventName: string, accountID: string, onEvent: (pushJSON: AnyOnyxUpdatesFromServer) => void) { +function subscribeToPrivateUserChannelEvent(eventName: EventName, accountID: string, onEvent: (pushJSON: EventData) => void) { const pusherChannelName = getUserChannelName(accountID); - function logPusherEvent(pushJSON: AnyOnyxUpdatesFromServer) { + function logPusherEvent(pushJSON: EventData) { Log.info(`[Report] Handled ${eventName} event sent by Pusher`, false, pushJSON); } - function onEventPush(pushJSON: AnyOnyxUpdatesFromServer) { + function onEventPush(pushJSON: EventData) { logPusherEvent(pushJSON); onEvent(pushJSON); } diff --git a/src/libs/Reauthentication.ts b/src/libs/Reauthentication.ts index 8d7afc5b2f4c..4684d4f1a6f9 100644 --- a/src/libs/Reauthentication.ts +++ b/src/libs/Reauthentication.ts @@ -38,6 +38,7 @@ type Parameters = { let isAuthenticatingWithShortLivedToken = false; let isSupportAuthTokenUsed = false; let isSupportSession = false; +let isDelegateTokenSession = false; type ReauthenticationResult = { wasSuccessful: boolean; @@ -59,6 +60,7 @@ Onyx.connectWithoutView({ callback: (value) => { isSupportAuthTokenUsed = !!value?.isSupportAuthTokenUsed; isSupportSession = value?.authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; + isDelegateTokenSession = value?.authTokenType === CONST.AUTH_TOKEN_TYPES.DELEGATE; Sentry.setUser({ id: value?.accountID, @@ -212,9 +214,12 @@ function reauthenticate(command = ''): Promise { return hasReadRequiredDataFromStorage().then(() => { // While connected as a delegate, ONYXKEYS.CREDENTIALS is wiped by clearOnyxForDelegateTransition; the original user's creds live in stashedCredentials. - const isDelegate = isConnectedAsDelegate({ - delegatedAccess: account?.delegatedAccess, - }); + // ACCOUNT can lose `delegate` once access is revoked while SESSION still holds the restricted token + const isDelegate = + isDelegateTokenSession || + isConnectedAsDelegate({ + delegatedAccess: account?.delegatedAccess, + }); const credentials = isDelegate ? stashedCredentials : getCredentials(); const {partnerName, partnerPassword} = getPartnerCredentials(credentials?.autoGeneratedLogin); diff --git a/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts index 2c08e28ca71b..9d928725614d 100644 --- a/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts +++ b/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts @@ -65,6 +65,32 @@ function getMerchantForRetry(merchant: string | undefined): string { return merchant; } +function buildRetryTransactionParams(context: ReceiptRetryContext, receiptFile: FileObject) { + const {transaction} = context; + const details = getTransactionDetails(transaction); + if (!transaction || !details) { + return undefined; + } + const receipt: Receipt = {...receiptFile, source: context.receiptError.source, state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; + + return { + amount: details.amount, + currency: details.currency, + created: details.created, + merchant: getMerchantForRetry(details.merchant), + comment: details.comment, + category: details.category, + tag: details.tag, + taxCode: details.taxCode, + taxAmount: details.taxAmount, + billable: details.billable, + reimbursable: details.reimbursable, + attendees: transaction.comment?.attendees, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + receipt, + }; +} + /** Checks the action here too, not only in `retryReceiptUpload`, so the button is hidden instead of doing nothing. */ function canBuildRetryPayload(context: ReceiptRetryContext): boolean { const {transaction, iouReport, receiptError, currentUserPersonalDetails} = context; @@ -98,11 +124,10 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject return undefined; } - const details = getTransactionDetails(transaction); - if (!details) { + const transactionParams = buildRetryTransactionParams(context, receiptFile); + if (!transactionParams) { return undefined; } - const receipt: Receipt = {...receiptFile, source: context.receiptError.source, state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; return { report: iouReport, @@ -112,23 +137,7 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject participant, }, policyParams, - transactionParams: { - amount: details.amount, - currency: details.currency, - created: details.created, - merchant: getMerchantForRetry(details.merchant), - comment: details.comment, - category: details.category, - tag: details.tag, - taxCode: details.taxCode, - taxAmount: details.taxAmount, - billable: details.billable, - reimbursable: details.reimbursable, - // Read from the transaction because `details` widens this to `string | Attendee[]`. - attendees: transaction.comment?.attendees, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - receipt, - }, + transactionParams, // Same ID, so if the first attempt reached Auth the retry gets `Transaction already created.` instead of a second expense. optimisticTransactionID: transaction.transactionID, optimisticIOUReportID: iouReport.reportID, @@ -162,4 +171,4 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject } export default buildRetryPayload; -export {canBuildRetryPayload}; +export {buildRetryTransactionParams, canBuildRetryPayload, isRetryableFlow, resolveParticipant}; diff --git a/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts new file mode 100644 index 000000000000..544051a6a73a --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts @@ -0,0 +1,86 @@ +/** Decides whether a failed `TrackExpense` can be retried and rebuilds the call from Onyx. */ +import {getAllReportActions, isCreatedAction} from '@libs/ReportActionsUtils'; +import {isSelfDM} from '@libs/ReportUtils'; + +import {getAllReports} from '@userActions/IOU'; +import type {CreateTrackExpenseParams} from '@userActions/IOU/types/CreateTrackExpenseParams'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import type {ReceiptRetryContext} from './types'; + +import {buildRetryTransactionParams, isRetryableFlow, resolveParticipant} from './buildRetryPayload'; + +function canBuildTrackExpenseRetryPayload(context: ReceiptRetryContext): boolean { + const {transaction, iouReport, receiptError, currentUserPersonalDetails} = context; + if (receiptError.action !== CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE || !isSelfDM(iouReport)) { + return false; + } + return isRetryableFlow(context) && !!iouReport?.reportID && !!transaction?.transactionID && !!resolveParticipant(iouReport, currentUserPersonalDetails.accountID); +} + +/** Rebuilds the `TrackExpense` call behind a failed receipt upload from the records the failure left in Onyx. */ +function buildTrackExpenseRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject): CreateTrackExpenseParams | undefined { + const { + transaction, + iouReport, + iouActionID, + transactionThreadReportID, + policyParams, + rules, + conciergeReportID, + isSelfTourViewed, + isASAPSubmitBetaEnabled, + introSelected, + delegateAccountID, + getCurrencyDecimals, + currentUserPersonalDetails: currentUser, + } = context; + const participant = resolveParticipant(iouReport, currentUser.accountID); + if (!canBuildTrackExpenseRetryPayload(context) || !transaction || !iouReport || !participant) { + return undefined; + } + + const transactionParams = buildRetryTransactionParams(context, receiptFile); + if (!transactionParams) { + return undefined; + } + + return { + report: iouReport, + isDraftPolicy: false, + isDraftChatReport: false, + participantParams: { + payeeEmail: currentUser.login, + payeeAccountID: currentUser.accountID, + participant, + }, + policyParams, + transactionParams, + optimisticTransactionID: transaction.transactionID, + currentReportActionID: iouActionID, + existingTransactionThreadReportID: transactionThreadReportID, + existingTransactionThreadCreatedReportActionID: Object.values(getAllReportActions(transactionThreadReportID)).find((action) => isCreatedAction(action))?.reportActionID, + existingTransaction: transaction, + isASAPSubmitBetaEnabled, + currentUser: { + accountID: currentUser.accountID, + email: currentUser.login ?? '', + }, + introSelected, + conciergeChat: conciergeReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`] : undefined, + quickAction: undefined, + recentWaypoints: [], + isSelfTourViewed, + currentUserLocalCurrency: undefined, + delegateAccountID, + reportActionsList: undefined, + getCurrencyDecimals, + rules, + }; +} + +export default buildTrackExpenseRetryPayload; +export {canBuildTrackExpenseRetryPayload}; diff --git a/src/libs/ReceiptUploadRetryHandler/index.ts b/src/libs/ReceiptUploadRetryHandler/index.ts index e3bc6939f3d0..d1113692007e 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.ts @@ -2,7 +2,7 @@ import Log from '@libs/Log'; import {replaceReceipt} from '@userActions/IOU/Receipt'; -import {requestMoney} from '@userActions/IOU/TrackExpense'; +import {requestMoney, trackExpense} from '@userActions/IOU/TrackExpense'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -11,6 +11,7 @@ import type {ReceiptRetryContext, RetryOutcome} from './types'; import buildReplaceReceiptRetryPayload, {canBuildReplaceReceiptRetryPayload} from './buildReplaceReceiptRetryPayload'; import buildRetryPayload, {canBuildRetryPayload} from './buildRetryPayload'; +import buildTrackExpenseRetryPayload, {canBuildTrackExpenseRetryPayload} from './buildTrackExpenseRetryPayload'; import resolveReceiptFile from './resolveReceiptFile'; type RetryFlow = { @@ -41,6 +42,17 @@ const RETRY_FLOWS: Partial> = { return true; }, }, + [CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE]: { + canBuild: canBuildTrackExpenseRetryPayload, + dispatch: (context, receiptFile) => { + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + if (!payload) { + return false; + } + trackExpense({...payload, isRetry: true, shouldPlaySound: false}); + return true; + }, + }, }; function getRetryFlow(context: ReceiptRetryContext): RetryFlow | undefined { diff --git a/src/libs/ReceiptUploadRetryHandler/types.ts b/src/libs/ReceiptUploadRetryHandler/types.ts index 4e93abee5b49..abdcd1dc38be 100644 --- a/src/libs/ReceiptUploadRetryHandler/types.ts +++ b/src/libs/ReceiptUploadRetryHandler/types.ts @@ -4,7 +4,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type BasePolicyParams from '@userActions/IOU/types/BasePolicyParams'; -import type {Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {IntroSelected, Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -53,6 +53,8 @@ type ReceiptRetryContext = { transactionViolations: OnyxEntry; currentUserPersonalDetails: CurrentUserPersonalDetails; + + introSelected: OnyxEntry; }; type RetryOutcome = 'dispatched' | 'fileMissing' | 'payloadIncomplete' | 'unsupportedAction' | 'dispatchFailed'; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6ad4b1aa7ec4..300d0f7106d9 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -239,6 +239,7 @@ function isDeletedAction(reportAction: OnyxInputOrEntry, ): boolean { if (!reportAction) { return false; @@ -1398,7 +1400,7 @@ function shouldReportActionBeVisible( // The isNewDot/shouldShow are baked at write time and can be stale for actions created outside NewDot (e.g OldDot or a background job). // Not applied to REIMBURSED, which carries bank account details PAY doesn't replace. - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) && hasSiblingPayReportAction(reportAction, reportAction.reportID ?? reportID)) { + if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) && hasSiblingPayReportAction(reportAction, reportAction.reportID ?? reportID, reportActions)) { return false; } } @@ -1436,6 +1438,7 @@ function isReportActionVisible( visibleReportActions?: VisibleReportActionsDerivedValue, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, + reportActions?: OnyxEntry, ): boolean { if (!reportAction?.reportActionID) { return false; @@ -1447,18 +1450,18 @@ function isReportActionVisible( // from what's cached in visibleReportActions (which reflects persisted Onyx data). // We must recalculate visibility at runtime to ensure accuracy for these transient states. if (reportAction.pendingAction) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } if (visibleReportActions && reportID) { const reportCache = visibleReportActions[reportID]; if (!reportCache) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } const staticVisibility = reportCache[reportAction.reportActionID]; // If action is not in derived value cache, fall back to runtime calculation if (staticVisibility === undefined) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } if (!staticVisibility) { return false; @@ -1468,7 +1471,7 @@ function isReportActionVisible( } return true; } - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } /** @@ -1980,24 +1983,13 @@ function isPaymentAttemptBoundary(action: OnyxEntry): boolean { } /** - * Finds MARKED_REIMBURSED actions with a PAY sibling in the same payment attempt. A historical PAY + * Finds the MARKED_REIMBURSED actions that have a PAY sibling in the same payment attempt. A historical PAY * must not hide a later manual reimbursement after that payment was canceled or failed. - * The write-time isNewDot/shouldShow flags can be stale for background jobs (Expensify/Expensify#636674). */ -function hasSiblingPayReportAction(reportAction: OnyxEntry, reportID: string | undefined): boolean { - if (!reportID || !reportAction?.reportActionID) { - return false; - } - - const reportActions = getAllReportActions(reportID); - // An action absent from loaded history cannot be matched. Avoid caching the empty fallback for unknown reports. - if (!reportActions[reportAction.reportActionID]) { - return false; - } - +function getPaySiblingActionIDs(reportActions: ReportActions): Set { const cachedSiblings = paySiblingCache.get(reportActions); if (cachedSiblings) { - return cachedSiblings.has(reportAction.reportActionID); + return cachedSiblings; } const paymentActions = getSortedReportActions( @@ -2027,7 +2019,33 @@ function hasSiblingPayReportAction(reportAction: OnyxEntry, report // Cache all siblings together, but invalidate on any history update: even a positive match can // change if an intervening cancellation arrives later. Weak keys release obsolete snapshots. paySiblingCache.set(reportActions, siblings); - return siblings.has(reportAction.reportActionID); + return siblings; +} + +/** + * Checks whether a MARKED_REIMBURSED action has a PAY sibling in the same payment attempt. + * The write-time isNewDot/shouldShow flags can be stale for background jobs (Expensify/Expensify#636674). + * `callerReportActions` (e.g. a Search snapshot) is used when the loaded Onyx history can't answer, e.g. the report's timeline isn't loaded. + */ +function hasSiblingPayReportAction(reportAction: OnyxEntry, reportID: string | undefined, callerReportActions?: OnyxEntry): boolean { + if (!reportID || !reportAction?.reportActionID) { + return false; + } + + const reportActionID = reportAction.reportActionID; + const loadedReportActions = getAllReportActions(reportID); + // An action absent from a collection cannot be matched. Avoid caching the empty fallback for unknown reports. + if (loadedReportActions[reportActionID]) { + if (getPaySiblingActionIDs(loadedReportActions).has(reportActionID)) { + return true; + } + // A loaded history that contains a PAY also holds the cancellations that separate payment attempts, so its answer is final. + if (Object.values(loadedReportActions).some((action) => isPayAction(action))) { + return false; + } + } + + return !!callerReportActions?.[reportActionID] && getPaySiblingActionIDs(callerReportActions).has(reportActionID); } function isTaskAction(reportAction: OnyxEntry): boolean { @@ -4698,7 +4716,7 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS)) { return getReportActionText(reportAction); } - const {newValue, unit, oldValue, updatedField} = getOriginalMessage(reportAction) ?? {}; + const {newValue, unit, oldValue, oldUnit, updatedField} = getOriginalMessage(reportAction) ?? {}; if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.METHOD) { let previousMethod: string; @@ -4727,7 +4745,8 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo return translate('workspaceActions.commuterExclusions.setFixedDistance', {formattedDistance}); } - const formattedOldDistance = getFormattedDistanceInUnits(Number(oldValue ?? 0), distanceUnit, translate); + const oldDistanceUnit = oldUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS || oldUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? oldUnit : distanceUnit; + const formattedOldDistance = getFormattedDistanceInUnits(Number(oldValue ?? 0), oldDistanceUnit, translate); return translate('workspaceActions.commuterExclusions.changedFixedDistance', {formattedOldDistance, formattedNewDistance: formattedDistance}); } @@ -4779,6 +4798,46 @@ function getUpdatedMemberWorkArrangementMessage(translate: LocalizedTranslate, r return translate('workspaceActions.updatedMemberWorkArrangement', {displayName, newArrangement, oldArrangement}); } +function getAddedOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + const {name = '', address} = getOriginalMessage(reportAction) ?? {}; + return translate('workspaceActions.officeLocation.added', {name, address: formatAddressToString(address)}); +} + +function getUpdatedOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + const {name = '', updatedField, newValue, oldValue, newAddress, oldAddress} = getOriginalMessage(reportAction) ?? {}; + + if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.NAME) { + return translate('workspaceActions.officeLocation.renamed', { + oldName: typeof oldValue === 'string' ? oldValue : '', + newName: typeof newValue === 'string' ? newValue : '', + }); + } + + if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.ADDRESS) { + return translate('workspaceActions.officeLocation.changedAddress', {name, newAddress: formatAddressToString(newAddress), oldAddress: formatAddressToString(oldAddress)}); + } + + if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT) { + // Unsetting the primary office makes the company address the primary one + return newValue === false ? translate('workspaceActions.officeLocation.setCompanyAddressAsPrimary') : translate('workspaceActions.officeLocation.setAsPrimary', {name}); + } + + return getReportActionText(reportAction); +} + +function getDeletedOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + return translate('workspaceActions.officeLocation.removed', {name: getOriginalMessage(reportAction)?.name ?? ''}); +} + function getUpdatedProhibitedExpensesMessage(translate: LocalizedTranslate, reportAction: OnyxEntry) { const {newProhibitedExpenses, oldProhibitedExpenses} = getOriginalMessage(reportAction as ReportAction) ?? {}; @@ -5614,6 +5673,9 @@ export { getUpdatedCommuterExclusionsMessage, getPolicyWorkArrangementMessage, getUpdatedMemberWorkArrangementMessage, + getAddedOfficeLocationMessage, + getUpdatedOfficeLocationMessage, + getDeletedOfficeLocationMessage, getWorkspaceTagUpdateMessage, getWorkspaceReportFieldUpdateMessage, getWorkspaceReportFieldDeleteMessage, diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index 8f01c40407f9..25941dd07656 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -14,13 +14,13 @@ import type { Report, ReportAction, ReportActions, - ReportAttributesDerivedValue, ReportMetadata, Rule, Transaction, VisibleReportActionsDerivedValue, WorkspaceCardsList, } from '@src/types/onyx'; +import type {ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -48,6 +48,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -69,6 +70,7 @@ import { getDelegateSubmitMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getDynamicExternalWorkflowRoutedMessage, getExportIntegrationLastMessageText, getForeignCurrencyDefaultTaxUpdateMessage, @@ -136,6 +138,7 @@ import { getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -518,15 +521,14 @@ function getLastMessageTextForReport({ reportMetadata?: OnyxEntry; visibleReportActionsDataParam?: VisibleReportActionsDerivedValue; lastAction?: OnyxEntry; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + reportAttributesDerived?: ReportNamesDerivedValue; policyTags?: OnyxEntry; currentUserLogin?: string; conciergeReportID: string | undefined; isTrackIntentUser?: boolean; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; - // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 - currentUserAccountID?: number; + currentUserAccountID: number; oneTransactionThreadReportID?: string; lastOriginalAction?: OnyxEntry; rules: OnyxCollection; @@ -860,6 +862,15 @@ function getLastMessageTextForReport({ if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { lastMessageTextFromReport = getUpdatedMemberWorkArrangementMessage(translate, lastReportAction); } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + lastMessageTextFromReport = getAddedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getUpdatedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getDeletedOfficeLocationMessage(translate, lastReportAction); + } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction, convertToDisplayString); } @@ -1180,7 +1191,7 @@ type GetReportAlternateTextParams = { isReportArchived: boolean | undefined; privateIsArchived: boolean; conciergeReportID: string | undefined; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + reportAttributesDerived?: ReportNamesDerivedValue; visibleReportActionsData?: VisibleReportActionsDerivedValue; sortedActions?: Record; oneTransactionThreadReportID?: string; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 8bc716e0f830..11baada8cb50 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1171,6 +1171,8 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS); } + options.push(CONST.REPORT.SECONDARY_ACTIONS.SHARE); + if (!isOpenReportUtils(report)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.PRINT); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c6094c6b8030..d82adf918452 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -129,7 +129,7 @@ import getReportURLForCurrentContext from './Navigation/helpers/getReportURLForC import getStateFromPath from './Navigation/helpers/getStateFromPath'; import {isFullScreenName} from './Navigation/helpers/isNavigatorName'; import isSearchTopmostFullScreenRoute from './Navigation/helpers/isSearchTopmostFullScreenRoute'; -import {linkingConfig} from './Navigation/linkingConfig'; +import linkingPrefixes from './Navigation/linkingConfig/prefixes'; import Navigation, {navigationRef} from './Navigation/Navigation'; import {getDBTimeWithSkew, getServerAnchoredDBTime} from './NetworkState'; import {rand64} from './NumberUtils'; @@ -1024,8 +1024,7 @@ type GetPolicyNameBaseParams = { reports?: Report[]; }; -// TODO: We'll remove the optional (?) modifier from the unavailableTranslation parameter once https://github.com/Expensify/App/issues/66430 is completed. -type GetPolicyNameParams = GetPolicyNameBaseParams & ({returnEmptyIfNotFound: true} | {returnEmptyIfNotFound?: false; unavailableTranslation?: string}); +type GetPolicyNameParams = GetPolicyNameBaseParams & ({returnEmptyIfNotFound: true} | {returnEmptyIfNotFound?: false; unavailableTranslation: string}); type GetReportStatusParams = { stateNum?: number; @@ -1246,7 +1245,6 @@ Onyx.connect({ }); let hiddenTranslation = ''; -let unavailableTranslation = ''; Onyx.connect({ key: ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, @@ -1255,7 +1253,6 @@ Onyx.connect({ return; } hiddenTranslation = translateLocal('common.hidden'); - unavailableTranslation = translateLocal('workspace.common.unavailable'); }, }); @@ -1413,10 +1410,13 @@ function getPolicyType(report: OnyxInputOrEntry, policies: OnyxCollectio /** * Get the policy name from a given report + * + * Callers pass their own `unavailableTranslation` (the "Unavailable workspace" copy) for the case where no policy resolves, unless they + * pass `returnEmptyIfNotFound`. Builders that persist their output pass the English literal, everything else passes `translate('workspace.common.unavailable')`. */ function getPolicyName(params: GetPolicyNameParams): string { const {report, policy, policies, reports} = params; - const noPolicyFound = params.returnEmptyIfNotFound ? '' : (params.unavailableTranslation ?? unavailableTranslation); + const noPolicyFound = params.returnEmptyIfNotFound ? '' : params.unavailableTranslation; const parentReport = report ? getRootParentReport({report, reports}) : undefined; // A caller that explicitly passes `policy` (e.g. a draft workspace's policy from getReportOption) can resolve a name @@ -2879,6 +2879,17 @@ function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string, rep return isIOUReport(report) || isExpenseReport(report); } +/** + * Checks if a thread member is the sender or receiver of the thread's parent IOU report. + * They keep access through the IOU report, so removing them from the thread doesn't stick. + */ +function isThreadMemberProtectedByParentReport(parentReport: OnyxInputOrEntry, accountID: number | undefined): boolean { + if (!parentReport || accountID === undefined || !isIOUReport(parentReport)) { + return false; + } + return accountID === parentReport.ownerAccountID || accountID === parentReport.managerID; +} + /** * Checks if a report aligns its action list to the top (transaction threads, money request / invoice reports). */ @@ -6838,10 +6849,18 @@ function getReportPreviewReportActionMessage( const {totalDisplaySpend: totalAmount} = getMoneyRequestSpendBreakdown(report); const parentReport = getParentReport(report); - const policyName = getPolicyName({report: parentReport ?? report, policy}); + // Like the rest of this function, the workspace and participant fallbacks below are the raw English copy: this message is stored on a + // report action and read back by every viewer, so it must not follow the locale of whoever happened to build it. These mirror + // `workspace.common.unavailable` and `common.hidden` in `src/languages/en.ts`, and ReportUtilsTest asserts they do not drift from them. + const policyName = getPolicyName({report: parentReport ?? report, policy, unavailableTranslation: 'Unavailable workspace'}); const payerName = isExpenseReport(report) ? policyName - : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + : getDisplayNameForParticipant({ + accountID: report.managerID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }); const formattedAmount = convertToDisplayStringEnLocale(totalAmount, report.currency, getCurrencyDecimals); @@ -6898,7 +6917,12 @@ function getReportPreviewReportActionMessage( let actualPayerName = report.managerID === deprecatedCurrentUserAccountID && !isForListPreview ? '' - : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + : getDisplayNameForParticipant({ + accountID: payerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; @@ -6931,7 +6955,13 @@ function getReportPreviewReportActionMessage( } if (report.isWaitingOnBankAccount) { - const submitterDisplayName = getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + const submitterDisplayName = + getDisplayNameForParticipant({ + accountID: report.ownerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }) ?? ''; return `started payment, but is waiting for ${submitterDisplayName} to add a personal bank account.`; } @@ -6960,13 +6990,18 @@ function getReportPreviewReportActionMessage( // We only want to show the actor name in the preview if it's not the current user who took the action const requestorName = lastActorID && lastActorID !== deprecatedCurrentUserAccountID - ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}) + ? getDisplayNameForParticipant({ + accountID: lastActorID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }) : ''; return `${requestorName ? `${requestorName}: ` : ''}${amountToDisplay}${comment ? ` for ${comment}` : ''}`; } if (containsNonReimbursable) { - const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: 'Hidden'}) ?? ''; return `${ownerName} spent ${formattedAmount}`; } return `${payerName ?? ''} owes ${formattedAmount}${comment ? ` for ${comment}` : ''}`; @@ -8042,7 +8077,8 @@ function buildOptimisticExpenseReport({ const storedTotal = total * -1; const storedNonReimbursableTotal = nonReimbursableTotal * -1; const report = chatReportID ? getReportOrDraftReport(chatReportID) : undefined; - const policyName = getPolicyName({report}); + // The report name below is stored in English (see the `reportName` comment), so its workspace fallback is the raw English copy too. + const policyName = getPolicyName({report, unavailableTranslation: 'Unavailable workspace'}); const formattedTotal = convertToDisplayStringEnLocale(storedTotal, currency, getCurrencyDecimals); // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 const policyReal = getPolicy(policyID); @@ -11380,7 +11416,7 @@ function getRouteFromLink(url: string | null): string { // Get the reportID from URL let route = url; const localWebAndroidRegEx = /^(https:\/\/([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3}))/; - for (const prefix of linkingConfig.prefixes) { + for (const prefix of linkingPrefixes) { if (route.startsWith(prefix)) { route = route.replace(prefix, ''); } else if (localWebAndroidRegEx.test(route)) { @@ -14084,6 +14120,16 @@ function canBeExported(report: OnyxEntry) { return isExpenseReport(report) && isCorrectState; } +/** + * Whether the current user can email the report to someone. The submitter, the approver and workspace admins can once the report is submitted. + */ +function canShareReport(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number): boolean { + if (!isExpenseReport(report) || isOpenReport(report)) { + return false; + } + return isCurrentUserSubmitter(report, currentUserAccountID) || report?.managerID === currentUserAccountID || isPolicyAdmin(policy); +} + function getIntegrationNameFromExportMessage(reportActions: OnyxEntry | ReportAction[]) { if (!reportActions) { return ''; @@ -15443,6 +15489,7 @@ export { isSystemChat, isTaskReport, isThread, + isThreadMemberProtectedByParentReport, isTrackExpenseReport, isUnread, isUnreadWithMention, @@ -15492,6 +15539,7 @@ export { findPolicyExpenseChatByPolicyID, getIntegrationIcon, canBeExported, + canShareReport, isExported, hasExpensifyGuidesEmails, hasExportError, diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index a3b269757198..d73871042833 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -301,21 +301,22 @@ function peg$parse(input, options) { var peg$c115 = "group-week"; var peg$c116 = "group-year"; var peg$c117 = "group-quarter"; - var peg$c118 = "!="; - var peg$c119 = ">="; - var peg$c120 = ">"; - var peg$c121 = "<="; - var peg$c122 = "<"; - var peg$c123 = "\\"; - var peg$c124 = "\u201C"; - var peg$c125 = "\u201D"; - var peg$c126 = "\""; - var peg$c127 = "footercount"; - var peg$c128 = "footer-count"; - var peg$c129 = "footercurrency"; - var peg$c130 = "footer-currency"; - var peg$c131 = "footertotal"; - var peg$c132 = "footer-total"; + var peg$c118 = "*:"; + var peg$c119 = "!="; + var peg$c120 = ">="; + var peg$c121 = ">"; + var peg$c122 = "<="; + var peg$c123 = "<"; + var peg$c124 = "\\"; + var peg$c125 = "\u201C"; + var peg$c126 = "\u201D"; + var peg$c127 = "\""; + var peg$c128 = "footercount"; + var peg$c129 = "footer-count"; + var peg$c130 = "footercurrency"; + var peg$c131 = "footer-currency"; + var peg$c132 = "footertotal"; + var peg$c133 = "footer-total"; var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; var peg$r1 = /^[:=]/; @@ -455,36 +456,37 @@ function peg$parse(input, options) { var peg$e120 = peg$literalExpectation("group-year", true); var peg$e121 = peg$literalExpectation("group-quarter", true); var peg$e122 = peg$otherExpectation("operator"); - var peg$e123 = peg$literalExpectation("!=", false); - var peg$e124 = peg$literalExpectation(">=", false); - var peg$e125 = peg$literalExpectation(">", false); - var peg$e126 = peg$literalExpectation("<=", false); - var peg$e127 = peg$literalExpectation("<", false); - var peg$e128 = peg$literalExpectation("\\", false); - var peg$e129 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); - var peg$e130 = peg$otherExpectation("word"); - var peg$e131 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e132 = peg$otherExpectation("whitespace"); - var peg$e133 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e134 = peg$otherExpectation("quote"); - var peg$e135 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e136 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e137 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e138 = peg$literalExpectation("\u201C", false); - var peg$e139 = peg$literalExpectation("\u201D", false); - var peg$e140 = peg$literalExpectation("\"", false); - var peg$e141 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e142 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e143 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e145 = peg$classExpectation([","], false, false); - var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - var peg$e147 = peg$literalExpectation("footerCount", true); - var peg$e148 = peg$literalExpectation("footer-count", true); - var peg$e149 = peg$literalExpectation("footerCurrency", true); - var peg$e150 = peg$literalExpectation("footer-currency", true); - var peg$e151 = peg$literalExpectation("footerTotal", true); - var peg$e152 = peg$literalExpectation("footer-total", true); + var peg$e123 = peg$literalExpectation("*:", false); + var peg$e124 = peg$literalExpectation("!=", false); + var peg$e125 = peg$literalExpectation(">=", false); + var peg$e126 = peg$literalExpectation(">", false); + var peg$e127 = peg$literalExpectation("<=", false); + var peg$e128 = peg$literalExpectation("<", false); + var peg$e129 = peg$literalExpectation("\\", false); + var peg$e130 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); + var peg$e131 = peg$otherExpectation("word"); + var peg$e132 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e133 = peg$otherExpectation("whitespace"); + var peg$e134 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e135 = peg$otherExpectation("quote"); + var peg$e136 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e137 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e138 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e139 = peg$literalExpectation("\u201C", false); + var peg$e140 = peg$literalExpectation("\u201D", false); + var peg$e141 = peg$literalExpectation("\"", false); + var peg$e142 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e143 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e145 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e146 = peg$classExpectation([","], false, false); + var peg$e147 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e148 = peg$literalExpectation("footerCount", true); + var peg$e149 = peg$literalExpectation("footer-count", true); + var peg$e150 = peg$literalExpectation("footerCurrency", true); + var peg$e151 = peg$literalExpectation("footer-currency", true); + var peg$e152 = peg$literalExpectation("footerTotal", true); + var peg$e153 = peg$literalExpectation("footer-total", true); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -493,6 +495,12 @@ function peg$parse(input, options) { const isNegated = !!neg && !nonNegatableKeys.has(key); + // The "contains" operator is only supported for the merchant field + if (op === "contains" && (neg || key !== "merchant")) { + autocomplete = null; + return; + } + if (!value) { autocomplete = { key, @@ -649,38 +657,39 @@ function peg$parse(input, options) { var peg$f102 = function() { return "groupweek"; }; var peg$f103 = function() { return "groupyear"; }; var peg$f104 = function() { return "groupquarter"; }; - var peg$f105 = function() { return "eq"; }; - var peg$f106 = function() { return "neq"; }; - var peg$f107 = function() { return "gte"; }; - var peg$f108 = function() { return "gt"; }; - var peg$f109 = function() { return "lte"; }; - var peg$f110 = function() { return "lt"; }; - var peg$f111 = function(o) { + var peg$f105 = function() { return "contains"; }; + var peg$f106 = function() { return "eq"; }; + var peg$f107 = function() { return "neq"; }; + var peg$f108 = function() { return "gte"; }; + var peg$f109 = function() { return "gt"; }; + var peg$f110 = function() { return "lte"; }; + var peg$f111 = function() { return "lt"; }; + var peg$f112 = function(o) { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; }; - var peg$f112 = function(c) { return c; }; - var peg$f113 = function(chars) { return chars.join("").trim(); }; - var peg$f114 = function() { + var peg$f113 = function(c) { return c; }; + var peg$f114 = function(chars) { return chars.join("").trim(); }; + var peg$f115 = function() { isColumnsContext = false; return "and"; }; - var peg$f115 = function() { return expectingNestedQuote; }; - var peg$f116 = function(start, inner, end) { //handle no-breaking space + var peg$f116 = function() { return expectingNestedQuote; }; + var peg$f117 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f117 = function(start) {return "“"}; - var peg$f118 = function(start) {return "”"}; - var peg$f119 = function(start) {return "\""}; - var peg$f120 = function(start, inner, end) { + var peg$f118 = function(start) {return "“"}; + var peg$f119 = function(start) {return "”"}; + var peg$f120 = function(start) {return "\""}; + var peg$f121 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; - var peg$f121 = function() { return "footerCount"; }; - var peg$f122 = function() { return "footerCurrency"; }; - var peg$f123 = function() { return "footerTotal"; }; + var peg$f122 = function() { return "footerCount"; }; + var peg$f123 = function() { return "footerCurrency"; }; + var peg$f124 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -4301,12 +4310,12 @@ function peg$parse(input, options) { peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r1.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c118) { + s1 = peg$c118; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e123); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -4315,12 +4324,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c118) { - s1 = peg$c118; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r1.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e123); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -4343,9 +4352,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c120) { s1 = peg$c120; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e125); } @@ -4357,9 +4366,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c121) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c121; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e126); } @@ -4371,9 +4380,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c122) { s1 = peg$c122; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e127); } @@ -4383,6 +4392,21 @@ function peg$parse(input, options) { s1 = peg$f110(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c123; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e128); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f111(); + } + s0 = s1; + } } } } @@ -4404,7 +4428,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f111(s1); + s1 = peg$f112(s1); } s0 = s1; @@ -4416,11 +4440,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c123; + s1 = peg$c124; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e128); } + if (peg$silentFails === 0) { peg$fail(peg$e129); } } if (s1 !== peg$FAILED) { s2 = input.charAt(peg$currPos); @@ -4428,11 +4452,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e129); } + if (peg$silentFails === 0) { peg$fail(peg$e130); } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f112(s2); + s0 = peg$f113(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4458,7 +4482,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } } if (s2 !== peg$FAILED) { @@ -4471,7 +4495,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } } } @@ -4480,13 +4504,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f113(s1); + s1 = peg$f114(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e130); } + if (peg$silentFails === 0) { peg$fail(peg$e131); } } return s0; @@ -4498,7 +4522,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f114(); + s1 = peg$f115(); s0 = s1; return s0; @@ -4514,7 +4538,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4523,12 +4547,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e132); } + if (peg$silentFails === 0) { peg$fail(peg$e133); } return s0; } @@ -4538,7 +4562,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f115(); + s1 = peg$f116(); if (s1) { s1 = undefined; } else { @@ -4575,7 +4599,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4584,7 +4608,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } s2 = input.charAt(peg$currPos); @@ -4592,7 +4616,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4603,7 +4627,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } while (s4 !== peg$FAILED) { @@ -4615,7 +4639,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } } @@ -4624,7 +4648,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4633,7 +4657,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4642,11 +4666,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } } peg$savedPos = s0; - s0 = peg$f116(s1, s3, s5); + s0 = peg$f117(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4658,7 +4682,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } } return s0; @@ -4675,7 +4699,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4684,7 +4708,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } s2 = input.charAt(peg$currPos); @@ -4692,7 +4716,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4703,7 +4727,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4719,15 +4743,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f118(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4750,15 +4774,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4781,15 +4805,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4811,7 +4835,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4827,15 +4851,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f118(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4858,15 +4882,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4889,15 +4913,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4914,7 +4938,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f120(s1, s3, s4); + s0 = peg$f121(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4926,7 +4950,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } } return s0; @@ -4941,7 +4965,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4979,7 +5003,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4988,7 +5012,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } } s2 = []; @@ -4997,7 +5021,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -5006,7 +5030,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } } s3 = []; @@ -5015,7 +5039,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -5024,7 +5048,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } } s4 = peg$parseoperator(); @@ -5043,7 +5067,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5052,7 +5076,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } } s2 = peg$currPos; @@ -5099,7 +5123,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } } } @@ -5115,7 +5139,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -5144,24 +5168,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c127) { + if (s1.toLowerCase() === peg$c128) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e147); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c128) { + if (s1.toLowerCase() === peg$c129) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e148); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f121(); + s1 = peg$f122(); } s0 = s1; @@ -5173,24 +5197,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c129) { + if (s1.toLowerCase() === peg$c130) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e149); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c130) { + if (s1.toLowerCase() === peg$c131) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e150); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f122(); + s1 = peg$f123(); } s0 = s1; @@ -5202,24 +5226,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c131) { + if (s1.toLowerCase() === peg$c132) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e151); } + if (peg$silentFails === 0) { peg$fail(peg$e152); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c132) { + if (s1.toLowerCase() === peg$c133) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e152); } + if (peg$silentFails === 0) { peg$fail(peg$e153); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f123(); + s1 = peg$f124(); } s0 = s1; diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 47773b51e148..faedf94501c3 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -40,6 +40,12 @@ defaultFilter const isNegated = !!neg && !nonNegatableKeys.has(key); + // The "contains" operator is only supported for the merchant field + if (op === "contains" && (neg || key !== "merchant")) { + autocomplete = null; + return; + } + if (!value) { autocomplete = { key, diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index a0e24d1e000e..1abb9f1bb854 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -191,7 +191,8 @@ groupQuarter = "group-quarter"i &wordBoundary { return "gr operator "operator" - = (":" / "=") { return "eq"; } + = "*:" { return "contains"; } + / (":" / "=") { return "eq"; } / "!=" { return "neq"; } / ">=" { return "gte"; } / ">" { return "gt"; } @@ -201,7 +202,7 @@ operator "operator" filterOperator = o:operator { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 68bf5cb29072..1a9f6d7b71be 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -302,21 +302,22 @@ function peg$parse(input, options) { var peg$c115 = "group-week"; var peg$c116 = "group-year"; var peg$c117 = "group-quarter"; - var peg$c118 = "!="; - var peg$c119 = ">="; - var peg$c120 = ">"; - var peg$c121 = "<="; - var peg$c122 = "<"; - var peg$c123 = "\\"; - var peg$c124 = "\u201C"; - var peg$c125 = "\u201D"; - var peg$c126 = "\""; - var peg$c127 = "footercount"; - var peg$c128 = "footer-count"; - var peg$c129 = "footercurrency"; - var peg$c130 = "footer-currency"; - var peg$c131 = "footertotal"; - var peg$c132 = "footer-total"; + var peg$c118 = "*:"; + var peg$c119 = "!="; + var peg$c120 = ">="; + var peg$c121 = ">"; + var peg$c122 = "<="; + var peg$c123 = "<"; + var peg$c124 = "\\"; + var peg$c125 = "\u201C"; + var peg$c126 = "\u201D"; + var peg$c127 = "\""; + var peg$c128 = "footercount"; + var peg$c129 = "footer-count"; + var peg$c130 = "footercurrency"; + var peg$c131 = "footer-currency"; + var peg$c132 = "footertotal"; + var peg$c133 = "footer-total"; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -459,36 +460,37 @@ function peg$parse(input, options) { var peg$e122 = peg$literalExpectation("group-year", true); var peg$e123 = peg$literalExpectation("group-quarter", true); var peg$e124 = peg$otherExpectation("operator"); - var peg$e125 = peg$literalExpectation("!=", false); - var peg$e126 = peg$literalExpectation(">=", false); - var peg$e127 = peg$literalExpectation(">", false); - var peg$e128 = peg$literalExpectation("<=", false); - var peg$e129 = peg$literalExpectation("<", false); - var peg$e130 = peg$literalExpectation("\\", false); - var peg$e131 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); - var peg$e132 = peg$otherExpectation("word"); - var peg$e133 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e134 = peg$otherExpectation("whitespace"); - var peg$e135 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e136 = peg$otherExpectation("quote"); - var peg$e137 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e138 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e139 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e140 = peg$literalExpectation("\u201C", false); - var peg$e141 = peg$literalExpectation("\u201D", false); - var peg$e142 = peg$literalExpectation("\"", false); - var peg$e143 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e144 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e145 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e147 = peg$classExpectation([","], false, false); - var peg$e148 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - var peg$e149 = peg$literalExpectation("footerCount", true); - var peg$e150 = peg$literalExpectation("footer-count", true); - var peg$e151 = peg$literalExpectation("footerCurrency", true); - var peg$e152 = peg$literalExpectation("footer-currency", true); - var peg$e153 = peg$literalExpectation("footerTotal", true); - var peg$e154 = peg$literalExpectation("footer-total", true); + var peg$e125 = peg$literalExpectation("*:", false); + var peg$e126 = peg$literalExpectation("!=", false); + var peg$e127 = peg$literalExpectation(">=", false); + var peg$e128 = peg$literalExpectation(">", false); + var peg$e129 = peg$literalExpectation("<=", false); + var peg$e130 = peg$literalExpectation("<", false); + var peg$e131 = peg$literalExpectation("\\", false); + var peg$e132 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); + var peg$e133 = peg$otherExpectation("word"); + var peg$e134 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e135 = peg$otherExpectation("whitespace"); + var peg$e136 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e137 = peg$otherExpectation("quote"); + var peg$e138 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e139 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e140 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e141 = peg$literalExpectation("\u201C", false); + var peg$e142 = peg$literalExpectation("\u201D", false); + var peg$e143 = peg$literalExpectation("\"", false); + var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e145 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e147 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e148 = peg$classExpectation([","], false, false); + var peg$e149 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e150 = peg$literalExpectation("footerCount", true); + var peg$e151 = peg$literalExpectation("footer-count", true); + var peg$e152 = peg$literalExpectation("footerCurrency", true); + var peg$e153 = peg$literalExpectation("footer-currency", true); + var peg$e154 = peg$literalExpectation("footerTotal", true); + var peg$e155 = peg$literalExpectation("footer-total", true); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -527,6 +529,10 @@ function peg$parse(input, options) { ); }; var peg$f2 = function(key, op, value) { + // The "contains" operator is only supported for the merchant field + if (op === "contains") { + return buildFilter("eq", "keyword", text().trim()); + } updateDefaultValues(key, value); return {...buildFilter(op, key, value), isDefault: true}; }; @@ -544,7 +550,17 @@ function peg$parse(input, options) { expectingNestedQuote = false; nameOperator = false; const key = field; - let operator = op; + let operator = op.value; + + // Positive `merchant:` filters predate explicit match types, so they must retain contains semantics. + if (!neg && key === "merchant" && op.token === ":") { + operator = "contains"; + } + + // The "contains" operator is only supported for the merchant field + if (operator === "contains" && (neg || key !== "merchant")) { + return buildFilter("eq", "keyword", text().trim()); + } // Apply negation only when "-" is present and the key can be negated if (neg && !nonNegatableKeys.has(key)) { @@ -555,15 +571,16 @@ function peg$parse(input, options) { return buildFilter(operator, key, values); }; - var peg$f5 = function(rest) { + var peg$f5 = function(value) { return {value, token: text()}; }; + var peg$f6 = function(rest) { const suffix = rest.replaceAll(/^-+/g, ""); return "reportField-" + (suffix ? suffix : ""); }; - var peg$f6 = function(k) { + var peg$f7 = function(k) { nameOperator = (k === "from" || k === "to" || k === "payer" || k === "paidBy" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); return k; }; - var peg$f7 = function(parts, empty) { + var peg$f8 = function(parts, empty) { const value = parts.flat().filter(Boolean).map((word) => { if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { return word.slice(1, -1); @@ -575,138 +592,139 @@ function peg$parse(input, options) { } return value[0]; }; - var peg$f8 = function() { return "date"; }; - var peg$f9 = function() { return "amount"; }; - var peg$f10 = function() { return "merchant"; }; - var peg$f11 = function() { return "description"; }; - var peg$f12 = function() { return "reportID"; }; - var peg$f13 = function() { return "keyword"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "groupCurrency"; }; - var peg$f17 = function() { return "tag"; }; - var peg$f18 = function() { return "category"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "exporter"; }; - var peg$f21 = function() { return "payer"; }; - var peg$f22 = function() { return "paidBy"; }; - var peg$f23 = function() { return "taxRate"; }; - var peg$f24 = function() { return "cardID"; }; - var peg$f25 = function() { return "bankAccount"; }; - var peg$f26 = function() { return "from"; }; - var peg$f27 = function() {return "attendee"}; - var peg$f28 = function() { return "expenseType"; }; - var peg$f29 = function() { return "receiptType"; }; - var peg$f30 = function() { return "transactionStatus"; }; - var peg$f31 = function() { return "withdrawalType"; }; - var peg$f32 = function() { return "withdrawalStatus"; }; - var peg$f33 = function() { return "paidStatus"; }; - var peg$f34 = function() { return "withdrawalID"; }; - var peg$f35 = function() { return "billable"; }; - var peg$f36 = function() { return "reimbursable"; }; - var peg$f37 = function() { return "type"; }; - var peg$f38 = function() { return "status"; }; - var peg$f39 = function() { return "sortBy"; }; - var peg$f40 = function() { return "sortOrder"; }; - var peg$f41 = function() { return "policyID"; }; - var peg$f42 = function() { return "submitted"; }; - var peg$f43 = function() { return "approved"; }; - var peg$f44 = function() { return "paid"; }; - var peg$f45 = function() { return "exported"; }; - var peg$f46 = function() { return "posted"; }; - var peg$f47 = function() { return "withdrawn"; }; - var peg$f48 = function() { return "groupBy"; }; - var peg$f49 = function() { return "limit"; }; - var peg$f50 = function() { return "feed"; }; - var peg$f51 = function() { return "title"; }; - var peg$f52 = function() { return "submitterUserID"; }; - var peg$f53 = function() { return "submitterPayrollID"; }; - var peg$f54 = function() { return "orderDealNumbers"; }; - var peg$f55 = function() { return "assignee"; }; - var peg$f56 = function() { return "createdBy"; }; - var peg$f57 = function() { return "action"; }; - var peg$f58 = function() {return "total"; }; - var peg$f59 = function() {return "has"; }; - var peg$f60 = function() {return "is"; }; - var peg$f61 = function() {return "purchaseAmount"}; - var peg$f62 = function() {return "purchaseCurrency"}; - var peg$f63 = function() {return "amountDebited"}; - var peg$f64 = function() {return "amountReimbursed"}; - var peg$f65 = function() { + var peg$f9 = function() { return "date"; }; + var peg$f10 = function() { return "amount"; }; + var peg$f11 = function() { return "merchant"; }; + var peg$f12 = function() { return "description"; }; + var peg$f13 = function() { return "reportID"; }; + var peg$f14 = function() { return "keyword"; }; + var peg$f15 = function() { return "in"; }; + var peg$f16 = function() { return "currency"; }; + var peg$f17 = function() { return "groupCurrency"; }; + var peg$f18 = function() { return "tag"; }; + var peg$f19 = function() { return "category"; }; + var peg$f20 = function() { return "to"; }; + var peg$f21 = function() { return "exporter"; }; + var peg$f22 = function() { return "payer"; }; + var peg$f23 = function() { return "paidBy"; }; + var peg$f24 = function() { return "taxRate"; }; + var peg$f25 = function() { return "cardID"; }; + var peg$f26 = function() { return "bankAccount"; }; + var peg$f27 = function() { return "from"; }; + var peg$f28 = function() {return "attendee"}; + var peg$f29 = function() { return "expenseType"; }; + var peg$f30 = function() { return "receiptType"; }; + var peg$f31 = function() { return "transactionStatus"; }; + var peg$f32 = function() { return "withdrawalType"; }; + var peg$f33 = function() { return "withdrawalStatus"; }; + var peg$f34 = function() { return "paidStatus"; }; + var peg$f35 = function() { return "withdrawalID"; }; + var peg$f36 = function() { return "billable"; }; + var peg$f37 = function() { return "reimbursable"; }; + var peg$f38 = function() { return "type"; }; + var peg$f39 = function() { return "status"; }; + var peg$f40 = function() { return "sortBy"; }; + var peg$f41 = function() { return "sortOrder"; }; + var peg$f42 = function() { return "policyID"; }; + var peg$f43 = function() { return "submitted"; }; + var peg$f44 = function() { return "approved"; }; + var peg$f45 = function() { return "paid"; }; + var peg$f46 = function() { return "exported"; }; + var peg$f47 = function() { return "posted"; }; + var peg$f48 = function() { return "withdrawn"; }; + var peg$f49 = function() { return "groupBy"; }; + var peg$f50 = function() { return "limit"; }; + var peg$f51 = function() { return "feed"; }; + var peg$f52 = function() { return "title"; }; + var peg$f53 = function() { return "submitterUserID"; }; + var peg$f54 = function() { return "submitterPayrollID"; }; + var peg$f55 = function() { return "orderDealNumbers"; }; + var peg$f56 = function() { return "assignee"; }; + var peg$f57 = function() { return "createdBy"; }; + var peg$f58 = function() { return "action"; }; + var peg$f59 = function() {return "total"; }; + var peg$f60 = function() {return "has"; }; + var peg$f61 = function() {return "is"; }; + var peg$f62 = function() {return "purchaseAmount"}; + var peg$f63 = function() {return "purchaseCurrency"}; + var peg$f64 = function() {return "amountDebited"}; + var peg$f65 = function() {return "amountReimbursed"}; + var peg$f66 = function() { isColumnsContext = true; return "columns"; }; - var peg$f66 = function() {return "view"}; - var peg$f67 = function() {return "compare"}; - var peg$f68 = function() { return isColumnsContext; }; - var peg$f69 = function() { return "perDiem"; }; - var peg$f70 = function() { return "drafts"; }; - var peg$f71 = function() { return "originalamount"; }; - var peg$f72 = function() { return "taxAmount"; }; - var peg$f73 = function() { return "taxrate"; }; - var peg$f74 = function() { return "policyname"; }; - var peg$f75 = function() { return "withdrawalID"; }; - var peg$f76 = function() { return "bankAccount"; }; - var peg$f77 = function() { return "reportID"; }; - var peg$f78 = function() { return "base62ReportID"; }; - var peg$f79 = function() { return "exportedto"; }; - var peg$f80 = function() { return "exportedTo"; }; + var peg$f67 = function() {return "view"}; + var peg$f68 = function() {return "compare"}; + var peg$f69 = function() { return isColumnsContext; }; + var peg$f70 = function() { return "perDiem"; }; + var peg$f71 = function() { return "drafts"; }; + var peg$f72 = function() { return "originalamount"; }; + var peg$f73 = function() { return "taxAmount"; }; + var peg$f74 = function() { return "taxrate"; }; + var peg$f75 = function() { return "policyname"; }; + var peg$f76 = function() { return "withdrawalID"; }; + var peg$f77 = function() { return "bankAccount"; }; + var peg$f78 = function() { return "reportID"; }; + var peg$f79 = function() { return "base62ReportID"; }; + var peg$f80 = function() { return "exportedto"; }; var peg$f81 = function() { return "exportedTo"; }; - var peg$f82 = function() { return "exchangeRate"; }; - var peg$f83 = function() { return "reimbursableTotal"; }; - var peg$f84 = function() { return "nonReimbursableTotal"; }; - var peg$f85 = function() { return "groupFrom"; }; - var peg$f86 = function() { return "groupExpenses"; }; - var peg$f87 = function() { return "groupTotal"; }; - var peg$f88 = function() { return "groupCard"; }; - var peg$f89 = function() { return "groupFeed"; }; - var peg$f90 = function() { return "groupBankAccount"; }; - var peg$f91 = function() { return "groupWithdrawn"; }; - var peg$f92 = function() { return "groupWithdrawalID"; }; - var peg$f93 = function() { return "groupAmountDebited"; }; - var peg$f94 = function() { return "groupAmountReimbursed"; }; - var peg$f95 = function() { return "amountDebited"; }; - var peg$f96 = function() { return "amountReimbursed"; }; - var peg$f97 = function() { return "groupCategory"; }; - var peg$f98 = function() { return "groupTag"; }; - var peg$f99 = function() { return "groupMerchant"; }; - var peg$f100 = function() { return "groupday"; }; - var peg$f101 = function() { return "groupmonth"; }; - var peg$f102 = function() { return "groupweek"; }; - var peg$f103 = function() { return "groupyear"; }; - var peg$f104 = function() { return "groupquarter"; }; - var peg$f105 = function() { return "eq"; }; - var peg$f106 = function() { return "neq"; }; - var peg$f107 = function() { return "gte"; }; - var peg$f108 = function() { return "gt"; }; - var peg$f109 = function() { return "lte"; }; - var peg$f110 = function() { return "lt"; }; - var peg$f111 = function(o) { + var peg$f82 = function() { return "exportedTo"; }; + var peg$f83 = function() { return "exchangeRate"; }; + var peg$f84 = function() { return "reimbursableTotal"; }; + var peg$f85 = function() { return "nonReimbursableTotal"; }; + var peg$f86 = function() { return "groupFrom"; }; + var peg$f87 = function() { return "groupExpenses"; }; + var peg$f88 = function() { return "groupTotal"; }; + var peg$f89 = function() { return "groupCard"; }; + var peg$f90 = function() { return "groupFeed"; }; + var peg$f91 = function() { return "groupBankAccount"; }; + var peg$f92 = function() { return "groupWithdrawn"; }; + var peg$f93 = function() { return "groupWithdrawalID"; }; + var peg$f94 = function() { return "groupAmountDebited"; }; + var peg$f95 = function() { return "groupAmountReimbursed"; }; + var peg$f96 = function() { return "amountDebited"; }; + var peg$f97 = function() { return "amountReimbursed"; }; + var peg$f98 = function() { return "groupCategory"; }; + var peg$f99 = function() { return "groupTag"; }; + var peg$f100 = function() { return "groupMerchant"; }; + var peg$f101 = function() { return "groupday"; }; + var peg$f102 = function() { return "groupmonth"; }; + var peg$f103 = function() { return "groupweek"; }; + var peg$f104 = function() { return "groupyear"; }; + var peg$f105 = function() { return "groupquarter"; }; + var peg$f106 = function() { return "contains"; }; + var peg$f107 = function() { return "eq"; }; + var peg$f108 = function() { return "neq"; }; + var peg$f109 = function() { return "gte"; }; + var peg$f110 = function() { return "gt"; }; + var peg$f111 = function() { return "lte"; }; + var peg$f112 = function() { return "lt"; }; + var peg$f113 = function(o) { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; }; - var peg$f112 = function(c) { return c; }; - var peg$f113 = function(chars) { return chars.join("").trim(); }; - var peg$f114 = function() { + var peg$f114 = function(c) { return c; }; + var peg$f115 = function(chars) { return chars.join("").trim(); }; + var peg$f116 = function() { isColumnsContext = false; return "and"; }; - var peg$f115 = function() { return expectingNestedQuote; }; - var peg$f116 = function(start, inner, end) { //handle no-breaking space + var peg$f117 = function() { return expectingNestedQuote; }; + var peg$f118 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f117 = function(start) {return "“"}; - var peg$f118 = function(start) {return "”"}; - var peg$f119 = function(start) {return "\""}; - var peg$f120 = function(start, inner, end) { + var peg$f119 = function(start) {return "“"}; + var peg$f120 = function(start) {return "”"}; + var peg$f121 = function(start) {return "\""}; + var peg$f122 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; - var peg$f121 = function() { return "footerCount"; }; - var peg$f122 = function() { return "footerCurrency"; }; - var peg$f123 = function() { return "footerTotal"; }; + var peg$f123 = function() { return "footerCount"; }; + var peg$f124 = function() { return "footerCurrency"; }; + var peg$f125 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1043,7 +1061,7 @@ function peg$parse(input, options) { s3 = peg$parsefilterKey(); if (s3 !== peg$FAILED) { s4 = peg$parse_(); - s5 = peg$parsefilterOperator(); + s5 = peg$parsesearchFilterOperator(); if (s5 !== peg$FAILED) { s6 = peg$parse_(); s7 = peg$parseidentifier(); @@ -1066,6 +1084,20 @@ function peg$parse(input, options) { return s0; } + function peg$parsesearchFilterOperator() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsefilterOperator(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(s1); + } + s0 = s1; + + return s0; + } + function peg$parsereportFieldDynamic() { var s0, s1, s2, s3, s4, s5, s6, s7; @@ -1174,7 +1206,7 @@ function peg$parse(input, options) { } s3 = input.substring(s3, peg$currPos); peg$savedPos = s0; - s0 = peg$f5(s3); + s0 = peg$f6(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1383,7 +1415,7 @@ function peg$parse(input, options) { s1 = peg$parsekey(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f6(s1); + s1 = peg$f7(s1); } s0 = s1; @@ -1546,7 +1578,7 @@ function peg$parse(input, options) { s3 = null; } peg$savedPos = s0; - s0 = peg$f7(s2, s3); + s0 = peg$f8(s2, s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1568,7 +1600,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f8(); + s1 = peg$f9(); } s0 = s1; @@ -1588,7 +1620,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f9(); + s1 = peg$f10(); } s0 = s1; @@ -1608,7 +1640,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f10(); + s1 = peg$f11(); } s0 = s1; @@ -1628,7 +1660,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f11(); + s1 = peg$f12(); } s0 = s1; @@ -1656,7 +1688,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f12(); + s1 = peg$f13(); } s0 = s1; } @@ -1677,7 +1709,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f13(); + s1 = peg$f14(); } s0 = s1; @@ -1697,7 +1729,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f14(); + s1 = peg$f15(); } s0 = s1; @@ -1717,7 +1749,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(); + s1 = peg$f16(); } s0 = s1; @@ -1745,7 +1777,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(); + s1 = peg$f17(); } s0 = s1; } @@ -1766,7 +1798,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f18(); } s0 = s1; @@ -1786,7 +1818,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f18(); + s1 = peg$f19(); } s0 = s1; @@ -1806,7 +1838,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f20(); } s0 = s1; @@ -1826,7 +1858,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f20(); + s1 = peg$f21(); } s0 = s1; @@ -1846,7 +1878,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(); + s1 = peg$f22(); } s0 = s1; @@ -1874,7 +1906,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(); + s1 = peg$f23(); } s0 = s1; } @@ -1903,7 +1935,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f23(); + s1 = peg$f24(); } s0 = s1; } @@ -1932,7 +1964,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f24(); + s1 = peg$f25(); } s0 = s1; } @@ -1961,7 +1993,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f25(); + s1 = peg$f26(); } s0 = s1; } @@ -1982,7 +2014,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f26(); + s1 = peg$f27(); } s0 = s1; @@ -2002,7 +2034,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f27(); + s1 = peg$f28(); } s0 = s1; @@ -2030,7 +2062,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f28(); + s1 = peg$f29(); } s0 = s1; } @@ -2059,7 +2091,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f29(); + s1 = peg$f30(); } s0 = s1; } @@ -2088,7 +2120,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f31(); } s0 = s1; } @@ -2117,7 +2149,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f32(); } s0 = s1; } @@ -2146,7 +2178,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f32(); + s1 = peg$f33(); } s0 = s1; } @@ -2175,7 +2207,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f33(); + s1 = peg$f34(); } s0 = s1; } @@ -2204,7 +2236,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f35(); } s0 = s1; } @@ -2225,7 +2257,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f35(); + s1 = peg$f36(); } s0 = s1; @@ -2245,7 +2277,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f36(); + s1 = peg$f37(); } s0 = s1; @@ -2265,7 +2297,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f37(); + s1 = peg$f38(); } s0 = s1; @@ -2285,7 +2317,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f38(); + s1 = peg$f39(); } s0 = s1; @@ -2313,7 +2345,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f40(); } s0 = s1; } @@ -2342,7 +2374,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f41(); } s0 = s1; } @@ -2371,7 +2403,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f42(); } s0 = s1; } @@ -2392,7 +2424,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f43(); } s0 = s1; @@ -2412,7 +2444,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f44(); } s0 = s1; @@ -2432,7 +2464,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f45(); } s0 = s1; @@ -2452,7 +2484,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f46(); } s0 = s1; @@ -2472,7 +2504,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f46(); + s1 = peg$f47(); } s0 = s1; @@ -2492,7 +2524,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f47(); + s1 = peg$f48(); } s0 = s1; @@ -2520,7 +2552,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); + s1 = peg$f49(); } s0 = s1; } @@ -2541,7 +2573,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f49(); + s1 = peg$f50(); } s0 = s1; @@ -2561,7 +2593,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f50(); + s1 = peg$f51(); } s0 = s1; @@ -2581,7 +2613,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f51(); + s1 = peg$f52(); } s0 = s1; @@ -2609,7 +2641,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f52(); + s1 = peg$f53(); } s0 = s1; } @@ -2638,7 +2670,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f53(); + s1 = peg$f54(); } s0 = s1; } @@ -2667,7 +2699,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f54(); + s1 = peg$f55(); } s0 = s1; } @@ -2688,7 +2720,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f55(); + s1 = peg$f56(); } s0 = s1; @@ -2716,7 +2748,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f56(); + s1 = peg$f57(); } s0 = s1; } @@ -2737,7 +2769,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f57(); + s1 = peg$f58(); } s0 = s1; @@ -2757,7 +2789,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f58(); + s1 = peg$f59(); } s0 = s1; @@ -2777,7 +2809,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f59(); + s1 = peg$f60(); } s0 = s1; @@ -2797,7 +2829,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f60(); + s1 = peg$f61(); } s0 = s1; @@ -2825,7 +2857,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f61(); + s1 = peg$f62(); } s0 = s1; } @@ -2854,7 +2886,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f62(); + s1 = peg$f63(); } s0 = s1; } @@ -2883,7 +2915,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f63(); + s1 = peg$f64(); } s0 = s1; } @@ -2912,7 +2944,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f64(); + s1 = peg$f65(); } s0 = s1; } @@ -2933,7 +2965,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f65(); + s1 = peg$f66(); } s0 = s1; @@ -2953,7 +2985,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f66(); + s1 = peg$f67(); } s0 = s1; @@ -2973,7 +3005,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f67(); + s1 = peg$f68(); } s0 = s1; @@ -2985,7 +3017,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f68(); + s1 = peg$f69(); if (s1) { s1 = undefined; } else { @@ -3139,7 +3171,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f69(); + s0 = peg$f70(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3185,7 +3217,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f70(); + s0 = peg$f71(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3222,7 +3254,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f71(); + s0 = peg$f72(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3259,7 +3291,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f72(); + s0 = peg$f73(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3296,7 +3328,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f73(); + s0 = peg$f74(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3333,7 +3365,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f74(); + s0 = peg$f75(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3370,7 +3402,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f75(); + s0 = peg$f76(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3407,7 +3439,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f76(); + s0 = peg$f77(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3444,7 +3476,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f77(); + s0 = peg$f78(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3481,7 +3513,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f78(); + s0 = peg$f79(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3518,7 +3550,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f79(); + s0 = peg$f80(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3581,7 +3613,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f80(); + s0 = peg$f81(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3612,7 +3644,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f81(); + s0 = peg$f82(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3650,7 +3682,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f82(); + s0 = peg$f83(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3687,7 +3719,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f83(); + s0 = peg$f84(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3724,7 +3756,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f84(); + s0 = peg$f85(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3761,7 +3793,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f85(); + s0 = peg$f86(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3798,7 +3830,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f86(); + s0 = peg$f87(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3835,7 +3867,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f87(); + s0 = peg$f88(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3872,7 +3904,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f88(); + s0 = peg$f89(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3909,7 +3941,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f89(); + s0 = peg$f90(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3946,7 +3978,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f90(); + s0 = peg$f91(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3983,7 +4015,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f91(); + s0 = peg$f92(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4020,7 +4052,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f92(); + s0 = peg$f93(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4057,7 +4089,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f93(); + s0 = peg$f94(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4094,7 +4126,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f94(); + s0 = peg$f95(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4131,7 +4163,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f95(); + s0 = peg$f96(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4168,7 +4200,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f96(); + s0 = peg$f97(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4205,7 +4237,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f97(); + s0 = peg$f98(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4242,7 +4274,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f98(); + s0 = peg$f99(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4279,7 +4311,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f99(); + s0 = peg$f100(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4316,7 +4348,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f100(); + s0 = peg$f101(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4353,7 +4385,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f101(); + s0 = peg$f102(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4390,7 +4422,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f102(); + s0 = peg$f103(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4427,7 +4459,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f103(); + s0 = peg$f104(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4464,7 +4496,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f104(); + s0 = peg$f105(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4482,30 +4514,30 @@ function peg$parse(input, options) { peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r2.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c118) { + s1 = peg$c118; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e125); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f105(); + s1 = peg$f106(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c118) { - s1 = peg$c118; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r2.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e125); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f106(); + s1 = peg$f107(); } s0 = s1; if (s0 === peg$FAILED) { @@ -4519,51 +4551,66 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f107(); + s1 = peg$f108(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c120) { s1 = peg$c120; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e127); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f108(); + s1 = peg$f109(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c121) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c121; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e128); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f109(); + s1 = peg$f110(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c122) { s1 = peg$c122; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e129); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f110(); + s1 = peg$f111(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c123; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e130); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f112(); + } + s0 = s1; + } } } } @@ -4585,7 +4632,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f111(s1); + s1 = peg$f113(s1); } s0 = s1; @@ -4597,11 +4644,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c123; + s1 = peg$c124; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e130); } + if (peg$silentFails === 0) { peg$fail(peg$e131); } } if (s1 !== peg$FAILED) { s2 = input.charAt(peg$currPos); @@ -4609,11 +4656,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f112(s2); + s0 = peg$f114(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4639,7 +4686,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } if (s2 !== peg$FAILED) { @@ -4652,7 +4699,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } } @@ -4661,13 +4708,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f113(s1); + s1 = peg$f115(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e132); } + if (peg$silentFails === 0) { peg$fail(peg$e133); } } return s0; @@ -4679,7 +4726,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f114(); + s1 = peg$f116(); s0 = s1; return s0; @@ -4695,7 +4742,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4704,12 +4751,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } return s0; } @@ -4719,7 +4766,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f115(); + s1 = peg$f117(); if (s1) { s1 = undefined; } else { @@ -4756,7 +4803,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4765,7 +4812,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } s2 = input.charAt(peg$currPos); @@ -4773,7 +4820,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4784,7 +4831,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } while (s4 !== peg$FAILED) { @@ -4796,7 +4843,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } } @@ -4805,7 +4852,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4814,7 +4861,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4823,11 +4870,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } peg$savedPos = s0; - s0 = peg$f116(s1, s3, s5); + s0 = peg$f118(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4839,7 +4886,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } return s0; @@ -4856,7 +4903,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4865,7 +4912,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } s2 = input.charAt(peg$currPos); @@ -4873,7 +4920,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4884,7 +4931,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4900,15 +4947,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4931,15 +4978,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4962,15 +5009,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f121(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4992,7 +5039,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -5008,15 +5055,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5039,15 +5086,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5070,15 +5117,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f121(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5095,7 +5142,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f120(s1, s3, s4); + s0 = peg$f122(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -5107,7 +5154,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } return s0; @@ -5122,7 +5169,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -5160,7 +5207,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5169,7 +5216,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } } s2 = []; @@ -5178,7 +5225,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -5187,7 +5234,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } } s3 = []; @@ -5196,7 +5243,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -5205,7 +5252,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } } s4 = peg$parseoperator(); @@ -5224,7 +5271,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5233,7 +5280,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } } s2 = peg$currPos; @@ -5280,7 +5327,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e147); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } } } @@ -5296,7 +5343,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e148); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -5325,24 +5372,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c127) { + if (s1.toLowerCase() === peg$c128) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e149); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c128) { + if (s1.toLowerCase() === peg$c129) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e150); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f121(); + s1 = peg$f123(); } s0 = s1; @@ -5354,24 +5401,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c129) { + if (s1.toLowerCase() === peg$c130) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e151); } + if (peg$silentFails === 0) { peg$fail(peg$e152); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c130) { + if (s1.toLowerCase() === peg$c131) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e152); } + if (peg$silentFails === 0) { peg$fail(peg$e153); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f122(); + s1 = peg$f124(); } s0 = s1; @@ -5383,24 +5430,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c131) { + if (s1.toLowerCase() === peg$c132) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e153); } + if (peg$silentFails === 0) { peg$fail(peg$e154); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c132) { + if (s1.toLowerCase() === peg$c133) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e154); } + if (peg$silentFails === 0) { peg$fail(peg$e155); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f123(); + s1 = peg$f125(); } s0 = s1; diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 3a469f4bf432..a0f8305422fb 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -216,6 +216,10 @@ filter = @(standardFilter / defaultFilter / freeTextFilter) defaultFilter = _ key:defaultKey _ op:operator _ value:identifier { + // The "contains" operator is only supported for the merchant field + if (op === "contains") { + return buildFilter("eq", "keyword", text().trim()); + } updateDefaultValues(key, value); return {...buildFilter(op, key, value), isDefault: true}; } @@ -233,11 +237,21 @@ freeTextFilter } standardFilter - = _ neg:"-"? field:filterKey _ op:filterOperator _ values:identifier { + = _ neg:"-"? field:filterKey _ op:searchFilterOperator _ values:identifier { expectingNestedQuote = false; nameOperator = false; const key = field; - let operator = op; + let operator = op.value; + + // Positive `merchant:` filters predate explicit match types, so they must retain contains semantics. + if (!neg && key === "merchant" && op.token === ":") { + operator = "contains"; + } + + // The "contains" operator is only supported for the merchant field + if (operator === "contains" && (neg || key !== "merchant")) { + return buildFilter("eq", "keyword", text().trim()); + } // Apply negation only when "-" is present and the key can be negated if (neg && !nonNegatableKeys.has(key)) { @@ -249,6 +263,9 @@ standardFilter return buildFilter(operator, key, values); } +searchFilterOperator + = value:filterOperator { return {value, token: text()}; } + reportFieldDynamic = ("report-field"i / "reportfield"i) "-" rest:$((!([ \t\r\n\xA0,:=<>!]) .)*) { const suffix = rest.replaceAll(/^-+/g, ""); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d19de92dfd7a..a6fb18b658c4 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -41,6 +41,7 @@ import type { HasFilterValues, IsFilterValue, IsFilterValues, + MerchantMatchType, ReceiptTypeValue, TransactionStatusValue, SearchAdvancedFiltersKey, @@ -104,6 +105,58 @@ const operatorToCharMap = { [CONST.SEARCH.SYNTAX_OPERATORS.AND]: ',' as const, [CONST.SEARCH.SYNTAX_OPERATORS.OR]: ' ' as const, }; +const EXPLICIT_EQUAL_TO_OPERATOR = '=' as const; + +const DEFAULT_MERCHANT_OPERATOR = CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + +function getMerchantOperator(operator: MerchantMatchType | undefined): MerchantMatchType { + return operator ?? DEFAULT_MERCHANT_OPERATOR; +} + +/** + * Builds the Merchant part of the query. The form has one Merchant field, so a changed condition updates every Merchant clause from flatFilters. + */ +function buildMerchantFilterQuery( + value: string, + prefix: string, + isNegated: boolean, + isMerchantNegated: boolean, + merchantOperator: MerchantMatchType | undefined, + flatFilters: QueryFilters | undefined, +): string { + const merchantFilters = flatFilters?.filter((filter) => filter.key === FILTER_KEYS.MERCHANT) ?? []; + const positiveMerchantFilters = merchantFilters.filter((filter) => !filter.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO)); + const negativeMerchantFilters = merchantFilters.filter((filter) => filter.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO)); + const wasMerchantNegated = negativeMerchantFilters.length > 0; + const hasMerchantNegationChanged = wasMerchantNegated !== isMerchantNegated; + const lastVisibleMerchantFilter = (wasMerchantNegated ? negativeMerchantFilters : positiveMerchantFilters).at(-1); + const selectedMerchantOperator = getMerchantOperator(merchantOperator); + const originalMerchantFilters = isNegated ? negativeMerchantFilters : positiveMerchantFilters; + const lastMerchantFilter = hasMerchantNegationChanged ? lastVisibleMerchantFilter : originalMerchantFilters.at(-1); + const isMerchantValueUnchanged = value === lastMerchantFilter?.filters.map((item) => item.value.toString()).join(','); + if (lastMerchantFilter && isMerchantValueUnchanged) { + const currentMerchantOperator = positiveMerchantFilters.at(-1)?.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) + ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + : DEFAULT_MERCHANT_OPERATOR; + const hasMerchantOperatorChanged = !isMerchantNegated && selectedMerchantOperator !== currentMerchantOperator; + const hasMerchantConditionChanged = hasMerchantNegationChanged || hasMerchantOperatorChanged; + const filtersToUpdate = hasMerchantConditionChanged ? merchantFilters : originalMerchantFilters; + const updatedOperator = isNegated ? CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO : selectedMerchantOperator; + // The single field cannot represent every clause or distinguish lists from names containing commas. + const merchantClauses = filtersToUpdate.map((filter) => { + const clauseFilters = hasMerchantConditionChanged ? filter.filters.map((item) => ({...item, operator: updatedOperator})) : filter.filters; + return buildFilterValuesString(FILTER_KEYS.MERCHANT, clauseFilters).trim(); + }); + // Different operators can become identical clauses after the change. + return [...new Set(merchantClauses)].join(' '); + } + + let operator: ValueOf | typeof EXPLICIT_EQUAL_TO_OPERATOR = operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]; + if (!isNegated) { + operator = selectedMerchantOperator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO ? EXPLICIT_EQUAL_TO_OPERATOR : operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]; + } + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}${operator}${sanitizeSearchValue(value)}`; +} // Pre-computed validation Sets for buildFilterFormValuesFromQuery (avoids recreating per filter iteration) const VALID_EXPENSE_TYPES = new Set(Object.values(CONST.SEARCH.TRANSACTION_TYPE)); @@ -387,7 +440,6 @@ function sanitizeIncompleteQuotedValue(str: string) { const sanitized = sanitizeSearchValue(str); return sanitized.startsWith('"') ? sanitized : `"${sanitized}"`; } - /** * Escapes each keyword that would otherwise be re-interpreted as query syntax by wrapping it in quotes. * A keyword that looks like a filter (e.g. `type:expense`) becomes `"type:expense"` so it is matched as a @@ -594,13 +646,16 @@ function buildAmountFilterQuery(filterKey: SearchAmountFilterKeys, filterValues: function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[]) { const delimiter = filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD ? ' ' : ','; const allowedOps = new Set([CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO]); + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + allowedOps.add(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + } let filterValueString = ''; for (const [index, queryFilter] of queryFilters.entries()) { const previousValueHasSameOp = allowedOps.has(queryFilter.operator) && queryFilters?.at(index - 1)?.operator === queryFilter.operator; const nextValueHasSameOp = allowedOps.has(queryFilter.operator) && queryFilters?.at(index + 1)?.operator === queryFilter.operator; - // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value + // If the previous queryFilter has the same operator and that operator supports comma-delimited values, append the current value if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { filterValueString += `${delimiter}${escapeKeyword(sanitizeSearchValue(queryFilter.value.toString()))}`; } else if (index !== 0 && (previousValueHasSameOp || nextValueHasSameOp)) { @@ -618,7 +673,10 @@ function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[] filterValueString += ` ${filterName}${operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO]}${sanitizeSearchValue(rangeBoundaries.to)}`; } } else { - const operatorChar = operatorToCharMap[queryFilter.operator]; + const operatorChar = + filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && queryFilter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + ? EXPLICIT_EQUAL_TO_OPERATOR + : operatorToCharMap[queryFilter.operator]; if (!operatorChar) { continue; } @@ -929,12 +987,6 @@ function getQueryHashes(query: SearchQueryJSON) { } const filterSet = new Set(orderedQuery); - const exactMatchFilterKeys = [...(query.exactMatchFilterKeys ?? [])].sort(); - if (exactMatchFilterKeys.length > 0) { - const exactMatchIdentity = `exactMatch:${exactMatchFilterKeys.join(',')}`; - orderedQuery += ` ${exactMatchIdentity}`; - filterSet.add(exactMatchIdentity); - } // Certain filters shouldn't affect whether two searchers are similar or not, since they dont // actually filter out results @@ -1005,17 +1057,6 @@ function getQueryHashes(query: SearchQueryJSON) { return {primaryHash, recentSearchHash, similarSearchHash}; } -function withExactMatchFilterKeys(queryJSON: Readonly, exactMatchFilterKeys: SearchFilterKey[]): SearchQueryJSON { - const queryWithExactMatches = {...queryJSON, exactMatchFilterKeys}; - const {primaryHash, recentSearchHash, similarSearchHash} = getQueryHashes(queryWithExactMatches); - return { - ...queryWithExactMatches, - hash: primaryHash, - recentSearchHash, - similarSearchHash, - }; -} - /** * Returns whether a given string is a date preset (e.g. Last month) */ @@ -1268,6 +1309,8 @@ type BuildQueryStringOptions = { sortBy?: string; sortOrder?: string; limit?: number; + /** Original query filters, including predicates that cannot fit in a single form field. */ + flatFilters?: QueryFilters; }; /** @@ -1305,7 +1348,7 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { - return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeSearchValue(filterValue as string)}`; + const value = filterValue as string; + if (filterKey === FILTER_KEYS.MERCHANT) { + return buildMerchantFilterQuery(value, prefix, isNegated, !!supportedFilterValues.merchantNot, merchantOperator, options?.flatFilters); + } + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}${operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]}${sanitizeSearchValue(value)}`; } } if ((filterKey === FILTER_KEYS.REPORT_ID || filterKey === FILTER_KEYS.WITHDRAWAL_ID) && filterValue) { @@ -1797,6 +1844,11 @@ function buildFilterFormValuesFromQuery( } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE) { filtersForm[addNegation(filterKey, isNegated)] = filterValues.join(','); + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && !isNegated) { + filtersForm[FILTER_KEYS.MERCHANT_OPERATOR] = filterList.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) + ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + : DEFAULT_MERCHANT_OPERATOR; + } } if ( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_USER_ID || @@ -2883,7 +2935,7 @@ function shouldResetSortForViewChange({newView, oldView, groupBy}: {newView: str function buildFilterQueryWithSortDefaults( filterValues: Partial, previousState: {view?: string; groupBy?: string}, - currentQueryOptions: {sortBy?: string; sortOrder?: string}, + currentQueryOptions: BuildQueryStringOptions, policies?: OnyxCollection, ): string | undefined { const resetSort = shouldResetSort({ @@ -2902,6 +2954,7 @@ function buildFilterQueryWithSortDefaults( const queryString = buildQueryStringFromFilterFormValues(filterValues, { sortBy: resetSort || resetSortForViewChange ? undefined : currentQueryOptions.sortBy, sortOrder: resetSort || resetSortForViewChange ? undefined : currentQueryOptions.sortOrder, + flatFilters: currentQueryOptions.flatFilters, }); if (!resetSort && !resetSortForViewChange) { @@ -2974,30 +3027,27 @@ function getEmptyDateValues(): SearchDateValues { } /** - * Set of filter keys that represent free-text fields where the default `:` (eq) operator - * should be treated as a substring/partial match (`contains`) when querying the backend. - * This allows searches like `merchant:coffee` to match "Coffee shop". + * Fields where `eq` should still be sent to the backend as `contains`. + * Merchant is excluded because the parser preserves legacy `merchant:` as contains and uses `merchant=` for exact matches. */ function isTextSearchField(key: string): key is SearchFilterKey { - return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; + return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; } /** * Recursively traverses a search AST and replaces the `eq` operator with `contains` - * for free-text filter fields (merchant, description). This enables partial/substring - * matching on the backend for text searches while preserving the user-facing `:` syntax. - * Keys in `exactMatchFilterKeys` keep their original `eq` operator. + * for fields that still use partial matching on the backend (description). + * Merchant is excluded because its parser output already distinguishes legacy contains from explicit exact matches. */ -function applyContainsOperatorToTextFields(node: ASTNode, exactMatchFilterKeys?: ReadonlySet): ASTNode { - const filterKey = typeof node.left === 'string' && isTextSearchField(node.left) ? node.left : undefined; - if (filterKey && !exactMatchFilterKeys?.has(filterKey) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { +function applyContainsOperatorToTextFields(node: ASTNode): ASTNode { + if (typeof node.left === 'string' && isTextSearchField(node.left) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...node, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return { ...node, - left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left, exactMatchFilterKeys) : node.left, - right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right, exactMatchFilterKeys) : node.right, + left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left) : node.left, + right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right) : node.right, }; } @@ -3017,16 +3067,15 @@ function getDateModifierTitle(modifier: ValueOf(queryData: T, exactMatchFilterKeys?: ReadonlySet): string { - const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters, exactMatchFilterKeys) : queryData.filters; +function serializeQueryJSONForBackend(queryData: T): string { + const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters) : queryData.filters; const normalizedRawFilterList = queryData.rawFilterList ? queryData.rawFilterList.map((filter) => { - if (isTextSearchField(filter.key) && !exactMatchFilterKeys?.has(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { + if (isTextSearchField(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return filter; @@ -3066,6 +3115,16 @@ function getFilterFormValues { + return { + ...getFilterFormValues(CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value, isNegated), + [FILTER_KEYS.MERCHANT_OPERATOR]: isNegated ? undefined : merchantOperator, + }; +} + /** * Checks whether a query still matches a default query: it contains all of the default query's filter keys and has the same type. * Used to detect when a query no longer represents a given default/suggested search (e.g. a filter was removed). @@ -3118,7 +3177,6 @@ export { hasFooterSelections, hasFiltersChangedFromDefault, isSearchQuerySavable, - withExactMatchFilterKeys, isSearchDatePreset, getDateRangeForPreset, getDateFilterRange, @@ -3169,6 +3227,7 @@ export { isFilterNegatable, removeNegation, getFilterFormValues, + getMerchantFilterFormValues, getFilterFromQuery, getValidLastQuery, doesQueryMatchDefaultFilterKeysAndType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b7f78c261878..7e529a2bbb8c 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -214,7 +214,6 @@ import { isSearchDatePreset, removeNegation, sortOptionsWithEmptyValue, - withExactMatchFilterKeys, } from './SearchQueryUtils'; import {expenseStatusActionMapping, getSuggestedSearches, isEligibleForStatus, SEARCH_TYPE_MENU_ICON_NAMES} from './SearchSuggestionUtils'; import StringUtils from './StringUtils'; @@ -2579,7 +2578,8 @@ function getReportActionsSections( for (const key in data) { if (isReportActionEntry(key)) { const reportIDFromKey = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - const reportActions = Object.values(data[key]); + const reportActionsCollection = data[key]; + const reportActions = Object.values(reportActionsCollection); n += reportActions.length; for (const reportAction of reportActions) { // Always use the container reportID so "In " rows open the parent chat, not a child task/thread report. @@ -2596,7 +2596,7 @@ function getReportActionsSections( const isReportArchived = isArchivedReport(data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]); if ( !reportID || - !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData) || + !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData, undefined, reportActionsCollection) || isDeletedAction(reportAction) || isResolvedActionableWhisper(reportAction) || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED || @@ -3056,11 +3056,7 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: QueryFil sortOrder: CONST.SEARCH.SORT_ORDER.DESC, flatFilters: newFlatFilters, }; - const specificGroupQueryJSON = buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); - if (!specificGroupQueryJSON || filterKey !== CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { - return specificGroupQueryJSON; - } - return withExactMatchFilterKeys(specificGroupQueryJSON, [filterKey]); + return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); } function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: Readonly | undefined): number[] { @@ -3526,6 +3522,7 @@ function getDaySections( ): [TransactionDayGroupListItemType[], number, boolean] { const daySections: Record = {}; const shouldShowShortLabelYear = doGroupsSpanMultipleYears(data); + const currentYear = new Date().getFullYear(); for (const key in data) { if (!isGroupEntry(key)) { continue; @@ -3544,6 +3541,7 @@ function getDaySections( ...dayGroup, formattedDay: DateUtils.formatToReadableString(dayGroup.day, dateFnsLocale), shortFormattedDay: DateUtils.getShortFormattedDayForSearch(dayGroup.day, dateFnsLocale, shouldShowShortLabelYear), + lineChartFormattedDay: DateUtils.getShortFormattedDayForSearch(dayGroup.day, dateFnsLocale, getYearOfDate(dayGroup.day) !== currentYear), keyForList: key, }; } @@ -3622,6 +3620,7 @@ function getWeekSections( // Years come from the week as trimmed to the date filter, so a filter starting Jan 1 doesn't count the days before it const shouldShowShortLabelYear = new Set(Object.values(weeks).flatMap(({weekStart, weekEnd}) => [getYearOfDate(weekStart), getYearOfDate(weekEnd)])).size > 1; + const currentYear = new Date().getFullYear(); for (const [key, {weekGroup, weekStart, weekEnd, transactionsQueryJSON}] of Object.entries(weeks)) { const formattedWeek = DateUtils.getFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); const shortFormattedWeek = DateUtils.getShortFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale, shouldShowShortLabelYear); @@ -3633,6 +3632,7 @@ function getWeekSections( ...weekGroup, formattedWeek, shortFormattedWeek, + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch(weekStart, dateFnsLocale, getYearOfDate(weekStart) !== currentYear), keyForList: key, }; } @@ -6047,6 +6047,16 @@ function getDisplayValue( return form[key]?.map((receiptType) => translate(getReceiptTypeTranslationKey(receiptType))).join(', '); } + if (key === FILTER_KEYS.MERCHANT) { + const merchant = form[key]; + if (!merchant) { + return; + } + + const matchTypeKey = form[FILTER_KEYS.MERCHANT_OPERATOR] === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO ? 'search.filters.merchant.equalTo' : 'search.filters.merchant.contains'; + return translate(matchTypeKey, merchant); + } + if (key === FILTER_KEYS.TRANSACTION_STATUS) { const transactionStatus = form[key]; return transactionStatus ? translate(`search.filters.transactionStatus.${transactionStatus}`) : undefined; @@ -6099,6 +6109,11 @@ function hasFilterContentValuesChanged( return true; } + // The Merchant content also reads its match type. + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + return previousValues?.[FILTER_KEYS.MERCHANT_OPERATOR] !== values?.[FILTER_KEYS.MERCHANT_OPERATOR]; + } + if (isTextFilterKey(baseFilterKey)) { return false; } diff --git a/src/libs/SelectionScraper/index.native.ts b/src/libs/SelectionScraper/index.native.ts index aeb9c5ae3ce2..db484ce39fea 100644 --- a/src/libs/SelectionScraper/index.native.ts +++ b/src/libs/SelectionScraper/index.native.ts @@ -1,8 +1,16 @@ +import CONST from '@src/CONST'; + import type GetCurrentSelection from './types'; +const COPYABLE_TEXT_DATA_SET = {[CONST.COPYABLE_TEXT_ELEMENT]: true} as const; +const COPYABLE_ROW_DATA_SET = {[CONST.COPYABLE_ROW_ELEMENT]: true} as const; +const COPYABLE_ROW_CONTINUATION_DATA_SET = {...COPYABLE_ROW_DATA_SET, [CONST.COPYABLE_ROW_CONTINUATION_ELEMENT]: true} as const; + // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. const getCurrentSelection: GetCurrentSelection = () => ''; +export {COPYABLE_ROW_CONTINUATION_DATA_SET, COPYABLE_ROW_DATA_SET, COPYABLE_TEXT_DATA_SET}; + export default { getCurrentSelection, }; diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts index 031f3e3c12cb..6b6153745c90 100644 --- a/src/libs/SelectionScraper/index.ts +++ b/src/libs/SelectionScraper/index.ts @@ -13,6 +13,254 @@ import installTransformedChildren from './installTransformedChildren'; const markdownElements = new Set(['h1', 'strong', 'em', 'del', 'blockquote', 'q', 'code', 'pre', 'a', 'br', 'li', 'ul', 'ol', 'b', 'i', 's', 'mention-user']); const tagAttribute = 'data-testid'; +const hiddenElementAttribute = `data-${CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT}`; +const hiddenElementSelector = `[${hiddenElementAttribute}=true]`; +const COPYABLE_TEXT_SELECTOR = `[data-${CONST.COPYABLE_TEXT_ELEMENT}=true]`; +const COPYABLE_TEXT_DATA_SET = {[CONST.COPYABLE_TEXT_ELEMENT]: true} as const; +const COPYABLE_ROW_SELECTOR = `[data-${CONST.COPYABLE_ROW_ELEMENT}=true]`; +const COPYABLE_ROW_DATA_SET = {[CONST.COPYABLE_ROW_ELEMENT]: true} as const; +const copyableRowContinuationAttribute = `data-${CONST.COPYABLE_ROW_CONTINUATION_ELEMENT}`; +const COPYABLE_ROW_CONTINUATION_DATA_SET = {...COPYABLE_ROW_DATA_SET, [CONST.COPYABLE_ROW_CONTINUATION_ELEMENT]: true} as const; + +function normalizeSelectedContent(content: globalThis.DocumentFragment) { + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); + const textNodes: globalThis.Text[] = []; + let textNode = walker.nextNode(); + let hasText = false; + let previousTextEndsWithSpace = false; + + while (textNode) { + if (textNode instanceof globalThis.Text) { + textNode.data = textNode.data.replaceAll(/[\t\n\r ]+/g, ' '); + if (!hasText || previousTextEndsWithSpace) { + textNode.data = textNode.data.trimStart(); + } + if (textNode.data) { + hasText = true; + previousTextEndsWithSpace = textNode.data.endsWith(' '); + } + textNodes.push(textNode); + } + textNode = walker.nextNode(); + } + + const lastTextNode = textNodes.findLast((node) => !!node.data); + if (lastTextNode) { + lastTextNode.data = lastTextNode.data.trimEnd(); + } +} + +function unwrapNonSemanticElements(content: globalThis.DocumentFragment) { + for (const element of content.querySelectorAll('*')) { + const renderedTag = element.getAttribute(tagAttribute); + const nativeTag = element.tagName.toLowerCase(); + if ((renderedTag && markdownElements.has(renderedTag)) || markdownElements.has(nativeTag)) { + continue; + } + + element.replaceWith(...Array.from(element.childNodes)); + } +} + +function getCopyableElementContent(element: globalThis.Element, selection: Selection): globalThis.DocumentFragment { + const elementRange = document.createRange(); + elementRange.selectNodeContents(element); + const selectedContent = document.createDocumentFragment(); + + for (let i = 0; i < selection.rangeCount; i++) { + const intersectionRange = selection.getRangeAt(i).cloneRange(); + if (!intersectionRange.intersectsNode(element)) { + continue; + } + + if (intersectionRange.compareBoundaryPoints(globalThis.Range.START_TO_START, elementRange) < 0) { + intersectionRange.setStart(elementRange.startContainer, elementRange.startOffset); + } + if (intersectionRange.compareBoundaryPoints(globalThis.Range.END_TO_END, elementRange) > 0) { + intersectionRange.setEnd(elementRange.endContainer, elementRange.endOffset); + } + + let content: globalThis.Node = intersectionRange.cloneContents(); + let parent = getElementFromNode(intersectionRange.commonAncestorContainer)?.closest(`[${tagAttribute}]`) ?? null; + while (parent && element.contains(parent)) { + const clonedParent = parent.cloneNode(); + clonedParent.appendChild(content); + content = clonedParent; + parent = parent.parentElement?.closest(`[${tagAttribute}]`) ?? null; + } + + const fragment = document.createDocumentFragment(); + fragment.appendChild(content); + for (const hiddenElement of fragment.querySelectorAll(hiddenElementSelector)) { + hiddenElement.remove(); + } + unwrapNonSemanticElements(fragment); + normalizeSelectedContent(fragment); + if (!fragment.textContent?.trim()) { + continue; + } + + if (selectedContent.textContent) { + selectedContent.appendChild(document.createTextNode(' ')); + } + selectedContent.appendChild(fragment); + } + + return selectedContent; +} + +function replaceElementContentWithLines(element: globalThis.Element, lines: Array<{row: globalThis.Element; content: globalThis.DocumentFragment}>) { + for (const [index, {content}] of lines.entries()) { + if (index > 0) { + const previousRow = lines.at(index - 1)?.row; + const separator = previousRow?.getAttribute(copyableRowContinuationAttribute) === 'true' ? document.createTextNode(' ') : document.createElement('br'); + element.appendChild(separator); + } + element.appendChild(content); + } +} + +function getSelectedTextForTextNode(textNode: globalThis.Text, range: Range): string { + const textNodeRange = document.createRange(); + textNodeRange.selectNodeContents(textNode); + + const intersectionRange = range.cloneRange(); + if (intersectionRange.compareBoundaryPoints(globalThis.Range.START_TO_START, textNodeRange) < 0) { + intersectionRange.setStart(textNodeRange.startContainer, textNodeRange.startOffset); + } + if (intersectionRange.compareBoundaryPoints(globalThis.Range.END_TO_END, textNodeRange) > 0) { + intersectionRange.setEnd(textNodeRange.endContainer, textNodeRange.endOffset); + } + + return intersectionRange.toString(); +} + +function getElementFromNode(node: globalThis.Node): globalThis.Element | null { + if (node instanceof globalThis.Element) { + return node; + } + + return node.parentElement; +} + +function getSelectedRowsForRange(range: Range): globalThis.Element[] { + const rootElement = getElementFromNode(range.commonAncestorContainer); + if (!rootElement) { + return []; + } + + // Use the live selection range so partially selected first/last grouped rows are still included. + return [rootElement.closest(COPYABLE_ROW_SELECTOR), ...Array.from(rootElement.querySelectorAll(COPYABLE_ROW_SELECTOR))].filter( + (row): row is globalThis.Element => !!row && range.intersectsNode(row), + ); +} + +function isTopLevelCopyableElementForRow(copyableElement: globalThis.Element, row: globalThis.Element): boolean { + if (!copyableElement.matches(COPYABLE_TEXT_SELECTOR) || copyableElement.closest(COPYABLE_ROW_SELECTOR) !== row) { + return false; + } + + const copyableAncestor = copyableElement.parentElement?.closest(COPYABLE_TEXT_SELECTOR); + return !copyableAncestor || copyableAncestor.closest(COPYABLE_ROW_SELECTOR) !== row; +} + +function getCopyableElementsForSelectedRow(row: globalThis.Element, range: Range): globalThis.Element[] { + return [row, ...Array.from(row.querySelectorAll(COPYABLE_TEXT_SELECTOR))].filter( + (copyableElement) => isTopLevelCopyableElementForRow(copyableElement, row) && range.intersectsNode(copyableElement), + ); +} + +function selectionContainsTextOutsideCopyableRows(selection: Selection): boolean { + if (typeof document === 'undefined' || typeof NodeFilter === 'undefined') { + return false; + } + + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + const rootElement = getElementFromNode(range.commonAncestorContainer); + if (!rootElement) { + continue; + } + + const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + + while (textNode) { + if (textNode instanceof globalThis.Text && range.intersectsNode(textNode) && getSelectedTextForTextNode(textNode, range).trim()) { + const textElement = getElementFromNode(textNode); + if (!textElement?.closest(COPYABLE_ROW_SELECTOR) && !textElement?.closest(hiddenElementSelector)) { + return true; + } + } + + textNode = walker.nextNode(); + } + } + + return false; +} + +function getHTMLOfSelectedCopyableRows(selection: Selection): string { + const selectedRows: globalThis.Element[] = []; + const selectedCopyableElementsByRow = new Map>(); + + // Build an ordered row -> cells map so multi-row copied output follows the visual table order. + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + for (const row of getSelectedRowsForRange(range)) { + const copyableElements = getCopyableElementsForSelectedRow(row, range); + if (copyableElements.length === 0) { + continue; + } + + if (!selectedCopyableElementsByRow.has(row)) { + selectedRows.push(row); + selectedCopyableElementsByRow.set(row, new Set()); + } + + const selectedCopyableElements = selectedCopyableElementsByRow.get(row); + for (const copyableElement of copyableElements) { + selectedCopyableElements?.add(copyableElement); + } + } + } + + const selectedCopyableElementCount = Array.from(selectedCopyableElementsByRow.values()).reduce((count, copyableElements) => count + copyableElements.size, 0); + if (selectedCopyableElementCount <= 1) { + // Single-cell selections should preserve the browser's exact selected text instead of forcing full-cell output. + return ''; + } + + const lines = selectedRows + .map((row) => { + const content = document.createDocumentFragment(); + for (const copyableElement of [row, ...Array.from(row.querySelectorAll(COPYABLE_TEXT_SELECTOR))]) { + if (!selectedCopyableElementsByRow.get(row)?.has(copyableElement)) { + continue; + } + + const copyableContent = getCopyableElementContent(copyableElement, selection); + if (!copyableContent.textContent) { + continue; + } + + if (content.textContent) { + content.appendChild(document.createTextNode(' ')); + } + content.appendChild(copyableContent); + } + return {row, content}; + }) + .filter(({content}) => !!content.textContent); + + if (lines.length === 0) { + return ''; + } + + const div = document.createElement('div'); + replaceElementContentWithLines(div, lines); + return div.innerHTML; +} /** * Reads html of selection. If browser doesn't support Selection API, returns empty string. @@ -32,6 +280,12 @@ const getHTMLOfSelection = (): string => { return window.getSelection()?.toString() ?? ''; } + const selectedCopyableRowsHTML = getHTMLOfSelectedCopyableRows(selection); + if (selectedCopyableRowsHTML && !selectionContainsTextOutsideCopyableRows(selection)) { + // Explicitly marked rows need normalized row text before generic selection cleanup. + return selectedCopyableRowsHTML; + } + const div = document.createElement('div'); // HTML tag of markdown comments is in data-testid attribute (em, strong, blockquote..). Our goal here is to @@ -91,12 +345,11 @@ const getHTMLOfSelection = (): string => { } } - // Find and remove the div housing the UnreadActionIndicator because we don't want - // the 'New/Nuevo' text inside it being copied. - const divsToRemove = div.querySelectorAll(`[data-${CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT}=true]`); + // Find and remove content that is intentionally hidden from copied selections. + const hiddenElements = div.querySelectorAll(hiddenElementSelector); - if (divsToRemove && divsToRemove.length > 0) { - for (const element of divsToRemove) { + if (hiddenElements && hiddenElements.length > 0) { + for (const element of hiddenElements) { element.remove(); } } @@ -169,6 +422,8 @@ const getCurrentSelection: GetCurrentSelection = () => { return newHtml || ''; }; +export {COPYABLE_ROW_CONTINUATION_DATA_SET, COPYABLE_ROW_DATA_SET, COPYABLE_TEXT_DATA_SET}; + export default { getCurrentSelection, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 456ca5fddf4b..4bf0a1485c12 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -18,7 +18,7 @@ import type { TransactionViolation, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; -import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; +import type {ReportAttributes, ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type Policy from '@src/types/onyx/Policy'; import type PriorityMode from '@src/types/onyx/PriorityMode'; @@ -788,7 +788,7 @@ function getOptionData({ movedToReport?: OnyxEntry; currentUserAccountID: number; visibleReportActionsData?: VisibleReportActionsDerivedValue; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + reportAttributesDerived?: ReportNamesDerivedValue; policyTags?: OnyxEntry; currentUserLogin: string; isTrackIntentUser?: boolean; @@ -974,7 +974,6 @@ function getOptionData({ rawReportName: report.reportName, hasDerivedEntry: !!derivedEntry, derivedReportName: derivedEntry?.reportName, - derivedCount: reportAttributesDerived ? Object.keys(reportAttributesDerived).length : 0, }); } diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 370a0370fed7..87b51d8d30c3 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -544,6 +544,13 @@ function optimizeAvatarSource(source?: AvatarSource): AvatarSource | undefined { return findLocalAvatarForURL(source) ?? source; } +/** Letter avatar for a free-text name, such as a list row title, seeded by the name so the color is stable */ +function getLetterAvatarURLForName(name: string): string | undefined { + const [firstName = '', ...otherNames] = name.trim().split(/\s+/); + const colorSeed = [...name].reduce((sum, character) => sum + character.charCodeAt(0), 0); + return getLetterAvatarURL(colorSeed, firstName, otherNames.at(-1) ?? '', '') || undefined; +} + export { buildUserIcon, getAccountIDFromAvatarID, @@ -557,6 +564,7 @@ export { getSmallSizeAvatar, getLetterAvatarInitials, getLetterAvatarURL, + getLetterAvatarURLForName, getUpdatedLetterAvatarURL, parseLetterAvatarURL, isCatalogAvatar, diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts new file mode 100644 index 000000000000..1f7bf9b8ebc8 --- /dev/null +++ b/src/libs/VacationDelegateUtils.ts @@ -0,0 +1,122 @@ +import CONST from '@src/CONST'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; + +import type {Locale as DateFnsLocale} from 'date-fns'; + +import {addMinutes, format, isValid, parse} from 'date-fns'; +import {fromZonedTime} from 'date-fns-tz'; + +import DateUtils from './DateUtils'; + +/** Format of the local clear date and time the user picks, e.g. 2026-10-01 17:00:00 */ +const LOCAL_DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss'; + +/** Time a clear date ends at when no time is picked, so the delegate stays active for the whole day */ +const END_OF_DAY_TIME = '23:59:59'; + +/** + * Combines the picked clear date (yyyy-MM-dd) and time into the local datetime (yyyy-MM-dd HH:mm:ss) the delegate clears at. + * The time can be a full datetime, as the time picker stores it; only its time part is used. Without a time, the delegate clears + * at the end of the picked day. + */ +function getVacationDelegateLocalClearDateTime(clearDate: string | undefined, clearTime: string | undefined): string { + if (!clearDate) { + return ''; + } + + return (clearTime ? DateUtils.combineDateAndTime(clearTime, clearDate) : '') || `${clearDate} ${END_OF_DAY_TIME}`; +} + +/** + * Converts the clear date (yyyy-MM-dd) and time picked for the vacation delegate into the UTC datetime the backend expects, + * reading them in the given timezone. + */ +function getVacationDelegateClearAfter(clearDate: string | undefined, clearTime: string | undefined, timezone: SelectedTimezone | undefined): string | undefined { + const localClearDateTime = getVacationDelegateLocalClearDateTime(clearDate, clearTime); + if (!localClearDateTime) { + return undefined; + } + + return DateUtils.formatDBTimeWithoutMilliseconds(fromZonedTime(localClearDateTime, timezone ?? CONST.DEFAULT_TIME_ZONE.selected).valueOf()); +} + +/** + * Converts the UTC datetime the vacation delegate clears after back into the local datetime (yyyy-MM-dd HH:mm:ss) it clears at in the given timezone. + */ +function getVacationDelegateClearDateTime(clearAfter: string | undefined, timezone: SelectedTimezone | undefined): string { + if (!clearAfter) { + return ''; + } + + return DateUtils.formatUTCDateTimeToDateInTimezone(clearAfter, timezone ?? CONST.DEFAULT_TIME_ZONE.selected, LOCAL_DATE_TIME_FORMAT); +} + +/** + * Converts the UTC datetime the vacation delegate clears after back into the day (yyyy-MM-dd) it clears on in the given timezone. + */ +function getVacationDelegateClearDate(clearAfter: string | undefined, timezone: SelectedTimezone | undefined): string { + return getVacationDelegateClearDateTime(clearAfter, timezone).slice(0, CONST.DATE.FNS_FORMAT_STRING.length); +} + +/** + * Formats a local clear datetime (yyyy-MM-dd HH:mm:ss) for display, e.g. "Oct 1, 2026 5:00 PM". + */ +function formatVacationDelegateClearDateTime(clearDateTime: string, dateFnsLocale: DateFnsLocale | undefined): string { + const date = parse(clearDateTime, LOCAL_DATE_TIME_FORMAT, new Date()); + if (!isValid(date)) { + return ''; + } + + return format(date, `${CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale}); +} + +/** + * Whether the vacation delegate's clearAfter datetime has passed. The backend clears the delegate at that time, so this only covers + * the gap before its update reaches the client. + */ +function isVacationDelegateExpired(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter <= DateUtils.getDBTime(); +} + +/** + * The delegate still in effect, ignoring one whose clearAfter has passed. Every screen that shows or opens the delegate uses this, + * so they all apply the same expiry rule. + */ +function getActiveVacationDelegate(vacationDelegate: BaseVacationDelegate | undefined): string | undefined { + return vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate.clearAfter) ? vacationDelegate.delegate : undefined; +} + +/** + * Whether the picked clear date (yyyy-MM-dd) has already ended in the given timezone. The day is read in the user's Profile timezone, + * not the device's, so a device in another timezone doesn't reject a day that hasn't ended yet for the user, or accept one that has. + */ +function isVacationDelegateClearDatePassed(clearDate: string, timezone: SelectedTimezone | undefined): boolean { + if (!isValid(parse(clearDate, CONST.DATE.FNS_FORMAT_STRING, new Date()))) { + return true; + } + + return isVacationDelegateExpired(getVacationDelegateClearAfter(clearDate, undefined, timezone)); +} + +/** + * Whether the picked clearAfter datetime is less than one minute from now. Such a delegate could clear while the request is still in + * flight, so the form asks for a time at least one minute ahead. + */ +function isVacationDelegateClearAfterTooSoon(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter < DateUtils.formatDBTimeWithoutMilliseconds(addMinutes(new Date(), 1).valueOf()); +} + +export { + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + formatVacationDelegateClearDateTime, + isVacationDelegateExpired, + getActiveVacationDelegate, + isVacationDelegateClearDatePassed, + isVacationDelegateClearAfterTooSoon, +}; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index d4942a674f0a..5122c8169bf8 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -233,6 +233,11 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir const hrAdvancedModeFinalApproverEmail = getHRAdvancedModeFinalApprover(policy); for (const employee of Object.values(employees)) { + // The type doesn't allow it, but `employeeList` can hold a null entry at runtime, so skip it rather than crash + if (!employee) { + continue; + } + const {email, submitsTo, pendingAction} = employee; if (!email) { continue; @@ -818,7 +823,8 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all */ function includesEveryWorkspaceMember(memberEmails: Array, employeeList: PolicyEmployeeList | undefined): boolean { const memberEmailSet = new Set(memberEmails); - const workspaceMembers = Object.values(employeeList ?? {}).filter((employee) => !!employee.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + // `employeeList` can hold a null entry at runtime, so read it optionally + const workspaceMembers = Object.values(employeeList ?? {}).filter((employee) => !!employee?.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); return workspaceMembers.length > 0 && workspaceMembers.every((employee) => memberEmailSet.has(employee.email)); } @@ -1656,6 +1662,11 @@ function convertApprovalWorkflowRulesToWorkflows({ const availableMembers: Member[] = []; for (const employee of Object.values(employees)) { + // The type doesn't allow it, but `employeeList` can hold a null entry at runtime, so skip it rather than crash + if (!employee) { + continue; + } + const {email, submitsTo, pendingAction} = employee; if (!email) { continue; @@ -1839,8 +1850,9 @@ function hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin, rul const hasNonMemberRoute = isNonMemberApprover(policy, getHRFinalApprover(policy) ?? getDefaultApprover(policy)) || Object.values(policy?.employeeList ?? {}).some( + // `employeeList` can hold a null entry at runtime, so read it optionally (employee) => - isNonMemberApprover(policy, employee.submitsTo) || isNonMemberApprover(policy, employee.forwardsTo) || isNonMemberApprover(policy, employee.overLimitForwardsTo), + isNonMemberApprover(policy, employee?.submitsTo) || isNonMemberApprover(policy, employee?.forwardsTo) || isNonMemberApprover(policy, employee?.overLimitForwardsTo), ); if (!hasNonMemberRoute) { return false; diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts index 5be435da4e24..012fbde98e98 100644 --- a/src/libs/actions/Agent.ts +++ b/src/libs/actions/Agent.ts @@ -14,6 +14,7 @@ import type {AvatarSource} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import type NewAgentTemplate from '@src/types/onyx/NewAgentTemplate'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; @@ -86,7 +87,7 @@ function createAgent( const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, value: {prompt, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, }, { @@ -125,7 +126,7 @@ function createAgent( buildPersonalDetailsUpdate({[optimisticAccountID]: optimisticPersonalDetail}), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, value: { prompt, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -173,23 +174,23 @@ function clearNewAgentTemplate() { function clearAgentError(optimisticAccountID: number) { Onyx.update([buildPersonalDetailsUpdate({[optimisticAccountID]: null})]); - Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, null); } function clearAgentUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {errors: null, nameErrors: null, promptErrors: null, avatarErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {errors: null, nameErrors: null, promptErrors: null, avatarErrors: null}); } function clearAgentNameUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {nameErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {nameErrors: null}); } function clearAgentPromptUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {promptErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {promptErrors: null}); } function clearAgentDeleteError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {pendingAction: null, errors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {pendingAction: null, errors: null}); } function updateAgentName(accountID: number, firstName: string, originalFirstName: string) { @@ -199,7 +200,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName buildPersonalDetailsUpdate({[agentAccountID]: {displayName: firstName}}), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, nameErrors: null}, }, ]; @@ -207,7 +208,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName const successData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, nameErrors: null}, }, ]; @@ -216,7 +217,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName buildPersonalDetailsUpdate({[agentAccountID]: {displayName: originalFirstName}}), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, nameErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateName')}, }, ]; @@ -227,7 +228,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: string) { // The caller may still hold an optimistic accountID that was already reconciled while its screen was open. const agentAccountID = resolveAgentAccountID(accountID); - const onyxKey = `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`; + const onyxKey = `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`; const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -256,7 +257,7 @@ function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: st } function clearAgentAvatarUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {avatarErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {avatarErrors: null}); } function updateAgentAvatar( @@ -279,7 +280,7 @@ function updateAgentAvatar( }), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, avatarErrors: null}, }, ]; @@ -293,7 +294,7 @@ function updateAgentAvatar( }), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, avatarErrors: null}, }, ]; @@ -309,7 +310,7 @@ function updateAgentAvatar( }), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, avatarErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateAvatar')}, }, ]; @@ -319,13 +320,13 @@ function updateAgentAvatar( write(WRITE_COMMANDS.UPDATE_AGENT_AVATAR, params, {optimisticData, successData, failureData}); } -function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxCollection, shouldNavigateBack = true) { +function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxCollection, shouldNavigateBack = true, backPath?: Route) { // The caller may still hold an optimistic accountID that was already reconciled while its screen was open. const agentAccountID = resolveAgentAccountID(accountID); const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, }, ]; @@ -333,7 +334,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC const successData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: null, }, buildPersonalDetailsUpdate({[agentAccountID]: null}), @@ -342,7 +343,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC const failureData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: { pendingAction: null, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), @@ -380,7 +381,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC // Callers that end the copilot session right after deleting (e.g. deleting the agent you're copiloting into) // don't want the extra navigation, since the delegate transition resets navigation on its own. if (shouldNavigateBack) { - Navigation.goBack(ROUTES.SETTINGS_AGENTS); + Navigation.goBack(backPath ?? ROUTES.SETTINGS_AGENTS); } } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index eed2dbb908e2..494cdb77db86 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -1242,7 +1242,7 @@ function sendReminderForCorpaySignerInformation(parameters: SendReminderForCorpa } function enableGlobalReimbursementsForUSDBankAccount(parameters: EnableGlobalReimbursementsForUSDBankAccountParams) { - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1262,6 +1262,11 @@ function enableGlobalReimbursementsForUSDBankAccount(parameters: EnableGlobalRei isSuccess: true, }, }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT, + value: null, + }, ], failureData: [ { diff --git a/src/libs/actions/Chronos.ts b/src/libs/actions/Chronos.ts index 1a9436049e00..2499961f2405 100644 --- a/src/libs/actions/Chronos.ts +++ b/src/libs/actions/Chronos.ts @@ -91,7 +91,7 @@ const startOrStopChronosTimer = (report: Report, currentUserAccountID: number, p const lastMessageText = formatReportLastMessageText(getReportActionMessage(reportAction)?.text ?? ''); // Capture the report's current last-visible message so we can restore it if the optimistic comment fails to send. - const previousLastVisibleAction = getLastVisibleAction(reportID); + const previousLastVisibleAction = getLastVisibleAction(reportID, undefined, {}, undefined, undefined, currentUserAccountID); const {lastMessageText: previousLastMessageText = ''} = getLastVisibleMessage(reportID); const failureReport: Partial = previousLastMessageText ? {lastMessageText: previousLastMessageText, lastVisibleActionCreated: previousLastVisibleAction?.created, lastActorAccountID: previousLastVisibleAction?.actorAccountID} diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 22933490a251..18bc8befb373 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -21,7 +21,8 @@ import type {Delegate, DelegatedAccess, DelegateRole} from '@src/types/onyx/Acco import type Credentials from '@src/types/onyx/Credentials'; import type Session from '@src/types/onyx/Session'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxEntry, OnyxMultiSetInput, OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import HybridAppModule from '@expensify/react-native-hybrid-app'; import Onyx from 'react-native-onyx'; @@ -39,6 +40,7 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.STASHED_SESSION, ONYXKEYS.HAS_LOADED_APP, ONYXKEYS.STASHED_CREDENTIALS, + ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, ONYXKEYS.HYBRID_APP, // Otherwise it's wiped and refetched by OpenApp, letting the product-marketing window flash back in @@ -61,9 +63,37 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.PERSONAL_DETAILS_LIST, ]; -/** Atomically reset Onyx for a delegate-access transition. */ -function clearOnyxForDelegateTransition(): Promise { - return clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_DELEGATE_ACCESS); +/** Atomically reset Onyx for a delegate-access transition. `extraSeeds` are written in the same step as the clear. */ +function clearOnyxForDelegateTransition(extraSeeds?: OnyxMultiSetInput): Promise { + return clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_DELEGATE_ACCESS, extraSeeds); +} + +type LoadAccountAfterDelegateTransitionParams = { + /** Account to reinitialize Pusher for */ + accountID: number | undefined; + + /** Email to reinitialize Pusher for */ + email: string | undefined; + + /** Account to switch OldDot to, omitted to leave OldDot as is */ + hybridAppAccount?: Parameters[0]; +}; + +/** + * Loads the account after a delegate-access transition and syncs OldDot to it. Pusher is not awaited: on Android it can + * stall right after the transition's disconnect, which would block the switch. See Expensify/App#93458. + */ +function loadAccountAfterDelegateTransition({accountID, email, hybridAppAccount}: LoadAccountAfterDelegateTransitionParams): Promise { + return openApp().then(() => { + requestPusherReinitialize({accountID, email}).catch((error: unknown) => { + Log.hmmm('[Delegate] Failed to reinitialize Pusher after a delegate-access transition', {error: ErrorUtils.getErrorMessage(error)}); + }); + + if (!CONFIG.IS_HYBRID_APP || !hybridAppAccount) { + return; + } + HybridAppModule.switchAccount(hybridAppAccount); + }); } type WithDelegatedAccess = { @@ -114,6 +144,22 @@ type WithActivePolicyID = { type DisconnectParams = WithStashedCredentials & WithStashedSession; +const DISCONNECT_RESULT = { + /** Session ended, original session restored with fresh tokens */ + RESTORED: 'restored', + + /** Incomplete response, original session restored from the stash */ + RESTORED_FROM_STASH: 'restoredFromStash', + + /** Session ended but the restore threw partway. SESSION is written first, so it is most likely restored. */ + RESTORE_INTERRUPTED: 'restoreInterrupted', + + /** Request failed, copilot session still active */ + FAILED: 'failed', +} as const; + +type DisconnectResult = ValueOf; + // Clear delegator-level errors type ClearDelegatorErrorsParams = WithDelegatedAccess; @@ -238,22 +284,20 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, Pusher.disconnect(); return clearOnyxForDelegateTransition(); }) - .then(() => { - return openApp() - .then(() => requestPusherReinitialize({accountID: response.accountID, email: response.email})) - .then(() => { - if (!CONFIG.IS_HYBRID_APP || !policyID) { - return true; - } - HybridAppModule.switchAccount({ - newDotCurrentAccountEmail: email, - authToken: restrictedToken, - policyID, - accountID: String(session?.accountID ?? CONST.DEFAULT_NUMBER_ID), - }); - return true; - }); - }); + .then(() => + loadAccountAfterDelegateTransition({ + accountID: response.accountID, + email: response.email, + hybridAppAccount: policyID + ? { + newDotCurrentAccountEmail: email, + authToken: restrictedToken, + policyID, + accountID: String(session?.accountID ?? CONST.DEFAULT_NUMBER_ID), + } + : undefined, + }).then(() => true), + ); }) .catch((error) => { Log.alert('[Delegate] Error connecting as delegate', {error}); @@ -262,7 +306,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, }); } -function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { +function disconnect({stashedCredentials, stashedSession}: DisconnectParams): Promise { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -302,11 +346,10 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { ]; // We need to access the authToken directly from the response to update the session - // The promise resolves to true only once the original session is fully restored, so callers can gate - // follow-up work that must run under the original user's identity (e.g. deleting the agent). + // Resolves to a DISCONNECT_RESULT so callers can gate work that must run as the original user (e.g. deleting the agent) // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE, {}, {optimisticData, successData, failureData}) - .then((response) => { + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE, {}, {optimisticData, successData, failureData}).then( + (response) => { const restoreToStashed = () => restoreDelegateSession({ authToken: stashedSession?.authToken, @@ -318,37 +361,46 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { creationDate: stashedSession?.creationDate, }); - if (!response?.authToken || !response?.encryptedAuthToken) { - Log.alert('[Delegate] No auth token returned while disconnecting as a delegate'); - return restoreToStashed().then(() => false); - } - - if (!response?.requesterID || !response?.requesterEmail) { - Log.alert('[Delegate] No requester data returned while disconnecting as a delegate'); - return restoreToStashed().then(() => false); - } + const restoreOriginalSession = (): Promise => { + if (!response?.authToken || !response?.encryptedAuthToken) { + Log.alert('[Delegate] No auth token returned while disconnecting as a delegate'); + return restoreToStashed().then(() => DISCONNECT_RESULT.RESTORED_FROM_STASH); + } - clearPreservedSearchNavigatorStates(); + if (!response?.requesterID || !response?.requesterEmail) { + Log.alert('[Delegate] No requester data returned while disconnecting as a delegate'); + return restoreToStashed().then(() => DISCONNECT_RESULT.RESTORED_FROM_STASH); + } - const requesterEmail = response.requesterEmail; - const authToken = response.authToken; - return SequentialQueue.waitForIdle() - .then(() => - restoreDelegateSession({ - authToken, - encryptedAuthToken: response.encryptedAuthToken, - accountID: response.requesterID, - email: requesterEmail, - stashedCredentials, - stashedSession, - }), - ) - .then(() => true); - }) - .catch((error) => { + clearPreservedSearchNavigatorStates(); + + const requesterEmail = response.requesterEmail; + const authToken = response.authToken; + return SequentialQueue.waitForIdle() + .then(() => + restoreDelegateSession({ + authToken, + encryptedAuthToken: response.encryptedAuthToken, + accountID: response.requesterID, + email: requesterEmail, + stashedCredentials, + stashedSession, + }), + ) + .then(() => DISCONNECT_RESULT.RESTORED); + }; + + return restoreOriginalSession().catch((error) => { + Log.alert('[Delegate] Error restoring the original session after disconnecting as a delegate', {error}); + return DISCONNECT_RESULT.RESTORE_INTERRUPTED; + }); + }, + // Only a failed request leaves the copilot session active + (error) => { Log.alert('[Delegate] Error disconnecting as a delegate', {error}); - return false; - }); + return DISCONNECT_RESULT.FAILED; + }, + ); } function clearDelegatorErrors({delegatedAccess}: ClearDelegatorErrorsParams) { @@ -688,6 +740,127 @@ function removeDelegator({email, delegatedAccess}: RemoveDelegateParams) { }); } +type RemoveSelfAsDelegateParams = WithStashedCredentials & + WithStashedSession & { + /** Delegator the copilot is acting as */ + delegatorEmail: string; + + /** Acting copilot, whose row shows the error if the session can't be ended */ + delegateEmail: string; + }; + +// Set while removeSelfAsDelegate runs, so resuming a pending removal can't queue it twice +let isRemovingSelfAsDelegate = false; + +/** Queues the restored copilot's removal from the delegator's copilots, clearing the pending removal with it */ +function queueSelfRemovalFromDelegator(delegatorEmail: string) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + errorFields: { + removeDelegator: { + [delegatorEmail]: null, + }, + }, + }, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + errorFields: { + removeDelegator: { + [delegatorEmail]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + }, + }, + }, + }, + ]; + + const parameters: APIRemoveDelegatorParams = {delegatorEmail}; + + // The restored account's delegators aren't known here, so refetch them after the removal + API.write(WRITE_COMMANDS.REMOVE_DELEGATOR, parameters, { + optimisticData, + failureData, + }); + openSecuritySettingsPage(); +} + +/** + * Removes the acting copilot from the delegator's copilots, restoring the original session first: revoking first + * invalidates the token the app runs on, and a kill before the restore persists it. See Expensify/App#93458. + * The removal is saved as pending first, so a kill before it is queued doesn't lose it. + */ +function removeSelfAsDelegate({delegatorEmail, delegateEmail, stashedCredentials, stashedSession}: RemoveSelfAsDelegateParams) { + isRemovingSelfAsDelegate = true; + return Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, delegatorEmail) + .then(() => disconnect({stashedCredentials, stashedSession})) + .then((disconnectResult) => { + // Only a failed request leaves the copilot on the delegator's account + if (disconnectResult === DISCONNECT_RESULT.FAILED) { + Log.alert('[Delegate] Unable to restore the original session, skipping copilot removal'); + Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, null); + + // Still acting as the delegator, so show the error on the copilot's own row + Onyx.merge(ONYXKEYS.ACCOUNT, { + delegatedAccess: { + errorFields: { + removeDelegate: { + [delegateEmail]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + }, + }, + }); + return; + } + + queueSelfRemovalFromDelegator(delegatorEmail); + }) + .finally(() => { + isRemovingSelfAsDelegate = false; + }); +} + +type ResumeSelfRemovalAsDelegateParams = { + /** Delegator saved as pending by an interrupted removeSelfAsDelegate */ + delegatorEmail: string; + + /** Whether the session still holds the delegator's restricted token */ + isDelegateSession: boolean; +}; + +/** + * Finishes a removeSelfAsDelegate a kill interrupted. Back on their own account, the copilot's removal is queued; + * still acting as the delegator, the restore never happened, so the removal counts as cancelled. + */ +function resumeSelfRemovalAsDelegate({delegatorEmail, isDelegateSession}: ResumeSelfRemovalAsDelegateParams) { + if (isRemovingSelfAsDelegate) { + return; + } + + if (isDelegateSession) { + Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, null); + return; + } + + queueSelfRemovalFromDelegator(delegatorEmail); +} + function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDelegateErrorsByFieldParams) { if (!delegatedAccess) { return; @@ -836,50 +1009,41 @@ function restoreDelegateSession({authToken, encryptedAuthToken, accountID, email // Write SESSION before the clear: SESSION is in KEYS_TO_PRESERVE_DELEGATE_ACCESS, so clearing // preserves the value at clear time. If we cleared first, an interrupted flow would leave // SESSION holding the prior delegate-restricted auth token. See Expensify/App#80073. - return ( - Onyx.set(ONYXKEYS.SESSION, { - ...(stashedSession ?? {}), - accountID, - email, - authToken, - encryptedAuthToken, - creationDate, - }) - .then(() => { - NetworkStore.setAuthToken(authToken ?? null); - // Disconnect Pusher before the transition so it cannot keep pushing updates for the delegate - // account; it is reinitialized for the restored account after `openApp()`. See Expensify/App#93265. - Pusher.disconnect(); - return clearOnyxForDelegateTransition(); - }) - .then(() => { - Onyx.set(ONYXKEYS.CREDENTIALS, { + return Onyx.set(ONYXKEYS.SESSION, { + ...(stashedSession ?? {}), + accountID, + email, + authToken, + encryptedAuthToken, + creationDate, + }) + .then(() => { + NetworkStore.setAuthToken(authToken ?? null); + // Disconnect Pusher before the transition so it cannot keep pushing updates for the delegate + // account; it is reinitialized for the restored account after `openApp()`. See Expensify/App#93265. + Pusher.disconnect(); + + // Seeded with the clear: written after it, an interrupted restore would leave the session without credentials + return clearOnyxForDelegateTransition({ + [ONYXKEYS.CREDENTIALS]: { ...(stashedCredentials ?? {}), accountID, - }); - Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, {}); - Onyx.set(ONYXKEYS.STASHED_SESSION, {}); - - NetworkStore.setIsAuthenticating(false); - - return openApp(); - }) - // Reinitialize Pusher for the restored account so realtime updates resume on the correct session. - .then(() => requestPusherReinitialize({accountID, email})) - .then(() => { - // Sync the account switch to the classic mobile app experience of HybridApp; otherwise the - // two experiences disagree about which account is active after a reauth-driven restoration. - if (!CONFIG.IS_HYBRID_APP || !email || !authToken) { - return; - } - HybridAppModule.switchAccount({ - newDotCurrentAccountEmail: email, - authToken, - policyID: '', - accountID: '', - }); - }) - ); + }, + [ONYXKEYS.STASHED_CREDENTIALS]: {}, + [ONYXKEYS.STASHED_SESSION]: {}, + }); + }) + .then(() => { + NetworkStore.setIsAuthenticating(false); + + // Sync the account switch to the classic mobile app experience of HybridApp; otherwise the + // two experiences disagree about which account is active after a reauth-driven restoration. + return loadAccountAfterDelegateTransition({ + accountID, + email, + hybridAppAccount: email && authToken ? {newDotCurrentAccountEmail: email, authToken, policyID: '', accountID: ''} : undefined, + }); + }); } function openSecuritySettingsPage() { @@ -888,6 +1052,7 @@ function openSecuritySettingsPage() { export { KEYS_TO_PRESERVE_DELEGATE_ACCESS, + DISCONNECT_RESULT, connect, disconnect, clearDelegatorErrors, @@ -898,6 +1063,8 @@ export { updateDelegateRole, removeDelegate, removeDelegator, + removeSelfAsDelegate, + resumeSelfRemovalAsDelegate, openSecuritySettingsPage, clearOnyxForDelegateTransition, }; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index d950eb15f721..e4c83f87a9d1 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1660,18 +1660,32 @@ function clearValidateDomainTwoFactorCodeError() { Onyx.set(ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, null); } -function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountID: number, creator: string, vacationer: string, delegate: string, vacationDelegate?: BaseVacationDelegate) { +/** + * @param clearAfter - UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the delegate. No value means it never clears. + */ +function setDomainVacationDelegate( + domainAccountID: number, + domainMemberAccountID: number, + creator: string, + vacationer: string, + delegate: string, + vacationDelegate?: BaseVacationDelegate, + clearAfter?: string, +) { + const optimisticVacationDelegate: NullishDeep = { + delegate, + creator, + clearAfter: clearAfter ?? null, + previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, + }; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, value: { - [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { - delegate, - creator, - previousDelegate: vacationDelegate?.delegate, - }, - } as PrefixedRecord, + [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: optimisticVacationDelegate, + } as PrefixedRecord>, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1704,6 +1718,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI value: { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { previousDelegate: null, + previousClearAfter: null, }, } as PrefixedRecord>, }, @@ -1760,6 +1775,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI creator, vacationerEmail: vacationer, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: true, domainAccountID, }; @@ -1776,7 +1792,9 @@ function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccou [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { creator: null, delegate: null, + clearAfter: null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, }, } as PrefixedRecord>, }, @@ -1864,10 +1882,11 @@ function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccou API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, {vacationerEmail: vacationer, domainAccountID}, {optimisticData, successData, failureData}); } -function clearVacationDelegateError(domainAccountID: number, domainMemberAccountID: number, domainMemberEmail: string, previousDelegate?: string) { +function clearVacationDelegateError(domainAccountID: number, domainMemberAccountID: number, domainMemberEmail: string, previousDelegate?: string, previousClearAfter?: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { delegate: previousDelegate ?? null, + clearAfter: previousClearAfter ?? null, }, } as PrefixedRecord>); diff --git a/src/libs/actions/DynamicForm.ts b/src/libs/actions/DynamicForm.ts new file mode 100644 index 000000000000..a328c508cbd1 --- /dev/null +++ b/src/libs/actions/DynamicForm.ts @@ -0,0 +1,38 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** A dynamic form's keys come from its schema, so they are merged into the draft without its Onyx type listing them */ +function saveDraftAnswers(formID: OnyxFormKey, answers: DynamicFormValues) { + Onyx.merge(`${formID}Draft`, answers); +} + +/** Sensitive answers live in memory only, so they reach the next page of the flow but are never written to the draft */ +function saveSensitiveAnswers(formID: OnyxFormKey, answers: DynamicFormValues) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: answers}); +} + +/** Drops some sensitive answers, such as those of a list entry the user removed */ +function forgetSensitiveAnswers(formID: OnyxFormKey, keys: string[]) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: Object.fromEntries(keys.map((key) => [key, null]))}); +} + +/** Fills the list entry editor with the entry's answers, or empties it for a new entry */ +function startListItemEdit(answers: DynamicFormValues): Promise { + return Onyx.set(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT, answers); +} + +/** Empties the list entry editor once it closes, so an entry's answers are not left in its draft */ +function clearListItemEdit() { + Onyx.set(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT, null); +} + +/** Call once the form is submitted or abandoned, so sensitive answers do not outlive the visit */ +function clearSensitiveAnswers(formID: OnyxFormKey) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: null}); +} + +export {clearListItemEdit, clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers, startListItemEdit}; diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index 8c1e4b72a146..395135e36353 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -746,7 +746,7 @@ function getCleanUpTransactionThreadReportOnyxData({ canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(chatReport, isChatIOUReportArchived ?? isArchivedExpenseReport); } - const lastVisibleAction = getLastVisibleAction(iouReportID, canUserPerformWriteAction); + const lastVisibleAction = getLastVisibleAction(iouReportID, canUserPerformWriteAction, {}, undefined, undefined, currentUserAccountID); const {childVisibleActionCount, childCommenterCount, childLastVisibleActionCreated, childOldestFourAccountIDs} = updateOptimisticParentReportAction( reportPreviewAction, @@ -963,7 +963,12 @@ function deleteMoneyRequest({ } const optimisticReportActions = reportPreviewAction?.reportActionID ? {[reportPreviewAction.reportActionID]: null} : {}; - const optimisticLastReportData = optimisticReportLastData(iouReport?.chatReportID ?? String(CONST.DEFAULT_NUMBER_ID), optimisticReportActions, canUserPerformWriteAction); + const optimisticLastReportData = optimisticReportLastData( + iouReport?.chatReportID ?? String(CONST.DEFAULT_NUMBER_ID), + currentUserAccountID, + optimisticReportActions, + canUserPerformWriteAction, + ); if (chatReport) { optimisticData.push({ diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 9a9b15a23817..e60c999dad4a 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -108,7 +108,8 @@ type BuildOnyxDataForMoneyRequestKeys = | typeof ONYXKEYS.PERSONAL_DETAILS_LIST | typeof ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE - | typeof ONYXKEYS.COLLECTION.SNAPSHOT; + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.REUSABLE_DISTANCE_ROUTES; type MoneyRequestInformation = { payerAccountID: number; diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 56f7675d202f..a2090468c2f5 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -17,7 +17,6 @@ import { buildOptimisticCancelPaymentReportAction, buildOptimisticIOUReportAction, getReimbursableTotal, - getReportTransactions, getUnheldReimbursableTotal, hasHeldExpenses as hasHeldExpensesReportUtils, hasOutstandingChildRequest, @@ -63,6 +62,7 @@ type PayInvoiceArgs = { paymentMethodType: PaymentMethodType; chatReport: OnyxTypes.Report; invoiceReport: OnyxEntry; + invoiceReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -102,6 +102,7 @@ type PayMoneyRequestFunctionParams = { paymentType: PaymentMethodType; chatReport: OnyxTypes.Report; iouReport: OnyxEntry; + iouReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountID: number; currentUserLogin: string; @@ -131,6 +132,7 @@ type PayMoneyRequestFunctionParams = { function getPayMoneyRequestParams({ initialChatReport, iouReport, + iouReportTransactions, recipient, paymentMethodType, full, @@ -158,6 +160,7 @@ function getPayMoneyRequestParams({ }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; + iouReportTransactions: OnyxTypes.Transaction[]; recipient: Participant; paymentMethodType: PaymentMethodType; full: boolean; @@ -254,14 +257,13 @@ function getPayMoneyRequestParams({ chatReport = existingB2BInvoiceReport; } - const reportTransactions = getReportTransactions(iouReport?.reportID); const unheldReimbursableTotal = getUnheldReimbursableTotal(iouReport); let total = getReimbursableTotal(iouReport); - if (hasHeldExpensesReportUtils(reportTransactions) && !full && !!unheldReimbursableTotal) { + if (hasHeldExpensesReportUtils(iouReportTransactions) && !full && !!unheldReimbursableTotal) { total = unheldReimbursableTotal; } - const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(reportTransactions, iouReport); + const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(iouReportTransactions, iouReport); const optimisticIOUReportAction = buildOptimisticIOUReportAction({ type: CONST.IOU.REPORT_ACTION_TYPE.PAY, @@ -439,7 +441,6 @@ function getPayMoneyRequestParams({ // Optimistically unhold all transactions if we pay all requests if (full) { - const iouReportTransactions = getReportTransactions(iouReport?.reportID); for (const transaction of iouReportTransactions) { onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -868,6 +869,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { paymentType, chatReport, iouReport, + iouReportTransactions, introSelected, currentUserAccountID, currentUserLogin, @@ -910,6 +912,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { const {params: payMoneyRequestParams, onyxData} = getPayMoneyRequestParams({ initialChatReport: chatReport, iouReport, + iouReportTransactions, recipient, paymentMethodType: paymentType, full, @@ -1116,6 +1119,7 @@ function payInvoice({ paymentMethodType, chatReport, invoiceReport, + invoiceReportTransactions, introSelected, currentUserAccountIDParam, currentUserEmailParam, @@ -1155,6 +1159,7 @@ function payInvoice({ } = getPayMoneyRequestParams({ initialChatReport: chatReport, iouReport: invoiceReport, + iouReportTransactions: invoiceReportTransactions, recipient, paymentMethodType, full: true, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 8ef1c03fa83e..60768a9ba11d 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -9,6 +9,7 @@ import type {WriteReadyBarrier} from '@libs/API'; import type {CompleteSplitBillParams, CreateDistanceRequestParams, SplitBillParams, StartSplitBillParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; import * as Localize from '@libs/Localize'; @@ -51,11 +52,13 @@ import { getUpdatedTransaction, isDistanceExpenseType, isDistanceRequest as isDistanceRequestTransactionUtils, + isMapDistanceRequest, isScanRequest as isScanRequestTransactionUtils, } from '@libs/TransactionUtils'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {notifyNewAction} from '@userActions/Report/reportActionSubscribers'; +import {getLocallyCreatedRouteOnyxData} from '@userActions/ReusableDistanceRoutes'; import {sanitizeWaypointsForAPI} from '@userActions/Transaction'; import CONST from '@src/CONST'; @@ -162,6 +165,9 @@ type CreateDistanceRequestInformation = { writeBarrier?: WriteReadyBarrier; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + + /** Current "Reuse route" list value, so the just used route can be saved locally */ + reusableDistanceRoutes?: OnyxTypes.ReusableDistanceRoute[]; }; type CreateSplitsTransactionParams = BaseTransactionParams & { @@ -2078,6 +2084,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest writeBarrier, rules, isVendorMatchingBetaEnabled, + reusableDistanceRoutes, } = distanceRequestInformation; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; const parsedComment = getParsedComment(transactionParams.comment); @@ -2343,6 +2350,32 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }; } + const isMapDistance = + (distanceRequestType ? distanceRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP : isMapDistanceRequest(existingTransaction)) || + (!distanceRequestType && !gpsCoordinates && odometerStart === undefined && odometerEnd === undefined && !isManualDistanceRequest); + + if (isMapDistance && validWaypoints && reusableDistanceRoutes !== undefined) { + const distanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy}); + const effectiveDistance = + modifiedDistance ?? + distance ?? + existingTransaction?.comment?.customUnit?.quantity ?? + (selectedRouteDistance ? Number(DistanceRequestUtils.convertDistanceUnit(selectedRouteDistance, distanceUnit).toFixed(CONST.DISTANCE_DECIMAL_PLACES)) : 0); + + const reusableRouteOnyxData = getLocallyCreatedRouteOnyxData( + { + transactionID: parameters.transactionID, + waypoints: validWaypoints, + distance: effectiveDistance, + routeDistanceMeters: selectedRouteDistance, + inserted: created ?? DateUtils.getDBTime(), + }, + reusableDistanceRoutes ?? [], + ); + onyxData?.optimisticData?.push(...reusableRouteOnyxData.optimisticData); + onyxData?.failureData?.push(...reusableRouteOnyxData.failureData); + } + if (previousOdometerDraft !== undefined && (odometerStart !== undefined || odometerEnd !== undefined)) { onyxData?.optimisticData?.push({ onyxMethod: Onyx.METHOD.SET, diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3d58b5fe5a27..1454fe695526 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -426,7 +426,7 @@ function updateSplitTransactions({ const expenseReportNameValuePairs = allReportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${expenseReport?.reportID}`]; const isArchivedExpenseReport = isArchivedReport(expenseReportNameValuePairs); const canUserPerformWriteAction = chatReport ? !!canUserPerformWriteActionReportUtils(chatReport, isArchivedExpenseReport) : true; - const lastVisibleAction = getLastVisibleAction(expenseReport?.reportID, canUserPerformWriteAction); + const lastVisibleAction = getLastVisibleAction(expenseReport?.reportID, canUserPerformWriteAction, {}, undefined, undefined, currentUserPersonalDetails.accountID); const isTransactionOnHold = isOnHold(originalTransaction); const holdReportAction = getReportAction(firstIOU?.childReportID, `${originalTransaction?.comment?.hold ?? ''}`); diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index b65ab435e85c..b3ab4da7d41c 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -87,6 +87,7 @@ import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; import type {GuidedSetupData} from '@userActions/Report'; import {buildInviteToRoomOnyxData} from '@userActions/Report'; import {notifyNewAction} from '@userActions/Report/reportActionSubscribers'; +import {getLocallyCreatedRouteOnyxData} from '@userActions/ReusableDistanceRoutes'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; @@ -192,6 +193,9 @@ type GetTrackExpenseInformationParams = { existingTransaction?: OnyxEntry; existingTransactionID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; + existingTransactionThreadCreatedReportActionID?: string; participantParams: GetTrackExpenseInformationParticipantParams; policyParams: BasePolicyParams; transactionParams: GetTrackExpenseInformationTransactionParams; @@ -426,6 +430,7 @@ function buildOnyxDataForTrackExpense({ value: { ...transactionThreadReport, pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {createChat: null}, }, }, { @@ -881,6 +886,9 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T existingTransaction, existingTransactionID, optimisticTransactionID: providedOptimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, + existingTransactionThreadCreatedReportActionID, participantParams, policyParams, transactionParams, @@ -1167,7 +1175,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T // 2. IOU action for the iouReport (if tracking in the Expense chat), otherwise – for chatReport // 3. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread // 4. REPORT_PREVIEW action for the chatReport (if tracking in the Expense chat) - const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ + const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, newCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ getCurrencyDecimals, iouReport: shouldUseMoneyReport && iouReport ? iouReport : chatReport, type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, @@ -1178,12 +1186,18 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T participants: [participant], transactionID: optimisticTransaction.transactionID, isPersonalTrackingExpense: !shouldUseMoneyReport, - existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, + existingTransactionThreadReportID: existingTransactionThreadReportID ?? linkedTrackedExpenseReportAction?.childReportID, linkedTrackedExpenseReportAction, + reportActionID: currentReportActionID, currentUserAccountID: currentUserAccountIDParam, delegateAccountIDParam: delegateAccountID, }); + const optimisticCreatedActionForTransactionThread = + existingTransactionThreadReportID && existingTransactionThreadCreatedReportActionID + ? buildOptimisticCreatedReportAction({emailCreatingAction: payeeEmail, optimisticReportActionID: existingTransactionThreadCreatedReportActionID}) + : newCreatedActionForTransactionThread; + let reportPreviewAction: OnyxInputValue> = null; if (shouldUseMoneyReport && iouReport) { reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewReportAction(chatReport.reportID, iouReport.reportID); @@ -1214,6 +1228,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T threadReport: optimisticTransactionThread ?? {}, }, shouldCreateNewMoneyRequestReport, + existingTransactionThreadReportID, actionableTrackExpenseWhisper, retryParams, isASAPSubmitBetaEnabled, @@ -2490,6 +2505,9 @@ function trackExpense(params: CreateTrackExpenseParams) { shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, + existingTransactionThreadCreatedReportActionID, isASAPSubmitBetaEnabled, currentUser, introSelected, @@ -2508,6 +2526,7 @@ function trackExpense(params: CreateTrackExpenseParams) { writeBarrier, rules, personalDetailsByLogins, + reusableDistanceRoutes, } = params; const {accountID: currentUserAccountIDParam, email: currentUserEmailParam = ''} = currentUser; const {participant, payeeAccountID, payeeEmail} = participantParams; @@ -2627,6 +2646,9 @@ function trackExpense(params: CreateTrackExpenseParams) { moneyRequestReportID, existingTransaction, optimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, + existingTransactionThreadCreatedReportActionID, existingTransactionID: isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && isMoneyRequestAction(linkedTrackedExpenseReportAction) ? getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID @@ -2953,6 +2975,32 @@ function trackExpense(params: CreateTrackExpenseParams) { parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; } + const isMapDistance = + (distanceRequestType ? distanceRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP : isMapDistanceRequest(existingTransaction)) || + (!distanceRequestType && !gpsCoordinates && odometerStart === undefined && odometerEnd === undefined); + + if (isMapDistance && validWaypoints && transaction?.transactionID && reusableDistanceRoutes !== undefined) { + const distanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy}); + const effectiveDistance = + modifiedDistance ?? + distance ?? + existingTransaction?.comment?.customUnit?.quantity ?? + (selectedRouteDistance ? Number(DistanceRequestUtils.convertDistanceUnit(selectedRouteDistance, distanceUnit).toFixed(CONST.DISTANCE_DECIMAL_PLACES)) : 0); + + const reusableRouteOnyxData = getLocallyCreatedRouteOnyxData( + { + transactionID: transaction.transactionID, + waypoints: validWaypoints, + distance: effectiveDistance, + routeDistanceMeters: selectedRouteDistance, + inserted: created ?? DateUtils.getDBTime(), + }, + reusableDistanceRoutes ?? [], + ); + onyxData?.optimisticData?.push(...reusableRouteOnyxData.optimisticData); + onyxData?.failureData?.push(...reusableRouteOnyxData.failureData); + } + API.writeWhenReady( WRITE_COMMANDS.TRACK_EXPENSE, parameters, diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 995614776d6a..43c554866644 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -39,6 +39,9 @@ type CreateTrackExpenseParams = { draftTransactionIDs?: string[]; optimisticChatReportID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; + existingTransactionThreadCreatedReportActionID?: string; isASAPSubmitBetaEnabled: boolean; currentUser: CurrentUser; introSelected: OnyxEntry; @@ -58,6 +61,9 @@ type CreateTrackExpenseParams = { writeBarrier?: WriteReadyBarrier; rules: OnyxCollection; personalDetailsByLogins?: PersonalDetailsByLogin; + + /** Current "Reuse route" list value, so the just used route can be saved locally */ + reusableDistanceRoutes?: OnyxTypes.ReusableDistanceRoute[]; }; export type {CreateTrackExpenseParams, TrackExpenseAccountantParams}; diff --git a/src/libs/actions/IOU/types/TrackedExpenseParams.ts b/src/libs/actions/IOU/types/TrackedExpenseParams.ts index 71ad91043fbf..fc20a2528c52 100644 --- a/src/libs/actions/IOU/types/TrackedExpenseParams.ts +++ b/src/libs/actions/IOU/types/TrackedExpenseParams.ts @@ -36,7 +36,8 @@ type BuildOnyxDataForTrackExpenseKeys = | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS; + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.REUSABLE_DISTANCE_ROUTES; type TrackedExpenseTransactionParams = Omit & { waypoints?: string; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index bacb1ac64eff..bff416e95e49 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -433,6 +433,7 @@ function openReportFromDeepLink( conciergeReportID: string | undefined, introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, + hasCompletedGuidedSetupFlow: boolean | undefined, callerAccountID: number, reportNameValuePairs: OnyxCollection, conciergePersonalDetails: OnyxEntry, @@ -611,7 +612,15 @@ function openReportFromDeepLink( Navigation.navigate(lastAccessedReportRoute, {forceReplace: Navigation.getTopmostReportId() === reportID, waitForTransition: true}); return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); return; } diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 2854b98a6b4c..f400604dbeee 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -23,7 +23,6 @@ import {getIOUActionForReportID, getReportAction, getTrackExpenseActionableWhisp import { buildOptimisticIOUReportAction, getReportOrDraftReport, - getReportTransactions, getTransactionDetails, isCurrentUserSubmitter, isMoneyRequestReportEligibleForMerge, @@ -31,24 +30,10 @@ import { } from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import {getReportOwnerAccountIDAsAttendee, getReportOwnerAsAttendee, isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; +import {isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type { - CardList, - MergeTransaction, - PersonalDetailsList, - Policy, - PolicyCategories, - PolicyTagLists, - Report, - ReportAction, - ReportActions, - Rule, - Transaction, - TransactionViolations, -} from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; +import type {CardList, MergeTransaction, Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, ReportActions, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx'; @@ -61,20 +46,6 @@ import {getCleanUpTransactionThreadReportOnyxData} from './IOU/DeleteMoneyReques import {getDeleteTrackExpenseInformation} from './IOU/TrackExpense'; import {getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU/UpdateMoneyRequest'; -/** - * Resolve a transaction's report owner as a default attendee, mirroring the `useReportOwnerAsAttendee` hook so the - * non-React merge entry point can apply the same empty-attendee fallback the details/receipt pages use. The Onyx data - * is passed in by the caller rather than read from a module subscription. - */ -function getReportOwnerAsAttendeeForTransaction( - transaction: OnyxEntry, - currentUserAccountID: number | undefined, - personalDetails: OnyxEntry, -): Attendee | undefined { - const ownerAccountID = getReportOwnerAccountIDAsAttendee(transaction, currentUserAccountID); - return getReportOwnerAsAttendee(ownerAccountID ? (personalDetails?.[ownerAccountID] ?? undefined) : undefined); -} - /** * Setup merge transaction data for merging flow */ @@ -119,8 +90,6 @@ function setupMergeTransactionDataAndNavigate( isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, policies?: Array>, - currentUserAccountID?: number, - personalDetails?: OnyxEntry, ) { if (!transactions.length || transactions.length > 2) { return; @@ -169,8 +138,6 @@ function setupMergeTransactionDataAndNavigate( searchReports, targetTransactionPolicy, sourceTransactionPolicy, - getReportOwnerAsAttendeeForTransaction(targetTransaction, currentUserAccountID, personalDetails), - getReportOwnerAsAttendeeForTransaction(sourceTransaction, currentUserAccountID, personalDetails), ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page @@ -195,10 +162,22 @@ function getTransactionsForMergingFromAPI(transactionID: string) { } /** - * Fetches eligible transactions for merging locally - * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call + * Fetches eligible transactions for merging locally. + * This is the offline FE equivalent of the READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call. + * Per-candidate admin access is checked against each candidate report’s own workspace so that + * cross-workspace admin merges are not granted based on the target’s workspace alone. + * When the candidate’s policy is not in the local cache the candidate is excluded rather than + * assuming access. */ -function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection, rules: OnyxCollection, isAdmin = false) { +function getTransactionsForMergingLocally( + transactionID: string, + targetTransaction: Transaction, + transactions: OnyxCollection, + rules: OnyxCollection, + allPolicies: OnyxCollection, + currentUserLogin: string | undefined, + allReports?: OnyxCollection, +) { const transactionsArray = Object.values(transactions ?? {}); const eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => { @@ -207,10 +186,29 @@ function getTransactionsForMergingLocally(transactionID: string, targetTransacti } const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + if (isUnreportedExpense) { + return areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction); + } + + // Resolve admin status against the candidate’s own workspace. If the policy is absent from + // the local cache we cannot verify access, so we exclude the candidate rather than assuming + // admin rights. + const candidateReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? getReportOrDraftReport(transaction.reportID); + if (!candidateReport) { + return false; + } + const candidatePolicyKey = `${ONYXKEYS.COLLECTION.POLICY}${candidateReport?.policyID}`; + const candidatePolicy = candidateReport?.policyID ? (allPolicies?.[candidatePolicyKey] ?? null) : null; + const isCandidatePolicyMissing = !!candidateReport?.policyID && candidatePolicy === null; + if (isCandidatePolicyMissing) { + return false; + } + const isCandidateAdmin = isPolicyAdmin(candidatePolicy, currentUserLogin); + return ( areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction) && - (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, isAdmin, rules))) + isMoneyRequestReportEligibleForMerge(candidateReport, isCandidateAdmin, rules) ); }); @@ -228,6 +226,8 @@ function getTransactionsForMerging({ report, currentUserLogin, rules, + allPolicies, + allReports, }: { isOffline: boolean; targetTransaction: Transaction; @@ -238,22 +238,27 @@ function getTransactionsForMerging({ currentUserLogin: string | undefined; cardList?: CardList; rules: OnyxCollection; + allPolicies?: OnyxCollection; + allReports?: OnyxCollection; }) { const transactionID = targetTransaction.transactionID; if (!transactionID) { return; } - // Collect/Control workspaces: - // - Admins and approvers: The list of eligible expenses will only contain the expenses from the report that the admin/approver triggered the merge from. This is intentionally limited since they’ll only be reviewing one report at a time. - // - Submitters will see all their editable expenses, including their IOUs/unreported expenses + // Candidate discovery: + // - Admins: See expenses from the same submitter across the submitter's open reports and submitted reports pending + // final approval that the admin can access, so admins can merge a card and cash expense on different reports. + // - Managers (non-admin approvers): Limited to same-report transactions on the processing report they triggered + // the merge from, as they review one report at a time. + // - Submitters will see all their editable expenses, including their IOUs/unreported expenses. // IOU: // - There are no admins/approvers outside of the submitter in these cases, so there’s no consideration for different roles. - // - The submitter, who is also the admin, will see all their editable expenses, including their IOUs/unreported expenses + // - The submitter, who is also the admin, will see all their editable expenses, including their IOUs/unreported expenses. const isAdmin = isPolicyAdmin(policy, currentUserLogin); const isManager = isReportManager(report); - if (isPaidGroupPolicy(policy) && (isAdmin || isManager) && !isCurrentUserSubmitter(report)) { + if (isPaidGroupPolicy(policy) && isManager && !isAdmin && !isCurrentUserSubmitter(report)) { const eligibleTransactions = reportTransactions.filter((transaction): transaction is Transaction => { if (!transaction || transaction.transactionID === transactionID) { return false; @@ -269,7 +274,7 @@ function getTransactionsForMerging({ } if (isOffline) { - getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, isAdmin); + getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, allPolicies, currentUserLogin, allReports); } else { getTransactionsForMergingFromAPI(transactionID); } @@ -412,6 +417,7 @@ type MergeTransactionRequestParams = { targetTransaction: Transaction; allTransactionViolations: OnyxCollection; sourceTransaction: Transaction; + transactionsOfDeletableReportCollection: Record; targetTransactionThreadReport: OnyxEntry; targetTransactionThreadParentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; @@ -450,6 +456,7 @@ function mergeTransactionRequest({ mergeTransaction, targetTransaction, sourceTransaction, + transactionsOfDeletableReportCollection, targetTransactionThreadReport, targetTransactionThreadParentReport, iouReportOwnerLogin, @@ -554,7 +561,7 @@ function mergeTransactionRequest({ }; const transactionToDelete = mergeTransaction.reportID === targetTransaction.reportID ? sourceTransaction : targetTransaction; - const transactionsOfDeletableReport = getReportTransactions(transactionToDelete.reportID); + const transactionsOfDeletableReport = Object.values(transactionsOfDeletableReportCollection); const deletableReport = getReportOrDraftReport(transactionToDelete.reportID); const reportPreviewActionIDOfDeletableReport = deletableReport?.parentReportActionID; const optimisticReportDeletionData: Array> = diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index c475b058144d..9d6173058431 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -5,7 +5,7 @@ import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDer import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; /** @@ -21,9 +21,13 @@ function shouldSkipCachingAction(action: ReportAction): boolean { * actions. Rebuilding the whole map rather than updating individual entries keeps deletions correct: * a removed action is absent from `reportActions`, so it drops out of the result. */ -function computeReportVisibility(reportActions: ReportActions, currentUserAccountID: number | undefined, report: Report | undefined): Record { +function computeReportVisibility( + reportActions: ReportActions, + currentUserAccountID: number | undefined, + isSupportTicketReport: boolean, + isReplacedSupportTicket: boolean, +): Record { const reportVisibility: Record = {}; - const isSupportTicketReport = isSupportTicket(report); for (const [actionID, action] of Object.entries(reportActions)) { if (!action) { @@ -37,6 +41,11 @@ function computeReportVisibility(reportActions: ReportActions, currentUserAccoun if (shouldSkipCachingAction(action)) { continue; } + // A replacement ticket has its own active survey, so the original ticket's survey is stale. + if (isReplacedSupportTicket && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { + reportVisibility[action.reportActionID] = false; + continue; + } reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, currentUserAccountID, undefined, isSupportTicketReport); } @@ -49,26 +58,35 @@ export default createOnyxDerivedValueConfig({ // (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) AND to provide the current // report collection to the visibility check, avoiding stale data from global connections. // SESSION dependency is needed for whisper targeting when user changes. - dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT], - compute: ([allReportActions, session, allReports], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { + dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS], + compute: ([allReportActions, session, allReports, allReportNameValuePairs], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { if (!allReportActions) { return {}; } const currentUserAccountID = session?.accountID; const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; + const reportNameValuePairsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]; - // Recompute only the reports whose actions changed when we have a usable delta. Otherwise - // recompute everything: on first load, when there's no delta, or on a SESSION change (the - // user changed, which affects whisper targeting for every report). SESSION is checked via - // triggeredKeys, not sourceValues, so a session cleared to `undefined` still forces the full recompute. - const isIncremental = !!reportActionsUpdates && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; + // Recompute only the reports whose actions or NVPs changed when we have a usable delta. + // Otherwise recompute everything: on first load, when there's no delta, or on a SESSION or + // REPORT change. SESSION is checked via triggeredKeys, not sourceValues, so a session cleared + // to `undefined` still forces the full recompute. + const isIncremental = + !!(reportActionsUpdates ?? reportNameValuePairsUpdates) && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; const result: VisibleReportActionsDerivedValue = isIncremental ? {...currentValue} : {}; - const reportActionsKeysToProcess = isIncremental ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions); + const reportIDsToProcess = isIncremental + ? new Set([ + ...Object.keys(reportActionsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '')), + ...Object.keys(reportNameValuePairsUpdates ?? {}) + .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, '')) + .filter((reportID) => isSupportTicket(allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])), + ]) + : new Set(Object.keys(allReportActions).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''))); - for (const reportActionsKey of reportActionsKeysToProcess) { - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + for (const reportID of reportIDsToProcess) { + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; const reportActions = allReportActions[reportActionsKey]; // The member was removed entirely — drop the report from the result. @@ -77,7 +95,10 @@ export default createOnyxDerivedValueConfig({ continue; } - result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const isSupportTicketReport = isSupportTicket(report); + const isReplacedSupportTicket = isSupportTicketReport && !!allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]?.reopenedAsReportID; + result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, isSupportTicketReport, isReplacedSupportTicket); } return result; diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 9f0694258c26..c09905ed64f1 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -2,7 +2,9 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import * as API from '@libs/API'; import type { + AddOfficeLocationParams, CreatePolicyDistanceRateParams, + DeleteOfficeLocationParams, DeletePolicyDistanceRatesParams, DisablePolicyCommuterExclusionsParams, EnablePolicyDistanceRatesParams, @@ -14,6 +16,7 @@ import type { SetPolicyRequireMapOrGPSParams, SetPolicyWorkArrangementParams, SetWorkspaceDistanceAutoUpdateParams, + UpdateOfficeLocationParams, UpdatePolicyDistanceRateParams, UpdatePolicyDistanceRateValueParams, } from '@libs/API/parameters'; @@ -21,7 +24,7 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import {rand64} from '@libs/NumberUtils'; +import {generateHexadecimalValue, rand64} from '@libs/NumberUtils'; import {buildOnyxDataForGovernmentRateAutoUpdate, buildOnyxDataForPolicyDistanceRateUpdates} from '@libs/PolicyDistanceRatesUtils'; import {goBackWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; import {getRoom} from '@libs/ReportUtils'; @@ -30,9 +33,10 @@ import {getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {GovernmentMileageRate, PersonalDetailsList, Policy, PolicyEmployee, ReportAction, TransactionViolation} from '@src/types/onyx'; -import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; -import type {CommuterExclusions, CustomUnit, Rate} from '@src/types/onyx/Policy'; +import type {ErrorFields, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {CommuterExclusions, CompanyAddress, CustomUnit, OfficeLocation, Rate} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -258,7 +262,12 @@ function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: stri }); } -function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { +function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, previousCommuterExclusions?: CommuterExclusions) { + const newDistanceUnit = newCustomUnit.attributes?.unit; + const shouldUpdateCommuterExclusionUnit = + previousCommuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && typeof previousCommuterExclusions.fixedDistance === 'number' && !!newDistanceUnit; + const optimisticCommuterExclusions = shouldUpdateCommuterExclusionUnit ? {...previousCommuterExclusions, fixedDistanceUnit: newDistanceUnit} : undefined; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -270,6 +279,13 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, }, }, + ...(optimisticCommuterExclusions + ? { + commuterExclusions: optimisticCommuterExclusions, + pendingFields: {commuterExclusions: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {commuterExclusions: null}, + } + : {}), }, }, ]; @@ -284,6 +300,7 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU pendingFields: {attributes: null}, }, }, + ...(optimisticCommuterExclusions ? {pendingFields: {commuterExclusions: null}} : {}), }, }, ]; @@ -300,6 +317,16 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU pendingFields: {attributes: null}, }, }, + ...(optimisticCommuterExclusions + ? { + commuterExclusions: { + ...previousCommuterExclusions, + fixedDistanceUnit: previousCommuterExclusions?.fixedDistanceUnit ?? null, + }, + pendingFields: {commuterExclusions: null}, + errorFields: {commuterExclusions: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + } + : {}), }, }, ]; @@ -841,6 +868,192 @@ function setEmployeeWorkArrangement( API.write(WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT, parameters, onyxData); } +/** + * Add an office to a workspace. Making it the default clears the flag on the current default office, so callers + * pass the workspace's current `officeLocations` for the failure path to restore it. An office added without a `name` + * is named by the server, and shows `defaultName` until then. + */ +function addOfficeLocation(policyID: string, officeLocations: Record | undefined, address: CompanyAddress, isDefault: boolean, name: string, defaultName: string) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const officeID = generateHexadecimalValue(16); + const previousDefaultOfficeID = isDefault + ? Object.entries(officeLocations ?? {}).find(([, officeLocation]) => officeLocation.isDefault && officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)?.[0] + : undefined; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: false}} : {}), + [officeID]: {name: name || defaultName, address, isDefault, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: null}}, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: true}} : {}), + [officeID]: {isDefault: false, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + }, + ], + }; + + const parameters: AddOfficeLocationParams = {policyID, officeID, officeName: name || undefined, address: JSON.stringify(address), isDefault}; + API.write(WRITE_COMMANDS.ADD_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Update the name, address or default flag of a workspace office. Unsetting the default flag makes the company address + * the default. Callers pass the workspace's current `officeLocations` so the failure path can restore the office, and + * the default office it replaced. + */ +function updateOfficeLocation( + policyID: string, + officeLocations: Record | undefined, + officeID: string, + changes: Partial>, +) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const officeLocation = officeLocations?.[officeID]; + const previousDefaultOfficeID = changes.isDefault + ? Object.entries(officeLocations ?? {}).find( + ([, otherOfficeLocation]) => otherOfficeLocation.isDefault && otherOfficeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + )?.[0] + : undefined; + + // An office that hasn't reached the server yet stays pending addition, so dismissing a failed addition still removes it + const pendingAction = officeLocation?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: false}} : {}), + [officeID]: {...changes, pendingAction, errors: null}, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: null}}, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: true}} : {}), + [officeID]: { + name: officeLocation?.name, + address: officeLocation?.address, + isDefault: officeLocation?.isDefault ?? false, + pendingAction: officeLocation?.pendingAction ?? null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + ], + }; + + const parameters: UpdateOfficeLocationParams = { + policyID, + officeID, + officeName: changes.name, + address: changes.address ? JSON.stringify(changes.address) : undefined, + isDefault: changes.isDefault, + }; + API.write(WRITE_COMMANDS.UPDATE_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Delete a workspace office. Deleting the default office makes the company address the default, or, in a workspace + * without a company address, the server makes another office the default. An office whose addition failed was never + * stored by the server, so it is only removed locally. + */ +function deleteOfficeLocation(policyID: string, officeID: string, officeLocation: OfficeLocation | undefined) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + if (officeLocation?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(officeLocation.errors)) { + Onyx.merge(policyKey, {officeLocations: {[officeID]: null}}); + return; + } + + // An office that hasn't reached the server yet stays pending addition, so dismissing the error still removes it + const failedPendingAction = officeLocation?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}}, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: null}, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: failedPendingAction, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}}, + }, + }, + ], + }; + + const parameters: DeleteOfficeLocationParams = {policyID, officeID}; + API.write(WRITE_COMMANDS.DELETE_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Dismiss the error on a workspace office. An office whose addition failed is removed, since the server never stored it. + */ +function clearOfficeLocationErrors(policyID: string, officeID: string, pendingAction: PendingAction | undefined) { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {[officeID]: null}}); + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {[officeID]: {errors: null}}}); +} + /** * Turn the "Require GPS or map entry" setting on or off for a policy. When it's on, the manual and odometer * distance flows are unavailable because neither can produce a mapped route. @@ -963,6 +1176,10 @@ export { disablePolicyCommuterExclusions, clearPolicyCommuterExclusionsErrors, setEmployeeWorkArrangement, + addOfficeLocation, + updateOfficeLocation, + deleteOfficeLocation, + clearOfficeLocationErrors, setPolicyRequireMapOrGPS, clearPolicyRequireMapOrGPSErrors, setWorkspaceDistanceAutoUpdate, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0da562f435d4..d80d9644bb46 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -41,6 +41,7 @@ import type { SearchForReportsParams, SearchForRoomsToMentionParams, SearchForUsersParams, + ShareReportParams, TogglePinnedChatParams, TransactionThreadInfo, UpdateChatNameParams, @@ -93,6 +94,7 @@ import Parser from '@libs/Parser'; import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; import {getAllPersonalDetails} from '@libs/PersonalDetailsStore'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {isMapOrGPSRequired} from '@libs/PolicyDistanceRatesUtils'; import { getDefaultApprover, @@ -263,6 +265,7 @@ import type {CurrentUserPersonalDetails, Timezone} from '@src/types/onyx/Persona import type {ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, Participant as ReportParticipant, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -1002,7 +1005,7 @@ function addActions({ // Check if the last visible action is from Concierge with unresolved followups // If so, optimistically resolve them by adding the updated action to optimisticReportActions - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, undefined, {}, undefined, undefined, currentUserAccountID); const lastActorAccountID = lastVisibleAction?.actorAccountID; const lastActionReportActionID = lastVisibleAction?.reportActionID; const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); @@ -3384,19 +3387,34 @@ function broadcastUserIsLeavingRoom(reportID: string, currentUserAccountID: numb Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus); } +type DeleteReportCommentParams = { + report: OnyxEntry; + reportAction: ReportAction; + originalReportActions: OnyxEntry; + reportActions: OnyxEntry; + ancestors: Ancestor[]; + isReportArchived: boolean | undefined; + isOriginalReportArchived: boolean | undefined; + currentEmail: string; + isOffline: boolean; + currentUserAccountID: number; + visibleReportActionsData?: VisibleReportActionsDerivedValue; +}; + /** Deletes a comment from the report, basically sets it as empty string */ -function deleteReportComment( - report: OnyxEntry, - reportAction: ReportAction, - originalReportActions: OnyxEntry, - reportActions: OnyxEntry, - ancestors: Ancestor[], - isReportArchived: boolean | undefined, - isOriginalReportArchived: boolean | undefined, - currentEmail: string, - isOffline: boolean, - visibleReportActionsDataParam?: VisibleReportActionsDerivedValue, -) { +function deleteReportComment({ + report, + reportAction, + originalReportActions, + reportActions, + ancestors, + isReportArchived, + isOriginalReportArchived, + currentEmail, + isOffline, + currentUserAccountID, + visibleReportActionsData, +}: DeleteReportCommentParams) { const reportID = report?.reportID; const originalReportID = getOriginalReportID(reportID, reportAction, reportActions, isOffline); const reportActionID = reportAction.reportActionID; @@ -3498,7 +3516,13 @@ function deleteReportComment( // If we are deleting the last visible message, let's find the previous visible one (or set an empty one if there are none) and update the lastMessageText in the LHN. // Similarly, if we are deleting the last read comment we will want to update the lastVisibleActionCreated to use the previous visible message. const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - const optimisticLastReportData = optimisticReportLastData(originalReportID, optimisticReportActions as ReportActions, canUserPerformWriteAction, isOriginalReportArchived); + const optimisticLastReportData = optimisticReportLastData( + originalReportID, + currentUserAccountID, + optimisticReportActions as ReportActions, + canUserPerformWriteAction, + isOriginalReportArchived, + ); const optimisticReport: Partial = { ...optimisticLastReportData, @@ -3510,7 +3534,7 @@ function deleteReportComment( (action) => action.reportActionID !== reportAction.reportActionID && ReportActionsUtils.didMessageMentionCurrentUser(action, currentEmail) && - ReportActionsUtils.isReportActionVisible(action, reportID, undefined, visibleReportActionsDataParam), + ReportActionsUtils.isReportActionVisible(action, reportID, undefined, visibleReportActionsData), ); optimisticReport.lastMentionedTime = latestMentionedReportAction?.created ?? null; } @@ -4470,6 +4494,14 @@ type NavigateToConciergeChatParams = { /** Whether the user has already viewed the self tour. */ isSelfTourViewed: boolean | undefined; + /** + * Whether the user has completed the guided setup (onboarding) flow. Only read on the branch where + * `conciergeReportID` is undefined — the one that has to find or create the Concierge chat — where it decides + * whether a still-pending onboarding OpenReport is enqueued. When the report ID is already known this function + * only navigates, so the value is unused there. + */ + hasCompletedGuidedSetupFlow: boolean | undefined; + /** Whether to dismiss the current modal instead of navigating on top of it. */ shouldDismissModal?: boolean; @@ -4504,6 +4536,7 @@ function navigateToConciergeChat({ introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, shouldDismissModal = false, checkIfCurrentPageActive = () => true, linkToOptions, @@ -4535,8 +4568,7 @@ function navigateToConciergeChat({ // The Concierge chat does not exist yet on this path (it is being created here), so there is no report to thread. conciergeChat: undefined, isSelfTourViewed, - // TODO: Pass the correct hasCompletedGuidedSetupFlow from Onyx data in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 - hasCompletedGuidedSetupFlow: undefined, + hasCompletedGuidedSetupFlow, // Not gated: this is the Concierge fallback, not the Start chat flow. Concierge is a core report reached while // simply navigating around, so blocking it for support agents would pop the denied modal during plain navigation. isSupportalSession: false, @@ -4889,18 +4921,64 @@ function isNoSupportRepAvailableResponse(response: {jsonCode?: number | string; function openSupportTicket(resolvedSupportTicketReportID?: string) { const newSupportTicketReportID = resolvedSupportTicketReportID ? undefined : generateReportID(); const parameters: CreateSupportTicketParams = resolvedSupportTicketReportID ? {resolvedSupportTicketReportID, idempotencyKey: Str.guid()} : {newSupportTicketReportID}; - - if (!resolvedSupportTicketReportID) { + const onyxData: OnyxData = newSupportTicketReportID + ? { + optimisticData: [ + // The request does not survive reloads, so its fetch guard must stay in RAM. + // Set it before publishing the report ID; merges can wait on storage reads. + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: true}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: false}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: null, + }, + ], + } + : {}; + + // Start the request before navigating so the optimistic support-ticket metadata is available for the pending LHN title. + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- Reopening must wait for the server-selected report ID before navigating. + const request = API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters, onyxData); + if (newSupportTicketReportID) { Navigation.navigate(getReportRouteForCurrentContext({reportID: newSupportTicketReportID, isPendingCreation: true})); } - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- reopening must wait for the server-selected report ID before navigating. - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters).then((response) => { - if (resolvedSupportTicketReportID && response?.reportID) { - Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); - } - return response; - }); + return request + .then((response) => { + if (resolvedSupportTicketReportID && response?.reportID) { + Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); + } + return response; + }) + .catch(async (error: unknown) => { + if (onyxData.failureData) { + await Onyx.update(onyxData.failureData); + } + throw error; + }); } function dismissFailedSupportTicket(supportTicketReportID: string, parentReportID: string, parentReportActionID: string) { @@ -5054,6 +5132,8 @@ function navigateToConciergeChatAndDeleteReport( introSelected, currentUserAccountID, isSelfTourViewed, + // TODO: Thread hasCompletedGuidedSetupFlow through this wrapper in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 + hasCompletedGuidedSetupFlow: undefined, shouldDismissModal: false, linkToOptions: {afterTransition: () => deleteReport(reportID, shouldDeleteChildReports)}, conciergePersonalDetails: personalDetails, @@ -5368,11 +5448,13 @@ function navigateToMostRecentReport( Navigation.goBack(); } + // TODO: Thread hasCompletedGuidedSetupFlow through this wrapper in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 navigateToConciergeChat({ conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: personalDetails, shouldDismissModal: false, linkToOptions: {forceReplace: true}, @@ -5902,12 +5984,13 @@ function removeFromGroupChat(report: Report, accountIDList: number[]) { function optimisticReportLastData( reportID: string, + currentUserAccountID: number, optimisticReportActions: Record | null> = {}, canUserPerformWriteAction?: boolean, isReportArchived?: boolean, ) { const lastMessageText = getLastVisibleMessage(reportID, isReportArchived, optimisticReportActions).lastMessageText ?? ''; - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction, optimisticReportActions); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction, optimisticReportActions, undefined, undefined, currentUserAccountID); return { lastMessageText, lastVisibleActionCreated: lastVisibleAction?.created ?? '', @@ -5916,7 +5999,13 @@ function optimisticReportLastData( } /** Flag a comment as offensive */ -function flagComment(reportAction: OnyxEntry, severity: string, originalReport: OnyxEntry | undefined, isOriginalReportArchived: boolean | undefined) { +function flagComment( + reportAction: OnyxEntry, + severity: string, + originalReport: OnyxEntry | undefined, + isOriginalReportArchived: boolean | undefined, + currentUserAccountID: number, +) { const originalReportID = originalReport?.reportID; const message = ReportActionsUtils.getReportActionMessage(reportAction); @@ -5972,6 +6061,7 @@ function flagComment(reportAction: OnyxEntry, severity: string, or const optimisticLastReportData = optimisticReportLastData( originalReportID ?? String(CONST.DEFAULT_NUMBER_ID), + currentUserAccountID, { [reportActionID]: {...reportAction, message: [updatedMessage], pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, } as ReportActions, @@ -6966,6 +7056,17 @@ async function exportReportToPDF({reportID}: ExportReportPDFParams) { return API.write(WRITE_COMMANDS.EXPORT_REPORT_TO_PDF, params, {optimisticData, failureData}); } +/** Emails the report to the given login and gives them access to it. */ +function shareReport(reportID: string, shareToLogin: string) { + const params = { + reportID, + shareToEmail: addSMSDomainIfPhoneNumber(shareToLogin), + permissions: [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE].join(', '), + } satisfies ShareReportParams; + + API.write(WRITE_COMMANDS.SHARE_REPORT, params); +} + function downloadReportPDF(fileName: string, reportName: string, translate: LocalizedTranslate, currentUserLogin: string, encryptedAuthToken: string) { const baseURL = getOldDotURLFromEnvironment(environment); const downloadFileName = `${reportName}.pdf`; @@ -9096,6 +9197,7 @@ export { expandURLPreview, exportReportToCSV, exportReportToPDF, + shareReport, exportToIntegration, joinReportViaSecureLink, flagComment, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 33c9e0975710..4f6926ef1db2 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -351,6 +351,36 @@ function resolveEnableFeatureConflicts( }; } +/** + * Removing the vacation delegate supersedes any change to it still waiting in the queue, e.g. a clear after date changed offline + * before the removal. Sending that change first would let its response bring the removed delegate back until the removal lands. + * Only the current user's own delegate is matched, so a domain admin's change for a member is left alone. + */ +function resolveVacationDelegateDeletionConflicts(persistedRequests: Array>): ConflictActionData { + const indicesToDelete = persistedRequests.reduce((indices, request, index) => { + if (request.command === WRITE_COMMANDS.SET_VACATION_DELEGATE && !request.data?.domainAccountID) { + indices.push(index); + } + return indices; + }, []); + + if (indicesToDelete.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + return { + conflictAction: { + type: 'delete', + indices: indicesToDelete, + pushNewRequest: true, + }, + }; +} + export { resolveDuplicationConflictAction, resolveOpenAppDuplicationConflictAction, @@ -365,6 +395,7 @@ export { createUpdateCommentMatcher, resolveEnableFeatureConflicts, enablePolicyFeatureCommand, + resolveVacationDelegateDeletionConflicts, }; export type {EnablePolicyFeatureCommand, AnyRequestMatcher}; diff --git a/src/libs/actions/ReusableDistanceRoutes.ts b/src/libs/actions/ReusableDistanceRoutes.ts index b79f149fa206..b6c67919d308 100644 --- a/src/libs/actions/ReusableDistanceRoutes.ts +++ b/src/libs/actions/ReusableDistanceRoutes.ts @@ -13,6 +13,24 @@ import Onyx from 'react-native-onyx'; import {updateWaypoints} from './Transaction'; +/** + * Onyx data saving the route of a newly created distance expense locally, so the just used + * route shows in "Reuse route" in offline mode before the server list refreshes. + */ +function getLocallyCreatedRouteOnyxData( + route: ReusableDistanceRoute, + currentRoutes: ReusableDistanceRoute[], +): { + optimisticData: Array>; + failureData: Array>; +} { + const updatedRoutes = [route, ...currentRoutes.filter((item) => item.transactionID !== route.transactionID)]; + return { + optimisticData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.REUSABLE_DISTANCE_ROUTES, value: updatedRoutes}], + failureData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.REUSABLE_DISTANCE_ROUTES, value: currentRoutes}], + }; +} + function fetchReusableDistanceRoutes() { const optimisticData: Array> = [ { @@ -55,4 +73,4 @@ function selectReusableRoute(transactionID: string, route: ReusableDistanceRoute ); } -export {fetchReusableDistanceRoutes, selectReusableRoute}; +export {fetchReusableDistanceRoutes, getLocallyCreatedRouteOnyxData, selectReusableRoute}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 4e0bbab9bcb4..84badc48dd84 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -387,6 +387,7 @@ function handleActionButtonPress({ conciergeChat, getCurrencyDecimals, rules, + iouReportTransactions: allReportTransactions, }); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: @@ -636,6 +637,7 @@ type GetPayActionCallbackParams = { conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; rules: OnyxCollection; + iouReportTransactions: Transaction[]; }; function getPayActionCallback({ @@ -666,6 +668,7 @@ function getPayActionCallback({ conciergeChat, getCurrencyDecimals, rules, + iouReportTransactions, }: GetPayActionCallbackParams) { if (!item.reportID) { Log.info('[SearchPay] Dropping row pay: item has no reportID'); @@ -698,6 +701,7 @@ function getPayActionCallback({ paymentType: lastPolicyPaymentMethod, chatReport: chatReportForPayment, iouReport: snapshotReport, + iouReportTransactions, introSelected, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserLogin: currentUserLogin ?? '', @@ -1265,7 +1269,7 @@ function handlePreventSearchAPI(hash: number | undefined) { * columns to a backend-supported date sort. */ function getBackendQueryJSON(queryJSON: Readonly) { - const {exactMatchFilterKeys, flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; + const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) ? { ...queryJSONWithoutFlatFilters, @@ -1279,7 +1283,7 @@ function getBackendQueryJSON(queryJSON: Readonly) { ), } : queryJSONWithoutFlatFilters; - return {backendQueryJSON, limit, exactMatchFilterKeys}; + return {backendQueryJSON, limit}; } function search({ @@ -1364,7 +1368,7 @@ function search({ inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals, shouldShowLoading); - const {backendQueryJSON, limit, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); + const {backendQueryJSON, limit} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, searchKey, @@ -1375,7 +1379,7 @@ function search({ // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), }; - const jsonQuery = serializeQueryJSONForBackend(query, exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined); + const jsonQuery = serializeQueryJSONForBackend(query); if (shouldUpdateLastSearchParams) { saveLastSearchParams({ @@ -1554,15 +1558,12 @@ function getFooterConvertedAmounts({ // searchKey changes what the backend query matches (e.g. unapprovedCash excludes card expenses), so it must be // sent exactly as search() sends it or the converted totals cover a different expense set than the snapshot. - const {backendQueryJSON, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); - const jsonQuery = serializeQueryJSONForBackend( - { - ...backendQueryJSON, - searchKey, - filters: backendQueryJSON.filters ?? null, - }, - exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined, - ); + const {backendQueryJSON} = getBackendQueryJSON(queryJSON); + const jsonQuery = serializeQueryJSONForBackend({ + ...backendQueryJSON, + searchKey, + filters: backendQueryJSON.filters ?? null, + }); read( READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, diff --git a/src/libs/actions/TaskDeletion.ts b/src/libs/actions/TaskDeletion.ts index ee843b7bc532..10aa39ff9b66 100644 --- a/src/libs/actions/TaskDeletion.ts +++ b/src/libs/actions/TaskDeletion.ts @@ -107,7 +107,12 @@ function deleteTask( }; const optimisticReportActions = parentReportAction?.reportActionID ? {[parentReportAction?.reportActionID]: optimisticReportAction} : {}; - const optimisticLastReportData = optimisticReportLastData(parentReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), optimisticReportActions, canUserPerformWriteAction); + const optimisticLastReportData = optimisticReportLastData( + parentReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + currentUserAccountID, + optimisticReportActions, + canUserPerformWriteAction, + ); const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 524e75902e0e..2b7e8a53407b 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -90,6 +90,7 @@ import lodashClone from 'lodash/clone'; import Onyx from 'react-native-onyx'; import {getSearchOnyxUpdate} from './IOU/SearchUpdate'; +import {removeActiveTransactionIDsForReport} from './TransactionThreadNavigation'; type SaveWaypointProps = { transactionID: string; @@ -1048,6 +1049,8 @@ function getChangeTransactionsReportOnyxData({ // The transactions that are actually moved to the destination report. Transactions already in the destination are // skipped below, so this excludes them from the transactionList sent to the backend (which does not filter them out). const movedTransactionIDs: string[] = []; + // The moved transactions grouped by the report they leave, so a carousel seeded from that report can drop them. + const movedTransactionIDsByOldReportID: Record = {}; let shouldFixViolations = false; const policyHasDependentTags = hasDependentTags(policy, policyTagList); @@ -1137,6 +1140,7 @@ function getChangeTransactionsReportOnyxData({ movedTransactionIDs.push(transaction.transactionID); const oldReportID = isUnreportedExpense ? CONST.REPORT.UNREPORTED_REPORT_ID : transaction.reportID; + (movedTransactionIDsByOldReportID[oldReportID] ??= []).push(transaction.transactionID); const oldReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldReportID}`]; const sourceCurrency = oldReport?.currency; const shouldClearAmount = shouldClearConvertedAmount(transaction, sourceCurrency, destinationCurrency); @@ -2133,9 +2137,20 @@ function getChangeTransactionsReportOnyxData({ updatedReportReimbursableTotals, updatedReportUnheldReimbursableTotals, movedTransactionIDs, + movedTransactionIDsByOldReportID, }; } +/** + * Drops moved expenses from a carousel seeded from the report they left. Every move goes through + * `changeTransactionsReport`, so doing it here covers all entry points at once. + */ +function removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID: Record | undefined) { + for (const [oldReportID, transactionIDs] of Object.entries(movedTransactionIDsByOldReportID ?? {})) { + removeActiveTransactionIDsForReport(transactionIDs, oldReportID); + } +} + function changeTransactionsReport(props: ChangeTransactionsReportProps) { const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; @@ -2188,6 +2203,7 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }; API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData}); + removeMovedTransactionsFromCarousel(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); return; } @@ -2195,8 +2211,15 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { if (!changeTransactionsReportOnyxData) { return; } - const {optimisticData, successData, failureData, transactionIDToReportActionAndThreadData, transactionIDToUpdatedCustomUnitRateID, movedTransactionIDs} = - changeTransactionsReportOnyxData; + const { + optimisticData, + successData, + failureData, + transactionIDToReportActionAndThreadData, + transactionIDToUpdatedCustomUnitRateID, + movedTransactionIDs, + movedTransactionIDsByOldReportID, + } = changeTransactionsReportOnyxData; // If every selected transaction is already in the destination report, there is nothing to move, so skip the API call. if (movedTransactionIDs.length === 0) { @@ -2219,6 +2242,7 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { successData, failureData, }); + removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID); } /** diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 026ec10e4d70..226885d42762 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -201,6 +201,35 @@ function clearActiveTransactionIDsForSource(source: string) { return clearActiveTransactionIDs(); } +/** + * Drops expenses that left `reportID` from a carousel seeded from that report, so its arrows stop paging to them. + * Carousels from other screens, such as search results, span several reports and are left alone. + */ +function removeActiveTransactionIDsForReport(transactionIDs: string[], reportID: string | undefined) { + if (!lastSetIDs?.length || !transactionIDs.length || !reportID) { + return Promise.resolve(); + } + + const reportSources = new Set([CAROUSEL_SOURCE.report(reportID), CAROUSEL_SOURCE.reportRow(reportID), CAROUSEL_SOURCE.reportPreview(reportID)]); + if (!lastSetSource || !reportSources.has(lastSetSource)) { + return Promise.resolve(); + } + + const removedIDs = new Set(transactionIDs); + const remainingIDs = lastSetIDs.filter((transactionID) => !removedIDs.has(transactionID)); + if (remainingIDs.length === lastSetIDs.length) { + return Promise.resolve(); + } + + // One expense left is nothing to page through. + if (remainingIDs.length < 2) { + return clearActiveTransactionIDs(); + } + + lastSetIDs = remainingIDs; + return Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, remainingIDs); +} + /** * Gives up ownership of the carousel without clearing it, for a writer that unmounts while the list it seeded is * still driving the arrows of an expense open on top of it. @@ -221,6 +250,7 @@ export { setActiveTransactionIDs, clearActiveTransactionIDs, clearActiveTransactionIDsForSource, + removeActiveTransactionIDsForReport, disownActiveTransactionIDs, getActiveTransactionIDs, shouldRefreshActiveTransactionIDs, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 418f1dcd1584..c60f3ab107ba 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -86,7 +86,7 @@ Onyx.connectWithoutView({ }, }); -// This value is used in the handler for a RECONNECT_APP Pusher event (anonymous users), so it's not possible to use useOnyx(). +// This value is used in the handler for a RECONNECT_APP Pusher event, so it's not possible to use useOnyx(). let lastUpdateIDAppliedToClient: OnyxEntry; Onyx.connectWithoutView({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, @@ -878,6 +878,26 @@ function subscribeToUserEvents( PusherUtils.onPrivateUserChannelResubscribe(currentUserAccountID.toString()); + // Temporary labels bypass the reliable update stream, but wait for the current API response so an older snapshot cannot overwrite them. + // Keep events in arrival order even when they wait on different queued requests. + let processingStatusEventsPromise = Promise.resolve(); + PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, currentUserAccountID.toString(), (pushJSON) => { + processingStatusEventsPromise = processingStatusEventsPromise + .then(() => SequentialQueue.getCurrentRequest()) + .then(() => { + if (sessionAccountID !== currentUserAccountID || !ActiveClientManager.isClientTheLeader()) { + return; + } + + return Onyx.update(pushJSON.updates); + }) + .catch((error: unknown) => { + Log.alert('[subscribeToUserEvents] Applying Agent Zero processing status failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + // Handles the mega multipleEvents from Pusher which contains an array of single events. // Each single event is passed to PusherUtils in order to trigger the callbacks for that event PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.MULTIPLE_EVENTS, currentUserAccountID.toString(), (pushJSON) => { @@ -943,14 +963,12 @@ function subscribeToUserEvents( // We have an event to reconnect the App. It is triggered when we detect that the user passed updateID // is not in the DB PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.RECONNECT_APP, () => { - // Anonymous public-room sessions already have report actions from OpenReport. A full ReconnectApp - // uses setCollection and clears them, which flashes the loading skeleton until GetNewerActions - // refills. Pass lastUpdateID so reconnect stays incremental for anonymous users (#97847). - if (isAnonymousUser()) { - reconnectApp(lastUpdateIDAppliedToClient); - } else { - reconnectApp(); - } + // This event is broadcast on the account-wide channel, so every signed-in device receives it, not only + // the one whose updateID is missing. A full ReconnectApp uses setCollection and clears report actions the + // device already holds (#97847, #100791). Pass lastUpdateID so the server can reply incrementally. It + // still returns full data when it can't serve that range, and we fall back to a full reconnect when the + // client has no lastUpdateID yet. + reconnectApp(lastUpdateIDAppliedToClient); return Promise.resolve(); }); } diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 3f814e73a5e9..c77e22e95b9f 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -24,27 +24,38 @@ import Onyx from 'react-native-onyx'; import type {CurrentUser} from './Policy/Policy'; import {addMembersToWorkspace} from './Policy/Member'; +import {resolveVacationDelegateDeletionConflicts} from './RequestConflictUtils'; type SetVacationDelegateOptions = { creator: string; delegate: string; + + /** UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the delegate. No value means it never clears. */ + clearAfter?: string; currentDelegate?: string; + currentClearAfter?: string; shouldOverridePolicyDiffWarning?: boolean; }; -async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { +async function setVacationDelegate({creator, delegate, clearAfter, currentDelegate, currentClearAfter, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { + // Without the override, the response can still be a 305 that leads to the missing workspaces step, so the pick is only applied once the + // backend saved it. Applying it optimistically would flash the new delegate on Profile, take it away on the 305, and show it again after that step. + const pickedDelegate = {creator, delegate, clearAfter: clearAfter ?? null}; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { - creator, - delegate, + ...(shouldOverridePolicyDiffWarning ? pickedDelegate : {}), errors: null, + // Set on both paths: the 305 ships an errors payload in its onyxData, and getVacationDelegateErrors only hides it while this is set pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousDelegate: currentDelegate ?? null, + previousClearAfter: currentClearAfter ?? null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -54,11 +65,14 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { + ...(shouldOverridePolicyDiffWarning ? {} : pickedDelegate), errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -77,6 +91,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv const parameters: SetVacationDelegateParams = { creator, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, }; @@ -107,7 +122,9 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, delegate: currentDelegate ?? null, + clearAfter: currentClearAfter ?? null, pendingDelegate: delegate, + pendingClearAfter: clearAfter ?? null, policyDiff: response.data.policyDiff, pendingAction: null, }); @@ -123,7 +140,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { return; } - const {creator, delegate} = vacationDelegate; + const {creator, delegate, clearAfter} = vacationDelegate; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -131,11 +148,14 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator: null, delegate: null, + clearAfter: null, errors: null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: clearAfter ?? null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -148,6 +168,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, }, }, ]; @@ -159,23 +180,34 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator, delegate, + clearAfter, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), pendingAction: null, }, }, ]; - API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, null, {optimisticData, successData, failureData}); + API.write( + WRITE_COMMANDS.DELETE_VACATION_DELEGATE, + null, + {optimisticData, successData, failureData}, + { + checkAndFixConflictingRequest: (persistedRequests) => resolveVacationDelegateDeletionConflicts(persistedRequests), + }, + ); } -function clearVacationDelegateError(previousDelegate?: string) { +function clearVacationDelegateError(previousDelegate?: string, previousClearAfter?: string) { Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, pendingAction: null, delegate: previousDelegate ?? null, + clearAfter: previousClearAfter ?? null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }); } diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 0a3b0fc0c97d..0a50edcdc43e 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -1,6 +1,6 @@ import {translate} from '@libs/Localize'; import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {config as linkingConfig} from '@libs/Navigation/linkingConfig/config'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {openApp} from '@userActions/App'; @@ -124,7 +124,7 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI isAccountValidated, } = getOnboardingInitialPathParams; const initialPath = onboardingInitialPath ?? ''; - const state = getStateFromPath(initialPath, linkingConfig.config); + const state = getStateFromPath(initialPath, linkingConfig); const currentOnboardingValues = onboardingValuesParam ?? onboardingValues; const isVsb = currentOnboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; const isSmb = currentOnboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; @@ -144,8 +144,11 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI CONST.ONBOARDING_CHOICES.TRACK_PERSONAL, ]); } + // Preserve onboarding progress after a direct add while the public domain flag is stale. + const shouldResumeOnboardingAfterDirectAdd = currentOnboardingValues?.shouldValidate === false && state?.routes?.at(-1)?.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR; + // A validated account has no reason to be on the onboarding "add work email" screen. - if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { + if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated && !shouldResumeOnboardingAfterDirectAdd) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index ef7a69b3499f..de53ddb61c37 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -108,8 +108,13 @@ function updateOnboardingValuesAndNavigation(onboardingValues: Onboarding | unde }); } -async function clearOnboardingShouldValidate(onboardingValues: Onboarding | undefined) { - await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {...onboardingValues, shouldValidate: undefined}); +async function resetOnboardingWorkEmailValidation(onboardingValues: Onboarding | undefined) { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, { + ...onboardingValues, + shouldValidate: undefined, + isMergeAccountStepCompleted: undefined, + isMergeAccountStepSkipped: undefined, + }); } function setOnboardingShouldValidate(shouldValidate: boolean) { @@ -312,7 +317,7 @@ export { createJoinWorkspaceOnboardingContent, setOnboardingShouldValidate, updateOnboardingValuesAndNavigation, - clearOnboardingShouldValidate, + resetOnboardingWorkEmailValidation, setOnboardingUserReportedIntegration, setOnboardingAccountingEnabled, setOnboardingInterestedFeaturesMap, diff --git a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts index bcb75c6b7944..35a9992dd4a2 100644 --- a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts +++ b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts @@ -27,7 +27,12 @@ import Onyx from 'react-native-onyx'; * rewrites the queued requests themselves. */ -const AGENT_SETTINGS_SCREENS = new Set([SCREENS.SETTINGS.AGENTS.EDIT, SCREENS.SETTINGS.AGENTS.EDIT_NAME, SCREENS.SETTINGS.AGENTS.EDIT_PROMPT, SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]); +const AGENT_SETTINGS_SCREENS = new Set([ + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR, +]); // Reports are only read inside the mapping callback, so connectWithoutView() is used. On app start the mapping can // arrive before this collection is hydrated, so consumers wait for this promise. Onyx always fires the callback at @@ -119,7 +124,7 @@ function replaceOptimisticAgentWithActualAgent(optimisticAccountID: number, real remapReportParticipants(optimisticAccountID, realAccountID); Onyx.update([buildPersonalDetailsUpdate({[optimisticAccountID]: null})]); - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, null); Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[optimisticAccountID]: null}); }, }); diff --git a/src/libs/telemetry/cancelTabNavigationSpans.ts b/src/libs/telemetry/cancelTabNavigationSpans.ts index 364a0cf2ab0e..604131421a1c 100644 --- a/src/libs/telemetry/cancelTabNavigationSpans.ts +++ b/src/libs/telemetry/cancelTabNavigationSpans.ts @@ -1,5 +1,9 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; + import CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import {cancelSpan} from './activeSpans'; // Spans grouped by the tab they belong to. The Reports tab owns three (legacy plus the FirstPaint/ContentLoad @@ -11,6 +15,10 @@ const REPORTS_TAB_SPAN_IDS: readonly string[] = [ ]; const INBOX_TAB_SPAN_IDS: readonly string[] = [CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB]; const TAB_NAVIGATION_SPAN_IDS: readonly string[] = [...REPORTS_TAB_SPAN_IDS, ...INBOX_TAB_SPAN_IDS]; +const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { + [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, + [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, +}; /** * Cancels running tab-navigation spans so an abandoned tap does not leave one ticking until the user returns. @@ -26,4 +34,4 @@ function cancelTabNavigationSpans(preserve: readonly string[] = []) { } export default cancelTabNavigationSpans; -export {REPORTS_TAB_SPAN_IDS, INBOX_TAB_SPAN_IDS, TAB_NAVIGATION_SPAN_IDS}; +export {TAB_NAVIGATION_SPAN_IDS, NAVIGATION_TAB_TO_SPANS}; diff --git a/src/libs/telemetry/startTabNavigationSpans.ts b/src/libs/telemetry/startTabNavigationSpans.ts new file mode 100644 index 000000000000..59817a74b9ba --- /dev/null +++ b/src/libs/telemetry/startTabNavigationSpans.ts @@ -0,0 +1,37 @@ +import {isReportsTabPreloaded} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import navigationRef from '@navigation/navigationRef'; + +import CONST from '@src/CONST'; + +import {startSpan} from './activeSpans'; +import {startNavigateToReportsSpans} from './navigateToReportsSpans'; + +/** + * Starts ManualNavigateToInboxTab at the tab tap. Call it before the tab navigation is dispatched, because the + * preloaded state it records is dropped once the tab is jumped to. + */ +function startNavigateToInboxTabSpan({isWideLayout}: {isWideLayout: boolean}) { + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + forceTransaction: true, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_WIDE_LAYOUT]: isWideLayout, + [CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]: isReportsTabPreloaded(navigationRef.getRootState()), + [CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT]: false, + }, + }); +} + +/** Starts the legacy ManualNavigateToReports span and its FirstPaint/ContentLoad split at the tab tap. */ +function startNavigateToReportsTabSpans() { + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, + forceTransaction: true, + }); + startNavigateToReportsSpans(); +} + +export {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans}; diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index b9ce3a383a96..f484c12c8b6d 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -1,4 +1,5 @@ import AddExistingExpenseFooter from '@components/AddExistingExpenseFooter'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -15,6 +16,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; @@ -69,6 +71,7 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const [allOpenReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: openExpenseReportIDsSelector}); const [openReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT, {selector: openExpenseReportIDsSelector}); const isInLandscapeMode = useIsInLandscapeMode(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const transactionsSelector = useCallback( @@ -199,10 +202,12 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { testID="NewChatSelectorPage" focusTrapSettings={{active: false}} > - + + + + {paginationFooterContent} + {footerContent} + + ) : ( + paginationFooterContent + ) + } + // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. + shouldFooterRenderAsLastRow={!!paginationFooterContent} /> - {footerContent} + {!shouldFooterBeInsideList && footerContent} ); } diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 6e71630490af..f6745787676a 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {useFocusEffect} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -33,7 +33,9 @@ function ConciergePage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; useFocusEffect( useCallback(() => { @@ -48,6 +50,7 @@ function ConciergePage() { introSelected, currentUserAccountID: session.accountID ?? CONST.DEFAULT_NUMBER_ID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true, checkIfCurrentPageActive: () => !isUnmounted.current, @@ -56,7 +59,7 @@ function ConciergePage() { } else { Navigation.navigate(ROUTES.INBOX); } - }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, conciergePersonalDetails]), + }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails]), ); useEffect(() => { diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 9dadbf096997..fcb48b715e4d 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -8,6 +8,7 @@ import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; @@ -73,6 +74,7 @@ function DebugReportActionCreatePage({ const [draftReportAction, setDraftReportAction] = useState(() => getInitialReportAction(reportID, session, currentUserPersonalDetail)); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); const reportAction = parseReportActionJSON(draftReportAction); @@ -142,6 +144,7 @@ function DebugReportActionCreatePage({ shouldDisplayNewMarker={false} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} + reportsParentHierarchy={reportsParentHierarchy} /> ) : ( {translate('debug.nothingToPreview')} diff --git a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx index ea59af7c6344..235702973b60 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx @@ -1,6 +1,7 @@ import ScrollView from '@components/ScrollView'; import useOnyx from '@hooks/useOnyx'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -25,6 +26,7 @@ function DebugReportActionPreview({reportAction, reportID}: DebugReportActionPre const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); return ( @@ -38,6 +40,7 @@ function DebugReportActionPreview({reportAction, reportID}: DebugReportActionPre shouldDisplayNewMarker={false} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} + reportsParentHierarchy={reportsParentHierarchy} /> ); diff --git a/src/pages/DynamicFlagCommentPage.tsx b/src/pages/DynamicFlagCommentPage.tsx index 234be63bfba6..c39ca57cf150 100644 --- a/src/pages/DynamicFlagCommentPage.tsx +++ b/src/pages/DynamicFlagCommentPage.tsx @@ -124,7 +124,7 @@ function DynamicFlagCommentPage({parentReportAction, report, parentReport, repor const flagComment = (severity: Severity) => { if (reportAction && canFlagReportAction(reportAction, reportID, currentUserAccountID)) { - flagCommentUtil(reportAction, severity, originalReport, isOriginalReportArchived); + flagCommentUtil(reportAction, severity, originalReport, isOriginalReportArchived, currentUserAccountID); } if (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0) { diff --git a/src/pages/DynamicRoomMemberDetailsPage.tsx b/src/pages/DynamicRoomMemberDetailsPage.tsx index bb0261e09fd0..caf5fe7634a8 100644 --- a/src/pages/DynamicRoomMemberDetailsPage.tsx +++ b/src/pages/DynamicRoomMemberDetailsPage.tsx @@ -11,6 +11,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -22,11 +23,12 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {isRoomMemberProtectedByPolicyRole} from '@libs/PolicyUtils'; -import {isPolicyExpenseChat} from '@libs/ReportUtils'; +import {isPolicyExpenseChat, isThreadMemberProtectedByParentReport} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetails} from '@src/types/onyx'; @@ -62,7 +64,12 @@ function DynamicRoomMemberDetailsPage({report, route}: DynamicRoomMemberDetailsP // Check the selected member's own role on the policy, not the viewer's, and fail closed when their login is missing. // Kept in sync with the members list through the shared helper. const isSelectedMemberProtectedByPolicyRole = isRoomMemberProtectedByPolicyRole(policy, details.login, accountID); - const shouldDisableRemoveUser = (isPolicyExpenseChat(report) && isSelectedMemberProtectedByPolicyRole) || isSelectedMemberCurrentUser || isSelectedMemberOwner; + // Select only the derived boolean so this page doesn't re-render on every unrelated change to the parent report. + const [isSelectedMemberProtectedByParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`, { + selector: (parentReport) => isThreadMemberProtectedByParentReport(parentReport, accountID), + }); + const shouldDisableRemoveUser = + (isPolicyExpenseChat(report) && isSelectedMemberProtectedByPolicyRole) || isSelectedMemberCurrentUser || isSelectedMemberOwner || !!isSelectedMemberProtectedByParentReport; const askForConfirmationToRemove = () => { showConfirmModal({ buttonVariant: CONST.BUTTON_VARIANT.DANGER, diff --git a/src/pages/DynamicRoomMembersPage.tsx b/src/pages/DynamicRoomMembersPage.tsx index c2d72666f3cb..cca064eed4b1 100644 --- a/src/pages/DynamicRoomMembersPage.tsx +++ b/src/pages/DynamicRoomMembersPage.tsx @@ -45,6 +45,7 @@ import { isChatThread, isDefaultRoom, isPolicyExpenseChat as isPolicyExpenseChatUtils, + isThreadMemberProtectedByParentReport, isUserCreatedPolicyRoom, } from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; @@ -223,7 +224,8 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { (isPolicyExpenseChat && isProtectedByPolicyRole) || accountID === session?.accountID || pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || - details.accountID === report.ownerAccountID; + details.accountID === report.ownerAccountID || + isThreadMemberProtectedByParentReport(parentReport, accountID); result.push({ keyForList: String(accountID), @@ -250,6 +252,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { isPolicyExpenseChat, localeCompare, openRoomMemberDetails, + parentReport, participants, personalDetails, policy, diff --git a/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts b/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts index 5c266c0cb7c3..a77744673f13 100644 --- a/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts +++ b/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts @@ -4,6 +4,7 @@ import type {UpdatePersonalDetailsForWalletParams} from '@libs/API/parameters'; import Navigation from '@libs/Navigation/Navigation'; import {parsePhoneNumber} from '@libs/PhoneNumber'; +import {setDraftValues} from '@userActions/FormActions'; import {updatePersonalDetails} from '@userActions/Wallet'; import CONST from '@src/CONST'; @@ -32,6 +33,19 @@ function useWalletPhoneValidateCode(getValidateCodeRoute: () => Route = ROUTES.S const normalizedStoredPhoneNumber = (storedPhoneNumber && parsePhoneNumber(storedPhoneNumber, {regionCode: CONST.COUNTRY.US}).number?.significant) ?? ''; const isSettingPhoneNumber = !!personalDetails.phoneNumber && personalDetails.phoneNumber !== normalizedStoredPhoneNumber; if (isSettingPhoneNumber) { + // The validateCode screen rebuilds the details from the form draft, which only holds the fields the user + // edited. Save everything being submitted so pre-filled values (e.g. the legal name) are not sent empty. + setDraftValues(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS, { + legalFirstName: personalDetails.legalFirstName, + legalLastName: personalDetails.legalLastName, + addressStreet: personalDetails.addressStreet, + addressCity: personalDetails.addressCity, + addressState: personalDetails.addressState, + addressZipCode: personalDetails.addressZip, + dob: personalDetails.dob, + ssn: personalDetails.ssn, + phoneNumber: personalDetails.phoneNumber, + }); Navigation.navigate(getValidateCodeRoute()); return; } diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index f4673a15d8b0..4fcc46f8a3aa 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,7 +1,8 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -15,6 +16,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -66,6 +68,13 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); + const triggerScrollEvent = useScrollEventEmitter(); + const isEmptyState = state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY; + const scrollProps = useTabRootScrollProps(isEmptyState ? [styles.flexGrow1, styles.flexShrink0] : [styles.flexGrow1, pageGutter, styles.pb5], { + hasBottomSafeAreaPadding: true, + hasOfflineIndicatorPadding: true, + fillsVisibleArea: isEmptyState, + }); const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; @@ -107,10 +116,10 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte ); } - if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY) { + if (isEmptyState) { return ( {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } @@ -124,7 +133,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte return ( @@ -163,6 +173,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const {translate} = useLocalize(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INSIGHTS); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {login} = useCurrentUserPersonalDetails(); @@ -211,7 +222,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { } + {...tabRootScreenWrapperProps} testID="InsightsPage" > - ) : null; + const groupByOptions = getInsightsGroupByOptions(filters.date); + + const groupByControl = + onGroupByChange && groupByOptions.length > 1 ? ( + + ) : null; const headerMenu = !!queryJSON && (state === INSIGHTS_CHART_STATE.READY || isLoading) ? ( diff --git a/src/pages/Insights/controls/InsightsGroupByDropdown.tsx b/src/pages/Insights/controls/InsightsGroupByDropdown.tsx index cdab886e72b7..3f60916ea578 100644 --- a/src/pages/Insights/controls/InsightsGroupByDropdown.tsx +++ b/src/pages/Insights/controls/InsightsGroupByDropdown.tsx @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; -import DEFAULT_INSIGHTS_FILTERS, {INSIGHTS_GROUP_BY_OPTIONS} from '@pages/Insights/insightsFilters'; +import DEFAULT_INSIGHTS_FILTERS from '@pages/Insights/insightsFilters'; import variables from '@styles/variables'; @@ -21,14 +21,17 @@ type InsightsGroupByDropdownProps = { /** Time bucket the headline chart aggregates into */ groupBy: InsightsFilters['groupBy']; + /** Options that fit the date range */ + options: Array; + onChange: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsGroupByDropdown({groupBy, onChange}: InsightsGroupByDropdownProps) { +function InsightsGroupByDropdown({groupBy, options, onChange}: InsightsGroupByDropdownProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const items: Array> = INSIGHTS_GROUP_BY_OPTIONS.map((option) => ({ + const items: Array> = options.map((option) => ({ text: translate(`search.filters.groupBy.${option}`), value: option, })); diff --git a/src/pages/Insights/insightsGroupByUtils.ts b/src/pages/Insights/insightsGroupByUtils.ts new file mode 100644 index 000000000000..f34a944b3a2d --- /dev/null +++ b/src/pages/Insights/insightsGroupByUtils.ts @@ -0,0 +1,66 @@ +import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import {addDays, differenceInCalendarDays, differenceInMonths, parseISO} from 'date-fns'; + +import type {InsightsFilters} from './insightsFilters'; + +import {INSIGHTS_GROUP_BY_OPTIONS} from './insightsFilters'; + +type InsightsGroupBy = InsightsFilters['groupBy']; + +const MAX_DAYS_GROUPED_BY_DAY = 62; +const MIN_DAYS_GROUPED_BY_WEEK = 14; +const MIN_MONTHS_BY_GROUP_BY = { + [CONST.SEARCH.GROUP_BY.MONTH]: 2, + [CONST.SEARCH.GROUP_BY.QUARTER]: 6, + [CONST.SEARCH.GROUP_BY.YEAR]: 24, +} as const; + +/** Returns the Group by options that fit the date range, in the dropdown's order */ +function getInsightsGroupByOptions(date: InsightsFilters['date']): InsightsGroupBy[] { + if ('on' in date) { + return [CONST.SEARCH.GROUP_BY.DAY]; + } + + const boundaries = 'from' in date ? {start: date.from, end: date.to} : getDateRangeForPreset(date.preset); + + // Presets like "never" have no fixed range, so there's no length to fit a grouping to + if (!boundaries.start || !boundaries.end) { + return []; + } + + const start = parseISO(boundaries.start); + const end = parseISO(boundaries.end); + const days = differenceInCalendarDays(end, start) + 1; + const months = differenceInMonths(addDays(end, 1), start); + + return INSIGHTS_GROUP_BY_OPTIONS.filter((option) => { + switch (option) { + case CONST.SEARCH.GROUP_BY.DAY: + return days <= MAX_DAYS_GROUPED_BY_DAY; + case CONST.SEARCH.GROUP_BY.WEEK: + return days >= MIN_DAYS_GROUPED_BY_WEEK; + default: + return months >= MIN_MONTHS_BY_GROUP_BY[option]; + } + }); +} + +/** Keeps the Group by when the options include it, otherwise picks the nearest option they do include. */ +function getFittingGroupBy(groupBy: InsightsGroupBy, options: InsightsGroupBy[]): InsightsGroupBy { + if (options.length === 0 || options.includes(groupBy)) { + return groupBy; + } + + const getDistanceFromSelection = (option: InsightsGroupBy) => Math.abs(INSIGHTS_GROUP_BY_OPTIONS.indexOf(option) - INSIGHTS_GROUP_BY_OPTIONS.indexOf(groupBy)); + return options.reduce((nearest, option) => (getDistanceFromSelection(option) < getDistanceFromSelection(nearest) ? option : nearest)); +} + +/** Returns the Group by the charts are queried with, fitted to the date range. */ +function getQueryGroupBy(filters: InsightsFilters): InsightsGroupBy { + return getFittingGroupBy(filters.groupBy, getInsightsGroupByOptions(filters.date)); +} + +export {getFittingGroupBy, getInsightsGroupByOptions, getQueryGroupBy}; diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index fe61577ac23b..5711c937ff1a 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -10,6 +10,7 @@ import type {InsightsChartSpec} from './dashboardSpecs'; import type {InsightsFilters} from './insightsFilters'; import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; +import {getQueryGroupBy} from './insightsGroupByUtils'; /** How many periods before the selected date range the Average mode spans (Typical on the ranking charts) */ const COMPARE_TYPICAL_PERIOD_COUNT = 1; @@ -42,7 +43,7 @@ function buildInsightsQueryString(filters: InsightsFilters): SearchQueryString { /** Builds a chart's query with the page's filters applied. */ function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode): SearchQueryString { return buildQueryStringFromFilterFormValues( - {...buildFilterFormValues(filters), groupBy: chart.groupBy ?? filters.groupBy, view: chart.view, compare}, + {...buildFilterFormValues(filters), groupBy: chart.groupBy ?? getQueryGroupBy(filters), view: chart.view, compare}, {sortBy: chart.sortBy, sortOrder: chart.sortOrder, limit: chart.limit}, ); } @@ -94,8 +95,9 @@ type InsightsQuery = { /** Builds one request for the whole dashboard, naming each graph's snapshots by its chart's query hashes, and returns those chart queries to read the snapshots back with. */ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: InsightsFilters, shouldIncludeComparisons: boolean): InsightsQuery | undefined { + // The backend saves inputQuery as the dashboard's filters, so it keeps the Group by the user picked, not the one fitted to the date range const inputQuery = buildInsightsQueryString({...filters, compare: undefined}); - const queryJSON = buildSearchQueryJSON(inputQuery); + const queryJSON = buildSearchQueryJSON(buildInsightsQueryString({...filters, groupBy: getQueryGroupBy(filters), compare: undefined})); if (!queryJSON) { return undefined; } diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index fda0d3c4b487..afb793632f04 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -108,13 +108,15 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard }, [email]); const handleBackButtonPress = useCallback(() => { - if (onboardingValues?.shouldValidate === false) { + const backTo = route.params?.backTo as Route | undefined; + // When this screen was opened directly from the intent list (backTo is the purpose route), go straight + // back there instead of running the work-email restore path, which would insert a work-email screen. + if (onboardingValues?.shouldValidate === false && backTo !== ROUTES.ONBOARDING_PURPOSE.getRoute()) { updateOnboardingValuesAndNavigation(onboardingValues); return; } - const routeToNavigate = (route.params?.backTo as Route) ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(); - Navigation.goBack(routeToNavigate); + Navigation.goBack(backTo ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()); }, [route.params?.backTo, onboardingValues]); const navigateToNextOnboardingStep = useCallback( diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 40858a9131c5..bd66c16d645a 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -32,6 +32,7 @@ import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage, setOnboar import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/OnboardingWorkEmailForm'; import type {OnboardingPurpose} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -86,6 +87,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); const [adminsChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${onboardingAdminsChatReportID}`); const [personalDetailsForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); + const [workEmailForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); + const workEmail = workEmailForm?.[INPUT_IDS.ONBOARDING_WORK_EMAIL]; const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -220,6 +223,17 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const onboardingLocalRef = useRef(null); useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]); + const getErrorMessage = () => { + if (!onboardingErrorMessage) { + return undefined; + } + // These merge-block messages interpolate the work email, so translate them with the email explicitly. + if (onboardingErrorMessage === 'onboarding.mergeBlockScreen.domainControlledSubtitle' || onboardingErrorMessage === 'onboarding.mergeBlockScreen.validatedPublicDomainSubtitle') { + return translate(onboardingErrorMessage, workEmail); + } + return translate(onboardingErrorMessage); + }; + if (isLoadingOnyxValue(onboardingErrorMessageResult)) { return null; } @@ -250,7 +264,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro - + ); diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 15b314007c43..d36a5e183e81 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -11,6 +11,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -32,8 +33,8 @@ import {AddWorkEmail} from '@userActions/Session'; import { addWorkEmailFormError, clearOnboardingMergeAccountBlocked, - clearOnboardingShouldValidate, clearWorkEmailFormErrors, + resetOnboardingWorkEmailValidation, setOnboardingErrorMessage, setOnboardingMergeAccountStepValue, setOnboardingMergingAccountBlocked, @@ -242,12 +243,13 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW // AddWorkEmail already made this the account's login, and resending it would merge the account into itself. if (sessionEmail && submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase()) { + setHasSubmittedWorkEmail(true); setOnboardingShouldValidate(false); return; } // The form's onSubmit must stay synchronous, so chain off the Onyx write instead of awaiting it. - clearOnboardingShouldValidate(onboardingValues).then(() => { + resetOnboardingWorkEmailValidation(onboardingValues).then(() => { setHasSubmittedWorkEmail(true); AddWorkEmail(submittedWorkEmail, addWorkEmailTaskReport); }); @@ -255,14 +257,19 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW [addWorkEmailTaskReport, isConciergeTaskFlow, isCurrentPrimaryValidated, onboardingValues, originReportID, sessionEmail], ); + const isBackButtonMessage = onboardingErrorMessageTranslationKey === 'onboarding.purpose.errorBackButton'; + + // The back button message is about leaving onboarding, not about the email, so it never becomes a field error and never blocks the submit + const workEmailErrorTranslationKey = onboardingErrorMessageTranslationKey && !isBackButtonMessage ? onboardingErrorMessageTranslationKey : undefined; + useEffect(() => { - if (!onboardingErrorMessageTranslationKey) { + if (!workEmailErrorTranslationKey) { clearWorkEmailFormErrors(); return; } - addWorkEmailFormError(translate(onboardingErrorMessageTranslationKey)); - }, [onboardingErrorMessageTranslationKey, translate]); + addWorkEmailFormError(translate(workEmailErrorTranslationKey)); + }, [workEmailErrorTranslationKey, translate]); const clearOnboardingErrorMessage = useCallback(() => { if (!onboardingErrorMessageTranslationKey) { @@ -271,6 +278,13 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW setOnboardingErrorMessage(null); }, [onboardingErrorMessageTranslationKey]); + const clearBackButtonMessage = () => { + if (!isBackButtonMessage) { + return; + } + setOnboardingErrorMessage(null); + }; + const handleRegularJoinWorkspaceErrorConfirm = useCallback(() => { setOnboardingErrorMessage(null); setOnboardingMergingAccountBlocked(false); @@ -291,6 +305,31 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW handleMergingAccountBlockedConfirm = handleRegularJoinWorkspaceErrorConfirm; } + // This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. + // The intent stays saved across a cold start, so check it instead of the route index. + const canGoBackToPurpose = isJoiningCompanyWorkspace && !isConciergeTaskFlow; + + const goBackToPurpose = () => { + // Clear any work email error so the purpose screen doesn't render it without its translation params. + clearOnboardingMergeAccountBlocked(); + // Pops to the intent list when it's in the stack, or replaces this screen with it, so the user stays in onboarding. + Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); + }; + + const handleAndroidBackPress = () => { + if (canGoBackToPurpose) { + goBackToPurpose(); + return true; + } + + // Otherwise there is no earlier step, so keep the press from closing onboarding and tell the user to finish setup. + setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + return true; + }; + + // Android's system back runs the same logic as the header back button. + useAndroidBackButtonHandler(handleAndroidBackPress); + const shouldRenderOfflineFeedback = useCallback((errorTranslation: string) => { if ( errorTranslation !== 'onboarding.workEmail2FAError' && @@ -359,10 +398,9 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW testID="BaseOnboardingWorkEmail" style={[styles.defaultModalContainer, shouldUseNativeStyles && styles.pt8]} > - {/* This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. */} Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute())} + shouldShowBackButton={canGoBackToPurpose} + onBackButtonPress={goBackToPurpose} shouldShowCloseButton={isConciergeTaskFlow} onCloseButtonPress={onboardingValues?.isMergingAccountBlocked ? handleConciergeTaskErrorConfirm : returnToOriginReport} /> @@ -379,6 +417,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW style={[styles.flexGrow1, onboardingIsMediumOrLargerScreenWidth && styles.mt5, onboardingIsMediumOrLargerScreenWidth ? styles.mh8 : styles.mh5]} formID={ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM} validate={validate} + onBeforeSubmit={clearBackButtonMessage} onSubmit={submitWorkEmail} submitButtonText={translate('onboarding.workEmail.addWorkEmail')} enabledWhenOffline diff --git a/src/pages/OnboardingWorkEmail/index.native.tsx b/src/pages/OnboardingWorkEmail/index.native.tsx index b43a629125a2..bec1f099003b 100644 --- a/src/pages/OnboardingWorkEmail/index.native.tsx +++ b/src/pages/OnboardingWorkEmail/index.native.tsx @@ -1,28 +1,10 @@ -import {setOnboardingErrorMessage} from '@userActions/Welcome'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback} from 'react'; -import {BackHandler} from 'react-native'; +import React from 'react'; import type {OnboardingWorkEmailProps} from './types'; import BaseOnboardingWorkEmail from './BaseOnboardingWorkEmail'; function OnboardingWorkEmail(props: OnboardingWorkEmailProps) { - // To block android native back button behavior - useFocusEffect( - useCallback(() => { - // Return true to indicate that the back button press is handled here - const backAction = () => { - setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - return true; - }; - - const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); - - return () => backHandler.remove(); - }, []), - ); return ( ) : null} {canManageMemberDisplayName ? ( - + Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID))} + onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.getRoute()))} /> )} diff --git a/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx b/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx index 73b365b6a17f..c106ec09a96b 100644 --- a/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx @@ -22,7 +22,7 @@ import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -37,14 +37,16 @@ function Finish() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); const handleBackButtonPress = () => { Navigation.dismissModal(); }; const handleNavigateToConciergeChat = () => - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: true}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true}); return ( { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: true}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true}); }; const bankAccountState = reimbursementAccount?.achData?.state ?? ''; const pendingAction = reimbursementAccount?.pendingAction; diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx index 22dcdc007160..33af33cfe3fc 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx @@ -24,7 +24,7 @@ import type {Policy, ReimbursementAccount} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -53,7 +53,9 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); useResetBankAccountModal({ @@ -68,6 +70,7 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true, reportActionID: reimbursementAccount?.achData?.ACHRequestReportActionID, diff --git a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx index ffa2ace762f7..02f93d6744c1 100644 --- a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx +++ b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx @@ -24,7 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {FileObject} from '@src/types/utils/Attachment'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; @@ -58,7 +58,9 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const reimbursementAccountVerificationData = reimbursementAccount?.achData?.verifications?.externalApiResponses; const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID; @@ -70,6 +72,7 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true, reportActionID: reimbursementAccount?.achData?.ACHRequestReportActionID, diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 72717f655a14..0ea285962af0 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,6 +1,7 @@ import BookTravelButton from '@components/BookTravelButton'; import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; import type {EmptyStateButton, HeaderMedia} from '@components/EmptyStateComponent/types'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; @@ -87,7 +88,7 @@ function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, violat const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(allPolicies); + const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(allPolicies, currentUserPersonalDetails?.login); const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, @@ -139,6 +140,7 @@ function EmptySearchViewContent({ const timezone = useCurrentTimezone(); const styles = useThemeStyles(); const isInLandscapeMode = useIsInLandscapeMode(); + const tabRootScrollProps = useTabRootScrollProps([styles.flexGrow1, styles.flexShrink0, contentContainerStyle], {fillsVisibleArea: true}); const illustrations = useMemoizedLazyIllustrations(['EmptyStateTravel']); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); @@ -421,7 +423,7 @@ function EmptySearchViewContent({ return ( diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx new file mode 100644 index 000000000000..d49a1e36d85a --- /dev/null +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx @@ -0,0 +1,23 @@ +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import type {MerchantFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +function MerchantFilterContentPageWrapper({baseFilterKey, value, isNegated, merchantOperator, buttonText, onChange}: MerchantFilterContentWrapperProps) { + return ( + + ); +} + +export default MerchantFilterContentPageWrapper; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 31e9652459fa..d93abad75c4b 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -33,6 +33,7 @@ import {View} from 'react-native'; import AmountFilterContentPageWrapper from './AmountFilterContentPageWrapper'; import DateFilterContentPageWrapper from './DateFilterContentPageWrapper'; import ListFilterContentPageWrapper from './ListFilterContentPageWrapper'; +import MerchantFilterContentPageWrapper from './MerchantFilterContentPageWrapper'; import ReportFieldFilterContentPageWrapper from './ReportFieldFilterContentPageWrapper'; import TextInputFilterContentPageWrapper from './TextInputFilterContentPageWrapper'; @@ -115,6 +116,7 @@ function SearchAdvancedFiltersContentBase() { ready={didScreenTransitionEnd} components={{ List: ListFilterContentPageWrapper, + Merchant: MerchantFilterContentPageWrapper, Text: TextInputFilterContentPageWrapper, Amount: AmountFilterContentPageWrapper, Date: DateFilterContentPageWrapper, diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index f13130a0df52..01703b4be243 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -109,6 +109,7 @@ function SearchColumnsPage() { const queryString = buildQueryStringFromFilterFormValues(updatedAdvancedFilters, { sortBy: currentQueryJSON?.sortBy, sortOrder: currentQueryJSON?.sortOrder, + flatFilters: currentQueryJSON?.flatFilters, }); // Only the columns change, so it's still the same search - carry the key over rather than letting it be diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 88d4ef719ed9..6174ee24dfca 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -12,6 +12,8 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useLoadPolicyCategories from '@hooks/useLoadPolicyCategories'; +import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -172,6 +174,9 @@ function SearchEditMultiplePage() { const areTagsEnabled = areSelectedTransactionsExpenses && !!policy?.areTagsEnabled && hasEnabledTags(policyTagLists); const areAttendeesEnabled = areSelectedTransactionsExpenses && isAttendeeTrackingEnabledForSelection; + useLoadPolicyCategories(policy?.areCategoriesEnabled ? policyID : undefined); + useLoadPolicyTags(policy?.areTagsEnabled ? policyID : undefined); + useEffect(() => { return () => { clearBulkEditDraftTransaction(); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f9fcd25fa461..0b56b4ff15a8 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -6,6 +6,7 @@ import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/Playbac import useDocumentTitle from '@hooks/useDocumentTitle'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; import useHasFilterBars from '@hooks/useHasFilterBars'; +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import {PaymentContextProvider} from '@hooks/usePaymentContext'; @@ -25,6 +26,7 @@ import {isQueryARefinement} from '@libs/SearchQueryRefinement'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import type SCREENS from '@src/SCREENS'; import type {SearchResults} from '@src/types/onyx'; @@ -51,7 +53,10 @@ function SearchPage({route}: SearchPageProps) { const [lastNonEmptySearchResults, setLastNonEmptySearchResults] = useState(undefined); - useSearchPageSetup(currentSearchQueryJSON); + // Until Spend is first shown, it neither requests a search nor saves its default query over the latest search that the + // Spend tab reopens. + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + useSearchPageSetup(hasSpendBeenShown ? currentSearchQueryJSON : undefined); useSeedMyExpensesSearch(); useReleaseOptionListCaches(); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 3092bb405f2a..2fde215162e1 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -3,7 +3,8 @@ import CollapsibleHeaderOnKeyboardGroup from '@components/CollapsibleHeaderOnKey import CollapsibleHeaderOnKeyboardGroupMember from '@components/CollapsibleHeaderOnKeyboard/GroupMember'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import PulsingView from '@components/PulsingView'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -15,6 +16,7 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -79,8 +81,6 @@ type SearchPageNarrowProps = { isOverlayActive: boolean; }; -const tabBarContent = ; - function SearchPageNarrow({ queryJSON, searchResults, @@ -103,6 +103,10 @@ function SearchPageNarrow({ const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); const {pageGutterMargin} = useLayoutSpacing(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SEARCH); + const tabBarCoveredHeight = useTabBarCoveredHeight(); + // The footer is shown and hidden by its own selection state, so its height is read from layout. + const [footerHeight, setFooterHeight] = useState(0); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {shouldUseLiveData} = useSearchResultsContext(); @@ -191,6 +195,8 @@ function SearchPageNarrow({ }); const [isInteractive, setIsInteractive] = useState(!useStaticRendering); const [isHeaderInteractive, setIsHeaderInteractive] = useState(!useStaticRendering); + // A page mounted in the background, as native tabs mount Spend, keeps its skeleton still until it is first shown. + const [hasBeenFocused, setHasBeenFocused] = useState(!useStaticRendering); const isHeaderInteractiveRef = useRef(isHeaderInteractive); const [, startTransition] = useTransition(); useEffect(() => { @@ -213,6 +219,7 @@ function SearchPageNarrow({ // useFocusEffect avoids the extra re-renders that useIsFocused causes on every focus change. useFocusEffect( useCallback(() => { + setHasBeenFocused(true); if (isInteractive) { return; } @@ -266,10 +273,9 @@ function SearchPageNarrow({ 0 ? styles.offlineIndicatorAboveFooter(footerHeight) : tabRootScreenWrapperProps.offlineIndicatorStyle]} > {!isMobileSelectionModeEnabled ? ( @@ -351,6 +357,12 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} + {!isInteractive && !searchOverlayContent && ( + + )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( )} - + setFooterHeight(event.nativeEvent.layout.height)} + > + + diff --git a/src/pages/Search/SearchReportsMergeReports.tsx b/src/pages/Search/SearchReportsMergeReports.tsx index 1f7fff4c4167..33cb628dbf47 100644 --- a/src/pages/Search/SearchReportsMergeReports.tsx +++ b/src/pages/Search/SearchReportsMergeReports.tsx @@ -223,7 +223,7 @@ function SearchMergeReports() { isLoadingOnyxData || selectedReportIDs?.some((reportID) => { const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; - return !allReports?.[reportKey] && !!currentSearchResults?.data[reportKey]; + return !allReports?.[reportKey] && !!currentSearchResults?.data?.[reportKey]; }); if (!isReportMergeBetaEnabled) { diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 17926abcaccc..8433679b766a 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -114,7 +114,7 @@ function SearchTransactionsChangeReport() { // Get the policyID from the selected transactions' report to pass to usePolicyForMovingExpenses // This ensures the "Create report" button shows the correct workspace instead of the user's default const selectedReportPolicyID = selectedReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`]?.policyID : undefined; - const {policyForMovingExpensesID, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses( + const {policyForMovingExpensesID, shouldSelectPolicy, shouldNavigateToUpgradePath, arePoliciesLoaded} = usePolicyForMovingExpenses( hasPerDiemTransactions, undefined, selectedReportPolicyID, @@ -234,6 +234,11 @@ function SearchTransactionsChangeReport() { setIsOfflineModalVisible(true); return; } + + if (!arePoliciesLoaded) { + return; + } + if (shouldNavigateToUpgradePath && selectedTransactionsKeys.length > 0) { const firstTransactionID = selectedTransactionsKeys.at(0); if (firstTransactionID) { diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 2f9cb6faf826..cf81b865c02b 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -58,6 +58,9 @@ type ShareCodePageOnyxProps = { /** The policy for the report currently being looked at */ policy?: OnyxEntry; + + /** Whether to show only the QR code, without the rows under it */ + shouldShowQRCodeOnly?: boolean; }; type ShareCodePageProps = ShareCodePageOnyxProps & BackToParams; @@ -98,7 +101,7 @@ function createAvatarQRLogo(LocalAvatarLogo: React.FC): React.FC - - Clipboard.setString(url)} - shouldLimitWidth={false} - /> - {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. - We shouldn't introduce platform specific code in our codebase. - This is a temporary solution while Web is not supported for the QR code download feature */} - {shouldAllowDownloadQRCode && ( - qrCodeRef.current?.download?.()}> - - - - - - {translate('common.download')} - - - - )} - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); - }} - /> - + {!shouldShowQRCodeOnly && ( + + Clipboard.setString(url)} + shouldLimitWidth={false} + /> + {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {shouldAllowDownloadQRCode && ( + qrCodeRef.current?.download?.()}> + + + + + + {translate('common.download')} + + + + )} + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); + }} + /> + + )} ); diff --git a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx index 77d345cb7f43..70118ea98532 100644 --- a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx @@ -91,6 +91,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReport?.reportID)}`); const [sourceReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(sourceTransaction?.reportID)}`); + const sourceReportTransactionsCollection = useReportTransactionsCollection(sourceTransaction?.reportID); const sourceIOUAction = sourceTransaction ? getIOUActionForTransactionID(Object.values(sourceReportActions ?? {}), sourceTransaction.transactionID) : undefined; const selfDMSourceIOUAction = selfDMReport?.reportID && sourceTransaction ? getIOUActionForTransactionID(Object.values(selfDMReportActions ?? {}), sourceTransaction.transactionID) : undefined; @@ -116,6 +117,8 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { // lingers in the stack and briefly flashes the "not found" page when the user taps back. Must be read pre-merge. const willDeleteTargetTransactionReport = Object.keys(targetReportTransactionsCollection ?? {}).length === 1; + const transactionsOfDeletableReportCollection = mergeTransaction.reportID === targetTransaction.reportID ? sourceReportTransactionsCollection : targetReportTransactionsCollection; + setIsMergingExpenses(true); mergeTransactionRequest({ @@ -126,6 +129,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { mergeTransaction, targetTransaction, sourceTransaction, + transactionsOfDeletableReportCollection, targetTransactionThreadReport, targetTransactionThreadParentReport, iouReportOwnerLogin, diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index 9760acfbb2dd..14c34dabd3b3 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -82,8 +82,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { [targetTransactionReport, sourceTransactionReport], targetTransactionPolicy, sourceTransactionPolicy, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ); setMergeTransactionKey(transactionID, mergeableData); @@ -98,8 +96,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { targetTransactionPolicy, sourceTransactionPolicy, getCurrencyDecimals, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ]); // Handle selection diff --git a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx index e4d2a4b7df9c..5415ec4e96f7 100644 --- a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx @@ -12,7 +12,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useMergeTransactions from '@hooks/useMergeTransactions'; import useOnyx from '@hooks/useOnyx'; -import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; import useThemeStyles from '@hooks/useThemeStyles'; import {setMergeTransactionKey} from '@libs/actions/MergeTransaction'; @@ -47,8 +46,6 @@ function DynamicReceiptReviewPage({route}: DynamicReceiptReviewPageProps) { const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); const {targetTransaction, sourceTransaction, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({mergeTransaction}); - const targetReportOwnerAsAttendee = useReportOwnerAsAttendee(targetTransaction); - const sourceReportOwnerAsAttendee = useReportOwnerAsAttendee(sourceTransaction); const transactions = [targetTransaction, sourceTransaction].filter((transaction): transaction is Transaction => !!transaction); @@ -69,8 +66,6 @@ function DynamicReceiptReviewPage({route}: DynamicReceiptReviewPageProps) { [], targetTransactionPolicy, sourceTransactionPolicy, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 6b541330b8b8..5953a67612c2 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -11,7 +11,6 @@ import useLocalize from '@hooks/useLocalize'; import useMergeTransactions from '@hooks/useMergeTransactions'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useReportTransactions from '@hooks/useReportTransactions'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -23,10 +22,10 @@ import {getAmount, getCreated, getCurrency, getDescription, getMerchant, isExpen import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Report} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -40,6 +39,23 @@ type MergeTransactionsListContentProps = { mergeTransaction: OnyxEntry; }; +const reportsSelector = (reports: OnyxCollection): OnyxCollection => + Object.fromEntries( + Object.entries(reports ?? {}).map(([key, report]) => [ + key, + report && { + reportID: report.reportID, + type: report.type, + policyID: report.policyID, + ownerAccountID: report.ownerAccountID, + managerID: report.managerID, + stateNum: report.stateNum, + statusNum: report.statusNum, + total: report.total, + }, + ]), + ) as OnyxCollection; + function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTransactionsListContentProps) { const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate, localeCompare} = useLocalize(); @@ -47,10 +63,31 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const [session] = useOnyx(ONYXKEYS.SESSION); - const [personalDetails] = useAllPersonalDetails(); const currentUserLogin = session?.email; const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const policiesSelector = (policies: OnyxCollection): OnyxCollection => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the fields used by the admin check are kept + Object.fromEntries( + Object.entries(policies ?? {}).map(([key, policy]) => { + const login = currentUserLogin ?? ''; + const employee = policy?.employeeList?.[login] ?? policy?.employeeList?.[login.toLowerCase()]; + return [ + key, + policy && { + id: policy.id, + role: policy.role, + employeeList: employee ? {[login]: employee} : undefined, + }, + ]; + }), + ) as OnyxCollection; + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: policiesSelector, + }); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: reportsSelector, + }); const {isOffline} = useNetwork(); const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -75,6 +112,8 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr report: targetTransactionReport, currentUserLogin, rules, + allPolicies, + allReports, }); }, [ transactions, @@ -86,6 +125,8 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr targetTransaction, targetReportTransactions, rules, + allPolicies, + allReports, ]); const data = !eligibleTransactions @@ -179,18 +220,10 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr } const reports = targetTransactionReport && sourceTransactionReport ? [targetTransactionReport, sourceTransactionReport] : undefined; - setupMergeTransactionDataAndNavigate( - transactionID, - [targetTransaction, sourceTransaction], - localeCompare, - getCurrencyDecimals, - reports, - true, - undefined, - [targetTransactionPolicy, sourceTransactionPolicy], - session?.accountID, - personalDetails, - ); + setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], localeCompare, getCurrencyDecimals, reports, true, undefined, [ + targetTransactionPolicy, + sourceTransactionPolicy, + ]); }; const confirmButtonOptions = { diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 64187a0751d4..a5686293daeb 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -2,7 +2,8 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -43,6 +44,8 @@ type DomainInitialPageProps = PlatformStackScreenProps findFocusedRoute(state)?.name); @@ -102,8 +105,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > Navigation.dismissModal()} @@ -121,7 +123,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { shouldDisplayAccountButton /> - + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 345e09535742..b012ff301f7b 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -22,6 +22,7 @@ import { closeUserAccount, setTwoFactorAuthExemptEmailForDomain, } from '@libs/actions/Domain'; +import {clearDraftValues} from '@libs/actions/FormActions'; import {requestUnlockAccount} from '@libs/actions/User'; import {getLatestError} from '@libs/ErrorUtils'; @@ -165,10 +166,14 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID))} + timezone={personalDetails?.timezone?.selected} + onPress={() => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); + }} pendingAction={domainPendingActions?.member?.[memberLogin]?.vacationDelegate} errors={getLatestError(domainErrors?.memberErrors?.[memberLogin]?.vacationDelegateErrors)} - onCloseError={() => clearVacationDelegateError(domainAccountID, accountID, memberLogin, vacationDelegate?.previousDelegate)} + onCloseError={() => clearVacationDelegateError(domainAccountID, accountID, memberLogin, vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} /> {!!domainSettings?.twoFactorAuthRequired && ( ; + +function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDelegateFormPageProps) { + const {domainAccountID, accountID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + + const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + selector: vacationDelegateSelector(accountID), + }); + + const [personalDetails] = usePersonalDetail(accountID); + const memberLogin = personalDetails?.login; + + const goBackToMemberDetails = () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + }; + + // Pops only this form, so a member picker opened straight from the member details page is shown again. + const goBack = () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(); + }; + + const onSubmit = (delegate: string, clearAfter: string | undefined) => { + if (!memberLogin) { + return; + } + + setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegate, vacationDelegate, clearAfter); + goBackToMemberDetails(); + }; + + const onRemove = () => { + if (!memberLogin || !vacationDelegate) { + return; + } + + deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); + goBackToMemberDetails(); + }; + + return ( + + + + {/* While the member is someone else's delegate, their own can't be changed, same as on the member picker */} + {vacationDelegate?.delegatorFor?.length ? ( + + + + ) : ( + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID))} + onSubmit={onSubmit} + onRemove={onRemove} + /> + )} + + + ); +} + +export default DomainMemberVacationDelegateFormPage; diff --git a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx index f86ddcef8c47..90e2ec7c8b05 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx @@ -6,34 +6,42 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import {setDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; +import {getActiveVacationDelegate} from '@libs/VacationDelegateUtils'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; -import {deleteDomainVacationDelegate, setDomainVacationDelegate} from '@userActions/Domain'; - import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; +import type {VacationDelegateForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; import type {Participant} from '@src/types/onyx/IOU'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useNavigationState} from '@react-navigation/native'; import {vacationDelegateSelector} from '@selectors/Domain'; import React from 'react'; -type DomainMemberVacationDelegatePageProps = PlatformStackScreenProps; +type DomainMemberVacationDelegatePageProps = PlatformStackScreenProps; + +const draftDelegateSelector = (draft: OnyxEntry) => draft?.[INPUT_IDS.DELEGATE]; function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegatePageProps) { const {domainAccountID, accountID} = route.params; const {translate} = useLocalize(); - const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: vacationDelegateSelector(accountID), }); + const [draftDelegate] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {selector: draftDelegateSelector}); + const isFormInStack = useNavigationState((state) => state.routes.some((stackRoute) => stackRoute.name === SCREENS.DOMAIN.VACATION_DELEGATE)); const [personalDetails] = usePersonalDetail(accountID); const memberLogin = personalDetails?.login; @@ -45,14 +53,16 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP return; } - if (delegateLogin === vacationDelegate?.delegate) { - deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + // The pick is only saved from the form, where the clear after date is chosen too. + setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: delegateLogin}); + + // Opened from the form's delegate row, so go back to it. Opened straight from the member details page, the form comes + // next, so push it and keep this picker under it for the form's back button. + if (isFormInStack) { + Navigation.goBack(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); return; } - - setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegateLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); }; return ( @@ -62,12 +72,16 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP testID="DomainMemberVacationDelegate" > Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID))} + onBackButtonPress={() => Navigation.goBack()} cannotSetDelegateMessage={translate('domain.members.cannotSetVacationDelegateForMember', memberLogin ?? '')} additionalExcludeLogins={memberLogin ? {[memberLogin]: true} : undefined} + // The member can't be their own delegate. The current user is included by default, and that would override the + // member exclusion above when an admin opens their own member page. + includeCurrentUser={memberLogin !== currentUserLogin} /> diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a09a8bb0a4f1..edfa2a4176ca 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,6 +1,7 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -13,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useThemeStyles from '@hooks/useThemeStyles'; import openHomePage from '@libs/actions/HomePage'; @@ -45,7 +47,10 @@ function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); + const tabRootScrollProps = useTabRootScrollProps([styles.homePageContentContainer, pageGutter], {hasBottomSafeAreaPadding: true, hasOfflineIndicatorPadding: true}); const {translate} = useLocalize(); + const triggerScrollEvent = useScrollEventEmitter(); useDocumentTitle(translate('common.home')); const {isOffline} = useNetwork({onReconnect: openHomePage}); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); @@ -146,8 +151,7 @@ function HomePage() { shouldShowOfflineIndicatorInWideScreen testID="HomePage" enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > diff --git a/src/pages/home/InsightsSection/InsightsSectionContent.tsx b/src/pages/home/InsightsSection/InsightsSectionContent.tsx index cb6a346e5c2e..9dbaeec3fa45 100644 --- a/src/pages/home/InsightsSection/InsightsSectionContent.tsx +++ b/src/pages/home/InsightsSection/InsightsSectionContent.tsx @@ -15,6 +15,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {setNameValuePair} from '@libs/actions/User'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; import type {SearchKey} from '@libs/SearchKeyUtils'; @@ -108,7 +109,7 @@ function InsightsSectionContent() { ); - if (!isInsightsPageEnabled) { + if (!isInsightsPageEnabled || canUseTouchScreen()) { return widget; } diff --git a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx index 0060836f5874..3d476c3d3e5b 100644 --- a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx +++ b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx @@ -15,7 +15,7 @@ import {showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; type UnlockBankAccountProps = { @@ -32,7 +32,9 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}`); @@ -51,7 +53,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) return; } pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID, initiatingBankAccountUnlock); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }; return ( diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 41272a88ce33..c5a041f96ce5 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -59,7 +59,6 @@ import { getReportStatusTranslation, hasReportNameError, isAdminRoom, - isArchivedReport, isChatRoom as isChatRoomReportUtils, isChatThread as isChatThreadReportUtils, isChatUsedForOnboarding as isChatUsedForOnboardingReportUtils, @@ -97,7 +96,9 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useRoute} from '@react-navigation/native'; import {accountGuideDetailsSelector} from '@selectors/Account'; import {accountIDSelector, isPersonalDetailOptimistic} from '@selectors/PersonalDetails'; +import {getStableReportSelector} from '@selectors/Report'; import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; +import {latestCalendlyCallSelector} from '@selectors/ReportNameValuePairs'; import {isPast} from 'date-fns'; import React, {useMemo} from 'react'; import {Keyboard, View} from 'react-native'; @@ -110,8 +111,8 @@ type HeaderViewProps = { }; function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const parentReportAction = useParentReportAction(report); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); + const parentReportAction = useParentReportAction(report, {shouldIgnoreThreadReplies: true}); const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Close', 'DotIndicator']); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -119,9 +120,11 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isInSidePanel = useIsInSidePanel(); const route = useRoute(); const openParentReportInCurrentTab = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID) ?? getNonEmptyStringOnyxID(report?.reportID)}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID) ?? getNonEmptyStringOnyxID(report?.reportID)}`, { + selector: getStableReportSelector, + }); const [grandParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(parentReport?.parentReportID)}`); - const grandParentReportAction = useParentReportAction(parentReport); + const grandParentReportAction = useParentReportAction(parentReport, {shouldIgnoreThreadReplies: true}); const policy = usePolicy(report?.policyID); const isPaidPolicyAdmin = useIsPaidPolicyAdmin(); const [personalDetails] = useAllPersonalDetails(); @@ -137,9 +140,9 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) partnerManagerCalendarLink: account?.partnerManagerCalendarLink, }), }); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + const [latestScheduledCall] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {selector: latestCalendlyCallSelector}); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingChatMembersSelector}); - const isReportArchived = isArchivedReport(reportNameValuePairs); + const isReportArchived = useReportIsArchived(report?.reportID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -349,7 +352,6 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) !shouldMirrorSidePanelHeader; const shouldShowOnBoardingHelpDropdownButton = (shouldShowRegisterForWebinar || shouldShowGuideBooking) && !isReportArchived && !shouldMirrorSidePanelHeader; const shouldShowEarlyDiscountBanner = shouldShowDiscount && isChatUsedForOnboarding && !shouldMirrorSidePanelHeader; - const latestScheduledCall = reportNameValuePairs?.calendlyCalls?.at(-1); const hasActiveScheduledCall = latestScheduledCall && !isPast(latestScheduledCall.eventTime) && latestScheduledCall.status !== CONST.SCHEDULE_CALL_STATUS.CANCELLED; // Not gated on !shouldUseNarrowLayout like the side-panel case: isInNarrowPaneModal makes // shouldUseNarrowLayout true for any RHP regardless of actual screen width. @@ -501,7 +503,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) /> )} {!shouldUseNarrowLayout && chatIncludesChronos(report) && } - {!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && } + {!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && } {!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton} {shouldShowCloseButton && ( @@ -551,7 +553,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) {!!report && shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && ( - + )} diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 7b250efc7157..ff7b6840976e 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -77,6 +77,7 @@ const defaultReportMetadata = { const defaultReportLoadingState = { hasOnceLoadedReportActions: false, + isCreatingSupportTicket: false, isLoadingInitialReportActions: true, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false, @@ -129,6 +130,7 @@ function ReportFetchHandler() { const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportOnyx?.chatReportID}`); const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); + const isPendingReportCreation = !!reportLoadingState.isCreatingSupportTicket; const isReportActionsLoaded = useIsReportActionsLoaded(reportIDFromRoute); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -190,9 +192,8 @@ function ReportFetchHandler() { // isPendingCreationFromRoute means reportIDFromRoute is a client-generated ID that doesn't exist // on the server yet. Calling openReport for it would 403 and show the not-found page instead. - // Once the real submit creates the report under this ID, reportOnyx?.reportID becomes truthy - // and normal fetching resumes. - if (isPendingCreationFromRoute && !reportOnyx?.reportID) { + // Wait until the report exists locally and is no longer optimistic before fetching it. + if (isPendingCreationFromRoute && (!reportOnyx?.reportID || isPendingReportCreation)) { return; } @@ -387,15 +388,16 @@ function ReportFetchHandler() { navigation.setParams({secureKey: undefined}); }, [secureKeyFromRoute, reportIDFromRoute, report?.reportID, report?.errorFields?.notFound, navigation]); - // isPendingCreation is only needed before the report exists. Clear it from the route params once it + // isPendingCreation is only needed before the report exists on the server. Clear it from the route params once it // does, so a copied URL, restored navigation state, or reload doesn't carry the stale flag and skip - // fetching again later. + // fetching again later. A support-ticket request is lost on reload, so its RAM-only guard also resets + // and lets OpenReport discover whether the server created the ticket. useEffect(() => { - if (!isPendingCreationFromRoute || !reportOnyx?.reportID) { + if (!isPendingCreationFromRoute || !reportOnyx?.reportID || isPendingReportCreation) { return; } navigation.setParams({isPendingCreation: undefined}); - }, [isPendingCreationFromRoute, reportOnyx?.reportID, navigation]); + }, [isPendingCreationFromRoute, reportOnyx?.reportID, isPendingReportCreation, navigation]); // A restored or shared route can carry isPendingCreation with no submit behind it. The owning submit writes the // report row within the dismiss transition budget (about 2s) after this screen gains focus, so a focused screen diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index 531a5ba69f16..a6a43484c86c 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -27,6 +27,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused, useRoute} from '@react-navigation/native'; +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {useEffect, useEffectEvent, useRef} from 'react'; import useReportWasDeleted from './hooks/useReportWasDeleted'; @@ -77,6 +78,7 @@ function ReportNavigateAwayHandler() { const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const isSelfTourViewed = onboarding?.selfTourViewed; + const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const parentReportAction = useParentReportAction(report); @@ -144,7 +146,15 @@ function ReportNavigateAwayHandler() { } Navigation.isNavigationReady().then(() => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }); }); @@ -235,7 +245,7 @@ function ReportNavigateAwayHandler() { } // Fallback to Concierge - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }); }, [ reportWasDeleted, @@ -246,6 +256,7 @@ function ReportNavigateAwayHandler() { introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, reportIDFromRoute, ]); diff --git a/src/pages/inbox/report/AncestorReportActionItem.tsx b/src/pages/inbox/report/AncestorReportActionItem.tsx index caf4dbb51e8d..86d8ab31407b 100644 --- a/src/pages/inbox/report/AncestorReportActionItem.tsx +++ b/src/pages/inbox/report/AncestorReportActionItem.tsx @@ -18,6 +18,7 @@ import { navigateToLinkedReportAction, } from '@libs/ReportUtils'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -64,6 +65,9 @@ type AncestorReportActionItemProps = { shouldAllowConciergeFeedback: boolean; shouldUseThreadDividerLine: boolean; transactionThreadReport: OnyxEntry; + + /** Parent links of the thread and its ancestors, used to clear related errors on dismiss */ + reportsParentHierarchy: OnyxCollection; }; function AncestorReportActionItem({ @@ -83,6 +87,7 @@ function AncestorReportActionItem({ shouldAllowConciergeFeedback, shouldUseThreadDividerLine, transactionThreadReport, + reportsParentHierarchy, }: AncestorReportActionItemProps) { const styles = useThemeStyles(); const currentUserPersonalDetail = useCurrentUserPersonalDetails(); @@ -167,6 +172,7 @@ function AncestorReportActionItem({ isThreadReportParentAction isLatestConciergeFeedbackAction={shouldAllowConciergeFeedback && !!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} linkedTransactionRouteError={linkedTransactionRouteError} + reportsParentHierarchy={reportsParentHierarchy} /> ); diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index e96364ae4c88..be41bd9b9378 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -35,6 +35,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -56,6 +57,7 @@ import { getDelegateSubmitMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getDismissedViolationMessageText, getDynamicExternalWorkflowApproveFailedActionMessage, getDynamicExternalWorkflowRoutedMessage, @@ -118,6 +120,7 @@ import { getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -1483,6 +1486,12 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { setClipboardMessage(getUpdatedMemberWorkArrangementMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + setClipboardMessage(getAddedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + setClipboardMessage(getUpdatedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + setClipboardMessage(getDeletedOfficeLocationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index e5ca3a3d9ba4..e6e1d50bfbeb 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -430,18 +430,19 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro }); } else if (reportAction) { callbackWhenDeleteModalHide.current = () => { - deleteReportComment( + deleteReportComment({ report, reportAction, originalReportActions, reportActions, - ancestorsRef.current, + ancestors: ancestorsRef.current, isReportArchived, isOriginalReportArchived, - email ?? '', + currentEmail: email ?? '', isOffline, - visibleReportActionsData ?? undefined, - ); + currentUserAccountID, + visibleReportActionsData: visibleReportActionsData ?? undefined, + }); }; } diff --git a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx index 11c5d7b12c95..f77da2477a38 100644 --- a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx @@ -18,7 +18,7 @@ import withReportOrNotFound from './withReportOrNotFound'; type DynamicReportDetailsShareCodePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetailsShareCodePageProps) { +function DynamicReportDetailsShareCodePage({report, policy, route}: DynamicReportDetailsShareCodePageProps) { const navigateBackRoute = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path); if (isSelfDM(report)) { @@ -30,6 +30,7 @@ function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetail backTo={navigateBackRoute} report={report} policy={policy} + shouldShowQRCodeOnly={route.params?.shouldShowQRCodeOnly === 'true'} /> ); } diff --git a/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx new file mode 100644 index 000000000000..464a9cb4bb8c --- /dev/null +++ b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx @@ -0,0 +1,168 @@ +/** + * Picks who to email an expense report to. Opens from Share report in the report's More menu. + */ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SingleSelectWithAvatarListItem from '@components/SelectionList/ListItem/SingleSelectWithAvatarListItem'; +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; +import usePressLoading from '@hooks/usePressLoading'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {searchUserInServer, shareReport} from '@libs/actions/Report'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; +import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import {canShareReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React, {useEffect, useState} from 'react'; + +import type {WithReportOrNotFoundProps} from './withReportOrNotFound'; + +import withReportOrNotFound from './withReportOrNotFound'; + +type DynamicReportDetailsShareReportPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; + +function DynamicReportDetailsShareReportPage({report, policy}: DynamicReportDetailsShareReportPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); + + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const {isLoading, startWithLoading} = usePressLoading(); + + const {searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, availableOptions, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + includeUserToInvite: true, + includeRecentReports: false, + shouldUpdateSelectedOptionsOnSingleSelect: true, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + shouldInitialize: didScreenTransitionEnd, + }); + + useEffect(() => { + searchUserInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); + + const selectedLogin = selectedOptions.at(0)?.login; + + const shareWithSelectedUser = () => { + if (!selectedLogin) { + return; + } + startWithLoading(() => { + shareReport(report.reportID, selectedLogin); + Navigation.goBack(backPath); + Growl.success(translate('common.reportShared')); + }); + }; + + const sections: Array> = []; + if (areOptionsInitialized) { + if (availableOptions.selectedOptions.length > 0) { + sections.push({ + title: undefined, + data: availableOptions.selectedOptions, + sectionIndex: 0, + }); + } + + if (availableOptions.personalDetails.length > 0) { + sections.push({ + title: translate('common.contacts'), + data: availableOptions.personalDetails, + sectionIndex: 1, + }); + } + + if (availableOptions.userToInvite) { + sections.push({ + title: undefined, + data: [availableOptions.userToInvite], + sectionIndex: 2, + }); + } + } + + const textInputOptions = { + label: translate('selectionList.nameEmailOrPhoneNumber'), + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage: sections.length > 0 ? '' : getHeaderMessage(translate, debouncedSearchTerm.trim().toLowerCase(), countryCode), + }; + + const footerContent = ( + + ); + + return ( + setDidScreenTransitionEnd(true)} + > + + Navigation.goBack(backPath)} + /> + + + + ); +} + +export default withReportOrNotFound()(DynamicReportDetailsShareReportPage); diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 22bc949234a7..331f584a96d8 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -20,7 +20,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -72,6 +71,7 @@ import {ReactionListContext} from '@pages/inbox/ReactionListContext'; import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; import {expandURLPreview} from '@userActions/Report'; import {clearErrorWithOriginalTransactionError} from '@userActions/Transaction'; @@ -87,7 +87,7 @@ import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent, TextInput} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; @@ -166,6 +166,9 @@ type ReportActionItemProps = { /** Whether the action is the "Created" action of a harvest-created expense report */ isHarvestCreatedExpenseReport?: boolean; + + /** Parent links of the report and its ancestors, used to clear related errors on dismiss */ + reportsParentHierarchy: OnyxCollection; }; function ReportActionItem({ @@ -189,12 +192,12 @@ function ReportActionItem({ shouldShowBorder, shouldHighlight = false, isHarvestCreatedExpenseReport = false, + reportsParentHierarchy, }: ReportActionItemProps) { const reportID = report?.reportID ?? action?.reportID; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const originalReportID = useOriginalReportID(report?.reportID, action); const {isOffline} = useNetwork(); - const reportsParentHierarchy = useReportsParentHierarchy(); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`, {selector: getStableReportSelector}); const [iouPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index 4c5504a2fb68..e25f109d1359 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -12,6 +12,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {shouldExcludeAncestorReportAction} from '@libs/ReportUtils'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -42,6 +43,9 @@ type ReportActionItemParentActionProps = { shouldDisplayReplyDivider: boolean; isFirstVisibleReportAction: boolean; shouldUseThreadDividerLine?: boolean; + + /** Parent links of the thread and its ancestors, shared by every ancestor shown above it */ + reportsParentHierarchy: OnyxCollection; }; function ReportActionItemParentAction({ @@ -53,6 +57,7 @@ function ReportActionItemParentAction({ shouldDisplayReplyDivider, isFirstVisibleReportAction = false, shouldUseThreadDividerLine = false, + reportsParentHierarchy, }: ReportActionItemParentActionProps) { const styles = useThemeStyles(); const ancestors = useAncestors(report, shouldExcludeAncestorReportAction); @@ -138,6 +143,7 @@ function ReportActionItemParentAction({ shouldAllowConciergeFeedback={!conciergeFeedbackForReportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} linkedTransactionRouteError={linkedTransactionRouteError} + reportsParentHierarchy={reportsParentHierarchy} /> ))} diff --git a/src/pages/inbox/report/ReportActionItemThread.tsx b/src/pages/inbox/report/ReportActionItemThread.tsx index 7bc7fe8e10b4..3c5f58a4b483 100644 --- a/src/pages/inbox/report/ReportActionItemThread.tsx +++ b/src/pages/inbox/report/ReportActionItemThread.tsx @@ -19,6 +19,7 @@ import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {getStableReportSelector} from '@selectors/Report'; import React from 'react'; import {View} from 'react-native'; @@ -46,11 +47,11 @@ function ReportActionItemThread({report, reportAction, isHovered, onSecondaryInt const styles = useThemeStyles(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {translate, datetimeToCalendarTime} = useLocalize(); - const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`, {selector: getStableReportSelector}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`, {selector: getStableReportSelector}); const personalDetails = usePersonalDetails(); const isAgentProcessingInThread = !!processingAgentIDs?.length; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 3f683a0ecaf2..0eb4016a6bc5 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -13,6 +13,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useReportScrollManager from '@hooks/useReportScrollManager'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -179,6 +180,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); const linkedReportActionID = reportActionIDFromRoute; @@ -462,6 +464,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct shouldUseThreadDividerLine={shouldUseThreadDividerLine} isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + reportsParentHierarchy={reportsParentHierarchy} /> {!!reportStable?.reportID && ( { trackVerticalScrolling(undefined); + flushPendingScrollToBottom(); }} /> diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index 4da6c57f43b5..91f543e7a4df 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -1,10 +1,12 @@ import {getOriginalMessage, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; import {isChatThread} from '@libs/ReportUtils'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; + import CONST from '@src/CONST'; import type {Report, ReportAction} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import React, {memo, useMemo} from 'react'; @@ -52,6 +54,9 @@ type ReportActionsListItemRendererProps = { /** Whether context menu should be disabled for the active Concierge draft */ shouldDisableContextMenuForConciergeDraft?: boolean; + + /** Parent links of the report and its ancestors, used to clear related errors on dismiss */ + reportsParentHierarchy: OnyxCollection; }; function ReportActionsListItemRenderer({ @@ -72,6 +77,7 @@ function ReportActionsListItemRenderer({ isHarvestCreatedExpenseReport = false, shouldDisableContextMenuForConciergeDraft = false, isLatestConciergeFeedbackAction = false, + reportsParentHierarchy, }: ReportActionsListItemRendererProps) { const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); @@ -160,6 +166,7 @@ function ReportActionsListItemRenderer({ transactionThreadReport={transactionThreadReport} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} + reportsParentHierarchy={reportsParentHierarchy} /> ); } @@ -182,6 +189,7 @@ function ReportActionsListItemRenderer({ isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReport} shouldDisplayContextMenu={!shouldDisableContextMenuForConciergeDraft} isLatestConciergeFeedbackAction={isLatestConciergeFeedbackAction} + reportsParentHierarchy={reportsParentHierarchy} /> ); } diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index b65a2e66dcaa..f53fa5721a39 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -1,5 +1,5 @@ import useBackfillWhenNoVisibleActions from '@hooks/useBackfillWhenNoVisibleActions'; -import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; +import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; @@ -50,7 +50,7 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG } = readinessSignals; // Side effects that must run whenever the chat list is shown, including while the skeleton renders. - useCopySelectionHelper(); + useCopySelectionEvent(); usePendingConciergeResponse(reportID); useStartConciergeSession({ diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 237a2ed3adf7..e879160ebb9b 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -1,6 +1,7 @@ import type {Emoji} from '@assets/emojis/types'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import EmojiReactionBubble from '@components/Reactions/EmojiReactionBubble'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; @@ -39,14 +40,29 @@ type ConciergeFeedbackPromptProps = { /** Whether to show the generic feedback prompt alongside the thumbs */ shouldShowPrompt?: boolean; + /** Custom prompt text shown alongside the thumbs */ + promptText?: string; + /** Whether the thumbs should remain available after the user rates the action */ shouldPersistAfterRating?: boolean; /** Whether hovering a thumb should show its tooltip */ shouldShowTooltips?: boolean; + /** Tooltip text for the positive feedback option */ + positiveTooltipText?: string; + + /** Tooltip text for the negative feedback option */ + negativeTooltipText?: string; + /** Whether to place only the thumbs inline with surrounding content */ shouldRenderInline?: boolean; + + /** Whether a selected thumb should use the standard selected reaction bubble */ + shouldRenderSelectedReactionBubble?: boolean; + + /** Whether to hide the unselected thumb after the user rates the action */ + shouldHideUnselectedThumbAfterRating?: boolean; }; /** A reaction can be stored under the emoji name or under its hexcode, so both keys are read */ @@ -90,28 +106,39 @@ type ConciergeFeedbackThumbProps = { /** Whether the current user selected this rating */ isSelected: boolean; + + /** Whether a selected thumb should use the standard selected reaction bubble */ + shouldRenderSelectedReactionBubble: boolean; }; -function ConciergeFeedbackThumb({emoji, label, accessibilityLabel, onPress, shouldShowTooltip, isSelected}: ConciergeFeedbackThumbProps) { +function ConciergeFeedbackThumb({emoji, label, accessibilityLabel, onPress, shouldShowTooltip, isSelected, shouldRenderSelectedReactionBubble}: ConciergeFeedbackThumbProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); - const thumb = ( - - {emoji.code} - - ); + const thumb = + isSelected && shouldRenderSelectedReactionBubble ? ( + + ) : ( + + {emoji.code} + + ); return shouldShowTooltip ? {thumb} : thumb; } @@ -120,9 +147,14 @@ function ConciergeFeedbackPrompt({ action, reportID, shouldShowPrompt = true, + promptText, shouldPersistAfterRating = false, shouldShowTooltips = true, + positiveTooltipText, + negativeTooltipText, shouldRenderInline = false, + shouldRenderSelectedReactionBubble = false, + shouldHideUnselectedThumbAfterRating = false, }: ConciergeFeedbackPromptProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -136,21 +168,53 @@ function ConciergeFeedbackPrompt({ const thumbsDown = findEmojiByName('-1'); const thumbsUpReactedAt = getReactedAtTimestamp(thumbsUp, reactions, currentUserAccountID); + const thumbsDownReactedAt = getReactedAtTimestamp(thumbsDown, reactions, currentUserAccountID); - // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged + // Either rating starts the acknowledgement window, and the zero fallback keeps an unanswered survey undefined. + const latestReactionTimestamp = Math.max(thumbsUpReactedAt ?? 0, thumbsDownReactedAt ?? 0) || undefined; + + // Regular Concierge feedback preserves its debounced thumbs-up acknowledgement. const [mountedWithThumbsUpReactedAt] = useState(thumbsUpReactedAt); - const hasJustReacted = thumbsUpReactedAt !== undefined && thumbsUpReactedAt !== mountedWithThumbsUpReactedAt; + const hasJustReactedWithThumbsUp = thumbsUpReactedAt !== undefined && thumbsUpReactedAt !== mountedWithThumbsUpReactedAt; + const hasSettledAfterThumbsUpReaction = useDebouncedValue(hasJustReactedWithThumbsUp, THANKS_VISIBLE_DURATION_MS); + const isRegularThankMessageDisplayed = hasJustReactedWithThumbsUp && !hasSettledAfterThumbsUpReaction; + + // A survey accepts either rating, so its acknowledgement restarts when the selected reaction changes. + const [mountedWithLatestReactionTimestamp] = useState(latestReactionTimestamp); + const debouncedLatestReactionTimestamp = useDebouncedValue(latestReactionTimestamp, THANKS_VISIBLE_DURATION_MS); + const isSurveyThankMessageDisplayed = + latestReactionTimestamp !== undefined && latestReactionTimestamp !== mountedWithLatestReactionTimestamp && latestReactionTimestamp !== debouncedLatestReactionTimestamp; - // The debounced copy catches up once the window has passed, which is what takes the acknowledgement back down - const hasSettledAfterReacting = useDebouncedValue(hasJustReacted, THANKS_VISIBLE_DURATION_MS); - const isDisplayedThankMessage = hasJustReacted && !hasSettledAfterReacting; + const isDisplayedThankMessage = shouldPersistAfterRating ? isSurveyThankMessageDisplayed : isRegularThankMessageDisplayed; const rate = (emoji: Emoji) => { // Skin tone is ignored on compare so a user whose preferred tone changed toggles their existing reaction instead of adding a second one toggleEmojiReaction(reportID, action, emoji, reactions, preferredSkinTone, currentUserAccountID, reportActions, true, true, emoji.name === thumbsUp.name ? thumbsDown : thumbsUp); }; - const hasRated = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID) || hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const hasReactedWithThumbsUp = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID); + const hasReactedWithThumbsDown = hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const hasRated = hasReactedWithThumbsUp || hasReactedWithThumbsDown; + const shouldHideUnselectedThumbs = shouldHideUnselectedThumbAfterRating && hasRated; + + const getFeedbackThumb = (emoji: Emoji, isSelected: boolean) => ( + rate(emoji))} + shouldShowTooltip={shouldShowTooltips} + isSelected={isSelected} + shouldRenderSelectedReactionBubble={shouldRenderSelectedReactionBubble} + /> + ); + + const feedbackThumbs = ( + + {(!shouldHideUnselectedThumbs || hasReactedWithThumbsUp) && getFeedbackThumb(thumbsUp, hasReactedWithThumbsUp)} + {(!shouldHideUnselectedThumbs || hasReactedWithThumbsDown) && getFeedbackThumb(thumbsDown, hasReactedWithThumbsDown)} + + ); // The acknowledgement comes from the reaction, so removing it from the reaction row brings the prompt back right away if (!shouldPersistAfterRating && isDisplayedThankMessage) { @@ -161,29 +225,23 @@ function ConciergeFeedbackPrompt({ return null; } - const feedbackThumbs = ( - - rate(thumbsUp))} - shouldShowTooltip={shouldShowTooltips} - isSelected={hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID)} - /> - rate(thumbsDown))} - shouldShowTooltip={shouldShowTooltips} - isSelected={hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID)} - /> - - ); + if (shouldPersistAfterRating && hasRated) { + return ( + + {shouldRenderInline && shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} + {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} + {feedbackThumbs} + + ); + } if (shouldRenderInline) { - return feedbackThumbs; + return ( + + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} + {feedbackThumbs} + + ); } return ( @@ -191,7 +249,7 @@ function ConciergeFeedbackPrompt({ layout="horizontal" style={styles.alignItemsCenter} > - {shouldShowPrompt && {translate('concierge.feedback.prompt')}} + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} {feedbackThumbs} ); diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index e59a1afc816c..946ab28ed808 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -13,6 +13,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -26,6 +27,7 @@ import { getDefaultApproverUpdateMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getForeignCurrencyDefaultTaxUpdateMessage, getForwardsToUpdateMessage, getInvoiceCompanyNameUpdateMessage, @@ -68,6 +70,7 @@ import { getUpdatedDefaultTitleMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -239,6 +242,9 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS]: (translate, action) => getUpdatedCommuterExclusionsMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT]: (translate, action) => getPolicyWorkArrangementMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT]: (translate, action) => getUpdatedMemberWorkArrangementMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION]: (translate, action) => getAddedOfficeLocationMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION]: (translate, action) => getUpdatedOfficeLocationMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION]: (translate, action) => getDeletedOfficeLocationMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE]: (translate, action) => getUpdatedReimbursementChoiceMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN]: (translate, action) => getSetAutoJoinMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE]: (translate, action) => getUpdatedDefaultTitleMessage(translate, action), diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx index fbd937960d5d..991e71dc9132 100644 --- a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -43,6 +43,7 @@ const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.UNDELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES, CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE, @@ -101,6 +102,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) { if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION)) { return ; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { return ; } diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 0c985a096315..c99fe489b71e 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -13,7 +13,6 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -101,18 +100,13 @@ function useReportActionsNewActionLiveTail({ if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } - if (!hasNewestReportAction && !isFromCurrentUser) { - if (Navigation.getReportRHPActiveRoute()) { - return; - } - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - }); - return; - } + // An unread marker only needs openReport when the newest actions are not loaded yet. When they are, they are + // just off-screen, so scroll to them directly instead of waiting on a network round trip. const shouldJumpToLiveTail = - !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActions || !!linkedReportActionID || unreadMarkerReportActionID); + !isOffline && + action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && + (hasNewerActions || !!linkedReportActionID || (!!unreadMarkerReportActionID && !hasNewestReportAction)); if (shouldJumpToLiveTail) { if (liveTailJumpRef.current.stage === 'idle') { diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index c02909053c2f..0b6451b97d23 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -194,6 +194,7 @@ export default function useReportUnreadMessageScrollTracking({ isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, + setIsActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility, diff --git a/src/pages/inbox/report/withReportOrNotFound.tsx b/src/pages/inbox/report/withReportOrNotFound.tsx index baee6ca53277..4fff348181e9 100644 --- a/src/pages/inbox/report/withReportOrNotFound.tsx +++ b/src/pages/inbox/report/withReportOrNotFound.tsx @@ -56,6 +56,7 @@ type ScreenProps = | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps + | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index dd92d4ecd347..1cacee978912 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -1,5 +1,5 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -22,6 +22,7 @@ import SidebarLinksData from './SidebarLinksData'; function BaseSidebarScreen() { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INBOX); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldShowSkeleton = useAppLoadSkeletonState(); @@ -40,8 +41,7 @@ function BaseSidebarScreen() { shouldEnableKeyboardAvoidingView={false} style={[styles.sidebar, isMobile() ? styles.userSelectNone : {}]} testID="BaseSidebarScreen" - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > {({insets}) => ( <> diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index 2ecdae3d7e9f..f308c8706689 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -37,27 +37,42 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation onPress={onPress} accessibilityLabel={`${translate('initialSettingsPage.account')}, ${translate('sidebarScreen.buttonMySettings')}. ${status ? `${translate('common.yourReviewIsRequired')}.` : ''}`} role={CONST.ROLE.TAB} - wrapperStyle={styles.flex1} + wrapperStyle={shouldUseNarrowLayout ? styles.navigationTabBarSlot : styles.flex1} accessibilityState={accountAccessibilityState} aria-selected={accountAccessibilityState.selected} style={({hovered}) => [style, !shouldUseNarrowLayout && hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.ACCOUNT} > {({hovered}) => ( - <> - + + {translate('initialSettingsPage.account')} - + )} ); diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index 6af8512410a8..00189aa18cfc 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -1,5 +1,6 @@ import LHNEmptyState from '@components/LHNOptionsList/LHNEmptyState'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import useOnyx from '@hooks/useOnyx'; @@ -82,6 +83,7 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]); const shouldShowEmptyLHN = optionListItems.length === 0; + const {contentContainerStyle: emptyLHNStyle} = useTabRootScrollProps([styles.flex1, isInLandscapeMode ? styles.pv4 : styles.emptyLHNWrapper], {fillsVisibleArea: true}); // Show the loading skeleton only while report data is loading AND the entire LHN report set is empty // (e.g. switching accounts/delegate sessions or re-authenticating after an Onyx clear, where openApp() repopulates reports). @@ -93,7 +95,7 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO {shouldShowEmptyLHN ? ( - + ) : ( diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx index d5b747707863..e6b935ca4414 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx @@ -29,7 +29,7 @@ import {getSelectedWorkspacePolicyID, pickReportForPolicy} from '@libs/IOUUtils' import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import {canEditWorkspaceSettings, hasAccountingConnections, isGroupPolicy} from '@libs/PolicyUtils'; +import {canCreateCategoryInSitu, canEditWorkspaceSettings, isGroupPolicy} from '@libs/PolicyUtils'; import {getTransactionDetails, isSelfDM} from '@libs/ReportUtils'; import {getRequestType} from '@libs/TransactionUtils'; @@ -100,9 +100,7 @@ function DynamicIOURequestStepCategory({ const categoryForDisplay = isCategoryMissing(transactionCategory) ? '' : transactionCategory; - const canCreateCategoryInSitu = canEditWorkspaceSettings(policy, currentUserEmailParam) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; - - const createCategoryMenuItems = canCreateCategoryInSitu + const createCategoryMenuItems = canCreateCategoryInSitu(policy, currentUserEmailParam) ? [ { icon: expensifyIcons.Plus, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index 0a572f2b8d7b..eaa76a092e2e 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -253,7 +253,9 @@ function DynamicIOURequestStepDestination({ shouldStyleAsCard={false} icon={illustrations.EmptyStateExpenses} title={translate('workspace.perDiem.requestEmptyList.title')} - subtitle={translate(isPolicyAdmin(policy) ? 'workspace.perDiem.requestEmptyList.adminSubtitle' : 'workspace.perDiem.requestEmptyList.subtitle')} + subtitle={ + isPolicyAdmin(policy) ? translate('workspace.perDiem.requestEmptyList.adminSubtitle') : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name) + } containerStyle={[styles.flex1, styles.justifyContentCenter]} /> {isPolicyAdmin(policy) && ( diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 371972c1fa47..7502b8c7ba64 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -49,6 +49,7 @@ import type { Rule, Transaction, TransactionViolation, + ReusableDistanceRoute, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; @@ -117,6 +118,9 @@ type MoneyRequestStepDistanceNavigationParams = { participantsPolicyTags: ParticipantsPolicyTags; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + + /** Current "Reuse route" list value, so the just used route can be saved locally */ + reusableDistanceRoutes?: ReusableDistanceRoute[]; }; /** Amount + merchant for a manual-distance submit; pending placeholders otherwise (waypoint/GPS distance is computed server-side). */ @@ -223,6 +227,7 @@ function handleMoneyRequestStepDistanceNavigation({ isOffline = false, rules, isVendorMatchingBetaEnabled, + reusableDistanceRoutes, }: MoneyRequestStepDistanceNavigationParams): void { const isManualDistance = manualDistance !== undefined; const isOdometerDistance = odometerDistance !== undefined; @@ -356,6 +361,7 @@ function handleMoneyRequestStepDistanceNavigation({ reportActionsList: undefined, getCurrencyDecimals, rules, + reusableDistanceRoutes, }); cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { report, @@ -443,6 +449,7 @@ function handleMoneyRequestStepDistanceNavigation({ getCurrencyDecimals, participantsPolicyTags, rules, + reusableDistanceRoutes, }); cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { report, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts index a1056136b76c..8c904e730bae 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts @@ -154,6 +154,7 @@ function useDistanceNavigation({ const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); const delegateAccountID = useDelegateAccountID(); const {formatPhoneNumber, dateFnsLocale} = useLocalize(); @@ -227,6 +228,7 @@ function useDistanceNavigation({ participants, participantsPolicyTags, rules, + reusableDistanceRoutes, }); }; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx index babdfae8ca5a..c40234f0be40 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getEffectiveDistance} from '@libs/GPSDraftDetailsUtils'; +import {formatDistanceToParts} from '@libs/NumberFormatUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Unit} from '@src/types/onyx/Policy'; @@ -20,12 +21,12 @@ type DistanceCounterProps = { function DistanceCounter({unit}: DistanceCounterProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Crosshair']); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); - const distance = DistanceRequestUtils.getRoundedDistanceInUnits(getEffectiveDistance(gpsDraftDetails), unit); + const distanceParts = formatDistanceToParts(preferredLocale, DistanceRequestUtils.convertDistanceUnit(getEffectiveDistance(gpsDraftDetails), unit), unit); return ( @@ -34,9 +35,19 @@ function DistanceCounter({unit}: DistanceCounterProps) { - - {distance} - {` ${unit}`} + + {distanceParts.map((part) => + part.type === 'unit' ? ( + + {part.value} + + ) : ( + part.value + ), + )} diff --git a/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts b/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts index 890acd1ec6b0..1f48fed2eeb4 100644 --- a/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts @@ -120,6 +120,7 @@ function useDistanceSubmission({ const [selectedParticipantsReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${firstSelectedParticipantReportID}`); const iouReportPolicyID = (moneyRequestReportID ? moneyRequestReport?.policyID : undefined) ?? currentChatReport?.policyID ?? selectedParticipantsReport?.policyID; const [iouReportPolicyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${iouReportPolicyID}`); + const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); function createTransaction({shouldHandleNavigation = true, writeBarrier}: CreateTransactionParams) { if (!transaction) { @@ -199,6 +200,7 @@ function useDistanceSubmission({ formatPhoneNumber, participantsPolicyTags, rules, + reusableDistanceRoutes, }); const isExpenseReport = isMoneyRequestReportReportUtils(report); diff --git a/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts index 0f6e06b25dff..1f3f0755547a 100644 --- a/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts @@ -134,6 +134,7 @@ function useTrackExpenseSubmission({ const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport @@ -273,6 +274,7 @@ function useTrackExpenseSubmission({ delegateAccountID, rules, personalDetailsByLogins: employeePersonalDetails, + reusableDistanceRoutes, }); } performPostBatchCleanup({ diff --git a/src/pages/settings/AboutPage/AboutPage.tsx b/src/pages/settings/AboutPage/AboutPage.tsx index 3b1c6f495f5a..29eecc032c43 100644 --- a/src/pages/settings/AboutPage/AboutPage.tsx +++ b/src/pages/settings/AboutPage/AboutPage.tsx @@ -37,7 +37,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ComponentRef} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; import DeviceInfo from 'react-native-device-info'; @@ -77,7 +77,9 @@ function AboutPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const menuItems = useMemo(() => { @@ -121,7 +123,15 @@ function AboutPage() { icon: icons.Bug, sentryLabel: CONST.SENTRY_LABEL.SETTINGS_ABOUT.REPORT_A_BUG, action: waitForNavigate(() => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }), }, ]; @@ -148,7 +158,7 @@ function AboutPage() { wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel, })); - }, [icons, styles, translate, waitForNavigate, conciergeReportID, introSelected, isSelfTourViewed, currentUserAccountID, conciergePersonalDetails]); + }, [icons, styles, translate, waitForNavigate, conciergeReportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, currentUserAccountID, conciergePersonalDetails]); const overlayContent = useCallback( () => ( diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index deb62181cbee..978dc7440f28 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -3,194 +3,63 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import type {TableHandle} from '@components/Table'; -import type {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; import AgentsTable from '@components/Tables/AgentsTable'; -import useChatWithAgent from '@hooks/useChatWithAgent'; -import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useAgents from '@hooks/useAgents'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; -import useSearchBackPress from '@hooks/useSearchBackPress'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; -import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; -import {getLatestError} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@userActions/Agent'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; function AgentsPage() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); - const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Trashcan']); - const chatWithAgent = useChatWithAgent(); - const switchToDelegator = useSwitchToDelegator(); - const {showConfirmModal} = useConfirmModal(); - const showRuleBotGuardModal = useRuleBotGuardModal(); - const [selectedAgents, setSelectedAgents] = useState([]); - const isMobileSelectionModeEnabled = useMobileSelectionMode(); - useDocumentTitle(translate('agentsPage.title')); - - const [agentPrompts] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const personalDetailsList = usePersonalDetails(); + const {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef} = useAgents(); + const hasAgents = agents.length > 0; const canSelectMultiple = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true; + const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; - useEffect(() => { - openAgentsPage(); - }, []); - - const handleErrorClose = (pendingAction: PendingAction | null | undefined, accountID: number) => { - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - clearAgentError(accountID); - } else if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - clearAgentDeleteError(accountID); - } else { - clearAgentUpdateError(accountID); - } - }; - - const agents: AgentRowData[] = Object.entries(agentPrompts ?? {}).flatMap(([key, agentPrompt]) => { - const accountID = Number(key.slice(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT.length)); - const details = personalDetailsList?.[accountID]; - if (!details) { - return []; - } - const pendingAction = agentPrompt?.pendingAction; - const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - if (!isOffline && isPendingDeletion) { - return []; - } - - const mergedErrors = { - ...getLatestError(agentPrompt?.errors ?? undefined), - ...getLatestError(agentPrompt?.nameErrors ?? undefined), - ...getLatestError(agentPrompt?.promptErrors ?? undefined), - ...getLatestError(agentPrompt?.avatarErrors ?? undefined), - }; - const rowErrors = getLatestError(mergedErrors); - - return [ - { - keyForList: String(accountID), - accountID, - displayName: details.displayName ?? details.login ?? '', - login: details.login ?? '', - pendingAction, - errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, - disabled: isPendingDeletion, - action: () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)), - onChatPress: () => chatWithAgent(accountID), - onCopilotPress: () => switchToDelegator(details.login ?? ''), - dismissError: () => handleErrorClose(pendingAction, accountID), - }, - ]; - }); - - const tableRef = useRef>(null); - const agentKeys = agents.map((agent) => agent.keyForList); - const prevAgentKeys = usePrevious(agentKeys); - - // Highlight (and scroll to) a newly created agent's row once it appears in the table, mirroring - // the same pattern used for newly-invited workspace members (see WorkspaceMembersPage). Not - // gated on useIsFocused: on wide layouts this page is the central pane of a split navigator and - // stays visible (but unfocused per react-navigation) while the new agent's DM opens in the RHP. - useEffect(() => { - const newAgentKeys = agentKeys.filter((key) => !prevAgentKeys.includes(key)); - if (!newAgentKeys.length) { - return; - } - - const tableAgents = tableRef.current?.getProcessedData() ?? []; - const newAgentIndex = tableAgents.findIndex((agent) => newAgentKeys.includes(agent.keyForList)); - if (newAgentIndex !== -1) { - tableRef.current?.scrollToIndex({ - index: newAgentIndex, - animated: false, - viewPosition: 0.5, - }); - } - tableRef.current?.highlightItems(newAgentKeys); - }, [agentKeys, prevAgentKeys]); - - const agentsByAccountID = new Map(agents.map((agent) => [agent.keyForList, agent])); - const selectedAgentKeys = selectedAgents.filter((accountIDString) => { - const agent = agentsByAccountID.get(accountIDString); - return !!agent && agent.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - }); - - const clearSelectedAgents = () => { - setSelectedAgents((prevSelectedAgents) => (prevSelectedAgents.length > 0 ? [] : prevSelectedAgents)); - }; - - useCleanupSelectedOptions(clearSelectedAgents); - - useSearchBackPress({ - onClearSelection: clearSelectedAgents, - onNavigationCallBack: () => Navigation.goBack(), - }); - - const removeSelectedAgents = () => { - for (const accountIDString of selectedAgentKeys) { - const accountID = Number(accountIDString); - const agentLogin = personalDetailsList?.[accountID]?.login; - deleteAgent(accountID, agentLogin, allPolicies, false); - } - clearSelectedAgents(); - }; - - const askForConfirmationToDelete = async () => { - const ruleBotEnforcedPolicy = selectedAgentKeys.map((accountIDString) => getRuleBotEnforcedPolicy(Number(accountIDString), allPolicies)).find(Boolean); - if (ruleBotEnforcedPolicy) { - showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); - return; - } - const result = await showConfirmModal({ - title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), - prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - shouldHandleNavigationBack: false, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } + useDocumentTitle(translate('agentsPage.title')); - removeSelectedAgents(); - }; + const newAgentButton = hasAgents ? ( + + ) : null; + const emptyStateButtons = [ + { + icon: icons.Plus, + buttonText: translate('agentsPage.newAgent'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + buttonAction: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_NEW.getRoute())), + }, + ]; const bulkActionsButtonOptions: Array>> = [ { @@ -202,20 +71,6 @@ function AgentsPage() { }, ]; - const hasAgents = agents.length > 0; - const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; - const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; - - const newAgentButton = ( - - ); - const headerButtons = shouldShowBulkActionsButton ? ( > variant={CONST.BUTTON_VARIANT.SUCCESS} @@ -226,7 +81,6 @@ function AgentsPage() { options={bulkActionsButtonOptions} isSplitButton={false} isDisabled={!selectedAgentKeys.length} - testID="AgentsPage-header-dropdown-menu-button" /> ) : ( newAgentButton @@ -243,6 +97,17 @@ function AgentsPage() { ); + const headerContent = shouldDisplayButtonsInSeparateLine ? undefined : headerButtons; + + const onBackButtonPress = () => { + if (isMobileSelectionModeEnabled) { + clearSelectedAgents(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }; + return ( { - if (isMobileSelectionModeEnabled) { - clearSelectedAgents(); - turnOffMobileSelectionMode(); - return; - } - Navigation.goBack(); - }} + onBackButtonPress={onBackButtonPress} shouldShowBackButton={shouldUseNarrowLayout} shouldUseHeadlineHeader={!selectionModeHeader} shouldDisplaySearchRouter shouldDisplayHelpButton title={selectionModeHeader ? translate('common.selectMultiple') : translate('agentsPage.title')} > - {!shouldDisplayButtonsInSeparateLine && headerButtons} + {headerContent} ); diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/DynamicAddAgentPage.tsx similarity index 93% rename from src/pages/settings/Agents/AddAgentPage.tsx rename to src/pages/settings/Agents/DynamicAddAgentPage.tsx index beb59393a788..fd5495ba6906 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicAddAgentPage.tsx @@ -12,6 +12,7 @@ import TextInput from '@components/TextInput'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -24,6 +25,7 @@ import {buildFileFromAvatarCropResult} from '@libs/AvatarCropUtils'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +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'; @@ -33,7 +35,7 @@ import {clearNewAgentAvatarDraft, clearNewAgentTemplate, createAgent, setNewAgen import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/AddAgentForm'; import type NewAgentTemplate from '@src/types/onyx/NewAgentTemplate'; @@ -48,17 +50,17 @@ import {Platform, View} from 'react-native'; import {PROMPT_MAX_AUTO_GROW_HEIGHT, PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from './const'; import scrollToMultilineInput from './scrollToMultilineInput'; -type AddAgentPageProps = PlatformStackScreenProps; +type DynamicAddAgentPageProps = PlatformStackScreenProps; -type AddAgentPageContentProps = { +type DynamicAddAgentPageContentProps = { /** Route params (policyID) forwarded from the screen */ - route: AddAgentPageProps['route']; + route: DynamicAddAgentPageProps['route']; /** Template picked in the "New agent" screen used to pre-fill the fields, or undefined for a blank agent */ template: NewAgentTemplate | undefined; }; -function AddAgentPageContent({route, template}: AddAgentPageContentProps) { +function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageContentProps) { const StyleUtils = useStyleUtils(); const policyID = route.params?.policyID; const {translate} = useLocalize(); @@ -80,6 +82,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { const hasSubmittedRef = useRef(false); const hasLeftPageRef = useRef(false); const formRef = useRef(null); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_ADD.path); const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { if (!('key' in event)) { @@ -198,7 +201,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { return ( Navigation.goBack(ROUTES.SETTINGS_AGENTS_NEW.getRoute(policyID ? {policyID} : undefined))} + onBackButtonPress={() => Navigation.goBack(backPath)} /> Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD_AVATAR)} + onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.getRoute()))} editIcon={expensifyIcons.Pencil} editIconStyle={styles.smallEditIconAccount} sentryLabel={CONST.SENTRY_LABEL.ADD_AGENT_PAGE.AVATAR} @@ -280,7 +283,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { ); } -function AddAgentPage({route}: AddAgentPageProps) { +function DynamicAddAgentPage({route}: DynamicAddAgentPageProps) { const [template, templateMetadata] = useOnyx(ONYXKEYS.NEW_AGENT_TEMPLATE); if (isLoadingOnyxValue(templateMetadata)) { @@ -288,13 +291,13 @@ function AddAgentPage({route}: AddAgentPageProps) { } return ( - ); } -AddAgentPage.displayName = 'AddAgentPage'; +DynamicAddAgentPage.displayName = 'DynamicAddAgentPage'; -export default AddAgentPage; +export default DynamicAddAgentPage; diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/DynamicEditAgentPage.tsx similarity index 87% rename from src/pages/settings/Agents/EditAgentPage.tsx rename to src/pages/settings/Agents/DynamicEditAgentPage.tsx index 1a950b1497a2..1084def7e73e 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicEditAgentPage.tsx @@ -11,6 +11,7 @@ import ScrollView from '@components/ScrollView'; import useChatWithAgent from '@hooks/useChatWithAgent'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -21,6 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearAgentAvatarUpdateError, clearAgentNameUpdateError, clearAgentPromptUpdateError, deleteAgent} from '@libs/actions/Agent'; import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -31,21 +33,21 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -type EditAgentPageProps = PlatformStackScreenProps; +type DynamicEditAgentPageProps = PlatformStackScreenProps; -function EditAgentPage({route}: EditAgentPageProps) { +function DynamicEditAgentPage({route}: DynamicEditAgentPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan', 'ChatBubble', 'MagnifyingGlass', 'Users']); const accountID = route.params.accountID; - const [agent, agentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const [agent, agentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`); const [personalDetails, personalDetailsMetadata] = usePersonalDetail(accountID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {showConfirmModal} = useConfirmModal(); @@ -55,12 +57,13 @@ function EditAgentPage({route}: EditAgentPageProps) { const isOnyxLoaded = agentMetadata.status === 'loaded' && personalDetailsMetadata.status === 'loaded'; const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const shouldShowNotFoundPage = isLoadingApp === false && isOnyxLoaded && !agent && !personalDetails; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT.path); const agentLogin = personalDetails?.login ?? ''; - const handleBackPress = () => Navigation.goBack(); - const handleEditAvatarPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.getRoute(accountID)); - const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(accountID)); - const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID)); + const handleBackPress = () => Navigation.goBack(backPath); + const handleEditAvatarPress = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.getRoute())); + const handleEditNamePress = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_NAME.getRoute())); + const handleEditPromptPress = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.getRoute())); const handleDeletePress = async () => { const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(accountID, allPolicies); if (ruleBotEnforcedPolicy) { @@ -78,7 +81,7 @@ function EditAgentPage({route}: EditAgentPageProps) { if (result.action !== ModalActions.CONFIRM) { return; } - deleteAgent(accountID, agentLogin, allPolicies); + deleteAgent(accountID, agentLogin, allPolicies, true, backPath); }; const isPendingAddOrDelete = agent?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || agent?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const areActionsDisabled = isPendingAddOrDelete || accountID <= 0 || !agentLogin; @@ -111,7 +114,7 @@ function EditAgentPage({route}: EditAgentPageProps) { return ( @@ -187,6 +190,6 @@ function EditAgentPage({route}: EditAgentPageProps) { ); } -EditAgentPage.displayName = 'EditAgentPage'; +DynamicEditAgentPage.displayName = 'DynamicEditAgentPage'; -export default EditAgentPage; +export default DynamicEditAgentPage; diff --git a/src/pages/settings/Agents/NewAgentPage.tsx b/src/pages/settings/Agents/DynamicNewAgentPage.tsx similarity index 89% rename from src/pages/settings/Agents/NewAgentPage.tsx rename to src/pages/settings/Agents/DynamicNewAgentPage.tsx index cc36f4ffef38..e71419de5e6b 100644 --- a/src/pages/settings/Agents/NewAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicNewAgentPage.tsx @@ -7,6 +7,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -16,6 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; import type {AgentAvatarID} from '@libs/Avatars/AgentAvatarCatalog'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -23,7 +25,7 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {clearNewAgentAvatarDraft, clearNewAgentTemplate, setNewAgentTemplate, getAgentTemplates} from '@userActions/Agent'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type SuggestedAgent from '@src/types/onyx/SuggestedAgent'; @@ -73,9 +75,9 @@ function AgentTemplateCard({template, avatarID, onAdd}: AgentTemplateCardProps) ); } -type NewAgentPageProps = PlatformStackScreenProps; +type DynamicNewAgentPageProps = PlatformStackScreenProps; -function NewAgentPage({route}: NewAgentPageProps) { +function DynamicNewAgentPage({route}: DynamicNewAgentPageProps) { const policyID = route.params?.policyID; const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -87,6 +89,7 @@ function NewAgentPage({route}: NewAgentPageProps) { const hasTemplates = templates.length > 0; const shouldShowLoadingIndicator = isLoading && !hasTemplates && !isOffline; const shouldShowEmptyState = !hasTemplates && (!isLoading || isOffline); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_NEW.path); useEffect(() => { if (isOffline) { @@ -98,14 +101,14 @@ function NewAgentPage({route}: NewAgentPageProps) { const handleBuildCustomAgent = () => { // Start from scratch — drop any previously stashed template and avatar draft. Promise.all([clearNewAgentTemplate(), clearNewAgentAvatarDraft()]).then(() => { - Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD.getRoute(policyID ? {policyID} : undefined)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute(policyID ? {policyID} : undefined))); }); }; const handleAddTemplate = (template: SuggestedAgent, avatarID: AgentAvatarID) => { // Stash the template in Onyx (persists across refresh) and drop any stale avatar draft. Promise.all([setNewAgentTemplate({name: template.name, prompt: template.prompt, avatarID}), clearNewAgentAvatarDraft()]).then(() => { - Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD.getRoute(policyID ? {policyID} : undefined)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute(policyID ? {policyID} : undefined))); }); }; @@ -177,20 +180,20 @@ function NewAgentPage({route}: NewAgentPageProps) { return ( Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(backPath)} /> {body} ); } -NewAgentPage.displayName = 'NewAgentPage'; +DynamicNewAgentPage.displayName = 'DynamicNewAgentPage'; -export default NewAgentPage; +export default DynamicNewAgentPage; diff --git a/src/pages/settings/Agents/Fields/AddAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/DynamicAddAgentAvatarPage.tsx similarity index 81% rename from src/pages/settings/Agents/Fields/AddAgentAvatarPage.tsx rename to src/pages/settings/Agents/Fields/DynamicAddAgentAvatarPage.tsx index 4682d0128bc0..8e8e6c870505 100644 --- a/src/pages/settings/Agents/Fields/AddAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicAddAgentAvatarPage.tsx @@ -1,5 +1,6 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useOnyx from '@hooks/useOnyx'; import Log from '@libs/Log'; @@ -8,19 +9,19 @@ import Navigation from '@libs/Navigation/Navigation'; import {setNewAgentAvatarPreset, setNewAgentUploadedAvatar} from '@userActions/Agent'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useNavigation} from '@react-navigation/native'; import React from 'react'; -import type {OnSaveParams} from './EditAgentAvatarPage'; +import type {OnSaveParams} from './DynamicEditAgentAvatarPage'; -import {EditAgentAvatarContent} from './EditAgentAvatarPage'; +import {EditAgentAvatarContent} from './DynamicEditAgentAvatarPage'; -function AddAgentAvatarPage() { +function DynamicAddAgentAvatarPage() { const navigation = useNavigation(); - const returnRoute = ROUTES.SETTINGS_AGENTS_ADD.getRoute(); + const returnRoute = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.path); const [avatarDraft, avatarDraftMetadata] = useOnyx(ONYXKEYS.AGENT_NEW_AVATAR_DRAFT); const initialPresetID = avatarDraft?.customExpensifyAvatarID; @@ -55,4 +56,4 @@ function AddAgentAvatarPage() { ); } -export default AddAgentAvatarPage; +export default DynamicAddAgentAvatarPage; diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx similarity index 94% rename from src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx rename to src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx index 14443666d669..e982f779ac2c 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import useAvatarCrop from '@hooks/useAvatarCrop'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; @@ -30,7 +31,7 @@ import {updateAgentAvatar} from '@userActions/Agent'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -38,7 +39,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; -type EditAgentAvatarPageProps = PlatformStackScreenProps; +type DynamicEditAgentAvatarPageProps = PlatformStackScreenProps; type ImageData = { uri: string; @@ -231,18 +232,19 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset EditAgentAvatarContent.displayName = 'EditAgentAvatarContent'; -function EditAgentAvatarPage({route}: EditAgentAvatarPageProps) { +function DynamicEditAgentAvatarPage({route}: DynamicEditAgentAvatarPageProps) { const {accountID} = route.params; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.path); return ( ); } -EditAgentAvatarPage.displayName = 'EditAgentAvatarPage'; +DynamicEditAgentAvatarPage.displayName = 'DynamicEditAgentAvatarPage'; export type {OnSaveParams}; export {EditAgentAvatarContent}; -export default EditAgentAvatarPage; +export default DynamicEditAgentAvatarPage; diff --git a/src/pages/settings/Agents/Fields/EditNamePage.tsx b/src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx similarity index 80% rename from src/pages/settings/Agents/Fields/EditNamePage.tsx rename to src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx index 092d13abb51f..208db2aaaa82 100644 --- a/src/pages/settings/Agents/Fields/EditNamePage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx @@ -6,6 +6,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,37 +18,38 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EditAgentNameForm'; import React from 'react'; -type EditNamePageProps = PlatformStackScreenProps; +type DynamicEditNamePageProps = PlatformStackScreenProps; -function EditNamePage({route}: EditNamePageProps) { +function DynamicEditNamePage({route}: DynamicEditNamePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const accountID = route.params.accountID; const [personalDetails] = usePersonalDetail(accountID); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_NAME.path); const {inputCallbackRef} = useAutoFocusInput(); const handleSubmit = (values: FormOnyxValues) => { updateAgentName(accountID, values[INPUT_IDS.FIRST_NAME].trim(), personalDetails?.displayName ?? ''); - Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)); + Navigation.goBack(backPath); }; return ( Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID))} + onBackButtonPress={() => Navigation.goBack(backPath)} /> ; +type DynamicEditPromptPageProps = PlatformStackScreenProps; -function EditPromptPage({route}: EditPromptPageProps) { +function DynamicEditPromptPage({route}: DynamicEditPromptPageProps) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -46,10 +48,11 @@ function EditPromptPage({route}: EditPromptPageProps) { const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode; const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const accountID = route.params.accountID; - const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`); const formRef = useRef(null); const promptTopOffsetRef = useRef(0); const scrollToInput = () => scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.path); const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; @@ -61,7 +64,7 @@ function EditPromptPage({route}: EditPromptPageProps) { const handleSubmit = (values: FormOnyxValues) => { updateAgentPrompt(accountID, values[INPUT_IDS.PROMPT].trim(), agentPrompt?.prompt ?? ''); - Navigation.goBack(); + Navigation.goBack(backPath); }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, (e) => { @@ -85,7 +88,7 @@ function EditPromptPage({route}: EditPromptPageProps) { return ( Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(backPath)} /> (null); const switchToDelegator = useSwitchToDelegator(); @@ -259,8 +261,9 @@ function CopilotPage() { const delegateMenuItems: MenuItemProps[] = useMemo(() => { return filteredDelegates.map(({email, role, pendingAction, pendingFields}) => { const personalDetail = personalDetailsByLogin[email.toLowerCase()]; - const addDelegateErrors = errorFields?.addDelegate?.[email]; - const error = getLatestError(addDelegateErrors); + const addDelegateError = getLatestError(errorFields?.addDelegate?.[email]); + const removeDelegateError = getLatestError(errorFields?.removeDelegate?.[email]); + const error = getLatestError({...addDelegateError, ...removeDelegateError}); const isOwnerRow = isAgentAccount === true && !!actingDelegateEmail && email.toLowerCase() === actingDelegateEmail; const isPendingDelete = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; @@ -293,7 +296,10 @@ function CopilotPage() { shouldShowRightIcon: !isOwnerRow && !isPendingDelete, pendingAction, shouldForceOpacity: !!pendingAction, - onPendingActionDismiss: () => clearDelegateErrorsByField({email, fieldName: 'addDelegate', delegatedAccess: account?.delegatedAccess}), + onPendingActionDismiss: () => { + clearDelegateErrorsByField({email, fieldName: 'addDelegate', delegatedAccess: account?.delegatedAccess}); + clearDelegateErrorsByField({email, fieldName: 'removeDelegate', delegatedAccess: account?.delegatedAccess}); + }, error, disabled: isPendingDelete, onPress: isOwnerRow ? undefined : onPress, @@ -395,7 +401,16 @@ function CopilotPage() { showDelegateNoAccessModal(); return; } - removeDelegate({email: selectedDelegate?.email ?? '', delegatedAccess: account?.delegatedAccess}); + if (selectedDelegate?.email === account?.delegatedAccess?.delegate && isActingAsDelegate) { + removeSelfAsDelegate({ + delegatorEmail: session?.email ?? '', + delegateEmail: selectedDelegate?.email ?? '', + stashedCredentials, + stashedSession, + }); + } else { + removeDelegate({email: selectedDelegate?.email ?? '', delegatedAccess: account?.delegatedAccess}); + } setSelectedDelegate(undefined); } }); diff --git a/src/pages/settings/EmailIssue/index.tsx b/src/pages/settings/EmailIssue/index.tsx index fc65099fe46a..363fc8aa439b 100644 --- a/src/pages/settings/EmailIssue/index.tsx +++ b/src/pages/settings/EmailIssue/index.tsx @@ -26,7 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useIsFocused} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -42,7 +42,9 @@ function EmailIssuePage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const hasEmailDeliveryFailure = !!account?.hasEmailDeliveryFailure; @@ -122,7 +124,15 @@ function EmailIssuePage() {