Skip to content

Commit f01635e

Browse files
authored
Merge pull request Expensify#89743 from daledah/follow-up/85681
Feat: add a new category within the category list part 2
2 parents 00eaba3 + a72b4a1 commit f01635e

13 files changed

Lines changed: 252 additions & 8 deletions

File tree

src/ROUTES.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,6 +1455,16 @@ const ROUTES = {
14551455
return getUrlWithBackToParam(`${action as string}/${iouType as string}/taxAmount/${transactionID}/${reportID}`, backTo);
14561456
},
14571457
},
1458+
MONEY_REQUEST_STEP_CATEGORY_CREATE: {
1459+
route: ':action/:iouType/category/new/:transactionID/:reportID/:reportActionID?',
1460+
getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string, backTo = '') => {
1461+
if (!transactionID || !reportID) {
1462+
Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_CATEGORY_CREATE route');
1463+
}
1464+
// 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)
1465+
return getUrlWithBackToParam(`${action as string}/${iouType as string}/category/new/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo);
1466+
},
1467+
},
14581468
MONEY_REQUEST_STEP_CATEGORY: {
14591469
route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?',
14601470
getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string) => {

src/SCREENS.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ const SCREENS = {
366366
STEP_UPGRADE: 'Money_Request_Step_Upgrade',
367367
STEP_AMOUNT: 'Money_Request_Step_Amount',
368368
STEP_CATEGORY: 'Money_Request_Step_Category',
369+
STEP_CATEGORY_CREATE: 'Money_Request_Step_Category_Create',
369370
STEP_DATE: 'Money_Request_Step_Date',
370371
STEP_DESCRIPTION: 'Money_Request_Step_Description',
371372
STEP_DISTANCE: 'Money_Request_Step_Distance',

src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ function useConfirmationValidation({
174174
return {errorKey: 'iou.error.invalidCategoryLength'};
175175
}
176176

177-
if (iouCategory && policyCategories && !policyCategories[iouCategory]?.enabled) {
177+
const isCategoryBeingCreated = policyCategories?.[iouCategory]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD;
178+
179+
if (iouCategory && policyCategories && !policyCategories[iouCategory]?.enabled && !isCategoryBeingCreated) {
178180
return {errorKey: 'violations.categoryOutOfPolicy'};
179181
}
180182

src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator<MoneyRequestNa
192192
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepTaxAmountPage').default,
193193
[SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepTaxRatePage').default,
194194
[SCREENS.MONEY_REQUEST.STEP_CATEGORY]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepCategory').default,
195+
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepCategoryCreate').default,
195196
[SCREENS.MONEY_REQUEST.STEP_DATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDate').default,
196197
[SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDescription').default,
197198
[SCREENS.MONEY_REQUEST.STEP_DISTANCE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDistance').default,

src/libs/Navigation/linkingConfig/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
17181718
[SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO]: ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.route,
17191719
[SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route,
17201720
[SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route,
1721+
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.route,
17211722
[SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route,
17221723
[SCREENS.MONEY_REQUEST.STEP_CONFIRMATION_VERIFY_ACCOUNT]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION_VERIFY_ACCOUNT.route,
17231724
[SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route,

src/libs/Navigation/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1873,6 +1873,15 @@ type MoneyRequestNavigatorParamList = {
18731873
// 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
18741874
backTo: Routes;
18751875
};
1876+
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: {
1877+
action: IOUAction;
1878+
iouType: Exclude<IOUType, typeof CONST.IOU.TYPE.REQUEST | typeof CONST.IOU.TYPE.SEND>;
1879+
transactionID: string;
1880+
reportID: string;
1881+
reportActionID?: string;
1882+
// eslint-disable-next-line no-restricted-syntax -- backTo is needed to track where editing was initiated from (search/view or r/:reportID)
1883+
backTo?: Routes;
1884+
};
18761885
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: {
18771886
action: IOUAction;
18781887
iouType: Exclude<IOUType, typeof CONST.IOU.TYPE.REQUEST | typeof CONST.IOU.TYPE.SEND>;

src/libs/Violations/ViolationsUtils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,10 @@ const ViolationsUtils = {
395395
const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy');
396396
const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory');
397397
const categoryKey = updatedTransaction.category;
398-
const isCategoryInPolicy = categoryKey ? policyCategories?.[categoryKey]?.enabled : false;
398+
const categoryData = policyCategories?.[categoryKey ?? ''];
399+
// A category being created optimistically (pendingAction === 'add') is treated as valid
400+
// so in-situ creation doesn't trigger a "categoryOutOfPolicy" violation before the server confirms it.
401+
const isCategoryInPolicy = categoryKey ? !!(categoryData?.enabled || categoryData?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) : false;
399402

400403
// Add 'categoryOutOfPolicy' violation if category is not in policy
401404
if (!hasCategoryOutOfPolicyViolation && !isCategoryMissing(categoryKey) && !isCategoryInPolicy) {

src/pages/iou/request/step/IOURequestStepCategory.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {useSearchStateContext} from '@components/Search/SearchContext';
1111
import type {ListItem} from '@components/SelectionList/types';
1212
import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
1313
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
14-
import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
14+
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
1515
import useLocalize from '@hooks/useLocalize';
1616
import useNetwork from '@hooks/useNetwork';
1717
import useOnyx from '@hooks/useOnyx';
@@ -30,7 +30,7 @@ import {isCategoryMissing} from '@libs/CategoryUtils';
3030
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
3131
import Navigation from '@libs/Navigation/Navigation';
3232
import {hasEnabledOptions} from '@libs/OptionsListUtils';
33-
import {isPolicyAdmin} from '@libs/PolicyUtils';
33+
import {getValidConnectedIntegration, isPolicyAdmin} from '@libs/PolicyUtils';
3434
import {getTransactionDetails, isGroupPolicy, isReportInGroupPolicy} from '@libs/ReportUtils';
3535
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
3636
import {getRequestType} from '@libs/TransactionUtils';
@@ -58,6 +58,7 @@ function IOURequestStepCategory({
5858
const styles = useThemeStyles();
5959
const {translate} = useLocalize();
6060
const illustrations = useMemoizedLazyIllustrations(['EmptyStateExpenses']);
61+
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']);
6162
const requestType = getRequestType(transaction);
6263
const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM;
6364
const transactionReport = useReportOrReportDraft(transaction?.reportID);
@@ -91,6 +92,24 @@ function IOURequestStepCategory({
9192

9293
const categoryForDisplay = isCategoryMissing(transactionCategory) ? '' : transactionCategory;
9394

95+
const canCreateCategoryInSitu = isPolicyAdmin(policy) && !getValidConnectedIntegration(policy) && !!policy?.areCategoriesEnabled;
96+
97+
const createCategoryMenuItems = canCreateCategoryInSitu
98+
? [
99+
{
100+
icon: expensifyIcons.Plus,
101+
text: translate('workspace.categories.addCategory'),
102+
onSelected: () => {
103+
const reportID = report?.reportID ?? routeReportID;
104+
if (!policyID || !reportID) {
105+
return;
106+
}
107+
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute(action, iouType, transactionID, reportID, reportActionID, backTo));
108+
},
109+
},
110+
]
111+
: undefined;
112+
94113
const shouldShowCategory =
95114
(isReportInGroupPolicy(report) || isGroupPolicy(policy?.type ?? '')) &&
96115
// The transactionCategory can be an empty string, so to maintain the logic we'd like to keep it in this shape until utils refactor
@@ -180,6 +199,8 @@ function IOURequestStepCategory({
180199
shouldShowOfflineIndicator={policyCategories !== undefined}
181200
testID="IOURequestStepCategory"
182201
shouldEnableKeyboardAvoidingView={false}
202+
threeDotsMenuItems={createCategoryMenuItems}
203+
shouldMinimizeMenuButton
183204
>
184205
{isLoading && (
185206
<ActivityIndicator
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import React from 'react';
2+
import type {FormOnyxValues} from '@components/Form/types';
3+
import {useSearchStateContext} from '@components/Search/SearchContext';
4+
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
5+
import useLocalize from '@hooks/useLocalize';
6+
import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation';
7+
import useOnyx from '@hooks/useOnyx';
8+
import usePermissions from '@hooks/usePermissions';
9+
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
10+
import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure';
11+
import {getIOURequestPolicyID, setMoneyRequestCategory} from '@libs/actions/IOU';
12+
import {setDraftSplitTransaction} from '@libs/actions/IOU/Split';
13+
import {updateMoneyRequestCategory} from '@libs/actions/IOU/UpdateMoneyRequest';
14+
import {createPolicyCategory} from '@libs/actions/Policy/Category';
15+
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
16+
import Navigation from '@libs/Navigation/Navigation';
17+
import {hasTags} from '@libs/PolicyUtils';
18+
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
19+
import CategoryForm from '@pages/workspace/categories/CategoryForm';
20+
import CONST from '@src/CONST';
21+
import ONYXKEYS from '@src/ONYXKEYS';
22+
import ROUTES from '@src/ROUTES';
23+
import type SCREENS from '@src/SCREENS';
24+
import StepScreenWrapper from './StepScreenWrapper';
25+
import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound';
26+
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
27+
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
28+
import withWritableReportOrNotFound from './withWritableReportOrNotFound';
29+
30+
type IOURequestStepCategoryCreateProps = WithWritableReportOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE> &
31+
WithFullTransactionOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE>;
32+
33+
function IOURequestStepCategoryCreate({
34+
report: reportReal,
35+
reportDraft,
36+
route: {
37+
params: {transactionID, action, iouType, reportID, reportActionID, backTo},
38+
},
39+
transaction,
40+
}: IOURequestStepCategoryCreateProps) {
41+
const {translate} = useLocalize();
42+
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
43+
const {isBetaEnabled} = usePermissions();
44+
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
45+
const {currentSearchHash} = useSearchStateContext();
46+
47+
const isEditing = action === CONST.IOU.ACTION.EDIT;
48+
const isEditingSplit = (iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) && isEditing;
49+
50+
const policyIdReal = getIOURequestPolicyID(transaction, reportReal);
51+
const policyIdDraft = getIOURequestPolicyID(transaction, reportDraft);
52+
const {policy} = usePolicyForTransaction({
53+
transaction,
54+
reportPolicyID: policyIdReal ?? policyIdDraft,
55+
action,
56+
iouType,
57+
isPerDiemRequest: false,
58+
});
59+
const policyID = policy?.id;
60+
61+
const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`);
62+
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
63+
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
64+
const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`);
65+
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`);
66+
const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`);
67+
68+
const report = reportReal ?? reportDraft;
69+
70+
useRestartOnReceiptFailure(transaction, reportID, iouType, action);
71+
72+
const policyHasTags = hasTags(policyTags);
73+
74+
const {
75+
taskReport: setupCategoryTaskReport,
76+
taskParentReport: setupCategoryTaskParentReport,
77+
isOnboardingTaskParentReportArchived: isSetupCategoryTaskParentReportArchived,
78+
hasOutstandingChildTask,
79+
parentReportAction,
80+
} = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES);
81+
82+
const {
83+
taskReport: setupCategoriesAndTagsTaskReport,
84+
taskParentReport: setupCategoriesAndTagsTaskParentReport,
85+
isOnboardingTaskParentReportArchived: isSetupCategoriesAndTagsTaskParentReportArchived,
86+
hasOutstandingChildTask: setupCategoriesAndTagsHasOutstandingChildTask,
87+
parentReportAction: setupCategoriesAndTagsParentReportAction,
88+
} = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES_AND_TAGS);
89+
90+
const createCategory = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM>) => {
91+
const categoryName = values.categoryName.trim();
92+
93+
if (!policyID) {
94+
return;
95+
}
96+
97+
// 1. Create the category in the workspace (optimistic update, queued API call).
98+
createPolicyCategory({
99+
policyID,
100+
categoryName,
101+
isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived,
102+
setupCategoryTaskReport,
103+
setupCategoryTaskParentReport,
104+
currentUserAccountID: currentUserPersonalDetails.accountID,
105+
hasOutstandingChildTask,
106+
parentReportAction,
107+
setupCategoriesAndTagsTaskReport,
108+
setupCategoriesAndTagsTaskParentReport,
109+
isSetupCategoriesAndTagsTaskParentReportArchived,
110+
setupCategoriesAndTagsHasOutstandingChildTask,
111+
setupCategoriesAndTagsParentReportAction,
112+
policyHasTags,
113+
});
114+
115+
// 2. Apply the newly created category to the transaction.
116+
const policyCategoriesWithNewCategory = {
117+
...policyCategories,
118+
[categoryName]: {
119+
name: categoryName,
120+
enabled: true,
121+
errors: null,
122+
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
123+
},
124+
};
125+
126+
if (isEditingSplit && transaction) {
127+
setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {category: categoryName}, policy);
128+
} else if (isEditing && report) {
129+
updateMoneyRequestCategory({
130+
transactionID: transaction?.transactionID ?? transactionID,
131+
transactionThreadReport: report,
132+
parentReport,
133+
parentReportNextStep,
134+
category: categoryName,
135+
policy,
136+
policyTagList: policyTags,
137+
policyCategories: policyCategoriesWithNewCategory,
138+
policyRecentlyUsedCategories,
139+
currentUserAccountIDParam: currentUserPersonalDetails.accountID,
140+
currentUserEmailParam: currentUserPersonalDetails.login ?? '',
141+
isASAPSubmitBetaEnabled,
142+
hash: currentSearchHash,
143+
});
144+
} else {
145+
setMoneyRequestCategory(transactionID, categoryName, policy);
146+
}
147+
148+
if (!isEditing && action === CONST.IOU.ACTION.CATEGORIZE && !backTo) {
149+
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, report?.reportID ?? reportID));
150+
return;
151+
}
152+
Navigation.goBack(backTo);
153+
};
154+
155+
return (
156+
<AccessOrNotFoundWrapper
157+
accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]}
158+
policyID={policyID}
159+
featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED}
160+
>
161+
<StepScreenWrapper
162+
headerTitle={translate('workspace.categories.addCategory')}
163+
onBackButtonPress={() => Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, backTo, reportActionID))}
164+
shouldShowWrapper
165+
testID="IOURequestStepCategoryCreate"
166+
>
167+
<CategoryForm
168+
onSubmit={createCategory}
169+
policyCategories={policyCategories}
170+
addBottomSafeAreaPadding={false}
171+
/>
172+
</StepScreenWrapper>
173+
</AccessOrNotFoundWrapper>
174+
);
175+
}
176+
177+
const IOURequestStepCategoryCreateWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepCategoryCreate);
178+
const IOURequestStepCategoryCreateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepCategoryCreateWithFullTransactionOrNotFound);
179+
export default IOURequestStepCategoryCreateWithWritableReportOrNotFound;

0 commit comments

Comments
 (0)