diff --git a/android/app/build.gradle b/android/app/build.gradle index 192537f08e3d..5b21487d92cd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000306 - versionName "9.0.3-6" + versionCode 1009000400 + versionName "9.0.4-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/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 17eaae3cc3fc..c1aae6e1265d 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.3.6 + 9.0.4.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 618d394349ed..579c99455525 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleSignature ???? CFBundleVersion - 9.0.3.6 + 9.0.4.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d5e50828e3c7..7981169f076b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleVersion - 9.0.3.6 + 9.0.4.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 9f63be958d1a..c92ca2ec3813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c61316e22030..e4bd1d99db16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-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.", diff --git a/src/CONST.ts b/src/CONST.ts index d82ce9d63372..00f2245a55c0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -369,6 +369,7 @@ const CONST = { WORKSPACE_FEEDS: 'workspaceFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', INTACCT_ON_NEW_EXPENSIFY: 'intacctOnNewExpensify', + COMMENT_LINKING: 'commentLinking', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1852,6 +1853,11 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, MORE_FEATURES: { ARE_CATEGORIES_ENABLED: 'areCategoriesEnabled', ARE_TAGS_ENABLED: 'areTagsEnabled', @@ -1862,21 +1868,6 @@ const CONST = { ARE_EXPENSIFY_CARDS_ENABLED: 'areExpensifyCardsEnabled', ARE_TAXES_ENABLED: 'tax', }, - CATEGORIES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, - TAGS_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, - DISTANCE_RATES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, DEFAULT_CATEGORIES: [ 'Advertising', 'Benefits', @@ -1907,11 +1898,6 @@ const CONST = { DUPLICATE_SUBSCRIPTION: 'duplicateSubscription', FAILED_TO_CLEAR_BALANCE: 'failedToClearBalance', }, - TAX_RATES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, COLLECTION_KEYS: { DESCRIPTION: 'description', REIMBURSER: 'reimburser', @@ -2259,6 +2245,7 @@ const CONST = { LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, TAG_NAME_LIMIT: 256, + WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH: 256, REPORT_NAME_LIMIT: 100, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, @@ -5082,6 +5069,12 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + REPORT_FIELD_TYPES: { + TEXT: 'text', + DATE: 'date', + LIST: 'dropdown', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5088c1d3158f..709347fa71cd 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -359,12 +359,17 @@ const ONYXKEYS = { /** Holds the checks used while transferring the ownership of the workspace */ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks', + // These statuses below are in separate keys on purpose - it allows us to have different behaviours of the banner based on the status + /** Indicates whether ClearOutstandingBalance failed */ SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED: 'subscriptionRetryBillingStatusFailed', /** Indicates whether ClearOutstandingBalance was successful */ SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL: 'subscriptionRetryBillingStatusSuccessful', + /** Indicates whether ClearOutstandingBalance is pending */ + SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING: 'subscriptionRetryBillingStatusPending', + /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', @@ -454,6 +459,8 @@ const ONYXKEYS = { WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft', WORKSPACE_TAX_CUSTOM_NAME: 'workspaceTaxCustomName', WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft', + WORKSPACE_REPORT_FIELDS_FORM: 'workspaceReportFieldsForm', + WORKSPACE_REPORT_FIELDS_FORM_DRAFT: 'workspaceReportFieldsFormDraft', POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', @@ -564,6 +571,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; + [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldsForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; @@ -781,6 +789,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean; + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 45c56abc71d5..a70d6e7502ae 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -783,6 +783,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const, }, + WORKSPACE_CREATE_REPORT_FIELD: { + route: 'settings/workspaces/:policyID/reportFields/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const, + }, + WORKSPACE_REPORT_FIELD_LIST_VALUES: { + route: 'settings/workspaces/:policyID/reportFields/new/listValues', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/listValues` as const, + }, + WORKSPACE_REPORT_FIELD_ADD_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/addValue', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/addValue` as const, + }, + WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}` as const, + }, + WORKSPACE_REPORT_FIELD_EDIT_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const, + }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8214c04cef75..e12ccfdab072 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -313,6 +313,11 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', + REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', + REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', + REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', + REPORT_FIELDS_VALUE_SETTINGS: 'Workspace_ReportFields_ValueSettings', + REPORT_FIELDS_EDIT_VALUE: 'Workspace_ReportFields_EditValue', TAX_EDIT: 'Workspace_Tax_Edit', TAX_NAME: 'Workspace_Tax_Name', TAX_VALUE: 'Workspace_Tax_Value', diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts index 87a9108d5f2e..5893bcd9936e 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import {createContext} from 'react'; +import type {GestureType} from 'react-native-gesture-handler'; import type PagerView from 'react-native-pager-view'; import type {SharedValue} from 'react-native-reanimated'; import type {AttachmentSource} from '@components/Attachments/types'; @@ -17,16 +18,28 @@ type AttachmentCarouselPagerItems = { }; type AttachmentCarouselPagerContextValue = { - /** The list of items that are shown in the pager */ + /** List of attachments displayed in the pager */ pagerItems: AttachmentCarouselPagerItems[]; - /** The index of the active page */ + /** Index of the currently active page */ activePage: number; - pagerRef?: ForwardedRef; + + /** Ref to the active attachment */ + pagerRef?: ForwardedRef; + + /** Indicates if the pager is currently scrolling */ isPagerScrolling: SharedValue; + + /** Indicates if scrolling is enabled for the attachment */ isScrollEnabled: SharedValue; + + /** Function to call after a tap event */ onTap: () => void; + + /** Function to call when the scale changes */ onScaleChanged: (scale: number) => void; + + /** Function to call after a swipe down event */ onSwipeDown: () => void; }; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index b7ef9309eb10..f16ba2c53ae8 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -1,4 +1,4 @@ -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, SetStateAction} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; @@ -8,6 +8,7 @@ import type {PagerViewProps} from 'react-native-pager-view'; import PagerView from 'react-native-pager-view'; import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated'; import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem'; +import useCarouselContextEvents from '@components/Attachments/AttachmentCarousel/useCarouselContextEvents'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; @@ -41,24 +42,21 @@ type AttachmentCarouselPagerProps = { >, ) => void; - /** - * A callback that can be used to toggle the attachment carousel arrows, when the scale of the image changes. - * @param showArrows If set, it will show/hide the arrows. If not set, it will toggle the arrows. - */ - onRequestToggleArrows: (showArrows?: boolean) => void; - /** A callback that is called when swipe-down-to-close gesture happens */ onClose: () => void; + + /** Sets the visibility of the arrows. */ + setShouldShowArrows: (show?: SetStateAction) => void; }; function AttachmentCarouselPager( - {items, activeSource, initialPage, onPageSelected, onRequestToggleArrows, onClose}: AttachmentCarouselPagerProps, + {items, activeSource, initialPage, setShouldShowArrows, onPageSelected, onClose}: AttachmentCarouselPagerProps, ref: ForwardedRef, ) { + const {handleTap, handleScaleChange} = useCarouselContextEvents(setShouldShowArrows); const styles = useThemeStyles(); const pagerRef = useRef(null); - const scale = useRef(1); const isPagerScrolling = useSharedValue(false); const isScrollEnabled = useSharedValue(true); @@ -80,42 +78,6 @@ function AttachmentCarouselPager( /** The `pagerItems` object that passed down to the context. Later used to detect current page, whether it's a single image gallery etc. */ const pagerItems = useMemo(() => items.map((item, index) => ({source: item.source, index, isActive: index === activePageIndex})), [activePageIndex, items]); - /** - * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. - * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, - * as well as enabling/disabling the carousel buttons. - */ - const handleScaleChange = useCallback( - (newScale: number) => { - if (newScale === scale.current) { - return; - } - - scale.current = newScale; - - const newIsScrollEnabled = newScale === 1; - if (isScrollEnabled.value === newIsScrollEnabled) { - return; - } - - isScrollEnabled.value = newIsScrollEnabled; - onRequestToggleArrows(newIsScrollEnabled); - }, - [isScrollEnabled, onRequestToggleArrows], - ); - - /** - * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. - * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. - */ - const handleTap = useCallback(() => { - if (!isScrollEnabled.value) { - return; - } - - onRequestToggleArrows(); - }, [isScrollEnabled.value, onRequestToggleArrows]); - const extractItemKey = useCallback( (item: Attachment, index: number) => typeof item.source === 'string' || typeof item.source === 'number' ? `source-${item.source}` : `reportActionID-${item.reportActionID}` ?? `index-${index}`, diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 15740725c42e..243fc52f1f5d 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -96,22 +96,6 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [autoHideArrows, page, updatePage], ); - /** - * Toggles the arrows visibility - * @param {Boolean} showArrows if showArrows is passed, it will set the visibility to the passed value - */ - const toggleArrows = useCallback( - (showArrows?: boolean) => { - if (showArrows === undefined) { - setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); - return; - } - - setShouldShowArrows(showArrows); - }, - [setShouldShowArrows], - ); - const containerStyles = [styles.flex1, styles.attachmentCarouselContainer]; if (page == null) { @@ -147,7 +131,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, items={attachments} initialPage={page} activeSource={activeSource} - onRequestToggleArrows={toggleArrows} + setShouldShowArrows={setShouldShowArrows} onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} onClose={onClose} ref={pagerRef} diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 076ba87e9341..36abe1e2e5ed 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -1,10 +1,12 @@ import isEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {MutableRefObject} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {Keyboard, PixelRatio, View} from 'react-native'; +import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; -import Animated, {scrollTo, useAnimatedRef} from 'react-native-reanimated'; +import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import BlockingView from '@components/BlockingViews/BlockingView'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -22,8 +24,10 @@ import CarouselActions from './CarouselActions'; import CarouselButtons from './CarouselButtons'; import CarouselItem from './CarouselItem'; import extractAttachments from './extractAttachments'; +import AttachmentCarouselPagerContext from './Pager/AttachmentCarouselPagerContext'; import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps, UpdatePageProps} from './types'; import useCarouselArrows from './useCarouselArrows'; +import useCarouselContextEvents from './useCarouselContextEvents'; const viewabilityConfig = { // To facilitate paging through the attachments, we want to consider an item "viewable" when it is @@ -33,13 +37,15 @@ const viewabilityConfig = { const MIN_FLING_VELOCITY = 500; -function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID}: AttachmentCarouselProps) { +function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const styles = useThemeStyles(); const {isFullScreenRef} = useFullScreenContext(); const scrollRef = useAnimatedRef>>(); + const nope = useSharedValue(false); + const pagerRef = useRef(null); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -52,6 +58,14 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, const [attachments, setAttachments] = useState([]); const [activeSource, setActiveSource] = useState(source); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); + const {handleTap, handleScaleChange, scale} = useCarouselContextEvents(setShouldShowArrows); + + useEffect(() => { + if (!canUseTouchScreen) { + return; + } + setShouldShowArrows(true); + }, [canUseTouchScreen, page, setShouldShowArrows]); const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]); @@ -169,6 +183,20 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [cellWidth], ); + const context = useMemo( + () => ({ + pagerItems: [{source, index: 0, isActive: true}], + activePage: 0, + pagerRef, + isPagerScrolling: nope, + isScrollEnabled: nope, + onTap: handleTap, + onScaleChanged: handleScaleChange, + onSwipeDown: onClose, + }), + [source, nope, handleTap, handleScaleChange, onClose], + ); + /** Defines how a single attachment should be rendered */ const renderItem = useCallback( ({item}: ListRenderItemInfo) => ( @@ -176,20 +204,30 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, setShouldShowArrows((oldState) => !oldState) : undefined} + onPress={canUseTouchScreen ? handleTap : undefined} isModalHovered={shouldShowArrows} /> ), - [activeSource, canUseTouchScreen, cellWidth, setShouldShowArrows, shouldShowArrows, styles.h100], + [activeSource, canUseTouchScreen, cellWidth, handleTap, shouldShowArrows, styles.h100], ); /** Pan gesture handing swiping through attachments on touch screen devices */ const pan = useMemo( () => Gesture.Pan() .enabled(canUseTouchScreen) - .onUpdate(({translationX}) => scrollTo(scrollRef, page * cellWidth - translationX, 0, false)) + .onUpdate(({translationX}) => { + if (scale.current !== 1) { + return; + } + + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); + }) .onEnd(({translationX, velocityX}) => { + if (scale.current !== 1) { + return; + } + let newIndex; if (velocityX > MIN_FLING_VELOCITY) { // User flung to the right @@ -204,8 +242,9 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, } scrollTo(scrollRef, newIndex * cellWidth, 0, true); - }), - [attachments.length, canUseTouchScreen, cellWidth, page, scrollRef], + }) + .withRef(pagerRef as MutableRefObject), + [attachments.length, canUseTouchScreen, cellWidth, page, scale, scrollRef], ); return ( @@ -233,27 +272,28 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, autoHideArrow={autoHideArrows} cancelAutoHideArrow={cancelAutoHideArrows} /> - - - - + + + + + diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts index 12ca3db4e2ff..a7ce0f93114b 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts @@ -32,6 +32,9 @@ function useCarouselArrows() { }, CONST.ARROW_HIDE_DELAY); }, [canUseTouchScreen, cancelAutoHideArrows]); + /** + * Sets the visibility of the arrows. + */ const setShouldShowArrows = useCallback( (show: SetStateAction = true) => { setShouldShowArrowsInternal(show); diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts new file mode 100644 index 000000000000..d516879322ea --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts @@ -0,0 +1,63 @@ +import {useCallback, useRef} from 'react'; +import type {SetStateAction} from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +function useCarouselContextEvents(setShouldShowArrows: (show?: SetStateAction) => void) { + const scale = useRef(1); + const isScrollEnabled = useSharedValue(true); + + /** + * Toggles the arrows visibility + */ + const onRequestToggleArrows = useCallback( + (showArrows?: boolean) => { + if (showArrows === undefined) { + setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); + return; + } + + setShouldShowArrows(showArrows); + }, + [setShouldShowArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, + * as well as enabling/disabling the carousel buttons. + */ + const handleScaleChange = useCallback( + (newScale: number) => { + if (newScale === scale.current) { + return; + } + + scale.current = newScale; + + const newIsScrollEnabled = newScale === 1; + if (isScrollEnabled.value === newIsScrollEnabled) { + return; + } + + isScrollEnabled.value = newIsScrollEnabled; + onRequestToggleArrows(newIsScrollEnabled); + }, + [isScrollEnabled, onRequestToggleArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. + */ + const handleTap = useCallback(() => { + if (!isScrollEnabled.value) { + return; + } + + onRequestToggleArrows(); + }, [isScrollEnabled.value, onRequestToggleArrows]); + + return {handleTap, handleScaleChange, scale}; +} + +export default useCarouselContextEvents; diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 702f0380ceef..d1eedd560694 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -10,9 +10,9 @@ type PaymentType = DeepValueOf; -type WorkspaceDistanceRatesBulkActionType = DeepValueOf; +type WorkspaceDistanceRatesBulkActionType = DeepValueOf; -type WorkspaceTaxRatesBulkActionType = DeepValueOf; +type WorkspaceTaxRatesBulkActionType = DeepValueOf; type DropdownOption = { value: TValueType; diff --git a/src/components/ConnectToQuickbooksOnlineButton/index.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.tsx index 71f1fba91187..50ee9165b8a3 100644 --- a/src/components/ConnectToQuickbooksOnlineButton/index.tsx +++ b/src/components/ConnectToQuickbooksOnlineButton/index.tsx @@ -40,6 +40,8 @@ function ConnectToQuickbooksOnlineButton({policyID, shouldDisconnectIntegrationB {shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect && isDisconnectModalOpen && ( { + // Since QBO doesn't support Taxes, we should disable them from the LHN when connecting to QBO + PolicyAction.enablePolicyTaxes(policyID, false); removePolicyConnection(policyID, integrationToDisconnect); Link.openLink(getQuickBooksOnlineSetupLink(policyID), environmentURL); setIsDisconnectModalOpen(false); diff --git a/src/components/EReceiptThumbnail.tsx b/src/components/EReceiptThumbnail.tsx index f4216dcc9f8a..a8d636db460b 100644 --- a/src/components/EReceiptThumbnail.tsx +++ b/src/components/EReceiptThumbnail.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TripReservationUtils from '@libs/TripReservationUtils'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -56,7 +57,8 @@ const backgroundImages = { function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptThumbnail = false, centerIconV = true, iconSize = 'large'}: EReceiptThumbnailProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const colorCode = isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction); + const {tripIcon, tripBGColor} = TripReservationUtils.getTripEReceiptData(transaction); + const colorCode = tripBGColor ?? (isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction)); const backgroundImage = useMemo(() => backgroundImages[colorCode], [colorCode]); @@ -141,6 +143,14 @@ function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptT fill={primaryColor} /> ) : null} + {tripIcon ? ( + + ) : null} diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 6245fdcf7b49..afbe2bb124b5 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -49,12 +49,14 @@ type ValidInputs = | typeof AddPlaidBankAccount | typeof EmojiPickerButtonDropdown; -type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country'; +type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues'; type ValueTypeMap = { string: string; boolean: boolean; date: Date; country: Country | ''; + reportFields: string[]; + disabledListValues: boolean[]; }; type FormValue = ValueOf; diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index c74d9bd5aa52..e12be53d01ae 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -7,6 +7,7 @@ import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Image from '@components/Image'; import RESIZE_MODES from '@components/Image/resizeModes'; import type {ImageOnLoadEvent} from '@components/Image/types'; +import Lightbox from '@components/Lightbox'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -200,25 +201,11 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV if (canUseTouchScreen) { return ( - - 1 ? RESIZE_MODES.center : RESIZE_MODES.contain} - onLoadStart={imageLoadingStart} - onLoad={imageLoad} - onError={onError} - /> - {((isLoading && (!isOffline || isLocalFile)) || (!isLoading && zoomScale === 0)) && } - {isLoading && !isLocalFile && } - + ); } return ( diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 2628e938e215..780c8c7d2ea4 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -437,6 +437,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea paymentType={paymentType} chatReport={chatReport} moneyRequestReport={moneyRequestReport} + transactionCount={transactionIDs.length} /> )} ; /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ - pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude + pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude /** Handles scale changed event */ onScaleChanged?: OnScaleChangedCallback; @@ -48,6 +49,7 @@ type MultiGestureCanvasProps = ChildrenProps & { /** Handles scale changed event */ onTap?: OnTapCallback; + /** Handles swipe down event */ onSwipeDown?: OnSwipeDownCallback; }; @@ -242,11 +244,12 @@ function MultiGestureCanvas({ e.preventDefault()} style={StyleUtils.getFullscreenCenteredContentStyles()} > {children} diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index 903f384dd525..636913fdf05d 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -3,6 +3,7 @@ import {Dimensions} from 'react-native'; import type {PanGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import * as Browser from '@libs/Browser'; import {SPRING_CONFIG} from './constants'; import type {MultiGestureCanvasVariables} from './types'; import * as MultiGestureCanvasUtils from './utils'; @@ -57,6 +58,8 @@ const usePanGesture = ({ const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); + const isMobileBrowser = Browser.isMobile(); + // Disable "swipe down to close" gesture when content is bigger than the canvas const enableSwipeDownToClose = useDerivedValue(() => canvasSize.height < zoomedContentHeight.value, [canvasSize.height]); @@ -207,7 +210,9 @@ const usePanGesture = ({ panVelocityY.value = evt.velocityY; if (!isSwipingDownToClose.value) { - panTranslateX.value += evt.changeX; + if (!isMobileBrowser || (isMobileBrowser && zoomScale.value !== 1)) { + panTranslateX.value += evt.changeX; + } } if (enableSwipeDownToClose.value || isSwipingDownToClose.value) { diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 46ec51994d90..872464d8a5b0 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; @@ -40,6 +40,9 @@ type ProcessMoneyReportHoldMenuProps = { /** Type of action handled */ requestType?: ActionHandledType; + + /** Number of transaction of a money request */ + transactionCount: number; }; function ProcessMoneyReportHoldMenu({ @@ -52,6 +55,7 @@ function ProcessMoneyReportHoldMenu({ paymentType, chatReport, moneyRequestReport, + transactionCount, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; @@ -68,12 +72,19 @@ function ProcessMoneyReportHoldMenu({ onClose(); }; + const promptText = useMemo(() => { + if (nonHeldAmount) { + return translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount'); + } + return translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {transactionCount}); + }, [nonHeldAmount, transactionCount, translate, isApprove]); + return ( onSubmit(false)} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 3c27ec64d130..9693b982ec4a 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -464,6 +464,7 @@ function ReportPreview({ paymentType={paymentType} chatReport={chatReport} moneyRequestReport={iouReport} + transactionCount={numberOfRequests} /> )} diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 553839ae8457..7119cee06cd9 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -88,8 +88,8 @@ function ReportListItem({ return null; } - const participantFrom = reportItem.transactions[0].from; - const participantTo = reportItem.transactions[0].to; + const participantFrom = reportItem.from; + const participantTo = reportItem.to; // These values should come as part of the item via SearchUtils.getSections() but ReportListItem is not yet 100% handled // This will be simplified in future once sorting of ReportListItem is done diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 9fc138254f8b..83bc8df36571 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -43,7 +43,7 @@ function TableListItem({ return ( ({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3, item.cursorStyle]} > - + {item.isSelected && ( (null); const focusTimeoutRef = useRef(null); + const hide = useCallback(() => { + onClose(); + if (shouldClearOnClose) { + setValue(''); + } + }, [onClose, shouldClearOnClose]); + useFocusEffect( useCallback(() => { focusTimeoutRef.current = setTimeout(() => { @@ -44,8 +52,8 @@ function TextSelectorModal({value, description = '', onValueSelected, isVisible, + {!!subtitle && {subtitle}} & + + /** Whether to clear the input value when the modal closes */ + shouldClearOnClose?: boolean; +} & Pick & TextProps; type TextPickerProps = { @@ -39,7 +42,7 @@ type TextPickerProps = { /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; -} & Pick & +} & Pick & TextProps; export type {TextSelectorModalProps, TextPickerProps}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 197b9dc48063..37b5c650e1b8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -16,6 +16,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -355,6 +356,9 @@ export default { companyID: 'Company ID', userID: 'User ID', disable: 'Disable', + initialValue: 'Initial value', + currentDate: 'Current date', + value: 'Value', }, location: { useCurrent: 'Use current location', @@ -789,8 +793,12 @@ export default { keepAll: 'Keep all', confirmApprove: 'Confirm approval amount', confirmApprovalAmount: "Approve what's not on hold, or approve the entire report.", + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to approve anyway?`, confirmPay: 'Confirm payment amount', - confirmPayAmount: "Pay what's not on hold, or pay all out-of-pocket spend.", + confirmPayAmount: "Pay what's not on hold, or pay the entire report.", + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to pay anyway?`, payOnly: 'Pay only', approveOnly: 'Approve only', holdEducationalTitle: 'This expense is on', @@ -1990,7 +1998,7 @@ export default { reimburse: 'Reimbursements', categories: 'Categories', tags: 'Tags', - reportFields: 'Report Fields', + reportFields: 'Report fields', taxes: 'Taxes', bills: 'Bills', invoices: 'Invoices', @@ -2433,9 +2441,42 @@ export default { title: "You haven't created any report fields", subtitle: 'Add a custom field (text, date, or dropdown) that appears on reports.', }, - subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information", + subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information.", disableReportFields: 'Disable report fields', disableReportFieldsConfirmation: 'Are you sure? Text and date fields will be deleted, and lists will be disabled.', + textType: 'Text', + dateType: 'Date', + dropdownType: 'List', + textAlternateText: 'Add a field for free text input.', + dateAlternateText: 'Add a calendar for date selection.', + dropdownAlternateText: 'Add a list of options to choose from.', + nameInputSubtitle: 'Choose a name for the report field.', + typeInputSubtitle: 'Choose what type of report field to use.', + initialValueInputSubtitle: 'Enter a starting value to show in the report field.', + listValuesInputSubtitle: 'These values will appear in your report field dropdown. Enabled values can be selected by members.', + listInputSubtitle: 'These values will appear in your report field list. Enabled values can be selected by members.', + deleteValue: 'Delete value', + deleteValues: 'Delete values', + disableValue: 'Disable value', + disableValues: 'Disable values', + enableValue: 'Enable value', + enableValues: 'Enable values', + emptyReportFieldsValues: { + title: "You haven't created any list values", + subtitle: 'Add custom values to appear on reports.', + }, + deleteValuePrompt: 'Are you sure you want to delete this list value?', + deleteValuesPrompt: 'Are you sure you want to delete these list values?', + listValueRequiredError: 'Please enter a list value name', + existingListValueError: 'A list value with this name already exists', + editValue: 'Edit value', + listValues: 'List values', + addValue: 'Add value', + existingReportFieldNameError: 'A report field with this name already exists', + reportFieldNameRequiredError: 'Please enter a report field name', + reportFieldTypeRequiredError: 'Please choose a report field type', + reportFieldInitialValueRequiredError: 'Please choose a report field initial value', + genericFailureMessage: 'An error occurred while updating the report field. Please try again.', }, tags: { tagName: 'Tag name', @@ -2847,6 +2888,8 @@ export default { editor: { descriptionInputLabel: 'Description', nameInputLabel: 'Name', + typeInputLabel: 'Type', + initialValueInputLabel: 'Initial value', nameInputHelpText: "This is the name you'll see on your workspace.", nameIsRequiredError: "You'll need to give your workspace a name.", currencyInputLabel: 'Default currency', diff --git a/src/languages/es.ts b/src/languages/es.ts index ced35f3f927d..754ca9037d3c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -15,6 +15,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -346,6 +347,9 @@ export default { companyID: 'Empresa ID', userID: 'Usuario ID', disable: 'Deshabilitar', + initialValue: 'Valor inicial', + currentDate: 'Fecha actual', + value: 'Valor', }, connectionComplete: { title: 'Conexión completa', @@ -783,8 +787,20 @@ export default { keepAll: 'Mantener todos', confirmApprove: 'Confirmar importe a aprobar', confirmApprovalAmount: 'Aprueba lo que no está bloqueado, o aprueba todo el informe.', + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'aprobar', + 'aprobarlos', + transactionCount, + )} de todos modos?`, confirmPay: 'Confirmar importe de pago', - confirmPayAmount: 'Paga lo que no está bloqueado, o paga todos los gastos por cuenta propia.', + confirmPayAmount: 'Paga lo que no está bloqueado, o paga el informe completo.', + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'pagar', + 'pagarlo', + transactionCount, + )} de todos modos?`, payOnly: 'Solo pagar', approveOnly: 'Solo aprobar', hold: 'Bloquear', @@ -2467,9 +2483,42 @@ export default { title: 'No has creado ningún campo de informe', subtitle: 'Añade un campo personalizado (texto, fecha o desplegable) que aparezca en los informes.', }, - subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando desees solicitar información adicional', + subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando quieras solicitar información adicional.', disableReportFields: 'Desactivar campos de informe', disableReportFieldsConfirmation: 'Estás seguro? Se eliminarán los campos de texto y fecha y se desactivarán las listas.', + textType: 'Texto', + dateType: 'Fecha', + dropdownType: 'Lista', + textAlternateText: 'Añade un campo para introducir texto libre.', + dateAlternateText: 'Añade un calendario para la selección de fechas.', + dropdownAlternateText: 'Añade una lista de opciones para elegir.', + nameInputSubtitle: 'Elige un nombre para el campo del informe.', + typeInputSubtitle: 'Elige qué tipo de campo de informe utilizar.', + initialValueInputSubtitle: 'Ingresa un valor inicial para mostrar en el campo del informe.', + listValuesInputSubtitle: 'Estos valores aparecerán en el desplegable del campo de tu informe. Los miembros pueden seleccionar los valores habilitados.', + listInputSubtitle: 'Estos valores aparecerán en la lista de campos de tu informe. Los miembros pueden seleccionar los valores habilitados.', + deleteValue: 'Eliminar valor', + deleteValues: 'Eliminar valores', + disableValue: 'Desactivar valor', + disableValues: 'Desactivar valores', + enableValue: 'Habilitar valor', + enableValues: 'Habilitar valores', + emptyReportFieldsValues: { + title: 'No has creado ningún valor en la lista', + subtitle: 'Añade valores personalizados para que aparezcan en los informes.', + }, + deleteValuePrompt: '¿Estás seguro de que quieres eliminar este valor de la lista?', + deleteValuesPrompt: '¿Estás seguro de que quieres eliminar estos valores de la lista?', + listValueRequiredError: 'Ingresa un nombre para el valor de la lista', + existingListValueError: 'Ya existe un valor en la lista con este nombre', + editValue: 'Editar valor', + listValues: 'Valores de la lista', + addValue: 'Añade valor', + existingReportFieldNameError: 'Ya existe un campo de informe con este nombre', + reportFieldNameRequiredError: 'Ingresa un nombre de campo de informe', + reportFieldTypeRequiredError: 'Elige un tipo de campo de informe', + reportFieldInitialValueRequiredError: 'Elige un valor inicial de campo de informe', + genericFailureMessage: 'Se ha producido un error al actualizar el campo del informe. Por favor, inténtalo de nuevo.', }, tags: { tagName: 'Nombre de etiqueta', @@ -2881,6 +2930,8 @@ export default { editor: { nameInputLabel: 'Nombre', descriptionInputLabel: 'Descripción', + typeInputLabel: 'Tipo', + initialValueInputLabel: 'Valor inicial', nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.', nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.', currencyInputLabel: 'Moneda por defecto', diff --git a/src/languages/types.ts b/src/languages/types.ts index 7ec56760c2f1..78a711fe8282 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -298,6 +298,8 @@ type DistanceRateOperationsParams = {count: number}; type ReimbursementRateParams = {unit: Unit}; +type ConfirmHoldExpenseParams = {transactionCount: number}; + type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string}; type ChangePolicyParams = {fromPolicy: string; toPolicy: string}; @@ -350,6 +352,7 @@ export type { BeginningOfChatHistoryDomainRoomPartOneParams, CanceledRequestParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, diff --git a/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts new file mode 100644 index 000000000000..13844a279905 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts @@ -0,0 +1,6 @@ +type CreateWorkspaceReportFieldParams = { + policyID: string; + reportFields: string; +}; + +export default CreateWorkspaceReportFieldParams; diff --git a/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts b/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts new file mode 100644 index 000000000000..9227f40997ff --- /dev/null +++ b/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts @@ -0,0 +1,6 @@ +type SyncPolicyToNetSuiteParams = { + policyID: string; + idempotencyKey: string; +}; + +export default SyncPolicyToNetSuiteParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 2f203a4cfd9a..0b63ec3ed465 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -14,6 +14,7 @@ export type {default as ConnectBankAccountParams} from './ConnectBankAccountPara export type {default as ConnectPolicyToAccountingIntegrationParams} from './ConnectPolicyToAccountingIntegrationParams'; export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyToQuickbooksOnlineParams'; export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams'; +export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams'; export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams'; export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams'; export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams'; @@ -237,6 +238,7 @@ export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyReq export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; +export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams'; export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index c5d5f1ad1e6e..bd10be8948bc 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -234,6 +234,7 @@ const WRITE_COMMANDS = { UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', + CREATE_WORKSPACE_REPORT_FIELD: 'CreatePolicyReportField', UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION: 'UpdateNetSuiteSyncTaxConfiguration', UPDATE_NETSUITE_EXPORTER: 'UpdateNetSuiteExporter', UPDATE_NETSUITE_EXPORT_DATE: 'UpdateNetSuiteExportDate', @@ -252,6 +253,7 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD: 'UpdateNetSuiteExportToNextOpenPeriod', REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', + CLEAR_OUTSTANDING_BALANCE: 'ClearOutstandingBalance', } as const; type WriteCommand = ValueOf; @@ -456,6 +458,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; + [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; @@ -492,6 +495,10 @@ type WriteCommandParameters = { // Netsuite parameters [WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams; + + // Workspace report field parameters + [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD]: Parameters.CreateWorkspaceReportFieldParams; + [WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORTER]: Parameters.UpdateNetSuiteGenericTypeParams<'email', string>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_DATE]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; @@ -515,6 +522,7 @@ const READ_COMMANDS = { CONNECT_POLICY_TO_XERO: 'ConnectPolicyToXero', SYNC_POLICY_TO_QUICKBOOKS_ONLINE: 'SyncPolicyToQuickbooksOnline', SYNC_POLICY_TO_XERO: 'SyncPolicyToXero', + SYNC_POLICY_TO_NETSUITE: 'SyncPolicyToNetSuite', OPEN_REIMBURSEMENT_ACCOUNT_PAGE: 'OpenReimbursementAccountPage', OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', @@ -564,6 +572,7 @@ type ReadCommandParameters = { [READ_COMMANDS.CONNECT_POLICY_TO_XERO]: Parameters.ConnectPolicyToAccountingIntegrationParams; [READ_COMMANDS.SYNC_POLICY_TO_QUICKBOOKS_ONLINE]: Parameters.SyncPolicyToQuickbooksOnlineParams; [READ_COMMANDS.SYNC_POLICY_TO_XERO]: Parameters.SyncPolicyToXeroParams; + [READ_COMMANDS.SYNC_POLICY_TO_NETSUITE]: Parameters.SyncPolicyToNetSuiteParams; [READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE]: Parameters.OpenReimbursementAccountPageParams; [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index f538e5e719e2..8a8888902e92 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -806,6 +806,19 @@ function doesDateBelongToAPastYear(date: string): boolean { return transactionYear !== new Date().getFullYear(); } +/** + * Returns a boolean value indicating whether the card has expired. + * @param expiryMonth month when card expires (starts from 1 so can be any number between 1 and 12) + * @param expiryYear year when card expires + */ + +function isCardExpired(expiryMonth: number, expiryYear: number): boolean { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + return expiryYear < currentYear || (expiryYear === currentYear && expiryMonth < currentMonth); +} + const DateUtils = { isDate, formatToDayOfWeek, @@ -850,6 +863,7 @@ const DateUtils = { getFormattedReservationRangeDate, getFormattedTransportDate, doesDateBelongToAPastYear, + isCardExpired, }; export default DateUtils; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e0fb17f882d3..4fd6251ec644 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -361,6 +361,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard').default, [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/WorkspaceCreateReportFieldPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldListValuesPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldAddListValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ValueSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 1ebbdb5aa0df..3baa0f9f6889 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -102,7 +102,13 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT, SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS, ], - [SCREENS.WORKSPACE.REPORT_FIELDS]: [], + [SCREENS.WORKSPACE.REPORT_FIELDS]: [ + SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, + SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES, + SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE, + SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS, + SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE, + ], [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 01b467fb53de..3f4896e0c5d2 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -525,6 +525,21 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { path: ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.route, }, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + path: ROUTES.WORKSPACE_CREATE_REPORT_FIELD.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route, + }, [SCREENS.REIMBURSEMENT_ACCOUNT]: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0a2809d97208..ac9710b65d19 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -268,6 +268,23 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { policyID: string; }; + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + policyID: string; + valueIndex: number; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + policyID: string; + valueIndex: number; + }; [SCREENS.WORKSPACE.MEMBER_DETAILS]: { policyID: string; accountID: string; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index faea5965fee4..aafc38a9040b 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -60,6 +60,10 @@ function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); } +function canUseCommentLinking(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.COMMENT_LINKING) || canUseAllBetas(betas); +} + /** * Link previews are temporarily disabled. */ @@ -82,4 +86,5 @@ export default { canUseReportFieldsFeature, canUseWorkspaceFeeds, canUseNetSuiteUSATax, + canUseCommentLinking, }; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 8ba468e87ed0..8bdf0cb1d5fe 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -157,6 +157,7 @@ function getPersonalDetailsOnyxDataForOptimisticUsers(newLogins: string[], newAc login, accountID, displayName: LocalePhoneNumber.formatPhoneNumber(login), + isOptimisticPersonalDetail: true, }; /** diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 119c20179a0c..cd641cdc8c90 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -166,6 +166,8 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...value, + from: data.personalDetailsList?.[value.accountID], + to: data.personalDetailsList?.[value.managerID], transactions, }; } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { @@ -199,7 +201,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx if (reportIDToTransactions[reportKey]?.transactions) { reportIDToTransactions[reportKey].transactions.push(transaction); } else { - reportIDToTransactions[reportKey] = {transactions: [transaction]}; + reportIDToTransactions[reportKey].transactions = [transaction]; } } } diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 8569a3f03128..c807d0ca4a7e 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -81,6 +81,7 @@ Onyx.connect({ let retryBillingSuccessful: OnyxEntry; Onyx.connect({ key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + initWithStoredValues: false, callback: (value) => { if (value === undefined) { return; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index ead786b8eafd..e937979ae7b9 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,3 +1,4 @@ +import type {EReceiptColorName} from '@styles/utils/types'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import type {Reservation, ReservationType} from '@src/types/onyx/Transaction'; @@ -24,4 +25,26 @@ function getReservationsFromTripTransactions(transactions: Transaction[]): Reser .flat(); } -export {getTripReservationIcon, getReservationsFromTripTransactions}; +type TripEReceiptData = { + /** Icon asset associated with the type of trip reservation */ + tripIcon?: IconAsset; + + /** EReceipt background color associated with the type of trip reservation */ + tripBGColor?: EReceiptColorName; +}; + +function getTripEReceiptData(transaction?: Transaction): TripEReceiptData { + const reservationType = transaction ? transaction.receipt?.reservationList?.[0]?.type : ''; + + switch (reservationType) { + case CONST.RESERVATION_TYPE.FLIGHT: + case CONST.RESERVATION_TYPE.CAR: + return {tripIcon: Expensicons.Plane, tripBGColor: CONST.ERECEIPT_COLORS.PINK}; + case CONST.RESERVATION_TYPE.HOTEL: + return {tripIcon: Expensicons.Bed, tripBGColor: CONST.ERECEIPT_COLORS.YELLOW}; + default: + return {}; + } +} + +export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptData}; diff --git a/src/libs/WorkspaceReportFieldsUtils.ts b/src/libs/WorkspaceReportFieldsUtils.ts new file mode 100644 index 000000000000..0cc3cae24a23 --- /dev/null +++ b/src/libs/WorkspaceReportFieldsUtils.ts @@ -0,0 +1,70 @@ +import type {FormInputErrors} from '@components/Form/types'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import type {InputID} from '@src/types/form/WorkspaceReportFieldsForm'; +import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; +import * as ErrorUtils from './ErrorUtils'; +import * as Localize from './Localize'; +import * as ValidationUtils from './ValidationUtils'; + +/** + * Gets the translation key for the report field type. + */ +function getReportFieldTypeTranslationKey(reportFieldType: PolicyReportFieldType): TranslationPaths { + const typeTranslationKeysStrategy: Record = { + [CONST.REPORT_FIELD_TYPES.TEXT]: 'workspace.reportFields.textType', + [CONST.REPORT_FIELD_TYPES.DATE]: 'workspace.reportFields.dateType', + [CONST.REPORT_FIELD_TYPES.LIST]: 'workspace.reportFields.dropdownType', + }; + + return typeTranslationKeysStrategy[reportFieldType]; +} + +/** + * Gets the translation key for the alternative text for the report field. + */ +function getReportFieldAlternativeTextTranslationKey(reportFieldType: PolicyReportFieldType): TranslationPaths { + const typeTranslationKeysStrategy: Record = { + [CONST.REPORT_FIELD_TYPES.TEXT]: 'workspace.reportFields.textAlternateText', + [CONST.REPORT_FIELD_TYPES.DATE]: 'workspace.reportFields.dateAlternateText', + [CONST.REPORT_FIELD_TYPES.LIST]: 'workspace.reportFields.dropdownAlternateText', + }; + + return typeTranslationKeysStrategy[reportFieldType]; +} + +/** + * Validates the list value name. + */ +function validateReportFieldListValueName( + valueName: string, + priorValueName: string, + listValues: string[], + inputID: InputID, +): FormInputErrors { + const errors: FormInputErrors = {}; + + if (!ValidationUtils.isRequiredFulfilled(valueName)) { + errors[inputID] = Localize.translateLocal('workspace.reportFields.listValueRequiredError'); + } else if (priorValueName !== valueName && listValues.some((currentValueName) => currentValueName === valueName)) { + errors[inputID] = Localize.translateLocal('workspace.reportFields.existingListValueError'); + } else if ([...valueName].length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. + ErrorUtils.addErrorMessage( + errors, + inputID, + Localize.translateLocal('common.error.characterLimitExceedCounter', {length: [...valueName].length, limit: CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH}), + ); + } + + return errors; +} +/** + * Generates a field ID based on the field name. + */ +function generateFieldID(name: string) { + return `field_id_${name.replace(CONST.REGEX.ANY_SPACE, '_').toUpperCase()}`; +} + +export {getReportFieldTypeTranslationKey, getReportFieldAlternativeTextTranslationKey, validateReportFieldListValueName, generateFieldID}; diff --git a/src/libs/actions/Policy/ReportFields.ts b/src/libs/actions/Policy/ReportFields.ts new file mode 100644 index 000000000000..220432cbc3c6 --- /dev/null +++ b/src/libs/actions/Policy/ReportFields.ts @@ -0,0 +1,209 @@ +import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {CreateWorkspaceReportFieldParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import {generateFieldID} from '@libs/WorkspaceReportFieldsUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {WorkspaceReportFieldsForm} from '@src/types/form/WorkspaceReportFieldsForm'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldsForm'; +import type {Policy, PolicyReportField} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +let listValues: string[]; +let disabledListValues: boolean[]; +Onyx.connect({ + key: ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, + callback: (value) => { + if (!value) { + return; + } + + listValues = value[INPUT_IDS.LIST_VALUES] ?? []; + disabledListValues = value[INPUT_IDS.DISABLED_LIST_VALUES] ?? []; + }, +}); + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (value, key) => { + if (!key) { + return; + } + + if (value === null || value === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = value; + }, +}); + +/** + * Sets the initial form values for the workspace report fields form. + */ +function setInitialCreateReportFieldsForm() { + Onyx.set(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.INITIAL_VALUE]: '', + }); +} + +/** + * Creates a new list value in the workspace report fields form. + */ +function createReportFieldsListValue(valueName: string) { + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: [...listValues, valueName], + [INPUT_IDS.DISABLED_LIST_VALUES]: [...disabledListValues, false], + }); +} + +/** + * Renames a list value in the workspace report fields form. + */ +function renameReportFieldsListValue(valueIndex: number, newValueName: string) { + const listValuesCopy = [...listValues]; + listValuesCopy[valueIndex] = newValueName; + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: listValuesCopy, + }); +} + +/** + * Sets the enabled state of a list value in the workspace report fields form. + */ +function setReportFieldsListValueEnabled(valueIndexes: number[], enabled: boolean) { + const disabledListValuesCopy = [...disabledListValues]; + + valueIndexes.forEach((valueIndex) => { + disabledListValuesCopy[valueIndex] = !enabled; + }); + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.DISABLED_LIST_VALUES]: disabledListValuesCopy, + }); +} + +/** + * Deletes a list value from the workspace report fields form. + */ +function deleteReportFieldsListValue(valueIndexes: number[]) { + const listValuesCopy = [...listValues]; + const disabledListValuesCopy = [...disabledListValues]; + + valueIndexes + .sort((a, b) => b - a) + .forEach((valueIndex) => { + listValuesCopy.splice(valueIndex, 1); + disabledListValuesCopy.splice(valueIndex, 1); + }); + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: listValuesCopy, + [INPUT_IDS.DISABLED_LIST_VALUES]: disabledListValuesCopy, + }); +} + +type CreateReportFieldArguments = Pick; + +/** + * Creates a new report field. + */ +function createReportField(policyID: string, {name, type, initialValue}: CreateReportFieldArguments) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const fieldID = generateFieldID(name); + const fieldKey = ReportUtils.getReportFieldKey(fieldID); + const newReportField: PolicyReportField = { + name, + type, + defaultValue: initialValue, + values: listValues, + disabledOptions: disabledListValues, + fieldID, + orderWeight: Object.keys(previousFieldList).length + 1, + deletable: false, + value: type === CONST.REPORT_FIELD_TYPES.LIST ? CONST.REPORT_FIELD_TYPES.LIST : null, + keys: [], + externalIDs: [], + isTax: false, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: newReportField, + }, + pendingFields: { + [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + pendingFields: { + [fieldKey]: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: null, + }, + pendingFields: { + [fieldKey]: null, + }, + errorFields: { + [fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + const parameters: CreateWorkspaceReportFieldParams = { + policyID, + reportFields: JSON.stringify([newReportField]), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD, parameters, onyxData); +} + +export type {CreateReportFieldArguments}; + +export {setInitialCreateReportFieldsForm, createReportFieldsListValue, renameReportFieldsListValue, setReportFieldsListValueEnabled, deleteReportFieldsListValue, createReportField}; diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index 19a3bf0c547e..beed2b1b2962 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -231,4 +231,60 @@ function clearUpdateSubscriptionSizeError() { }); } -export {openSubscriptionPage, updateSubscriptionAutoRenew, updateSubscriptionAddNewUsersAutomatically, updateSubscriptionSize, clearUpdateSubscriptionSizeError, updateSubscriptionType}; +function clearOutstandingBalance() { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: true, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: true, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: false, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: true, + }, + ], + }; + + API.write(WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE, null, onyxData); +} + +export { + openSubscriptionPage, + updateSubscriptionAutoRenew, + updateSubscriptionAddNewUsersAutomatically, + updateSubscriptionSize, + clearUpdateSubscriptionSizeError, + updateSubscriptionType, + clearOutstandingBalance, +}; diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 872e82951834..fd6440c3a92c 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -132,6 +132,9 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) { case CONST.POLICY.CONNECTIONS.NAME.XERO: { return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_XERO, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.STARTING_IMPORT_XERO}; } + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: { + return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_NETSUITE, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION}; + } default: return undefined; } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 78cb5dfcd991..91fdd903ec3a 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -197,7 +197,6 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen onSubmit={inviteUsers} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} enabledWhenOffline - disablePressOnEnter /> ), [selectedOptions.length, inviteUsers, translate, styles], diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index afa57755ad70..8a807655ae57 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -266,7 +266,6 @@ function RoomInvitePage({ onSubmit={inviteUsers} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.mb5, styles.ph5]} enabledWhenOffline - disablePressOnEnter isAlertVisible={false} /> diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 136d8e5b59eb..5ab38cbf2e7e 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -166,7 +166,6 @@ function BaseReportActionContextMenu({ disabledIndexes, maxIndex: filteredContextMenuActions.length - 1, isActive: shouldEnableArrowNavigation, - disableCyclicTraversal: true, }); /** diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index bf634b4ac8ae..0e29e7496def 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -19,6 +19,7 @@ import * as Localize from '@libs/Localize'; import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage'; import Navigation from '@libs/Navigation/Navigation'; import {parseHtmlToMarkdown, parseHtmlToText} from '@libs/OnyxAwareParser'; +import Permissions from '@libs/Permissions'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -425,6 +426,10 @@ const ContextMenuActions: ContextMenuAction[] = [ successIcon: Expensicons.Checkmark, successTextTranslateKey: 'reportActionContextMenu.copied', shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget) => { + if (!Permissions.canUseCommentLinking(betas)) { + return false; + } + const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); // Only hide the copylink menu item when context menu is opened over img element. diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index 4587dfee2fe6..bbb06dac4549 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithoutFeedback} from '@components/Pressable'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,12 +36,50 @@ type BillingBannerProps = { /** An icon to be rendered instead of the RBR / GBR indicator. */ rightIcon?: IconAsset; + + /** Callback to be called when the right icon is pressed. */ + onRightIconPress?: () => void; + + /** Accessibility label for the right icon. */ + rightIconAccessibilityLabel?: string; }; -function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon}: BillingBannerProps) { +function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon, onRightIconPress, rightIconAccessibilityLabel}: BillingBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); + const rightIconComponent = useMemo(() => { + if (rightIcon) { + return onRightIconPress && rightIconAccessibilityLabel ? ( + + + + ) : ( + + ); + } + + return ( + !!brickRoadIndicator && ( + + ) + ); + }, [brickRoadIndicator, onRightIconPress, rightIcon, rightIconAccessibilityLabel, styles.touchableButtonImage, theme.danger, theme.icon, theme.success]); + return ( {title} : title} {typeof subtitle === 'string' ? {subtitle} : subtitle} - {rightIcon ? ( - - ) : ( - !!brickRoadIndicator && ( - - ) - )} + {rightIconComponent} ); } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx index dce215e7dbbc..d949e2699e44 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx @@ -14,7 +14,7 @@ type SubscriptionBillingBannerProps = Omit ); } diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index e873569e4583..f3b78b3f2b95 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -1,6 +1,7 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -8,6 +9,7 @@ import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +17,7 @@ import * as User from '@libs/actions/User'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import * as Subscription from '@userActions/Subscription'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -24,6 +27,7 @@ import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; +import type {BillingStatusResult} from './utils'; import CardSectionUtils from './utils'; function CardSection() { @@ -35,8 +39,10 @@ function CardSection() { const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const subscriptionPlan = useSubscriptionPlan(); - const [network] = useOnyx(ONYXKEYS.NETWORK); - + const [subscriptionRetryBillingStatusPending] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING); + const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL); + const [subscriptionRetryBillingStatusFailed] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED); + const {isOffline} = useNetwork(); const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); const cardMonth = useMemo(() => DateUtils.getMonthNames(preferredLocale)[(defaultCard?.accountData?.cardMonth ?? 1) - 1], [defaultCard?.accountData?.cardMonth, preferredLocale]); @@ -47,12 +53,24 @@ function CardSection() { Navigation.resetToHome(); }, []); - const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? ''); + const [billingStatus, setBillingStatus] = useState(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; const sectionSubtitle = defaultCard && !!nextPaymentDate ? translate('subscription.cardSection.cardNextPayment', {nextPaymentDate}) : translate('subscription.cardSection.subtitle'); + useEffect(() => { + setBillingStatus(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); + }, [subscriptionRetryBillingStatusPending, subscriptionRetryBillingStatusSuccessful, subscriptionRetryBillingStatusFailed, translate, defaultCard?.accountData]); + + const handleRetryPayment = () => { + Subscription.clearOutstandingBalance(); + }; + + const handleBillingBannerClose = () => { + setBillingStatus(undefined); + }; + let BillingBanner: React.ReactNode | undefined; if (CardSectionUtils.shouldShowPreTrialBillingBanner()) { BillingBanner = ; @@ -66,6 +84,8 @@ function CardSection() { isError={billingStatus.isError} icon={billingStatus.icon} rightIcon={billingStatus.rightIcon} + onRightIconPress={handleBillingBannerClose} + rightIconAccessibilityLabel={translate('common.close')} /> ); } @@ -105,6 +125,18 @@ function CardSection() { {isEmptyObject(defaultCard?.accountData) && } + + {billingStatus?.isRetryAvailable !== undefined && ( +