diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ff233b1d41a0..e27494b980bb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1315,6 +1315,16 @@ const ROUTES = { return getUrlWithBackToParam(`${action as string}/${iouType as string}/taxAmount/${transactionID}/${reportID}`, backTo); }, }, + MONEY_REQUEST_STEP_CATEGORY_CREATE: { + route: ':action/:iouType/category/new/:transactionID/:reportID/:reportActionID?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string, backTo = '') => { + if (!transactionID || !reportID) { + Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_CATEGORY_CREATE route'); + } + // eslint-disable-next-line no-restricted-syntax -- backTo is needed here to track where editing was initiated from (e.g. search/view or r/:reportID) + return getUrlWithBackToParam(`${action as string}/${iouType as string}/category/new/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo); + }, + }, MONEY_REQUEST_STEP_CATEGORY: { route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index b21d78b3b9f4..d6c6baa136f6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -355,6 +355,7 @@ const SCREENS = { STEP_UPGRADE: 'Money_Request_Step_Upgrade', STEP_AMOUNT: 'Money_Request_Step_Amount', STEP_CATEGORY: 'Money_Request_Step_Category', + STEP_CATEGORY_CREATE: 'Money_Request_Step_Category_Create', STEP_DATE: 'Money_Request_Step_Date', STEP_DESCRIPTION: 'Money_Request_Step_Description', STEP_DISTANCE: 'Money_Request_Step_Distance', diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 3a885a5f8e1e..cbe3ff2e30e6 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -174,7 +174,9 @@ function useConfirmationValidation({ return {errorKey: 'iou.error.invalidCategoryLength'}; } - if (iouCategory && policyCategories && !policyCategories[iouCategory]?.enabled) { + const isCategoryBeingCreated = policyCategories?.[iouCategory]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + + if (iouCategory && policyCategories && !policyCategories[iouCategory]?.enabled && !isCategoryBeingCreated) { return {errorKey: 'violations.categoryOutOfPolicy'}; } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 69262506cb48..65ee8548d801 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -178,6 +178,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepTaxAmountPage').default, [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: () => require('../../../../pages/iou/request/step/IOURequestStepTaxRatePage').default, [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: () => require('../../../../pages/iou/request/step/IOURequestStepCategory').default, + [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: () => require('../../../../pages/iou/request/step/IOURequestStepCategoryCreate').default, [SCREENS.MONEY_REQUEST.STEP_DATE]: () => require('../../../../pages/iou/request/step/IOURequestStepDate').default, [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require('../../../../pages/iou/request/step/IOURequestStepDescription').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: () => require('../../../../pages/iou/request/step/IOURequestStepDistance').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index c84494257cad..21b5288da63b 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1713,6 +1713,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO]: ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.route, [SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route, [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route, + [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.route, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION_VERIFY_ACCOUNT]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION_VERIFY_ACCOUNT.route, [SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f074a7677296..cf0acd8cfd9d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1873,6 +1873,15 @@ type MoneyRequestNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo: Routes; }; + [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + reportActionID?: string; + // eslint-disable-next-line no-restricted-syntax -- backTo is needed to track where editing was initiated from (search/view or r/:reportID) + backTo?: Routes; + }; [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: { action: IOUAction; iouType: Exclude; diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index fe9b3faf5088..c5d7f6f927f0 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -395,7 +395,10 @@ const ViolationsUtils = { const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); const categoryKey = updatedTransaction.category; - const isCategoryInPolicy = categoryKey ? policyCategories?.[categoryKey]?.enabled : false; + const categoryData = policyCategories?.[categoryKey ?? '']; + // A category being created optimistically (pendingAction === 'add') is treated as valid + // so in-situ creation doesn't trigger a "categoryOutOfPolicy" violation before the server confirms it. + const isCategoryInPolicy = categoryKey ? !!(categoryData?.enabled || categoryData?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) : false; // Add 'categoryOutOfPolicy' violation if category is not in policy if (!hasCategoryOutOfPolicyViolation && !isCategoryMissing(categoryKey) && !isCategoryInPolicy) { diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index 415e22fd9ff1..80194f18d065 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -11,7 +11,7 @@ import {useSearchStateContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -29,7 +29,7 @@ import {isCategoryMissing} from '@libs/CategoryUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {getValidConnectedIntegration, isPolicyAdmin} from '@libs/PolicyUtils'; import {getReportOrDraftReport, getTransactionDetails, isGroupPolicy, isReportInGroupPolicy} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getRequestType} from '@libs/TransactionUtils'; @@ -57,6 +57,7 @@ function IOURequestStepCategory({ const styles = useThemeStyles(); const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['EmptyStateExpenses']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); const requestType = getRequestType(transaction); const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; const transactionReport = getReportOrDraftReport(transaction?.reportID); @@ -90,6 +91,23 @@ function IOURequestStepCategory({ const categoryForDisplay = isCategoryMissing(transactionCategory) ? '' : transactionCategory; + const canCreateCategoryInSitu = isPolicyAdmin(policy) && !getValidConnectedIntegration(policy) && !!policy?.areCategoriesEnabled; + + const createCategoryMenuItems = canCreateCategoryInSitu + ? [ + { + icon: expensifyIcons.Plus, + text: translate('workspace.categories.addCategory'), + onSelected: () => { + if (!policyID || !report?.reportID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute(action, iouType, transactionID, report.reportID, reportActionID, backTo)); + }, + }, + ] + : undefined; + const shouldShowCategory = (isReportInGroupPolicy(report) || isGroupPolicy(policy?.type ?? '')) && // The transactionCategory can be an empty string, so to maintain the logic we'd like to keep it in this shape until utils refactor @@ -179,6 +197,8 @@ function IOURequestStepCategory({ shouldShowOfflineIndicator={policyCategories !== undefined} testID="IOURequestStepCategory" shouldEnableKeyboardAvoidingView={false} + threeDotsMenuItems={createCategoryMenuItems} + shouldMinimizeMenuButton > {isLoading && ( & + WithFullTransactionOrNotFoundProps; + +function IOURequestStepCategoryCreate({ + report: reportReal, + reportDraft, + route: { + params: {transactionID, action, iouType, reportID, backTo}, + }, + transaction, +}: IOURequestStepCategoryCreateProps) { + const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const {currentSearchHash} = useSearchStateContext(); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isEditingSplit = (iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) && isEditing; + + const policyIdReal = getIOURequestPolicyID(transaction, reportReal); + const policyIdDraft = getIOURequestPolicyID(transaction, reportDraft); + const {policy} = usePolicyForTransaction({ + transaction, + reportPolicyID: policyIdReal ?? policyIdDraft, + action, + iouType, + isPerDiemRequest: false, + }); + const policyID = policy?.id; + + const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`); + const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`); + + const report = reportReal ?? reportDraft; + + useRestartOnReceiptFailure(transaction, reportID, iouType, action); + + const policyHasTags = hasTags(policyTags); + + const { + taskReport: setupCategoryTaskReport, + taskParentReport: setupCategoryTaskParentReport, + isOnboardingTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, + hasOutstandingChildTask, + parentReportAction, + } = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES); + + const { + taskReport: setupCategoriesAndTagsTaskReport, + taskParentReport: setupCategoriesAndTagsTaskParentReport, + isOnboardingTaskParentReportArchived: isSetupCategoriesAndTagsTaskParentReportArchived, + hasOutstandingChildTask: setupCategoriesAndTagsHasOutstandingChildTask, + parentReportAction: setupCategoriesAndTagsParentReportAction, + } = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES_AND_TAGS); + + const createCategory = useCallback( + (values: FormOnyxValues) => { + const categoryName = values.categoryName.trim(); + + if (!policyID) { + return; + } + + // 1. Create the category in the workspace (optimistic update, queued API call). + createPolicyCategory({ + policyID, + categoryName, + isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, + setupCategoryTaskReport, + setupCategoryTaskParentReport, + currentUserAccountID: currentUserPersonalDetails.accountID, + hasOutstandingChildTask, + parentReportAction, + setupCategoriesAndTagsTaskReport, + setupCategoriesAndTagsTaskParentReport, + isSetupCategoriesAndTagsTaskParentReportArchived, + setupCategoriesAndTagsHasOutstandingChildTask, + setupCategoriesAndTagsParentReportAction, + policyHasTags, + }); + + // 2. Apply the newly created category to the transaction. + const policyCategoriesWithNewCategory = { + ...policyCategories, + [categoryName]: { + name: categoryName, + enabled: true, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }; + + if (isEditingSplit && transaction) { + setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {category: categoryName}, policy); + } else if (isEditing && report) { + updateMoneyRequestCategory({ + transactionID: transaction?.transactionID ?? transactionID, + transactionThreadReport: report, + parentReport, + parentReportNextStep, + category: categoryName, + policy, + policyTagList: policyTags, + policyCategories: policyCategoriesWithNewCategory, + policyRecentlyUsedCategories, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.login ?? '', + isASAPSubmitBetaEnabled, + hash: currentSearchHash, + }); + } else { + setMoneyRequestCategory(transactionID, categoryName, policy); + } + + if (isEditing) { + Navigation.goBack(backTo); + } else { + Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID)); + } + }, + [ + action, + backTo, + currentSearchHash, + currentUserPersonalDetails.accountID, + currentUserPersonalDetails.login, + hasOutstandingChildTask, + isASAPSubmitBetaEnabled, + isEditing, + isEditingSplit, + isSetupCategoriesAndTagsTaskParentReportArchived, + isSetupCategoryTaskParentReportArchived, + iouType, + parentReport, + parentReportAction, + parentReportNextStep, + policy, + policyCategories, + policyHasTags, + policyID, + policyRecentlyUsedCategories, + policyTags, + report, + reportID, + setupCategoriesAndTagsHasOutstandingChildTask, + setupCategoriesAndTagsParentReportAction, + setupCategoriesAndTagsTaskParentReport, + setupCategoriesAndTagsTaskReport, + setupCategoryTaskParentReport, + setupCategoryTaskReport, + splitDraftTransaction, + transaction, + transactionID, + ], + ); + + return ( + + Navigation.goBack()} + shouldShowWrapper + testID="IOURequestStepCategoryCreate" + > + + + + ); +} + +const IOURequestStepCategoryCreateWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepCategoryCreate); +const IOURequestStepCategoryCreateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepCategoryCreateWithFullTransactionOrNotFound); +export default IOURequestStepCategoryCreateWithWritableReportOrNotFound; diff --git a/src/pages/iou/request/step/StepScreenWrapper.tsx b/src/pages/iou/request/step/StepScreenWrapper.tsx index 7b4283e368fe..dff47036cf2c 100644 --- a/src/pages/iou/request/step/StepScreenWrapper.tsx +++ b/src/pages/iou/request/step/StepScreenWrapper.tsx @@ -3,6 +3,7 @@ import type {ReactNode} from 'react'; import {View} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {ScreenWrapperChildrenProps} from '@components/ScreenWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -39,6 +40,12 @@ type StepScreenWrapperProps = { /** Flag to indicate if the keyboard avoiding view should be enabled */ shouldEnableKeyboardAvoidingView?: boolean; + + /** Menu items to display in the header three-dots / action button */ + threeDotsMenuItems?: PopoverMenuItem[]; + + /** When true and there is a single menu item, renders it as a direct icon button instead of a three-dots menu */ + shouldMinimizeMenuButton?: boolean; }; function StepScreenWrapper({ @@ -52,6 +59,8 @@ function StepScreenWrapper({ includeSafeAreaPaddingBottom, shouldShowOfflineIndicator = true, shouldEnableKeyboardAvoidingView = true, + threeDotsMenuItems, + shouldMinimizeMenuButton, }: StepScreenWrapperProps) { const styles = useThemeStyles(); @@ -74,6 +83,9 @@ function StepScreenWrapper({ { // If props.children is a function, call it to provide the insets to the children diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 9a99774e8415..19555ecfad4e 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -56,7 +56,8 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.ODOMETER_IMAGE | typeof SCREENS.MONEY_REQUEST.STEP_TIME_RATE | typeof SCREENS.MONEY_REQUEST.STEP_HOURS - | typeof SCREENS.MONEY_REQUEST.STEP_HOURS_EDIT; + | typeof SCREENS.MONEY_REQUEST.STEP_HOURS_EDIT + | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE; type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & PlatformStackScreenProps; diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 0fdb535c2ca6..2f56d246d763 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -59,7 +59,8 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL | typeof SCREENS.MONEY_REQUEST.STEP_TIME_RATE | typeof SCREENS.MONEY_REQUEST.STEP_HOURS - | typeof SCREENS.MONEY_REQUEST.STEP_HOURS_EDIT; + | typeof SCREENS.MONEY_REQUEST.STEP_HOURS_EDIT + | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE; type WithWritableReportOrNotFoundProps = WithWritableReportOrNotFoundOnyxProps & PlatformStackScreenProps;