Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eca73c4
feature: add a new category within the category list
daledah Mar 18, 2026
feb7bdf
Merge branch 'main' into fix/85681
daledah Mar 24, 2026
af61ad1
Merge branch 'main' into fix/85681
daledah Mar 25, 2026
abd7805
Merge branch 'main' into fix/85681
daledah Mar 30, 2026
623faa6
fix auto select created category
daledah Mar 30, 2026
420e10c
Merge branch 'main' into fix/85681
daledah Apr 1, 2026
6bee40a
fix comments and keyboard flicker
daledah Apr 1, 2026
eaae0b7
resolve conflict
daledah Apr 6, 2026
9b492fe
fix auto select created category
daledah Apr 6, 2026
8b352bb
resolve conflict
daledah Apr 8, 2026
c893136
fix ts
daledah Apr 8, 2026
a0d34f2
Merge branch 'main' into fix/85681
daledah Apr 9, 2026
c3a8fe1
resolve conflict
daledah Apr 13, 2026
55338c1
fix the error appears below the category field for a while
daledah Apr 13, 2026
2cb5b17
Merge branch 'main' into fix/85681
daledah Apr 15, 2026
ea19b9a
fix: blank page briefly appears after saving a new category
daledah Apr 15, 2026
297f1cd
resolve conflict
daledah Apr 16, 2026
b508b6c
Merge branch 'main' into fix/85681
daledah Apr 20, 2026
cf9518b
Merge branch 'main' into fix/85681
daledah Apr 24, 2026
4d8a2d3
create new component and fix the navigation bug
daledah Apr 24, 2026
d6e3b4e
fix lint
daledah Apr 24, 2026
9b96a1a
fix ts
daledah Apr 24, 2026
bc53029
Merge branch 'main' into fix/85681
daledah Apr 28, 2026
bf805d4
fix comments
daledah Apr 28, 2026
7dc939f
resolve conflict
daledah Apr 30, 2026
9504eb4
fix lint
daledah Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator<MoneyRequestNa
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepTaxAmountPage').default,
[SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepTaxRatePage').default,
[SCREENS.MONEY_REQUEST.STEP_CATEGORY]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepCategory').default,
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepCategoryCreate').default,
[SCREENS.MONEY_REQUEST.STEP_DATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDate').default,
[SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDescription').default,
[SCREENS.MONEY_REQUEST.STEP_DISTANCE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDistance').default,
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,7 @@ const config: LinkingOptions<RootNavigatorParamList>['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,
Expand Down
9 changes: 9 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IOUType, typeof CONST.IOU.TYPE.REQUEST | typeof CONST.IOU.TYPE.SEND>;
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;
Comment thread
daledah marked this conversation as resolved.
};
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: {
action: IOUAction;
iouType: Exclude<IOUType, typeof CONST.IOU.TYPE.REQUEST | typeof CONST.IOU.TYPE.SEND>;
Expand Down
5 changes: 4 additions & 1 deletion src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 22 additions & 2 deletions src/pages/iou/request/step/IOURequestStepCategory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -179,6 +197,8 @@ function IOURequestStepCategory({
shouldShowOfflineIndicator={policyCategories !== undefined}
testID="IOURequestStepCategory"
shouldEnableKeyboardAvoidingView={false}
threeDotsMenuItems={createCategoryMenuItems}
shouldMinimizeMenuButton
>
{isLoading && (
<ActivityIndicator
Expand Down
214 changes: 214 additions & 0 deletions src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import React, {useCallback} from 'react';
import type {FormOnyxValues} from '@components/Form/types';
import {useSearchStateContext} from '@components/Search/SearchContext';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure';
import {getIOURequestPolicyID, setMoneyRequestCategory} from '@libs/actions/IOU';
import {setDraftSplitTransaction} from '@libs/actions/IOU/Split';
import {updateMoneyRequestCategory} from '@libs/actions/IOU/UpdateMoneyRequest';
import {createPolicyCategory} from '@libs/actions/Policy/Category';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import Navigation from '@libs/Navigation/Navigation';
import {hasTags} from '@libs/PolicyUtils';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import CategoryForm from '@pages/workspace/categories/CategoryForm';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import StepScreenWrapper from './StepScreenWrapper';
import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';

type IOURequestStepCategoryCreateProps = WithWritableReportOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE> &
WithFullTransactionOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE>;

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<typeof ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM>) => {
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 (
<AccessOrNotFoundWrapper
accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]}
policyID={policyID}
featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED}
>
<StepScreenWrapper
headerTitle={translate('workspace.categories.addCategory')}
onBackButtonPress={() => Navigation.goBack()}
shouldShowWrapper
testID="IOURequestStepCategoryCreate"
>
<CategoryForm
onSubmit={createCategory}
policyCategories={policyCategories}
/>
</StepScreenWrapper>
</AccessOrNotFoundWrapper>
);
}

const IOURequestStepCategoryCreateWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepCategoryCreate);
const IOURequestStepCategoryCreateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepCategoryCreateWithFullTransactionOrNotFound);
export default IOURequestStepCategoryCreateWithWritableReportOrNotFound;
Loading
Loading