Skip to content

Commit 9adead4

Browse files
authored
Merge pull request #86177 from daledah/fix/85681
Feat: add a new category within the category list
2 parents b8a58d9 + 9504eb4 commit 9adead4

12 files changed

Lines changed: 281 additions & 6 deletions

File tree

src/ROUTES.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,16 @@ const ROUTES = {
13641364
return getUrlWithBackToParam(`${action as string}/${iouType as string}/taxAmount/${transactionID}/${reportID}`, backTo);
13651365
},
13661366
},
1367+
MONEY_REQUEST_STEP_CATEGORY_CREATE: {
1368+
route: ':action/:iouType/category/new/:transactionID/:reportID/:reportActionID?',
1369+
getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string, backTo = '') => {
1370+
if (!transactionID || !reportID) {
1371+
Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_CATEGORY_CREATE route');
1372+
}
1373+
// 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)
1374+
return getUrlWithBackToParam(`${action as string}/${iouType as string}/category/new/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo);
1375+
},
1376+
},
13671377
MONEY_REQUEST_STEP_CATEGORY: {
13681378
route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?',
13691379
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
@@ -360,6 +360,7 @@ const SCREENS = {
360360
STEP_UPGRADE: 'Money_Request_Step_Upgrade',
361361
STEP_AMOUNT: 'Money_Request_Step_Amount',
362362
STEP_CATEGORY: 'Money_Request_Step_Category',
363+
STEP_CATEGORY_CREATE: 'Money_Request_Step_Category_Create',
363364
STEP_DATE: 'Money_Request_Step_Date',
364365
STEP_DESCRIPTION: 'Money_Request_Step_Description',
365366
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
@@ -178,6 +178,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator<MoneyRequestNa
178178
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepTaxAmountPage').default,
179179
[SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepTaxRatePage').default,
180180
[SCREENS.MONEY_REQUEST.STEP_CATEGORY]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepCategory').default,
181+
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepCategoryCreate').default,
181182
[SCREENS.MONEY_REQUEST.STEP_DATE]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDate').default,
182183
[SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require<ReactComponentModule>('../../../../pages/iou/request/step/IOURequestStepDescription').default,
183184
[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
@@ -1717,6 +1717,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
17171717
[SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO]: ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.route,
17181718
[SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route,
17191719
[SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route,
1720+
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.route,
17201721
[SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route,
17211722
[SCREENS.MONEY_REQUEST.STEP_CONFIRMATION_VERIFY_ACCOUNT]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION_VERIFY_ACCOUNT.route,
17221723
[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
@@ -1889,6 +1889,15 @@ type MoneyRequestNavigatorParamList = {
18891889
// 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
18901890
backTo: Routes;
18911891
};
1892+
[SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: {
1893+
action: IOUAction;
1894+
iouType: Exclude<IOUType, typeof CONST.IOU.TYPE.REQUEST | typeof CONST.IOU.TYPE.SEND>;
1895+
transactionID: string;
1896+
reportID: string;
1897+
reportActionID?: string;
1898+
// eslint-disable-next-line no-restricted-syntax -- backTo is needed to track where editing was initiated from (search/view or r/:reportID)
1899+
backTo?: Routes;
1900+
};
18921901
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: {
18931902
action: IOUAction;
18941903
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: 22 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';
@@ -29,7 +29,7 @@ import {isCategoryMissing} from '@libs/CategoryUtils';
2929
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
3030
import Navigation from '@libs/Navigation/Navigation';
3131
import {hasEnabledOptions} from '@libs/OptionsListUtils';
32-
import {isPolicyAdmin} from '@libs/PolicyUtils';
32+
import {getValidConnectedIntegration, isPolicyAdmin} from '@libs/PolicyUtils';
3333
import {getReportOrDraftReport, getTransactionDetails, isGroupPolicy, isReportInGroupPolicy} from '@libs/ReportUtils';
3434
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
3535
import {getRequestType} from '@libs/TransactionUtils';
@@ -57,6 +57,7 @@ function IOURequestStepCategory({
5757
const styles = useThemeStyles();
5858
const {translate} = useLocalize();
5959
const illustrations = useMemoizedLazyIllustrations(['EmptyStateExpenses']);
60+
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']);
6061
const requestType = getRequestType(transaction);
6162
const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM;
6263
const transactionReport = getReportOrDraftReport(transaction?.reportID);
@@ -90,6 +91,23 @@ function IOURequestStepCategory({
9091

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

94+
const canCreateCategoryInSitu = isPolicyAdmin(policy) && !getValidConnectedIntegration(policy) && !!policy?.areCategoriesEnabled;
95+
96+
const createCategoryMenuItems = canCreateCategoryInSitu
97+
? [
98+
{
99+
icon: expensifyIcons.Plus,
100+
text: translate('workspace.categories.addCategory'),
101+
onSelected: () => {
102+
if (!policyID || !report?.reportID) {
103+
return;
104+
}
105+
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute(action, iouType, transactionID, report.reportID, reportActionID, backTo));
106+
},
107+
},
108+
]
109+
: undefined;
110+
93111
const shouldShowCategory =
94112
(isReportInGroupPolicy(report) || isGroupPolicy(policy?.type ?? '')) &&
95113
// 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({
179197
shouldShowOfflineIndicator={policyCategories !== undefined}
180198
testID="IOURequestStepCategory"
181199
shouldEnableKeyboardAvoidingView={false}
200+
threeDotsMenuItems={createCategoryMenuItems}
201+
shouldMinimizeMenuButton
182202
>
183203
{isLoading && (
184204
<ActivityIndicator
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import React, {useCallback} 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, 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 = useCallback(
91+
(values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM>) => {
92+
const categoryName = values.categoryName.trim();
93+
94+
if (!policyID) {
95+
return;
96+
}
97+
98+
// 1. Create the category in the workspace (optimistic update, queued API call).
99+
createPolicyCategory({
100+
policyID,
101+
categoryName,
102+
isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived,
103+
setupCategoryTaskReport,
104+
setupCategoryTaskParentReport,
105+
currentUserAccountID: currentUserPersonalDetails.accountID,
106+
hasOutstandingChildTask,
107+
parentReportAction,
108+
setupCategoriesAndTagsTaskReport,
109+
setupCategoriesAndTagsTaskParentReport,
110+
isSetupCategoriesAndTagsTaskParentReportArchived,
111+
setupCategoriesAndTagsHasOutstandingChildTask,
112+
setupCategoriesAndTagsParentReportAction,
113+
policyHasTags,
114+
});
115+
116+
// 2. Apply the newly created category to the transaction.
117+
const policyCategoriesWithNewCategory = {
118+
...policyCategories,
119+
[categoryName]: {
120+
name: categoryName,
121+
enabled: true,
122+
errors: null,
123+
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
124+
},
125+
};
126+
127+
if (isEditingSplit && transaction) {
128+
setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {category: categoryName}, policy);
129+
} else if (isEditing && report) {
130+
updateMoneyRequestCategory({
131+
transactionID: transaction?.transactionID ?? transactionID,
132+
transactionThreadReport: report,
133+
parentReport,
134+
parentReportNextStep,
135+
category: categoryName,
136+
policy,
137+
policyTagList: policyTags,
138+
policyCategories: policyCategoriesWithNewCategory,
139+
policyRecentlyUsedCategories,
140+
currentUserAccountIDParam: currentUserPersonalDetails.accountID,
141+
currentUserEmailParam: currentUserPersonalDetails.login ?? '',
142+
isASAPSubmitBetaEnabled,
143+
hash: currentSearchHash,
144+
});
145+
} else {
146+
setMoneyRequestCategory(transactionID, categoryName, policy);
147+
}
148+
149+
if (isEditing) {
150+
Navigation.goBack(backTo);
151+
} else {
152+
Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID));
153+
}
154+
},
155+
[
156+
action,
157+
backTo,
158+
currentSearchHash,
159+
currentUserPersonalDetails.accountID,
160+
currentUserPersonalDetails.login,
161+
hasOutstandingChildTask,
162+
isASAPSubmitBetaEnabled,
163+
isEditing,
164+
isEditingSplit,
165+
isSetupCategoriesAndTagsTaskParentReportArchived,
166+
isSetupCategoryTaskParentReportArchived,
167+
iouType,
168+
parentReport,
169+
parentReportAction,
170+
parentReportNextStep,
171+
policy,
172+
policyCategories,
173+
policyHasTags,
174+
policyID,
175+
policyRecentlyUsedCategories,
176+
policyTags,
177+
report,
178+
reportID,
179+
setupCategoriesAndTagsHasOutstandingChildTask,
180+
setupCategoriesAndTagsParentReportAction,
181+
setupCategoriesAndTagsTaskParentReport,
182+
setupCategoriesAndTagsTaskReport,
183+
setupCategoryTaskParentReport,
184+
setupCategoryTaskReport,
185+
splitDraftTransaction,
186+
transaction,
187+
transactionID,
188+
],
189+
);
190+
191+
return (
192+
<AccessOrNotFoundWrapper
193+
accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]}
194+
policyID={policyID}
195+
featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED}
196+
>
197+
<StepScreenWrapper
198+
headerTitle={translate('workspace.categories.addCategory')}
199+
onBackButtonPress={() => Navigation.goBack()}
200+
shouldShowWrapper
201+
testID="IOURequestStepCategoryCreate"
202+
>
203+
<CategoryForm
204+
onSubmit={createCategory}
205+
policyCategories={policyCategories}
206+
/>
207+
</StepScreenWrapper>
208+
</AccessOrNotFoundWrapper>
209+
);
210+
}
211+
212+
const IOURequestStepCategoryCreateWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepCategoryCreate);
213+
const IOURequestStepCategoryCreateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepCategoryCreateWithFullTransactionOrNotFound);
214+
export default IOURequestStepCategoryCreateWithWritableReportOrNotFound;

0 commit comments

Comments
 (0)