From a54d3be9c0639c7bcef0c33554250756cfb09623 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 21 Jul 2026 12:53:04 +0530 Subject: [PATCH 1/4] Add contextual Category rules section to category details RHP Signed-off-by: krishna2323 --- src/ROUTES.ts | 12 +- src/languages/en.ts | 3 + src/libs/CategoryContextualRulesUtils.ts | 97 +++++++++ src/libs/FlagForReviewRulesUtils.ts | 10 +- src/libs/Navigation/types.ts | 4 + src/libs/RequireFieldsRulesUtils.ts | 13 +- .../categories/CategorySettingsPage.tsx | 192 +++++++++++++----- .../AddFlagForReviewRulePage.tsx | 3 +- .../FlagForReviewRulePageBase.tsx | 8 +- .../MerchantRules/AddMerchantRulePage.tsx | 1 + .../MerchantRules/MerchantRulePageBase.tsx | 55 +++-- .../AddRequireFieldsRulePage.tsx | 3 +- .../RequireFieldsRulePageBase.tsx | 8 +- src/pages/workspace/rules/RulesNewPage.tsx | 8 +- .../unit/CategoryContextualRulesUtilsTest.ts | 55 +++++ 15 files changed, 379 insertions(+), 93 deletions(-) create mode 100644 src/libs/CategoryContextualRulesUtils.ts create mode 100644 tests/unit/CategoryContextualRulesUtilsTest.ts diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ba39e03b75f4..08888102847e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -33,6 +33,10 @@ function getRulesRevampRuleEditSegment(categoryName: string): string { return `edit/${encodeURIComponent(categoryName)}`; } +function getOptionalCategoryNameQuery(categoryName?: string): string { + return categoryName ? `?categoryName=${encodeURIComponent(categoryName)}` : ''; +} + // This is a file containing constants for all the routes we want to be able to go to /** @@ -3198,7 +3202,7 @@ const ROUTES = { }, RULES_REQUIRE_FIELDS_RULE_NEW: { route: 'workspaces/:policyID/rules/require-fields-rules/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/require-fields-rules/new` as const, + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/require-fields-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_REQUIRE_FIELDS_RULE_EDIT: { route: 'workspaces/:policyID/rules/require-fields-rules/edit/:categoryName', @@ -3214,7 +3218,7 @@ const ROUTES = { }, RULES_FLAG_FOR_REVIEW_RULE_NEW: { route: 'workspaces/:policyID/rules/flag-for-review-rules/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/flag-for-review-rules/new` as const, + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/flag-for-review-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_FLAG_FOR_REVIEW_RULE_EDIT: { route: 'workspaces/:policyID/rules/flag-for-review-rules/edit/:categoryName', @@ -3278,11 +3282,11 @@ const ROUTES = { }, RULES_NEW: { route: 'workspaces/:policyID/rules/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/new` as const, + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_MERCHANT_NEW: { route: 'workspaces/:policyID/rules/merchant-rules/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_MERCHANT_IMPORT: { route: 'workspaces/:policyID/rules/merchant-rules/import', diff --git a/src/languages/en.ts b/src/languages/en.ts index 94ae78abc843..9fd6e5292fa8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7910,6 +7910,9 @@ const translations = { }, defaultTaxRate: 'Default tax rate', enableWorkflows: (moreFeaturesLink: string) => `Go to [More features](${moreFeaturesLink}) and enable workflows, then add approvals to unlock this feature.`, + createNewRule: 'Create new rule', + contextualFlagForReview: (amount: string) => `If amount is above ${amount}, flag for review`, + contextualFlagForReviewDaily: (amount: string) => `If daily category total is above ${amount}, flag for review`, }, customRules: { title: 'Expense policy', diff --git a/src/libs/CategoryContextualRulesUtils.ts b/src/libs/CategoryContextualRulesUtils.ts new file mode 100644 index 000000000000..72f65dffa298 --- /dev/null +++ b/src/libs/CategoryContextualRulesUtils.ts @@ -0,0 +1,97 @@ +/** + * Helpers for listing Rules Revamp rules that apply to a specific workspace category, + * used by the category details RHP contextual "Category rules" section. + */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +import {hasExplicitFlagAmount} from './FlagForReviewRulesUtils'; +import {categoryHasAnyRequireFieldsRule, formatRequireFieldsRuleDescriptions, getRequireFieldsPendingAction, getRequireFieldsRuleDescriptionsForCategory} from './RequireFieldsRulesUtils'; + +type CategoryContextualRule = { + key: string; + summary: string; + route: Route; + pendingAction?: PendingAction; +}; + +function getFlagForReviewContextualSummary( + category: PolicyCategory, + translate: LocaleContextProps['translate'], + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], + policyCurrency: string, +): string | undefined { + if (!hasExplicitFlagAmount(category.maxExpenseAmount)) { + return undefined; + } + + const amountDisplay = convertToDisplayString(category.maxExpenseAmount, policyCurrency); + const expenseLimitType = category.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE; + + if (expenseLimitType === CONST.POLICY.EXPENSE_LIMIT_TYPES.DAILY) { + return translate('workspace.rules.categoryRules.contextualFlagForReviewDaily', amountDisplay); + } + + return translate('workspace.rules.categoryRules.contextualFlagForReview', amountDisplay); +} + +/** + * Returns Rules Revamp rules scoped to a category for display in the category details RHP. + * Each item navigates to the same edit route used from the centralized Rules page. + */ +function getCategoryContextualRules({ + policy, + category, + categoryName, + translate, + convertToDisplayString, +}: { + policy: Policy | undefined; + category: PolicyCategory | undefined; + categoryName: string; + translate: LocaleContextProps['translate']; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; +}): CategoryContextualRule[] { + if (!policy?.id || !category) { + return []; + } + + const policyID = policy.id; + const policyCurrency = policy.outputCurrency ?? CONST.CURRENCY.USD; + const rules: CategoryContextualRule[] = []; + + const flagSummary = getFlagForReviewContextualSummary(category, translate, convertToDisplayString, policyCurrency); + if (flagSummary) { + rules.push({ + key: `flag-for-review-${categoryName}`, + summary: flagSummary, + route: ROUTES.RULES_FLAG_FOR_REVIEW_RULE_EDIT.getRoute(policyID, categoryName), + pendingAction: category.pendingFields?.maxExpenseAmount, + }); + } + + if (categoryHasAnyRequireFieldsRule(category)) { + const descriptions = getRequireFieldsRuleDescriptionsForCategory(category, translate, convertToDisplayString, policyCurrency); + const summary = formatRequireFieldsRuleDescriptions(descriptions); + if (summary) { + rules.push({ + key: `require-fields-${categoryName}`, + summary, + route: ROUTES.RULES_REQUIRE_FIELDS_RULE_EDIT.getRoute(policyID, categoryName), + pendingAction: getRequireFieldsPendingAction(category.pendingFields), + }); + } + } + + return rules; +} + +export {getCategoryContextualRules}; +export type {CategoryContextualRule}; diff --git a/src/libs/FlagForReviewRulesUtils.ts b/src/libs/FlagForReviewRulesUtils.ts index 1d263567f5d9..818ba9d08e3a 100644 --- a/src/libs/FlagForReviewRulesUtils.ts +++ b/src/libs/FlagForReviewRulesUtils.ts @@ -168,5 +168,13 @@ function getFlagForReviewTableData({ return rules; } -export {deleteFlagForReviewRule, getEffectiveFlagForReviewRuleForm, getFlagForReviewFormFromCategory, getFlagForReviewRuleAmountError, getFlagForReviewTableData, saveFlagForReviewRule}; +export { + deleteFlagForReviewRule, + getEffectiveFlagForReviewRuleForm, + getFlagForReviewFormFromCategory, + getFlagForReviewRuleAmountError, + getFlagForReviewTableData, + hasExplicitFlagAmount, + saveFlagForReviewRule, +}; export type {FlagForReviewTableItem}; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f9ec1effdf1b..0f7a77cee8be 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1552,9 +1552,11 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.RULES_NEW]: { policyID: string; + categoryName?: string; }; [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; + categoryName?: string; }; [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { policyID: string; @@ -1567,6 +1569,7 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: { policyID: string; + categoryName?: string; }; [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: { policyID: string; @@ -1581,6 +1584,7 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_NEW]: { policyID: string; + categoryName?: string; }; [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EDIT]: { policyID: string; diff --git a/src/libs/RequireFieldsRulesUtils.ts b/src/libs/RequireFieldsRulesUtils.ts index c13d3711eb84..1d11ec2dfa71 100644 --- a/src/libs/RequireFieldsRulesUtils.ts +++ b/src/libs/RequireFieldsRulesUtils.ts @@ -314,5 +314,16 @@ function getRequireFieldsTableData({ return rules.sort((a, b) => localeCompare(a.conditionText, b.conditionText)); } -export {categoryHasLegacyReceiptRules, deleteRequireFieldsRule, getEffectiveRequireFieldsRuleForm, getRequireFieldsFormFromCategory, getRequireFieldsTableData, saveRequireFieldsRule}; +export { + categoryHasAnyRequireFieldsRule, + categoryHasLegacyReceiptRules, + deleteRequireFieldsRule, + formatRequireFieldsRuleDescriptions, + getEffectiveRequireFieldsRuleForm, + getRequireFieldsFormFromCategory, + getRequireFieldsPendingAction, + getRequireFieldsRuleDescriptionsForCategory, + getRequireFieldsTableData, + saveRequireFieldsRule, +}; export type {RequireFieldsTableItem}; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index bf2e61b59b1a..c787400ff612 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -23,6 +23,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; import {formatRequiredFieldsTitle} from '@libs/AttendeeUtils'; +import {getCategoryContextualRules} from '@libs/CategoryContextualRulesUtils'; import { formatDefaultTaxRateText, formatRequireItemizedReceiptsOverText, @@ -65,11 +66,12 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Trashcan']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Trashcan', 'Plus', 'Bolt']); const {showConfirmModal} = useConfirmModal(); const policyData = usePolicyData(policyID); const {policy, categories: policyCategories} = policyData; const {canWrite: canWriteCategories, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.CATEGORIES); + const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); const {isBetaEnabled} = usePermissions(); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const {environmentURL} = useEnvironment(); @@ -79,6 +81,21 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; const policyCategoryExpenseLimitType = policyCategory?.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE; const decodedCategoryName = getDecodedCategoryName(policyCategory?.name ?? ''); + const categoryRulesEnabled = arePolicyRulesEnabled(policy, policyCategories); + + const contextualRules = useMemo(() => { + if (!isRulesRevampEnabled || !policyCategory) { + return []; + } + + return getCategoryContextualRules({ + policy, + category: policyCategory, + categoryName: policyCategory.name, + translate, + convertToDisplayString, + }); + }, [convertToDisplayString, isRulesRevampEnabled, policy, policyCategory, translate]); const shouldPreventDisableOrDelete = isDisablingOrDeletingLastEnabledCategory(policy, policyData.categories, [policyCategory]); const isQuickSettingsFlow = name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS; @@ -364,6 +381,49 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti shouldShowRightIcon={canWriteCategories} /> + {categoryRulesEnabled && isRulesRevampEnabled && ( + <> + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + shouldRenderAsHTML + /> + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_APPROVER.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + disabled={approverDisabled} + helperText={ + approverDisabled + ? translate('workspace.rules.categoryRules.enableWorkflows', `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)}`) + : undefined + } + shouldParseHelperText + /> + {!!policy?.tax?.trackingEnabled && ( + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + /> + )} + + )} {canWriteCategories && !isThereAnyAccountingConnection && ( )} - {arePolicyRulesEnabled(policy, policyCategories) && ( + {categoryRulesEnabled && !isRulesRevampEnabled && ( <> {translate('workspace.rules.categoryRules.title')} @@ -419,60 +479,55 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti shouldShowRightIcon={canWriteCategories} /> )} - {/* - * Rules Revamp R2: category-based rule creation moves to Workspace > Rules. - * When removing the RULES_REVAMP beta, delete this entire block (and the related useMemo - * values above) instead of keeping the legacy Category settings entry points. + * Legacy category rule entry points. When removing the RULES_REVAMP beta, + * delete this entire non-revamp block (and related useMemo values) instead + * of keeping these Category settings routes. */} - {!isRulesRevampEnabled && ( - <> - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - /> - - - )} + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + /> + + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + /> + + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + /> + + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.path)); + }} + interactive={canWriteCategories} + shouldShowRightIcon={canWriteCategories} + /> + )} + + {categoryRulesEnabled && isRulesRevampEnabled && ( + <> + + {translate('workspace.rules.categoryRules.title')} + + {contextualRules.map((rule) => ( + + Navigation.navigate(rule.route)} + shouldShowRightIcon + interactive={canWriteRules} + /> + + ))} + {canWriteRules && ( + Navigation.navigate(ROUTES.RULES_NEW.getRoute(policyID, policyCategory.name))} + /> + )} + + )} diff --git a/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx b/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx index 9d55dc94288b..2c5973b0bc64 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/AddFlagForReviewRulePage.tsx @@ -10,11 +10,12 @@ import FlagForReviewRulePageBase from './FlagForReviewRulePageBase'; type AddFlagForReviewRulePageProps = PlatformStackScreenProps; function AddFlagForReviewRulePage({route}: AddFlagForReviewRulePageProps) { - const {policyID} = route.params; + const {policyID, categoryName} = route.params; return ( ); diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 1a245237d7b1..b11128940891 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -41,6 +41,8 @@ import {View} from 'react-native'; type FlagForReviewRulePageBaseProps = { policyID: string; categoryName?: string; + /** Pre-scopes the category when creating a rule (e.g. from the category details RHP). */ + initialCategoryName?: string; testID: string; }; @@ -52,7 +54,7 @@ function getValidationError(form: FlagForReviewRuleForm | null | undefined, tran return getFlagForReviewRuleAmountError(form[INPUT_IDS.MAX_EXPENSE_AMOUNT], translate) ?? ''; } -function FlagForReviewRulePageBase({policyID, categoryName, testID}: FlagForReviewRulePageBaseProps) { +function FlagForReviewRulePageBase({policyID, categoryName, initialCategoryName, testID}: FlagForReviewRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyData = usePolicyData(policyID); @@ -83,7 +85,7 @@ function FlagForReviewRulePageBase({policyID, categoryName, testID}: FlagForRevi if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftFlagForReviewRule({}); + setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); } return; } @@ -98,7 +100,7 @@ function FlagForReviewRulePageBase({policyID, categoryName, testID}: FlagForRevi initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); - }, [category, categoryName, getCurrencyDecimals, isEditing, policyCurrency]); + }, [category, categoryName, getCurrencyDecimals, initialCategoryName, isEditing, policyCurrency]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 2fecb39bc9e3..74693dcbfb3c 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -13,6 +13,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { return ( diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 0b9d7bb2d3a4..b466b664b020 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -53,12 +53,14 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; type MerchantRulePageBaseProps = { policyID: string; ruleID?: string; + /** Pre-scopes the category default when creating a rule (e.g. from the category details RHP). */ + initialCategoryName?: string; titleKey: TranslationPaths; testID: string; }; @@ -108,7 +110,7 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) return translate('workspace.rules.merchantRules.confirmError'); }; -function MerchantRulePageBase({policyID, ruleID, titleKey, testID}: MerchantRulePageBaseProps) { +function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); @@ -129,32 +131,43 @@ function MerchantRulePageBase({policyID, ruleID, titleKey, testID}: MerchantRule const [shouldShowError, setShouldShowError] = useState(false); const {showConfirmModal} = useConfirmModal(); const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); + const didInitializeCreateDraftRef = useRef(false); // Get the existing rule from the policy (for edit mode) const existingRule = ruleID ? policy?.rules?.codingRules?.[ruleID] : undefined; - // Initialize the form with existing rule data (for edit mode) + // Initialize the form with existing rule data (for edit mode), or a pre-scoped category for create useEffect(() => { - if (!isEditing || !existingRule) { + if (isEditing) { + if (!existingRule) { + return; + } + // Convert the operator to matchType for the form + // 'eq' = exact match, 'contains' = contains match + const matchType = existingRule.filters?.operator; + // Convert HTML comment back to markdown for editing + const commentMarkdown = existingRule.comment ? Parser.htmlToMarkdown(existingRule.comment) : undefined; + setDraftMerchantRule({ + merchantToMatch: existingRule.filters?.right, + matchType, + merchant: existingRule.merchant, + category: existingRule.category, + tag: existingRule.tag, + tax: existingRule.tax?.field_id_TAX?.externalID, + comment: commentMarkdown, + reimbursable: existingRule.reimbursable, + billable: existingRule.billable, + }); return; } - // Convert the operator to matchType for the form - // 'eq' = exact match, 'contains' = contains match - const matchType = existingRule.filters?.operator; - // Convert HTML comment back to markdown for editing - const commentMarkdown = existingRule.comment ? Parser.htmlToMarkdown(existingRule.comment) : undefined; - setDraftMerchantRule({ - merchantToMatch: existingRule.filters?.right, - matchType, - merchant: existingRule.merchant, - category: existingRule.category, - tag: existingRule.tag, - tax: existingRule.tax?.field_id_TAX?.externalID, - comment: commentMarkdown, - reimbursable: existingRule.reimbursable, - billable: existingRule.billable, - }); - }, [isEditing, existingRule]); + + if (!initialCategoryName || didInitializeCreateDraftRef.current) { + return; + } + + didInitializeCreateDraftRef.current = true; + setDraftMerchantRule({category: initialCategoryName}); + }, [isEditing, existingRule, initialCategoryName]); // Clear the form on unmount useEffect(() => () => clearDraftMerchantRule(), []); diff --git a/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx b/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx index 845134d62e66..e4a5d3e950ce 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage.tsx @@ -10,11 +10,12 @@ import RequireFieldsRulePageBase from './RequireFieldsRulePageBase'; type AddRequireFieldsRulePageProps = PlatformStackScreenProps; function AddRequireFieldsRulePage({route}: AddRequireFieldsRulePageProps) { - const {policyID} = route.params; + const {policyID, categoryName} = route.params; return ( ); diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index d19eb10278ea..887726f7563d 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -44,6 +44,8 @@ import {View} from 'react-native'; type RequireFieldsRulePageBaseProps = { policyID: string; categoryName?: string; + /** Pre-scopes the category when creating a rule (e.g. from the category details RHP). */ + initialCategoryName?: string; testID: string; }; @@ -69,7 +71,7 @@ function getValidationError( return ''; } -function RequireFieldsRulePageBase({policyID, categoryName, testID}: RequireFieldsRulePageBaseProps) { +function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, testID}: RequireFieldsRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -99,7 +101,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, testID}: RequireFiel if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftRequireFieldsRule({}); + setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); } return; } @@ -117,7 +119,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, testID}: RequireFiel [INPUT_IDS.CATEGORY]: categoryName, ...getRequireFieldsFormFromCategory(category), }); - }, [category, categoryName, isEditing]); + }, [category, categoryName, initialCategoryName, isEditing]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 3caf7170f4d2..79c2bb6c39a6 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -27,7 +27,7 @@ import {View} from 'react-native'; type RulesNewPageProps = PlatformStackScreenProps; function RulesNewPage({route}: RulesNewPageProps) { - const {policyID} = route.params; + const {policyID, categoryName} = route.params; const {translate} = useLocalize(); const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); @@ -72,7 +72,7 @@ function RulesNewPage({route}: RulesNewPageProps) { title={translate('workspace.rules.newRule.flagForReview')} description={translate('workspace.rules.newRule.flagForReviewDescription')} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID))} + onPress={() => Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, categoryName))} displayInDefaultIconColor iconWidth={variables.iconSizeExtraLarge} iconHeight={variables.iconSizeExtraLarge} @@ -84,7 +84,7 @@ function RulesNewPage({route}: RulesNewPageProps) { title={translate('workspace.rules.newRule.requireFields')} description={translate('workspace.rules.newRule.requireFieldsDescription')} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID))} + onPress={() => Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, categoryName))} displayInDefaultIconColor iconWidth={variables.iconSizeExtraLarge} iconHeight={variables.iconSizeExtraLarge} @@ -96,7 +96,7 @@ function RulesNewPage({route}: RulesNewPageProps) { title={translate('workspace.rules.newRule.applyExpenseDefaults')} description={translate('workspace.rules.newRule.applyExpenseDefaultsDescription')} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID))} + onPress={() => Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, categoryName))} displayInDefaultIconColor iconWidth={variables.iconSizeExtraLarge} iconHeight={variables.iconSizeExtraLarge} diff --git a/tests/unit/CategoryContextualRulesUtilsTest.ts b/tests/unit/CategoryContextualRulesUtilsTest.ts new file mode 100644 index 000000000000..b16492b961e5 --- /dev/null +++ b/tests/unit/CategoryContextualRulesUtilsTest.ts @@ -0,0 +1,55 @@ +import {getCategoryContextualRules} from '@libs/CategoryContextualRulesUtils'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; + +import CONST from '@src/CONST'; +import type {PolicyCategory} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import {translateLocal} from '../utils/TestHelper'; + +const policy = { + ...createRandomPolicy(0), + id: 'policy123', + outputCurrency: CONST.CURRENCY.USD, +}; + +describe('getCategoryContextualRules', () => { + it('returns an empty list when the category has no Rules Revamp overrides', () => { + const category: PolicyCategory = {name: 'Travel', enabled: true}; + + expect( + getCategoryContextualRules({ + policy, + category, + categoryName: 'Travel', + translate: translateLocal, + convertToDisplayString, + }), + ).toEqual([]); + }); + + it('returns flag-for-review and require-fields summaries with Rules edit routes', () => { + const category: PolicyCategory = { + name: 'Travel', + enabled: true, + maxExpenseAmount: 20000, + expenseLimitType: CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE, + areCommentsRequired: true, + }; + + const rules = getCategoryContextualRules({ + policy, + category, + categoryName: 'Travel', + translate: translateLocal, + convertToDisplayString, + }); + + expect(rules).toHaveLength(2); + expect(rules.at(0)?.summary).toContain('flag for review'); + expect(rules.at(0)?.summary).toContain('200'); + expect(rules.at(0)?.route).toContain('flag-for-review-rules/edit/Travel'); + expect(rules.at(1)?.summary).toContain('Require description'); + expect(rules.at(1)?.route).toContain('require-fields-rules/edit/Travel'); + }); +}); From d533ca9756399d6b97bf443bf836fff1a7bffc46 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 22 Jul 2026 23:45:33 +0530 Subject: [PATCH 2/4] revert Mobile-Expensify changes Signed-off-by: krishna2323 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index b6b55c09f08a..f8b358086076 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b6b55c09f08ab5818863f800aba9ebe012a4cb5e +Subproject commit f8b358086076b4e2074a53b65b9c83253406d669 From 2287ad52e8b89202c224da7bc389e97d8983bb6d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 22 Jul 2026 23:49:48 +0530 Subject: [PATCH 3/4] Fix knip and prefer-default-export for CategoryContextualRulesUtils Signed-off-by: krishna2323 --- src/libs/CategoryContextualRulesUtils.ts | 3 +-- src/pages/workspace/categories/CategorySettingsPage.tsx | 2 +- tests/unit/CategoryContextualRulesUtilsTest.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/libs/CategoryContextualRulesUtils.ts b/src/libs/CategoryContextualRulesUtils.ts index b6afc3a5eeaf..484f128c8685 100644 --- a/src/libs/CategoryContextualRulesUtils.ts +++ b/src/libs/CategoryContextualRulesUtils.ts @@ -98,5 +98,4 @@ function getCategoryContextualRules({ return rules; } -export {getCategoryContextualRules}; -export type {CategoryContextualRule}; +export default getCategoryContextualRules; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index c787400ff612..997dcd138c22 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -23,7 +23,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; import {formatRequiredFieldsTitle} from '@libs/AttendeeUtils'; -import {getCategoryContextualRules} from '@libs/CategoryContextualRulesUtils'; +import getCategoryContextualRules from '@libs/CategoryContextualRulesUtils'; import { formatDefaultTaxRateText, formatRequireItemizedReceiptsOverText, diff --git a/tests/unit/CategoryContextualRulesUtilsTest.ts b/tests/unit/CategoryContextualRulesUtilsTest.ts index b16492b961e5..8d82760b2274 100644 --- a/tests/unit/CategoryContextualRulesUtilsTest.ts +++ b/tests/unit/CategoryContextualRulesUtilsTest.ts @@ -1,4 +1,4 @@ -import {getCategoryContextualRules} from '@libs/CategoryContextualRulesUtils'; +import getCategoryContextualRules from '@libs/CategoryContextualRulesUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import CONST from '@src/CONST'; From 20a180145594bd27c76cae0ac6b7b12f909a89f0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 23 Jul 2026 00:09:20 +0530 Subject: [PATCH 4/4] Fix category-entry Flag and Require Fields create navigation Signed-off-by: krishna2323 --- src/ROUTES.ts | 11 ++++++++++- src/libs/RequireFieldsRulesUtils.ts | 4 +++- .../FlagForReviewRuleAmountPageBase.tsx | 4 +++- .../FlagForReviewRuleCategoryPageBase.tsx | 2 +- .../FlagForReviewRules/FlagForReviewRulePageBase.tsx | 7 ++++++- .../RequireFieldsRuleCategoryPageBase.tsx | 3 ++- .../RequireFieldsRules/RequireFieldsRulePageBase.tsx | 7 ++++++- 7 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7d5001004551..04f4f63d78bc 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4474,8 +4474,17 @@ function getFlagForReviewRuleAmountRoute(policyID: string, categoryName?: string return ROUTES.RULES_FLAG_FOR_REVIEW_RULE_AMOUNT.getRoute(policyID); } +/** + * Category Settings destination after creating a Rules Revamp rule from the category RHP. + * Built as a concrete path (not createDynamicRoute) so it can be used from ROUTES helpers + * without circular imports. + */ +function getWorkspaceCategorySettingsRoute(policyID: string, categoryName: string) { + return `workspaces/${policyID}/categories/category/${encodeURIComponent(categoryName)}` as const; +} + export default ROUTES; -export {getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute, getRequireFieldsRuleCategoryRoute}; +export {getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute, getRequireFieldsRuleCategoryRoute, getWorkspaceCategorySettingsRoute}; type ReportAttachmentsRoute = typeof ROUTES.REPORT_ATTACHMENTS.route; type ReportAddAttachmentRoute = `r/${string}/attachment/add`; diff --git a/src/libs/RequireFieldsRulesUtils.ts b/src/libs/RequireFieldsRulesUtils.ts index 819b1ac19d10..527384f5a317 100644 --- a/src/libs/RequireFieldsRulesUtils.ts +++ b/src/libs/RequireFieldsRulesUtils.ts @@ -753,7 +753,9 @@ function getRequireFieldsRuleBackToRoute({policyID, isEditing, categoryName}: Re return ROUTES.RULES_REQUIRE_FIELDS_RULE_EDIT.getRoute(policyID, categoryName); } - return ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID); + // Preserve ?categoryName= on create so goBack(compareParams) matches the stack entry + // instead of REPLACE-remounting and wiping the draft. + return ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID, categoryName); } function getRequireFieldsFieldSettingUpdate( diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx index 682499a7774a..52ec9813f66a 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx @@ -54,7 +54,9 @@ function FlagForReviewRuleAmountPageBase({policyID, categoryName}: FlagForReview const [expenseLimitType, setExpenseLimitType] = useState(draftExpenseLimitType); const selectedExpenseLimitType = expenseLimitType ?? draftExpenseLimitType; - const backToRoute = isEditing ? ROUTES.RULES_FLAG_FOR_REVIEW_RULE_EDIT.getRoute(policyID, categoryName) : ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID); + const backToRoute = isEditing + ? ROUTES.RULES_FLAG_FOR_REVIEW_RULE_EDIT.getRoute(policyID, categoryName) + : ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, form?.[FLAG_FOR_REVIEW_RULE_INPUT_IDS.CATEGORY]); const expenseLimitTypes = useMemo( () => diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleCategoryPageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleCategoryPageBase.tsx index ecf97735a67c..142dac095572 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleCategoryPageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleCategoryPageBase.tsx @@ -63,7 +63,7 @@ function FlagForReviewRuleCategoryPageBase({policyID, categoryName}: FlagForRevi return {name: decodedCategoryName, value: category.name}; }); - const backToRoute = isEditing ? ROUTES.RULES_FLAG_FOR_REVIEW_RULE_EDIT.getRoute(policyID, categoryName) : ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID); + const backToRoute = isEditing ? ROUTES.RULES_FLAG_FOR_REVIEW_RULE_EDIT.getRoute(policyID, categoryName) : ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, selectedCategoryName); const onSave = (value?: string) => { // Preserve the current draft amount / limit type. Merging from the destination category diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 1957a91e0a2a..bf0e9b4899fb 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -30,7 +30,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute} from '@src/ROUTES'; +import ROUTES, {getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {FlagForReviewRuleForm} from '@src/types/form/FlagForReviewRuleForm'; import INPUT_IDS from '@src/types/form/FlagForReviewRuleForm'; @@ -131,6 +131,11 @@ function FlagForReviewRulePageBase({policyID, categoryName, initialCategoryName, saveFlagForReviewRule(policyID, policyData.categories, form, isEditing ? categoryName : undefined); if (!isEditing && isRulesRevampEnabled) { + if (initialCategoryName) { + Navigation.goBack(getWorkspaceCategorySettingsRoute(policyID, initialCategoryName)); + return; + } + Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.FLAG_FOR_REVIEW); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); return; diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx index 9515058573c9..7a149b336fb0 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRuleCategoryPageBase.tsx @@ -80,7 +80,8 @@ function RequireFieldsRuleCategoryPageBase({policyID, categoryName}: RequireFiel getRequireFieldsRuleBackToRoute({ policyID, isEditing, - categoryName, + // Create flow: use the draft category so ?categoryName= survives goBack. + categoryName: isEditing ? categoryName : selectedCategoryName, }); const onSave = (value?: string) => { diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 2a2d60caf6c5..735db58545f3 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -43,7 +43,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {getRequireFieldsRuleCategoryRoute} from '@src/ROUTES'; +import ROUTES, {getRequireFieldsRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; @@ -359,6 +359,11 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, clearDraftRequireFieldsRule(); if (!isEditing && isRulesRevampEnabled) { + if (initialCategoryName) { + Navigation.goBack(getWorkspaceCategorySettingsRoute(policyID, initialCategoryName)); + return; + } + Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.REQUIRE_FIELDS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); return;