From 3eb1a09ea1506a3ee16b693c50f6c74ce63bec6e Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 15:16:14 -0800 Subject: [PATCH 01/49] Add AddMerchantRulePage and field sub-pages for merchant rules - Add routes for merchant rules pages in ROUTES.ts - Add SCREENS for merchant rules in SCREENS.ts - Add navigation types and linking config - Create MERCHANT_RULES.FIELDS constants - Create MerchantRuleForm type and Onyx keys - Create AddMerchantRulePage main component - Create reusable MerchantRuleTextBasePage and MerchantRuleBooleanBasePage - Create field pages: AddMerchantToMatchPage, AddMerchantPage, AddCategoryPage, AddTagPage, AddTaxPage, AddDescriptionPage, AddReimbursablePage, AddBillablePage - Add translations for merchant rules - Update MerchantRulesSection to navigate to AddMerchantRulePage --- src/CONST/index.ts | 12 + src/ONYXKEYS.ts | 3 + src/ROUTES.ts | 36 +++ src/SCREENS.ts | 9 + src/languages/en.ts | 8 + src/languages/es.ts | 8 + .../ModalStackNavigators/index.tsx | 9 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 9 + src/libs/Navigation/linkingConfig/config.ts | 27 +++ src/libs/Navigation/types.ts | 27 +++ src/libs/actions/User.ts | 12 +- .../rules/MerchantRules/AddBillablePage.tsx | 24 ++ .../rules/MerchantRules/AddCategoryPage.tsx | 73 ++++++ .../MerchantRules/AddDescriptionPage.tsx | 26 ++ .../rules/MerchantRules/AddMerchantPage.tsx | 26 ++ .../MerchantRules/AddMerchantRulePage.tsx | 222 ++++++++++++++++++ .../MerchantRules/AddMerchantToMatchPage.tsx | 28 +++ .../MerchantRules/AddReimbursablePage.tsx | 24 ++ .../rules/MerchantRules/AddTagPage.tsx | 78 ++++++ .../rules/MerchantRules/AddTaxPage.tsx | 70 ++++++ .../MerchantRuleBooleanBasePage.tsx | 98 ++++++++ .../MerchantRuleTextBasePage.tsx | 80 +++++++ .../workspace/rules/MerchantRulesSection.tsx | 4 +- src/types/form/MerchantRuleForm.ts | 24 ++ 24 files changed, 935 insertions(+), 2 deletions(-) create mode 100644 src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddTagPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx create mode 100644 src/types/form/MerchantRuleForm.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d5a7fad86758..5072c2618f44 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3737,6 +3737,18 @@ const CONST = { DELETE: 'delete', }, }, + MERCHANT_RULES: { + FIELDS: { + BILLABLE: 'billable', + CATEGORY: 'category', + DESCRIPTION: 'comment', + MERCHANT_TO_MATCH: 'merchantToMatch', + MERCHANT: 'merchant', + REIMBURSABLE: 'reimbursable', + TAG: 'tag', + TAX: 'tax', + }, + }, get SUBSCRIPTION_PRICES() { return { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index dd7b1cdea9ac..05aa671b9397 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -978,6 +978,8 @@ const ONYXKEYS = { SPLIT_EXPENSE_EDIT_DATES_DRAFT: 'splitExpenseEditDatesDraft', EXPENSE_RULE_FORM: 'expenseRuleForm', EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft', + MERCHANT_RULE_FORM: 'merchantRuleForm', + MERCHANT_RULE_FORM_DRAFT: 'merchantRuleFormDraft', }, DERIVED: { REPORT_ATTRIBUTES: 'reportAttributes', @@ -1097,6 +1099,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm; [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; + [ONYXKEYS.FORMS.MERCHANT_RULE_FORM]: FormTypes.MerchantRuleForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cfcfe09941df..b6b5b7a501e1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2662,6 +2662,42 @@ const ROUTES = { route: 'workspaces/:policyID/overview/policy', getRoute: (policyID: string) => `workspaces/${policyID}/overview/policy` as const, }, + RULES_MERCHANT_NEW: { + route: 'workspaces/:policyID/rules/merchant-rules/new', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, + }, + RULES_MERCHANT_MERCHANT_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/new/merchant-to-match', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/merchant-to-match` as const, + }, + RULES_MERCHANT_MERCHANT: { + route: 'workspaces/:policyID/rules/merchant-rules/new/merchant', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/merchant` as const, + }, + RULES_MERCHANT_CATEGORY: { + route: 'workspaces/:policyID/rules/merchant-rules/new/category', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/category` as const, + }, + RULES_MERCHANT_TAG: { + route: 'workspaces/:policyID/rules/merchant-rules/new/tag', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/tag` as const, + }, + RULES_MERCHANT_TAX: { + route: 'workspaces/:policyID/rules/merchant-rules/new/tax', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/tax` as const, + }, + RULES_MERCHANT_DESCRIPTION: { + route: 'workspaces/:policyID/rules/merchant-rules/new/description', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/description` as const, + }, + RULES_MERCHANT_REIMBURSABLE: { + route: 'workspaces/:policyID/rules/merchant-rules/new/reimbursable', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/reimbursable` as const, + }, + RULES_MERCHANT_BILLABLE: { + route: 'workspaces/:policyID/rules/merchant-rules/new/billable', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/billable` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 617ab3c4d9a6..54de9d858b44 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -754,6 +754,15 @@ const SCREENS = { RULES_REIMBURSABLE_DEFAULT: 'Rules_Reimbursable_Default', RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', + RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', + RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', + RULES_MERCHANT_CATEGORY: 'Rules_Merchant_Category', + RULES_MERCHANT_TAG: 'Rules_Merchant_Tag', + RULES_MERCHANT_TAX: 'Rules_Merchant_Tax', + RULES_MERCHANT_DESCRIPTION: 'Rules_Merchant_Description', + RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', + RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', PER_DIEM: 'Per_Diem', PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', diff --git a/src/languages/en.ts b/src/languages/en.ts index a8b4098c2780..56aa96ea5e15 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6170,6 +6170,14 @@ const translations = { title: 'Merchant', subtitle: 'Set the merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', + addRuleTitle: 'Add rule', + expensesWith: 'For expenses with:', + applyUpdates: 'Apply these updates:', + merchantHint: 'Match a merchant name with case-insensitive "contains" matching', + saveRule: 'Save rule', + confirmError: 'Enter merchant and apply at least one update', + confirmErrorMerchant: 'Please enter merchant', + confirmErrorUpdate: 'Please apply at least one update', ruleSummaryTitle: (merchantName: string) => `If merchant contains "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 6b62d6109ddc..3c7f49647c9a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5945,6 +5945,14 @@ ${amount} para ${merchant} - ${date}`, title: 'Comerciante', subtitle: 'Configura las reglas de comerciante para que los gastos lleguen correctamente codificados y requieran menos limpieza.', addRule: 'Añadir regla de comerciante', + addRuleTitle: 'Añadir regla', + expensesWith: 'Para gastos con:', + applyUpdates: 'Aplicar estas actualizaciones:', + merchantHint: 'Coincide con un nombre de comerciante con coincidencia "contiene" sin distinción de mayúsculas y minúsculas', + saveRule: 'Guardar regla', + confirmError: 'Ingresa comerciante y aplica al menos una actualización', + confirmErrorMerchant: 'Por favor ingresa comerciante', + confirmErrorUpdate: 'Por favor aplica al menos una actualización', ruleSummaryTitle: (merchantName: string) => `Si el comerciante contiene "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f8a60900b6af..996206cd6c56 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -819,6 +819,15 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesReimbursableDefaultPage').default, [SCREENS.WORKSPACE.RULES_CUSTOM]: () => require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTaxPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: () => require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: () => require('../../../../pages/workspace/perDiem/ImportedPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 0beb9e8ba14f..14cc3007df1e 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -279,6 +279,15 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: { path: ROUTES.RULES_PROHIBITED_DEFAULT.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + path: ROUTES.RULES_MERCHANT_NEW.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + path: ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + path: ROUTES.RULES_MERCHANT_MERCHANT.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + path: ROUTES.RULES_MERCHANT_CATEGORY.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + path: ROUTES.RULES_MERCHANT_TAG.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + path: ROUTES.RULES_MERCHANT_TAX.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + path: ROUTES.RULES_MERCHANT_DESCRIPTION.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + path: ROUTES.RULES_MERCHANT_REIMBURSABLE.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + path: ROUTES.RULES_MERCHANT_BILLABLE.route, + }, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { path: ROUTES.WORKSPACE_PER_DIEM_IMPORT.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 73c5c1c8cd8b..72f6f735db69 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1387,6 +1387,33 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_CUSTOM]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + policyID: string; + }; [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { policyID: string; }; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index a0e14ff5cbc9..993272f45b1f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -48,7 +48,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ExpenseRuleForm} from '@src/types/form'; +import type {ExpenseRuleForm, MerchantRuleForm} from '@src/types/form'; import type {AppReview, BlockedFromConcierge, CustomStatusDraft, LoginList, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -1682,6 +1682,14 @@ function clearDraftRule() { Onyx.set(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, null); } +function updateDraftMerchantRule(ruleData: Partial) { + Onyx.merge(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, ruleData); +} + +function clearDraftMerchantRule() { + Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, null); +} + export { closeAccount, setServerErrorsOnForm, @@ -1728,4 +1736,6 @@ export { setDraftRule, updateDraftRule, clearDraftRule, + updateDraftMerchantRule, + clearDraftMerchantRule, }; diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx new file mode 100644 index 000000000000..7f0f0165e6ab --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import MerchantRuleBooleanBasePage from './MerchantRuleBooleanBasePage'; + +type AddBillablePageProps = PlatformStackScreenProps; + +function AddBillablePage({route}: AddBillablePageProps) { + const policyID = route.params?.policyID ?? '-1'; + + return ( + + ); +} + +AddBillablePage.displayName = 'AddBillablePage'; + +export default AddBillablePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx new file mode 100644 index 000000000000..28ec45033cb8 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -0,0 +1,73 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddCategoryPageProps = PlatformStackScreenProps; + +function AddCategoryPage({route}: AddCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + + const selectedCategoryItem = form?.category ? {name: getDecodedCategoryName(form.category), value: form.category} : undefined; + + const categoryItems = useMemo(() => { + const categories = policy?.categories ?? {}; + return Object.values(categories) + .filter((category) => category.enabled) + .map((category) => { + const decodedCategoryName = getDecodedCategoryName(category.name); + return {name: decodedCategoryName, value: category.name}; + }); + }, [policy?.categories]); + + const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({category: value}); + }; + + return ( + + Navigation.goBack(backToRoute)} + /> + + + + + ); +} + +AddCategoryPage.displayName = 'AddCategoryPage'; + +export default AddCategoryPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx new file mode 100644 index 000000000000..17a8e4f2d0f6 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; + +type AddDescriptionPageProps = PlatformStackScreenProps; + +function AddDescriptionPage({route}: AddDescriptionPageProps) { + const policyID = route.params?.policyID ?? '-1'; + + return ( + + ); +} + +AddDescriptionPage.displayName = 'AddDescriptionPage'; + +export default AddDescriptionPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx new file mode 100644 index 000000000000..767a10c5cd05 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; + +type AddMerchantPageProps = PlatformStackScreenProps; + +function AddMerchantPage({route}: AddMerchantPageProps) { + const policyID = route.params?.policyID ?? '-1'; + + return ( + + ); +} + +AddMerchantPage.displayName = 'AddMerchantPage'; + +export default AddMerchantPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx new file mode 100644 index 000000000000..8467031dc245 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -0,0 +1,222 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getCleanedTagName} from '@libs/PolicyUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {MerchantRuleForm} from '@src/types/form'; + +type AddMerchantRulePageProps = PlatformStackScreenProps; + +type SectionType = { + titleTranslationKey: 'workspace.rules.merchantRules.expensesWith' | 'workspace.rules.merchantRules.applyUpdates'; + items: Array< + | { + descriptionTranslationKey: 'common.merchant' | 'common.category' | 'common.tag' | 'common.tax' | 'common.description' | 'common.reimbursable' | 'common.billable'; + required?: boolean; + title?: string; + onPress: () => void; + } + | undefined + >; +}; + +const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { + const hasAtLeastOneUpdate = Object.entries(form ?? {}).some( + ([key, value]) => key !== CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH && !!value, + ); + if (form?.merchantToMatch && hasAtLeastOneUpdate) { + return ''; + } + if (hasAtLeastOneUpdate) { + return translate('workspace.rules.merchantRules.confirmErrorMerchant'); + } + if (form?.merchantToMatch) { + return translate('workspace.rules.merchantRules.confirmErrorUpdate'); + } + return translate('workspace.rules.merchantRules.confirmError'); +}; + +function AddMerchantRulePage({route}: AddMerchantRulePageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = route.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [isSaving, setIsSaving] = useState(false); + const [shouldShowError, setShouldShowError] = useState(false); + + useEffect(() => () => clearDraftMerchantRule(), []); + + const hasPolicyCategories = useMemo(() => { + return Object.values(policy?.categories ?? {}).length > 0; + }, [policy?.categories]); + + const hasPolicyTags = useMemo(() => { + const tagLists = policy?.tagList ?? {}; + return Object.values(tagLists).some((tagList) => Object.keys(tagList?.tags ?? {}).length > 0); + }, [policy?.tagList]); + + const hasTaxRates = useMemo(() => { + return Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; + }, [policy?.taxRates?.taxes]); + + const selectedTaxRate = useMemo(() => { + if (!form?.tax || !policy?.taxRates?.taxes) { + return undefined; + } + const taxRate = policy.taxRates.taxes[form.tax]; + return taxRate ? {name: taxRate.name, value: taxRate.value} : undefined; + }, [form?.tax, policy?.taxRates?.taxes]); + + const errorMessage = getErrorMessage(translate, form); + + const handleSubmit = () => { + if (errorMessage) { + setShouldShowError(true); + return; + } + if (!form) { + return; + } + + setIsSaving(true); + + // TODO: Call SetPolicyMerchantRule API when available + // For now, just navigate back + Navigation.goBack(); + }; + + const sections: SectionType[] = [ + { + titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', + items: [ + { + descriptionTranslationKey: 'common.merchant', + required: true, + title: form?.merchantToMatch, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID)), + }, + ], + }, + { + titleTranslationKey: 'workspace.rules.merchantRules.applyUpdates', + items: [ + { + descriptionTranslationKey: 'common.merchant', + title: form?.merchant, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), + }, + hasPolicyCategories + ? { + descriptionTranslationKey: 'common.category', + title: form?.category, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), + } + : undefined, + hasPolicyTags + ? { + descriptionTranslationKey: 'common.tag', + title: form?.tag ? getCleanedTagName(form.tag) : undefined, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), + } + : undefined, + hasTaxRates + ? { + descriptionTranslationKey: 'common.tax', + title: selectedTaxRate ? `${selectedTaxRate.name} (${selectedTaxRate.value})` : undefined, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), + } + : undefined, + { + descriptionTranslationKey: 'common.description', + title: form?.comment, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.reimbursable', + title: form?.reimbursable ? translate(form.reimbursable === 'true' ? 'common.yes' : 'common.no') : '', + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.billable', + title: form?.billable ? translate(form.billable === 'true' ? 'common.yes' : 'common.no') : '', + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), + }, + ], + }, + ]; + + return ( + + + + + {sections.map((section) => ( + + + {translate(section.titleTranslationKey)} + + {section.items.map((item) => { + if (!item) { + return null; + } + return ( + + ); + })} + + ))} + + + + + ); +} + +AddMerchantRulePage.displayName = 'AddMerchantRulePage'; + +export default AddMerchantRulePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx new file mode 100644 index 000000000000..c94bf3f69ec2 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; + +type AddMerchantToMatchPageProps = PlatformStackScreenProps; + +function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { + const policyID = route.params?.policyID ?? '-1'; + + return ( + + ); +} + +AddMerchantToMatchPage.displayName = 'AddMerchantToMatchPage'; + +export default AddMerchantToMatchPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx new file mode 100644 index 000000000000..efc3b8ff64b5 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import MerchantRuleBooleanBasePage from './MerchantRuleBooleanBasePage'; + +type AddReimbursablePageProps = PlatformStackScreenProps; + +function AddReimbursablePage({route}: AddReimbursablePageProps) { + const policyID = route.params?.policyID ?? '-1'; + + return ( + + ); +} + +AddReimbursablePage.displayName = 'AddReimbursablePage'; + +export default AddReimbursablePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx new file mode 100644 index 000000000000..03ab5a221551 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -0,0 +1,78 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getCleanedTagName} from '@libs/PolicyUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddTagPageProps = PlatformStackScreenProps; + +function AddTagPage({route}: AddTagPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + + const selectedTagItem = form?.tag ? {name: getCleanedTagName(form.tag), value: form.tag} : undefined; + + const tagItems = useMemo(() => { + const tagLists = policy?.tagList ?? {}; + const tags: Array<{name: string; value: string}> = []; + + Object.values(tagLists).forEach((tagList) => { + Object.values(tagList?.tags ?? {}).forEach((tag) => { + if (tag.enabled) { + tags.push({name: getCleanedTagName(tag.name), value: tag.name}); + } + }); + }); + + return tags; + }, [policy?.tagList]); + + const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({tag: value}); + }; + + return ( + + Navigation.goBack(backToRoute)} + /> + + + + + ); +} + +AddTagPage.displayName = 'AddTagPage'; + +export default AddTagPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx new file mode 100644 index 000000000000..3eb3406dbdf4 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -0,0 +1,70 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddTaxPageProps = PlatformStackScreenProps; + +function AddTaxPage({route}: AddTaxPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + + const taxItems = useMemo(() => { + const taxes = policy?.taxRates?.taxes ?? {}; + return Object.entries(taxes).map(([taxKey, tax]) => ({ + name: `${tax.name} (${tax.value})`, + value: taxKey, + })); + }, [policy?.taxRates?.taxes]); + + const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; + + const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({tax: value}); + }; + + return ( + + Navigation.goBack(backToRoute)} + /> + + + + + ); +} + +AddTaxPage.displayName = 'AddTaxPage'; + +export default AddTaxPage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx new file mode 100644 index 000000000000..102ef8e88be9 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {InputID} from '@src/types/form/MerchantRuleForm'; + +type BooleanFilterItem = ListItem & { + value: ValueOf; +}; + +type MerchantRuleBooleanBasePageProps = { + /** The key from boolean-based InputID */ + fieldID: InputID; + + /** The translation key for the page title */ + titleKey: TranslationPaths; + + /** The policy ID */ + policyID: string; +}; + +const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); + +function MerchantRuleBooleanBasePage({fieldID, titleKey, policyID}: MerchantRuleBooleanBasePageProps) { + const {translate} = useLocalize(); + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const styles = useThemeStyles(); + + const selectedItem = + booleanValues.find((value) => { + if (!form?.[fieldID]) { + return false; + } + const booleanValue = form[fieldID] === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + return booleanValue === value; + }) ?? null; + + const items = booleanValues.map((value) => ({ + value, + keyForList: value, + text: translate(`common.${value}`), + isSelected: selectedItem === value, + })); + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSelectItem = (selectedValue: BooleanFilterItem) => { + const newValue = selectedValue.isSelected ? null : selectedValue.value; + let value = ''; + if (newValue === CONST.SEARCH.BOOLEAN.YES) { + value = 'true'; + } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { + value = 'false'; + } + updateDraftMerchantRule({[fieldID]: value}); + goBack(); + }; + + return ( + + + + + + + ); +} + +export default MerchantRuleBooleanBasePage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx new file mode 100644 index 000000000000..a7ef9c769825 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import TextBase from '@components/Rule/TextBase'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {InputID} from '@src/types/form/MerchantRuleForm'; + +type MerchantRuleTextBasePageProps = { + /** The key from InputID */ + fieldID: InputID; + + /** The translation key for the page title and input label if labelKey is missing */ + titleKey: TranslationPaths; + + /** The translation key for the input label */ + labelKey?: TranslationPaths; + + /** Test ID for the screen wrapper */ + testID: string; + + /** The translation key for the hint text to display below the TextInput */ + hintKey?: TranslationPaths; + + /** Whether this field is required */ + isRequired?: boolean; + + /** The character limit for the input */ + characterLimit?: number; + + /** The policy ID */ + policyID: string; +}; + +function MerchantRuleTextBasePage({fieldID, hintKey, isRequired, titleKey, labelKey, testID, policyID, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: MerchantRuleTextBasePageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + + return ( + + + + + ); +} + +export default MerchantRuleTextBasePage; diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index deef0d21e154..575623ebf47c 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -11,6 +11,8 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -127,7 +129,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { iconHeight={20} iconWidth={20} style={[styles.sectionMenuItemTopDescription, !hasRules && styles.mt6, styles.mbn3]} - onPress={() => {}} + onPress={() => Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID))} /> ); diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts new file mode 100644 index 000000000000..60361f9f09df --- /dev/null +++ b/src/types/form/MerchantRuleForm.ts @@ -0,0 +1,24 @@ +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type Form from './Form'; + +const INPUT_IDS = CONST.MERCHANT_RULES.FIELDS; + +type InputID = ValueOf; + +type MerchantRuleForm = Form< + InputID, + { + [INPUT_IDS.BILLABLE]: 'true' | 'false'; + [INPUT_IDS.CATEGORY]: string; + [INPUT_IDS.DESCRIPTION]: string; + [INPUT_IDS.MERCHANT]: string; + [INPUT_IDS.MERCHANT_TO_MATCH]: string; + [INPUT_IDS.REIMBURSABLE]: 'true' | 'false'; + [INPUT_IDS.TAG]: string; + [INPUT_IDS.TAX]: string; + } +>; + +export type {MerchantRuleForm, InputID}; +export default INPUT_IDS; From c6599f3cc56e3e56f213b4aca58dde66e6c183e0 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 15:24:00 -0800 Subject: [PATCH 02/49] Always show category, tag, and tax options in AddMerchantRulePage --- .../MerchantRules/AddMerchantRulePage.tsx | 109 ++++++------------ 1 file changed, 34 insertions(+), 75 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 8467031dc245..dc4a437ae439 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -9,13 +9,11 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getCleanedTagName} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,15 +25,12 @@ type AddMerchantRulePageProps = PlatformStackScreenProps void; - } - | undefined - >; + items: Array<{ + descriptionTranslationKey: 'common.merchant' | 'common.category' | 'common.tag' | 'common.tax' | 'common.description' | 'common.reimbursable' | 'common.billable'; + required?: boolean; + title?: string; + onPress: () => void; + }>; }; const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { @@ -58,35 +53,12 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = route.params?.policyID ?? '-1'; - const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); - const [isSaving, setIsSaving] = useState(false); const [shouldShowError, setShouldShowError] = useState(false); useEffect(() => () => clearDraftMerchantRule(), []); - const hasPolicyCategories = useMemo(() => { - return Object.values(policy?.categories ?? {}).length > 0; - }, [policy?.categories]); - - const hasPolicyTags = useMemo(() => { - const tagLists = policy?.tagList ?? {}; - return Object.values(tagLists).some((tagList) => Object.keys(tagList?.tags ?? {}).length > 0); - }, [policy?.tagList]); - - const hasTaxRates = useMemo(() => { - return Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; - }, [policy?.taxRates?.taxes]); - - const selectedTaxRate = useMemo(() => { - if (!form?.tax || !policy?.taxRates?.taxes) { - return undefined; - } - const taxRate = policy.taxRates.taxes[form.tax]; - return taxRate ? {name: taxRate.name, value: taxRate.value} : undefined; - }, [form?.tax, policy?.taxRates?.taxes]); - const errorMessage = getErrorMessage(translate, form); const handleSubmit = () => { @@ -98,8 +70,6 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { return; } - setIsSaving(true); - // TODO: Call SetPolicyMerchantRule API when available // For now, just navigate back Navigation.goBack(); @@ -125,27 +95,21 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { title: form?.merchant, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), }, - hasPolicyCategories - ? { - descriptionTranslationKey: 'common.category', - title: form?.category, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), - } - : undefined, - hasPolicyTags - ? { - descriptionTranslationKey: 'common.tag', - title: form?.tag ? getCleanedTagName(form.tag) : undefined, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), - } - : undefined, - hasTaxRates - ? { - descriptionTranslationKey: 'common.tax', - title: selectedTaxRate ? `${selectedTaxRate.name} (${selectedTaxRate.value})` : undefined, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), - } - : undefined, + { + descriptionTranslationKey: 'common.category', + title: form?.category, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.tag', + title: form?.tag, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.tax', + title: form?.tax, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), + }, { descriptionTranslationKey: 'common.description', title: form?.comment, @@ -184,23 +148,18 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { {translate(section.titleTranslationKey)} - {section.items.map((item) => { - if (!item) { - return null; - } - return ( - - ); - })} + {section.items.map((item) => ( + + ))} ))} From 05b79ef31a262f572b193a4325efd0e574c3ea6a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 15:28:21 -0800 Subject: [PATCH 03/49] Conditionally show category, tag, tax, and billable based on policy settings --- .../MerchantRules/AddMerchantRulePage.tsx | 142 ++++++++++-------- 1 file changed, 81 insertions(+), 61 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index dc4a437ae439..19f508272015 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -9,6 +9,7 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -23,14 +24,16 @@ import type {MerchantRuleForm} from '@src/types/form'; type AddMerchantRulePageProps = PlatformStackScreenProps; +type SectionItemType = { + descriptionTranslationKey: 'common.merchant' | 'common.category' | 'common.tag' | 'common.tax' | 'common.description' | 'common.reimbursable' | 'common.billable'; + required?: boolean; + title?: string; + onPress: () => void; +}; + type SectionType = { titleTranslationKey: 'workspace.rules.merchantRules.expensesWith' | 'workspace.rules.merchantRules.applyUpdates'; - items: Array<{ - descriptionTranslationKey: 'common.merchant' | 'common.category' | 'common.tag' | 'common.tax' | 'common.description' | 'common.reimbursable' | 'common.billable'; - required?: boolean; - title?: string; - onPress: () => void; - }>; + items: Array; }; const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { @@ -53,12 +56,18 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = route.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); const [shouldShowError, setShouldShowError] = useState(false); useEffect(() => () => clearDraftMerchantRule(), []); + const areCategoriesEnabled = !!policy?.areCategoriesEnabled; + const areTagsEnabled = !!policy?.areTagsEnabled; + const isTaxTrackingEnabled = !!policy?.isTaxTrackingEnabled; + const trackBillables = policy?.disabledFields?.defaultBillable !== true; + const errorMessage = getErrorMessage(translate, form); const handleSubmit = () => { @@ -75,59 +84,70 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { Navigation.goBack(); }; - const sections: SectionType[] = [ - { - titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', - items: [ - { - descriptionTranslationKey: 'common.merchant', - required: true, - title: form?.merchantToMatch, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID)), - }, - ], - }, - { - titleTranslationKey: 'workspace.rules.merchantRules.applyUpdates', - items: [ - { - descriptionTranslationKey: 'common.merchant', - title: form?.merchant, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.category', - title: form?.category, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.tag', - title: form?.tag, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.tax', - title: form?.tax, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.description', - title: form?.comment, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.reimbursable', - title: form?.reimbursable ? translate(form.reimbursable === 'true' ? 'common.yes' : 'common.no') : '', - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.billable', - title: form?.billable ? translate(form.billable === 'true' ? 'common.yes' : 'common.no') : '', - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), - }, - ], - }, - ]; + const sections: SectionType[] = useMemo( + () => [ + { + titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', + items: [ + { + descriptionTranslationKey: 'common.merchant', + required: true, + title: form?.merchantToMatch, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID)), + }, + ], + }, + { + titleTranslationKey: 'workspace.rules.merchantRules.applyUpdates', + items: [ + { + descriptionTranslationKey: 'common.merchant', + title: form?.merchant, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), + }, + areCategoriesEnabled + ? { + descriptionTranslationKey: 'common.category', + title: form?.category, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), + } + : undefined, + areTagsEnabled + ? { + descriptionTranslationKey: 'common.tag', + title: form?.tag, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), + } + : undefined, + isTaxTrackingEnabled + ? { + descriptionTranslationKey: 'common.tax', + title: form?.tax, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), + } + : undefined, + { + descriptionTranslationKey: 'common.description', + title: form?.comment, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.reimbursable', + title: form?.reimbursable ? translate(form.reimbursable === 'true' ? 'common.yes' : 'common.no') : '', + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), + }, + trackBillables + ? { + descriptionTranslationKey: 'common.billable', + title: form?.billable ? translate(form.billable === 'true' ? 'common.yes' : 'common.no') : '', + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), + } + : undefined, + ], + }, + ], + [areCategoriesEnabled, areTagsEnabled, isTaxTrackingEnabled, trackBillables, form, policyID, translate], + ); return ( {translate(section.titleTranslationKey)} - {section.items.map((item) => ( + {section.items.filter((item): item is SectionItemType => !!item).map((item) => ( Date: Mon, 26 Jan 2026 15:29:57 -0800 Subject: [PATCH 04/49] add page to form types --- src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx | 2 +- src/types/form/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 19f508272015..e2089e214f64 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -65,7 +65,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const areCategoriesEnabled = !!policy?.areCategoriesEnabled; const areTagsEnabled = !!policy?.areTagsEnabled; - const isTaxTrackingEnabled = !!policy?.isTaxTrackingEnabled; + const isTaxTrackingEnabled = !!policy?.tax?.trackingEnabled; const trackBillables = policy?.disabledFields?.defaultBillable !== true; const errorMessage = getErrorMessage(translate, form); diff --git a/src/types/form/index.ts b/src/types/form/index.ts index ac687cf145dd..ddc68afe37e4 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -103,3 +103,4 @@ export type {CreateDomainForm} from './CreateDomainForm'; export type {SplitExpenseEditDateForm} from './SplitExpenseEditDateForm'; export type {ResetDomainForm} from './ResetDomainForm'; export type {ExpenseRuleForm} from './ExpenseRuleForm'; +export type {MerchantRuleForm} from './MerchantRuleForm'; \ No newline at end of file From 971c5a3bb322a77b39358203f9408f139b69383d Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 15:32:43 -0800 Subject: [PATCH 05/49] Use workspace-specific Onyx collections for categories, tags, and taxes --- .../rules/MerchantRules/AddCategoryPage.tsx | 8 +++----- .../workspace/rules/MerchantRules/AddTagPage.tsx | 8 +++----- .../workspace/rules/MerchantRules/AddTaxPage.tsx | 13 +++++++------ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx index 28ec45033cb8..e0876e5a86f5 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -5,7 +5,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; @@ -22,21 +21,20 @@ function AddCategoryPage({route}: AddCategoryPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID ?? '-1'; - const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); const selectedCategoryItem = form?.category ? {name: getDecodedCategoryName(form.category), value: form.category} : undefined; const categoryItems = useMemo(() => { - const categories = policy?.categories ?? {}; - return Object.values(categories) + return Object.values(policyCategories ?? {}) .filter((category) => category.enabled) .map((category) => { const decodedCategoryName = getDecodedCategoryName(category.name); return {name: decodedCategoryName, value: category.name}; }); - }, [policy?.categories]); + }, [policyCategories]); const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx index 03ab5a221551..132fed24dab3 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -5,7 +5,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -22,17 +21,16 @@ function AddTagPage({route}: AddTagPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID ?? '-1'; - const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); const selectedTagItem = form?.tag ? {name: getCleanedTagName(form.tag), value: form.tag} : undefined; const tagItems = useMemo(() => { - const tagLists = policy?.tagList ?? {}; const tags: Array<{name: string; value: string}> = []; - Object.values(tagLists).forEach((tagList) => { + Object.values(policyTags ?? {}).forEach((tagList) => { Object.values(tagList?.tags ?? {}).forEach((tag) => { if (tag.enabled) { tags.push({name: getCleanedTagName(tag.name), value: tag.name}); @@ -41,7 +39,7 @@ function AddTagPage({route}: AddTagPageProps) { }); return tags; - }, [policy?.tagList]); + }, [policyTags]); const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx index 3eb3406dbdf4..476d27210891 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -5,7 +5,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -21,16 +20,18 @@ function AddTaxPage({route}: AddTaxPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID ?? '-1'; - const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); const taxItems = useMemo(() => { const taxes = policy?.taxRates?.taxes ?? {}; - return Object.entries(taxes).map(([taxKey, tax]) => ({ - name: `${tax.name} (${tax.value})`, - value: taxKey, - })); + return Object.entries(taxes) + .filter(([, tax]) => !tax.isDisabled) + .map(([taxKey, tax]) => ({ + name: `${tax.name} (${tax.value})`, + value: taxKey, + })); }, [policy?.taxRates?.taxes]); const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; From 60571ee8fe47c93ccb9e5f8bb002f9409ff60627 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 15:40:49 -0800 Subject: [PATCH 06/49] apply translations --- src/languages/de.ts | 8 +++++ src/languages/fr.ts | 8 +++++ src/languages/it.ts | 8 +++++ src/languages/ja.ts | 8 +++++ src/languages/nl.ts | 8 +++++ src/languages/pl.ts | 8 +++++ src/languages/pt-BR.ts | 8 +++++ src/languages/zh-hans.ts | 8 +++++ .../MerchantRules/AddMerchantRulePage.tsx | 34 +++++++++---------- src/types/form/index.ts | 2 +- 10 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 6d54352c36f8..e9c5bed30db1 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6326,6 +6326,14 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aktualisiere ${fieldName} zu „${fieldValue}“`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Als "${reimbursable ? 'erstattungsfähig' : 'nicht erstattungsfähig'}" markieren`, ruleSummarySubtitleBillable: (billable: boolean) => `Als „${billable ? 'Abrechenbar' : 'nicht abrechenbar'}“ markieren`, + addRuleTitle: 'Regel hinzufügen', + expensesWith: 'Für Ausgaben mit:', + applyUpdates: 'Diese Updates anwenden:', + merchantHint: 'Einem Händlernamen mit groß-/kleinschreibungsunabhängiger „Enthält“-Übereinstimmung zuordnen', + saveRule: 'Regel speichern', + confirmError: 'Geben Sie den Händler ein und nehmen Sie mindestens eine Änderung vor', + confirmErrorMerchant: 'Bitte geben Sie den Händler ein', + confirmErrorUpdate: 'Bitte wenden Sie mindestens eine Aktualisierung an', }, }, planTypePage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7a781bc2dacf..001c14e07442 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6337,6 +6337,14 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Mettre à jour ${fieldName} sur « ${fieldValue} »`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marquer comme « ${reimbursable ? 'remboursable' : 'non remboursable'} »`, ruleSummarySubtitleBillable: (billable: boolean) => `Marquer comme « ${billable ? 'facturable' : 'non facturable'} »`, + addRuleTitle: 'Ajouter une règle', + expensesWith: 'Pour les dépenses avec :', + applyUpdates: 'Appliquer ces mises à jour :', + merchantHint: 'Faire correspondre un nom de commerçant avec une correspondance « contient » insensible à la casse', + saveRule: 'Enregistrer la règle', + confirmError: 'Saisissez un marchand et appliquez au moins une mise à jour', + confirmErrorMerchant: 'Veuillez saisir le commerçant', + confirmErrorUpdate: 'Veuillez appliquer au moins une mise à jour', }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 97ce434bb2db..c5bc724f1aa1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6310,6 +6310,14 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aggiorna ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Segna come "${reimbursable ? 'rimborsabile' : 'non rimborsabile'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Contrassegna come "${billable ? 'fatturabile' : 'non fatturabile'}"`, + addRuleTitle: 'Aggiungi regola', + expensesWith: 'Per spese con:', + applyUpdates: 'Applica questi aggiornamenti:', + merchantHint: 'Abbina un nome commerciante con una corrispondenza "contiene" che non distingue tra maiuscole e minuscole', + saveRule: 'Salva regola', + confirmError: 'Inserisci l’esercente e applica almeno un aggiornamento', + confirmErrorMerchant: 'Per favore inserisci l’esercente', + confirmErrorUpdate: 'Applica almeno un aggiornamento', }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0a70a59323df..566b65b9f8e5 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6267,6 +6267,14 @@ ${reportName} ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `${fieldName} を「${fieldValue}」に更新`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `「${reimbursable ? '払い戻し対象' : '精算対象外'}」としてマーク`, ruleSummarySubtitleBillable: (billable: boolean) => `「${billable ? '請求可能' : '請求対象外'}」としてマーク`, + addRuleTitle: 'ルールを追加', + expensesWith: '次の条件の経費について:', + applyUpdates: 'これらの更新を適用:', + merchantHint: '大文字小文字を区別しない「含む」一致で支払先名を照合する', + saveRule: 'ルールを保存', + confirmError: '支払先を入力し、少なくとも 1 つの更新を適用してください', + confirmErrorMerchant: '商人を入力してください', + confirmErrorUpdate: '少なくとも 1 件の更新を適用してください', }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a4421c1d3ab6..b8265167886d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6296,6 +6296,14 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Werk ${fieldName} bij naar "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Markeren als "${reimbursable ? 'Vergoedbaar' : 'niet-vergoedbaar'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Markeren als "${billable ? 'factureerbaar' : 'niet-factureerbaar'}"`, + addRuleTitle: 'Regel toevoegen', + expensesWith: 'Voor uitgaven met:', + applyUpdates: 'Deze updates toepassen:', + merchantHint: 'Een handelsnaam koppelen met hoofdletterongevoelige "bevat"-overeenkomst', + saveRule: 'Regel opslaan', + confirmError: 'Voer een leverancier in en pas ten minste één wijziging toe', + confirmErrorMerchant: 'Voer handelaar in', + confirmErrorUpdate: 'Breng ten minste één wijziging aan alstublieft', }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 28a17b01098f..f7eb36673d3b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6290,6 +6290,14 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Zaktualizuj ${fieldName} na „${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Oznacz jako "${reimbursable ? 'kwalifikujący się do zwrotu kosztów' : 'niepodlegający zwrotowi'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Oznacz jako „${billable ? 'fakturowalne' : 'poza fakturą'}”`, + addRuleTitle: 'Dodaj regułę', + expensesWith: 'Dla wydatków z:', + applyUpdates: 'Zastosuj te aktualizacje:', + merchantHint: 'Dopasuj nazwę sprzedawcy przy użyciu nieczułego na wielkość liter dopasowania typu „zawiera”', + saveRule: 'Zapisz regułę', + confirmError: 'Wprowadź sprzedawcę i zastosuj co najmniej jedną aktualizację', + confirmErrorMerchant: 'Wprowadź sprzedawcę', + confirmErrorUpdate: 'Proszę wprowadzić co najmniej jedną zmianę', }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ef12d476e5b3..fbf7733a01af 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6291,6 +6291,14 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Atualizar ${fieldName} para "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsável' : 'não reembolsável'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'faturável' : 'não faturável'}"`, + addRuleTitle: 'Adicionar regra', + expensesWith: 'Para despesas com:', + applyUpdates: 'Aplicar estas atualizações:', + merchantHint: 'Corresponder um nome de comerciante com correspondência "contém" sem diferenciação entre maiúsculas e minúsculas', + saveRule: 'Salvar regra', + confirmError: 'Insira o estabelecimento e aplique pelo menos uma atualização', + confirmErrorMerchant: 'Insira o comerciante', + confirmErrorUpdate: 'Por favor, aplique pelo menos uma atualização', }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5c997de1759e..289129a857e2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6157,6 +6157,14 @@ ${reportName} ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `将 ${fieldName} 更新为“${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `标记为“${reimbursable ? '可报销' : '不予报销'}”`, ruleSummarySubtitleBillable: (billable: boolean) => `标记为“${billable ? '可计费' : '不可计费'}”`, + addRuleTitle: '添加规则', + expensesWith: '对于以下费用:', + applyUpdates: '应用这些更新:', + merchantHint: '使用不区分大小写的“包含”匹配来匹配商户名称', + saveRule: '保存规则', + confirmError: '输入商家并至少应用一项更新', + confirmErrorMerchant: '请输入商家', + confirmErrorUpdate: '请至少进行一次更新', }, }, planTypePage: { diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index e2089e214f64..d653f0dbad21 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -37,9 +37,7 @@ type SectionType = { }; const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { - const hasAtLeastOneUpdate = Object.entries(form ?? {}).some( - ([key, value]) => key !== CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH && !!value, - ); + const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => key !== CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH && !!value); if (form?.merchantToMatch && hasAtLeastOneUpdate) { return ''; } @@ -165,21 +163,21 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { {sections.map((section) => ( - - {translate(section.titleTranslationKey)} - - {section.items.filter((item): item is SectionItemType => !!item).map((item) => ( - - ))} + {translate(section.titleTranslationKey)} + {section.items + .filter((item): item is SectionItemType => !!item) + .map((item) => ( + + ))} ))} diff --git a/src/types/form/index.ts b/src/types/form/index.ts index ddc68afe37e4..5d5fb8f02ab6 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -103,4 +103,4 @@ export type {CreateDomainForm} from './CreateDomainForm'; export type {SplitExpenseEditDateForm} from './SplitExpenseEditDateForm'; export type {ResetDomainForm} from './ResetDomainForm'; export type {ExpenseRuleForm} from './ExpenseRuleForm'; -export type {MerchantRuleForm} from './MerchantRuleForm'; \ No newline at end of file +export type {MerchantRuleForm} from './MerchantRuleForm'; From 6d0d4aea9168c2371951d964c2870c608c7974c9 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 15:55:13 -0800 Subject: [PATCH 07/49] Add SetPolicyMerchantRule API call - Create SetPolicyMerchantRuleParams type - Add SET_POLICY_MERCHANT_RULE write command - Implement setPolicyMerchantRule action with optimistic updates - Call API from AddMerchantRulePage on submit --- .../parameters/SetPolicyMerchantRuleParams.ts | 13 +++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 3 + src/libs/actions/Policy/Rules.ts | 110 +++++++++++++++++- .../MerchantRules/AddMerchantRulePage.tsx | 4 +- 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/libs/API/parameters/SetPolicyMerchantRuleParams.ts diff --git a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts new file mode 100644 index 000000000000..c884086c236f --- /dev/null +++ b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts @@ -0,0 +1,13 @@ +type SetPolicyMerchantRuleParams = { + policyID: string; + merchantToMatch: string; + merchant?: string; + category?: string; + tag?: string; + tax?: string; + comment?: string; + reimbursable?: boolean; + billable?: boolean; +}; + +export default SetPolicyMerchantRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 13cfd3171822..3fb850742b4a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -463,3 +463,4 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; +export type {default as SetPolicyMerchantRuleParams} from './SetPolicyMerchantRuleParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a4f0123a3d7d..774057b26731 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -254,6 +254,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', ENABLE_POLICY_TIME_TRACKING: 'EnablePolicyTimeTracking', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', + SET_POLICY_MERCHANT_RULE: 'SetPolicyMerchantRule', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', SET_POLICY_PROHIBITED_EXPENSES: 'SetPolicyProhibitedExpenses', @@ -795,6 +796,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE]: Parameters.SetPolicyMerchantRuleParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; @@ -831,6 +833,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION]: Parameters.CancelBillingSubscriptionParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE]: Parameters.SetPolicyMerchantRuleParams; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE]: Parameters.SetPolicyExpenseMaxAge; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index db75f1082bf0..2e5d586f5023 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,7 +1,15 @@ +import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; -import {READ_COMMANDS} from '@libs/API/types'; +import type SetPolicyMerchantRuleParams from '@libs/API/parameters/SetPolicyMerchantRuleParams'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {OnyxData} from '@src/Onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CodingRule} from '@src/types/onyx/Policy'; +import type {MerchantRuleForm} from '@src/types/form'; /** * Fetches policy rules data when the rules page is opened. @@ -17,5 +25,101 @@ function openPolicyRulesPage(policyID: string | undefined) { API.read(READ_COMMANDS.OPEN_POLICY_RULES_PAGE, params); } -// eslint-disable-next-line import/prefer-default-export -export {openPolicyRulesPage}; +/** + * Creates a new merchant rule for the given policy + * @param policyID - The ID of the policy to create the rule for + * @param form - The form data for the merchant rule + */ +function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { + if (!policyID || !form.merchantToMatch) { + Log.warn('Invalid params for setPolicyMerchantRule', {policyID, merchantToMatch: form.merchantToMatch}); + return; + } + + // Generate a temporary rule ID for optimistic update + const tempRuleID = `temp_${Date.now()}`; + + // Create the optimistic coding rule + const optimisticRule: CodingRule = { + filters: { + left: 'merchant', + operator: 'contains', + right: form.merchantToMatch, + }, + merchant: form.merchant || undefined, + category: form.category || undefined, + tag: form.tag || undefined, + comment: form.comment || undefined, + reimbursable: form.reimbursable === 'true' ? true : form.reimbursable === 'false' ? false : undefined, + billable: form.billable === 'true' ? true : form.billable === 'false' ? false : undefined, + created: new Date().toISOString(), + }; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + codingRules: { + [tempRuleID]: optimisticRule, + }, + }, + pendingFields: { + codingRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + codingRules: null, + }, + errorFields: { + codingRules: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + codingRules: { + [tempRuleID]: null, + }, + }, + pendingFields: { + codingRules: null, + }, + errorFields: { + codingRules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + const parameters: SetPolicyMerchantRuleParams = { + policyID, + merchantToMatch: form.merchantToMatch, + merchant: form.merchant || undefined, + category: form.category || undefined, + tag: form.tag || undefined, + tax: form.tax || undefined, + comment: form.comment || undefined, + reimbursable: form.reimbursable === 'true' ? true : form.reimbursable === 'false' ? false : undefined, + billable: form.billable === 'true' ? true : form.billable === 'false' ? false : undefined, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); +} + +export {openPolicyRulesPage, setPolicyMerchantRule}; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index d653f0dbad21..cdf642050313 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setPolicyMerchantRule} from '@libs/actions/Policy/Rules'; import {clearDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -77,8 +78,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { return; } - // TODO: Call SetPolicyMerchantRule API when available - // For now, just navigate back + setPolicyMerchantRule(policyID, form); Navigation.goBack(); }; From a82da0820b814621cdc4f56e485ac1290dc5b285 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:04:01 -0800 Subject: [PATCH 08/49] Fix lint errors in MerchantRules pages - Remove default values for policyID (rulesdir/no-default-id-values) - Use specific form properties in useMemo dependencies instead of form object - Replace forEach with for...of loops in AddTagPage - Use early return pattern in AddTagPage --- .../rules/MerchantRules/AddBillablePage.tsx | 4 +--- .../rules/MerchantRules/AddCategoryPage.tsx | 2 +- .../rules/MerchantRules/AddDescriptionPage.tsx | 4 +--- .../rules/MerchantRules/AddMerchantPage.tsx | 4 +--- .../rules/MerchantRules/AddMerchantRulePage.tsx | 4 ++-- .../MerchantRules/AddMerchantToMatchPage.tsx | 4 +--- .../rules/MerchantRules/AddReimbursablePage.tsx | 4 +--- .../workspace/rules/MerchantRules/AddTagPage.tsx | 15 ++++++++------- .../workspace/rules/MerchantRules/AddTaxPage.tsx | 2 +- 9 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx index 7f0f0165e6ab..165e6a0ea500 100644 --- a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -8,13 +8,11 @@ import MerchantRuleBooleanBasePage from './MerchantRuleBooleanBasePage'; type AddBillablePageProps = PlatformStackScreenProps; function AddBillablePage({route}: AddBillablePageProps) { - const policyID = route.params?.policyID ?? '-1'; - return ( ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx index e0876e5a86f5..0fe59bffaa38 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -20,7 +20,7 @@ type AddCategoryPageProps = PlatformStackScreenProps; function AddDescriptionPage({route}: AddDescriptionPageProps) { - const policyID = route.params?.policyID ?? '-1'; - return ( ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx index 767a10c5cd05..c2f336c92f52 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx @@ -8,15 +8,13 @@ import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; type AddMerchantPageProps = PlatformStackScreenProps; function AddMerchantPage({route}: AddMerchantPageProps) { - const policyID = route.params?.policyID ?? '-1'; - return ( ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index cdf642050313..5499c2895cab 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -54,7 +54,7 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const policyID = route.params?.policyID ?? '-1'; + const policyID = route.params.policyID; const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); @@ -144,7 +144,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { ], }, ], - [areCategoriesEnabled, areTagsEnabled, isTaxTrackingEnabled, trackBillables, form, policyID, translate], + [areCategoriesEnabled, areTagsEnabled, isTaxTrackingEnabled, trackBillables, form?.merchantToMatch, form?.merchant, form?.category, form?.tag, form?.tax, form?.comment, form?.reimbursable, form?.billable, policyID, translate], ); return ( diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx index c94bf3f69ec2..ce5f20c9f586 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -8,8 +8,6 @@ import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; type AddMerchantToMatchPageProps = PlatformStackScreenProps; function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { - const policyID = route.params?.policyID ?? '-1'; - return ( ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx index efc3b8ff64b5..5dc214782c30 100644 --- a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -8,13 +8,11 @@ import MerchantRuleBooleanBasePage from './MerchantRuleBooleanBasePage'; type AddReimbursablePageProps = PlatformStackScreenProps; function AddReimbursablePage({route}: AddReimbursablePageProps) { - const policyID = route.params?.policyID ?? '-1'; - return ( ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx index 132fed24dab3..902ef168e21b 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -20,7 +20,7 @@ type AddTagPageProps = PlatformStackScreenProps { const tags: Array<{name: string; value: string}> = []; - Object.values(policyTags ?? {}).forEach((tagList) => { - Object.values(tagList?.tags ?? {}).forEach((tag) => { - if (tag.enabled) { - tags.push({name: getCleanedTagName(tag.name), value: tag.name}); + for (const tagList of Object.values(policyTags ?? {})) { + for (const tag of Object.values(tagList?.tags ?? {})) { + if (!tag.enabled) { + continue; } - }); - }); + tags.push({name: getCleanedTagName(tag.name), value: tag.name}); + } + } return tags; }, [policyTags]); diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx index 476d27210891..d1e1fdd5dbfa 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -19,7 +19,7 @@ type AddTaxPageProps = PlatformStackScreenProps Date: Mon, 26 Jan 2026 16:07:35 -0800 Subject: [PATCH 09/49] fix prettier --- src/libs/actions/Policy/Rules.ts | 6 +++--- .../rules/MerchantRules/AddMerchantRulePage.tsx | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 2e5d586f5023..d8d1157f87d4 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -3,13 +3,13 @@ import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyMerchantRuleParams from '@libs/API/parameters/SetPolicyMerchantRuleParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import Log from '@libs/Log'; import * as ErrorUtils from '@libs/ErrorUtils'; -import type {OnyxData} from '@src/Onyx'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; +import type {OnyxData} from '@src/Onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CodingRule} from '@src/types/onyx/Policy'; import type {MerchantRuleForm} from '@src/types/form'; +import type {CodingRule} from '@src/types/onyx/Policy'; /** * Fetches policy rules data when the rules page is opened. diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 5499c2895cab..5e996a5bc9ef 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -144,7 +144,22 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { ], }, ], - [areCategoriesEnabled, areTagsEnabled, isTaxTrackingEnabled, trackBillables, form?.merchantToMatch, form?.merchant, form?.category, form?.tag, form?.tax, form?.comment, form?.reimbursable, form?.billable, policyID, translate], + [ + areCategoriesEnabled, + areTagsEnabled, + isTaxTrackingEnabled, + trackBillables, + form?.merchantToMatch, + form?.merchant, + form?.category, + form?.tag, + form?.tax, + form?.comment, + form?.reimbursable, + form?.billable, + policyID, + translate, + ], ); return ( From c7674f77e829361fbfdfc1c18b86abb3cb387893 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:15:31 -0800 Subject: [PATCH 10/49] Fix OnyxData import path in Rules.ts - Remove incorrect '@src/Onyx' import - Use inline object pattern instead of explicit OnyxData type --- src/libs/actions/Policy/Rules.ts | 89 ++++++++++++++++---------------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index d8d1157f87d4..72ff1c7fa8f1 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -6,7 +6,6 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import CONST from '@src/CONST'; -import type {OnyxData} from '@src/Onyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; import type {CodingRule} from '@src/types/onyx/Policy'; @@ -55,57 +54,57 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { created: new Date().toISOString(), }; - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - rules: { - codingRules: { - [tempRuleID]: optimisticRule, - }, - }, - pendingFields: { - codingRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + codingRules: { + [tempRuleID]: optimisticRule, }, }, + pendingFields: { + codingRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - pendingFields: { - codingRules: null, - }, - errorFields: { - codingRules: null, - }, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + codingRules: null, + }, + errorFields: { + codingRules: null, }, }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - rules: { - codingRules: { - [tempRuleID]: null, - }, - }, - pendingFields: { - codingRules: null, - }, - errorFields: { - codingRules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + codingRules: { + [tempRuleID]: null, }, }, + pendingFields: { + codingRules: null, + }, + errorFields: { + codingRules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, }, - ], - }; + }, + ]; const parameters: SetPolicyMerchantRuleParams = { policyID, @@ -119,7 +118,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { billable: form.billable === 'true' ? true : form.billable === 'false' ? false : undefined, }; - API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); + API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, {optimisticData, successData, failureData}); } export {openPolicyRulesPage, setPolicyMerchantRule}; From 5adaf6d71cb946f5e88d85ba97eca3fe76a45ae9 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:16:23 -0800 Subject: [PATCH 11/49] Fix lint errors in Rules.ts - Add parseStringBoolean helper to avoid nested ternaries - Add OnyxUpdate[] type annotations to fix unsafe argument error --- src/libs/actions/Policy/Rules.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 72ff1c7fa8f1..a2906c1721ef 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,3 +1,4 @@ +import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; @@ -10,6 +11,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; import type {CodingRule} from '@src/types/onyx/Policy'; +/** + * Converts a string boolean value ('true'/'false') to a boolean or undefined + */ +function parseStringBoolean(value: string | undefined): boolean | undefined { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return undefined; +} + /** * Fetches policy rules data when the rules page is opened. * @param policyID - The ID of the policy to fetch rules for @@ -49,12 +63,12 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { category: form.category || undefined, tag: form.tag || undefined, comment: form.comment || undefined, - reimbursable: form.reimbursable === 'true' ? true : form.reimbursable === 'false' ? false : undefined, - billable: form.billable === 'true' ? true : form.billable === 'false' ? false : undefined, + reimbursable: parseStringBoolean(form.reimbursable), + billable: parseStringBoolean(form.billable), created: new Date().toISOString(), }; - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -71,7 +85,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -86,7 +100,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -114,8 +128,8 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { tag: form.tag || undefined, tax: form.tax || undefined, comment: form.comment || undefined, - reimbursable: form.reimbursable === 'true' ? true : form.reimbursable === 'false' ? false : undefined, - billable: form.billable === 'true' ? true : form.billable === 'false' ? false : undefined, + reimbursable: parseStringBoolean(form.reimbursable), + billable: parseStringBoolean(form.billable), }; API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, {optimisticData, successData, failureData}); From be259152fa15e55e81d597e1013f55633b4a83de Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:45:20 -0800 Subject: [PATCH 12/49] Fix TypeScript errors in Rules.ts - Use OnyxData type pattern - Consolidate optimisticData, successData, failureData into single onyxData object --- src/libs/actions/Policy/Rules.ts | 90 ++++++++++++++++---------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index a2906c1721ef..8829b290196e 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,4 +1,3 @@ -import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; @@ -10,6 +9,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; import type {CodingRule} from '@src/types/onyx/Policy'; +import type {OnyxData} from '@src/types/onyx/Request'; /** * Converts a string boolean value ('true'/'false') to a boolean or undefined @@ -68,57 +68,57 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { created: new Date().toISOString(), }; - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - rules: { - codingRules: { - [tempRuleID]: optimisticRule, + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + codingRules: { + [tempRuleID]: optimisticRule, + }, + }, + pendingFields: { + codingRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, - }, - pendingFields: { - codingRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - pendingFields: { - codingRules: null, - }, - errorFields: { - codingRules: null, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + codingRules: null, + }, + errorFields: { + codingRules: null, + }, }, }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - rules: { - codingRules: { - [tempRuleID]: null, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + codingRules: { + [tempRuleID]: null, + }, + }, + pendingFields: { + codingRules: null, + }, + errorFields: { + codingRules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, - }, - pendingFields: { - codingRules: null, - }, - errorFields: { - codingRules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, - }, - ]; + ], + }; const parameters: SetPolicyMerchantRuleParams = { policyID, @@ -132,7 +132,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { billable: parseStringBoolean(form.billable), }; - API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); } export {openPolicyRulesPage, setPolicyMerchantRule}; From ccf233c961d4457cb56f11116347ab8b2ca44a7f Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:46:47 -0800 Subject: [PATCH 13/49] fix ts --- src/pages/workspace/rules/MerchantRulesSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index d7e4c33cd36b..31e709a8e67a 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -11,10 +11,10 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; import {getCleanedTagName} from '@libs/PolicyUtils'; +import ROUTES from '@src/ROUTES'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; From f6a68faa30e7e057a441b1ca69aedc25b53fb802 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 17:22:07 -0800 Subject: [PATCH 14/49] Fix pendingFields/errorFields key in Rules.ts Use 'rules' instead of 'codingRules' as the pending field key since codingRules is a nested property --- src/libs/actions/Policy/Rules.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 8829b290196e..1932966bf22f 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -80,7 +80,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { }, }, pendingFields: { - codingRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + rules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, }, }, @@ -91,10 +91,10 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { pendingFields: { - codingRules: null, + rules: null, }, errorFields: { - codingRules: null, + rules: null, }, }, }, @@ -110,10 +110,10 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { }, }, pendingFields: { - codingRules: null, + rules: null, }, errorFields: { - codingRules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + rules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, }, From b3a5b7bc5524792850e2c8062261d624051e898a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 17:33:47 -0800 Subject: [PATCH 15/49] Rename trackBillables to isBillableEnabled Fix cspell unknown word error for 'Billables' --- .../workspace/rules/MerchantRules/AddMerchantRulePage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 5e996a5bc9ef..0de866295253 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -65,7 +65,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const areCategoriesEnabled = !!policy?.areCategoriesEnabled; const areTagsEnabled = !!policy?.areTagsEnabled; const isTaxTrackingEnabled = !!policy?.tax?.trackingEnabled; - const trackBillables = policy?.disabledFields?.defaultBillable !== true; + const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; const errorMessage = getErrorMessage(translate, form); @@ -134,7 +134,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { title: form?.reimbursable ? translate(form.reimbursable === 'true' ? 'common.yes' : 'common.no') : '', onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), }, - trackBillables + isBillableEnabled ? { descriptionTranslationKey: 'common.billable', title: form?.billable ? translate(form.billable === 'true' ? 'common.yes' : 'common.no') : '', @@ -148,7 +148,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { areCategoriesEnabled, areTagsEnabled, isTaxTrackingEnabled, - trackBillables, + isBillableEnabled, form?.merchantToMatch, form?.merchant, form?.category, From d85d81cc134a9653ffd51d3092f79fbb585ecdc6 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 17:50:35 -0800 Subject: [PATCH 16/49] Use NumberUtils.rand64() for optimistic rule ID Replace Date.now() based ID with proper random 64-bit ID generation, matching the pattern used elsewhere in the codebase --- src/libs/actions/Policy/Rules.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 1932966bf22f..14028a4c285a 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -5,6 +5,7 @@ import type SetPolicyMerchantRuleParams from '@libs/API/parameters/SetPolicyMerc import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import * as NumberUtils from '@libs/NumberUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; @@ -49,14 +50,14 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { return; } - // Generate a temporary rule ID for optimistic update - const tempRuleID = `temp_${Date.now()}`; + // Generate a random client-side ID for optimistic update + const optimisticRuleID = NumberUtils.rand64(); // Create the optimistic coding rule const optimisticRule: CodingRule = { filters: { left: 'merchant', - operator: 'contains', + operator: 'eq', right: form.merchantToMatch, }, merchant: form.merchant || undefined, @@ -76,7 +77,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { value: { rules: { codingRules: { - [tempRuleID]: optimisticRule, + [optimisticRuleID]: optimisticRule, }, }, pendingFields: { @@ -106,7 +107,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { value: { rules: { codingRules: { - [tempRuleID]: null, + [optimisticRuleID]: null, }, }, pendingFields: { From 2b7c42cad1a4ce9f2a78a1fb1ce73e0d458a2a22 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 17:51:32 -0800 Subject: [PATCH 17/49] generate proper optimistic id --- src/libs/actions/Policy/Rules.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 14028a4c285a..3c682ec1d1c9 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -50,10 +50,8 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { return; } - // Generate a random client-side ID for optimistic update const optimisticRuleID = NumberUtils.rand64(); - // Create the optimistic coding rule const optimisticRule: CodingRule = { filters: { left: 'merchant', From d8fffb4e0dce7b25a80ae3283e7926380a04b2f2 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:02:11 -0800 Subject: [PATCH 18/49] Refactor Rule components to be reusable for merchant rules - Make RuleBooleanBase generic with formID, onSelect, onBack props - Make RuleTextBase generic with formID, onSave, onBack props - Update personal rules pages to use new generic interface - Update merchant rules pages to use RuleBooleanBase and RuleTextBase directly - Delete MerchantRuleBooleanBasePage and MerchantRuleTextBasePage - Delete unused BooleanBase component --- src/components/Rule/RuleBooleanBase.tsx | 93 ++++++++-------- src/components/Rule/RuleTextBase.tsx | 101 ++++++++++-------- src/components/Rule/TextBase.tsx | 27 ++--- .../settings/Rules/Fields/AddBillablePage.tsx | 25 ++++- .../Rules/Fields/AddDescriptionPage.tsx | 22 +++- .../settings/Rules/Fields/AddMerchantPage.tsx | 22 +++- .../Rules/Fields/AddReimbursablePage.tsx | 25 ++++- .../Rules/Fields/AddRenameMerchantPage.tsx | 22 +++- .../settings/Rules/Fields/AddReportPage.tsx | 22 +++- .../rules/MerchantRules/AddBillablePage.tsx | 23 +++- .../MerchantRules/AddDescriptionPage.tsx | 24 ++++- .../rules/MerchantRules/AddMerchantPage.tsx | 24 ++++- .../MerchantRules/AddMerchantToMatchPage.tsx | 24 ++++- .../MerchantRules/AddReimbursablePage.tsx | 23 +++- .../MerchantRuleBooleanBasePage.tsx | 98 ----------------- .../MerchantRuleTextBasePage.tsx | 80 -------------- 16 files changed, 350 insertions(+), 305 deletions(-) delete mode 100644 src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx delete mode 100644 src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 1e06c4fa3137..2a56ef9169f3 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -9,43 +9,49 @@ import type {ListItem} from '@components/SelectionList/ListItem/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; -import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; type BooleanFilterItem = ListItem & { value: ValueOf; }; -type RuleBooleanBasePageProps = { - /** The key from boolean-based InputID */ - fieldID: InputID; +type RuleBooleanBaseProps = { + /** The field ID from the form */ + fieldID: string; /** The translation key for the page title */ titleKey: TranslationPaths; - /** The rule identifier */ - hash?: string; + /** The form ID to read from Onyx */ + formID: OnyxFormKey; + + /** Callback when a value is selected */ + onSelect: (fieldID: string, value: string) => void; + + /** Callback to go back */ + onBack: () => void; + + /** Optional wrapper component for the content */ + ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; }; const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); -function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps) { +function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, ContentWrapper}: RuleBooleanBaseProps) { const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); + const formValue = (form as Record)?.[fieldID]; + const selectedItem = booleanValues.find((value) => { - if (!form?.[fieldID]) { + if (!formValue) { return false; } - const booleanValue = form[fieldID] === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + const booleanValue = formValue === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; return booleanValue === value; }) ?? null; @@ -56,10 +62,6 @@ function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps isSelected: selectedItem === value, })); - const goBack = () => { - Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); - }; - const onSelectItem = (selectedValue: BooleanFilterItem) => { const newValue = selectedValue.isSelected ? null : selectedValue.value; let value = ''; @@ -68,34 +70,37 @@ function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { value = 'false'; } - updateDraftRule({[fieldID]: value}); - goBack(); + onSelect(fieldID, value); }; - return ( - - - + + + - - - - - + + ); + + if (ContentWrapper) { + return {content}; + } + + return content; } -export default RuleBooleanBasePage; +export default RuleBooleanBase; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index 9775aa2027c2..f55b82ac4e5d 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -4,20 +4,14 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; -import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import TextBase from './TextBase'; -// Text-based field IDs that accept string input -type RuleTextBaseProps = { +type RuleTextBaseProps = { /** The key from text-based InputID */ - fieldID: InputID; + fieldID: string; /** The translation key for the page title and input label if labelKey is missing */ titleKey: TranslationPaths; @@ -37,48 +31,65 @@ type RuleTextBaseProps = { /** The character limit for the input */ characterLimit?: number; - /** The rule identifier */ - hash?: string; + /** The form ID to read from Onyx */ + formID: TFormID; + + /** Callback when the form is saved */ + onSave: (values: FormOnyxValues) => void; + + /** Callback to go back */ + onBack: () => void; + + /** Optional wrapper component for the content */ + ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; }; -function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, hash, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: RuleTextBaseProps) { +function RuleTextBase({ + fieldID, + hintKey, + isRequired, + titleKey, + labelKey, + testID, + characterLimit = CONST.MERCHANT_NAME_MAX_BYTES, + formID, + onSave, + onBack, + ContentWrapper, +}: RuleTextBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const goBack = () => { - Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); - }; - - const onSave = (values: FormOnyxValues) => { - updateDraftRule(values); - goBack(); - }; - - return ( - - - - - - + const content = ( + + + + ); + + if (ContentWrapper) { + return {content}; + } + + return content; } export default RuleTextBase; diff --git a/src/components/Rule/TextBase.tsx b/src/components/Rule/TextBase.tsx index eb05f4760f08..265b7fb614c5 100644 --- a/src/components/Rule/TextBase.tsx +++ b/src/components/Rule/TextBase.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; @@ -11,29 +12,29 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isRequiredFulfilled, isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; -type TextBaseProps = { - fieldID: InputID; +type TextBaseProps = { + fieldID: string; hint?: string; isRequired?: boolean; title: string; label: string; characterLimit?: number; - onSubmit: (values: FormOnyxValues) => void; + formID: TFormID; + onSubmit: (values: FormOnyxValues) => void; }; -function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { +function TextBase({fieldID, hint, isRequired, title, label, onSubmit, formID, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const currentValue = form?.[fieldID] ?? ''; + const currentValue = (form as Record)?.[fieldID] ?? ''; const {inputCallbackRef} = useAutoFocusInput(); - const validate = (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const fieldValue = values[fieldID] ?? ''; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const fieldValue = (values as Record)[fieldID] ?? ''; if (typeof fieldValue !== 'string') { return errors; @@ -42,12 +43,12 @@ function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterL const trimmedValue = fieldValue.trim(); if (isRequired && !isRequiredFulfilled(fieldValue)) { - errors[fieldID] = translate('common.error.fieldRequired'); + (errors as Record)[fieldID] = translate('common.error.fieldRequired'); } else { const {isValid, byteLength} = isValidInputLength(trimmedValue, characterLimit); if (!isValid) { - errors[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit); + (errors as Record)[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit); } } @@ -57,7 +58,7 @@ function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterL return ( ; function AddBillablePage({route}: AddBillablePageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSelect = (fieldID: string, value: string) => { + updateDraftRule({[fieldID]: value}); + goBack(); + }; + return ( - {children}} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx index a62365e955f8..5c184e72722f 100644 --- a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx +++ b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx @@ -1,20 +1,40 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type AddDescriptionPageProps = PlatformStackScreenProps; function AddDescriptionPage({route}: AddDescriptionPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( {children}} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx index 79db39824fb6..b20bf4281fbf 100644 --- a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx @@ -1,22 +1,42 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type AddMerchantPageProps = PlatformStackScreenProps; function AddMerchantPage({route}: AddMerchantPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( {children}} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx index 968b0a6ddb1f..28feaecc9ccf 100644 --- a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx @@ -1,18 +1,37 @@ import React from 'react'; -import RuleBooleanBasePage from '@components/Rule/RuleBooleanBase'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; +import {updateDraftRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type AddReimbursablePageProps = PlatformStackScreenProps; function AddReimbursablePage({route}: AddReimbursablePageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSelect = (fieldID: string, value: string) => { + updateDraftRule({[fieldID]: value}); + goBack(); + }; + return ( - {children}} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx index 1418a8f9b357..6dc84bf34b01 100644 --- a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx @@ -1,20 +1,40 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type AddRenameMerchantPageProps = PlatformStackScreenProps; function AddRenameMerchantPage({route}: AddRenameMerchantPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( {children}} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddReportPage.tsx b/src/pages/settings/Rules/Fields/AddReportPage.tsx index 46fc5dbc08a9..2f889eb5d290 100644 --- a/src/pages/settings/Rules/Fields/AddReportPage.tsx +++ b/src/pages/settings/Rules/Fields/AddReportPage.tsx @@ -1,21 +1,41 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type AddReportPageProps = PlatformStackScreenProps; function AddReportPage({route}: AddReportPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( {children}} /> ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx index 165e6a0ea500..94b3c135e9b2 100644 --- a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -1,18 +1,35 @@ import React from 'react'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import MerchantRuleBooleanBasePage from './MerchantRuleBooleanBasePage'; type AddBillablePageProps = PlatformStackScreenProps; function AddBillablePage({route}: AddBillablePageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSelect = (fieldID: string, value: string) => { + updateDraftMerchantRule({[fieldID]: value}); + goBack(); + }; + return ( - ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx index 449523414aea..9b4328e1910f 100644 --- a/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx @@ -1,20 +1,38 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; type AddDescriptionPageProps = PlatformStackScreenProps; function AddDescriptionPage({route}: AddDescriptionPageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + return ( - ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx index c2f336c92f52..5a6756906377 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx @@ -1,20 +1,38 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; type AddMerchantPageProps = PlatformStackScreenProps; function AddMerchantPage({route}: AddMerchantPageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + return ( - ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx index ce5f20c9f586..45ddd6b04f2c 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -1,22 +1,40 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import MerchantRuleTextBasePage from './MerchantRuleTextBasePage'; type AddMerchantToMatchPageProps = PlatformStackScreenProps; function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + return ( - ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx index 5dc214782c30..92f0c76458c0 100644 --- a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -1,18 +1,35 @@ import React from 'react'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import MerchantRuleBooleanBasePage from './MerchantRuleBooleanBasePage'; type AddReimbursablePageProps = PlatformStackScreenProps; function AddReimbursablePage({route}: AddReimbursablePageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSelect = (fieldID: string, value: string) => { + updateDraftMerchantRule({[fieldID]: value}); + goBack(); + }; + return ( - ); } diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx deleted file mode 100644 index 102ef8e88be9..000000000000 --- a/src/pages/workspace/rules/MerchantRules/MerchantRuleBooleanBasePage.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import type {ListItem} from '@components/SelectionList/ListItem/types'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftMerchantRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/MerchantRuleForm'; - -type BooleanFilterItem = ListItem & { - value: ValueOf; -}; - -type MerchantRuleBooleanBasePageProps = { - /** The key from boolean-based InputID */ - fieldID: InputID; - - /** The translation key for the page title */ - titleKey: TranslationPaths; - - /** The policy ID */ - policyID: string; -}; - -const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); - -function MerchantRuleBooleanBasePage({fieldID, titleKey, policyID}: MerchantRuleBooleanBasePageProps) { - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); - const styles = useThemeStyles(); - - const selectedItem = - booleanValues.find((value) => { - if (!form?.[fieldID]) { - return false; - } - const booleanValue = form[fieldID] === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; - return booleanValue === value; - }) ?? null; - - const items = booleanValues.map((value) => ({ - value, - keyForList: value, - text: translate(`common.${value}`), - isSelected: selectedItem === value, - })); - - const goBack = () => { - Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - }; - - const onSelectItem = (selectedValue: BooleanFilterItem) => { - const newValue = selectedValue.isSelected ? null : selectedValue.value; - let value = ''; - if (newValue === CONST.SEARCH.BOOLEAN.YES) { - value = 'true'; - } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { - value = 'false'; - } - updateDraftMerchantRule({[fieldID]: value}); - goBack(); - }; - - return ( - - - - - - - ); -} - -export default MerchantRuleBooleanBasePage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx deleted file mode 100644 index a7ef9c769825..000000000000 --- a/src/pages/workspace/rules/MerchantRules/MerchantRuleTextBasePage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import type {FormOnyxValues} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import TextBase from '@components/Rule/TextBase'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftMerchantRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; -import type ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/MerchantRuleForm'; - -type MerchantRuleTextBasePageProps = { - /** The key from InputID */ - fieldID: InputID; - - /** The translation key for the page title and input label if labelKey is missing */ - titleKey: TranslationPaths; - - /** The translation key for the input label */ - labelKey?: TranslationPaths; - - /** Test ID for the screen wrapper */ - testID: string; - - /** The translation key for the hint text to display below the TextInput */ - hintKey?: TranslationPaths; - - /** Whether this field is required */ - isRequired?: boolean; - - /** The character limit for the input */ - characterLimit?: number; - - /** The policy ID */ - policyID: string; -}; - -function MerchantRuleTextBasePage({fieldID, hintKey, isRequired, titleKey, labelKey, testID, policyID, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: MerchantRuleTextBasePageProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - - const goBack = () => { - Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - }; - - const onSave = (values: FormOnyxValues) => { - updateDraftMerchantRule(values); - goBack(); - }; - - return ( - - - - - ); -} - -export default MerchantRuleTextBasePage; From 4c5ec75537aa5d76fb736ed44beb0dcf07659f11 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:05:12 -0800 Subject: [PATCH 19/49] Add RuleSelectionBase for reusable category/tag/tax selection - Create RuleSelectionBase component for selection picker pages - Update personal rules AddCategoryPage and AddTagPage to use RuleSelectionBase - Update merchant rules AddCategoryPage, AddTagPage, and AddTaxPage to use RuleSelectionBase --- src/components/Rule/RuleSelectionBase.tsx | 75 +++++++++++++++++++ .../settings/Rules/Fields/AddCategoryPage.tsx | 42 +++-------- .../settings/Rules/Fields/AddTagPage.tsx | 42 +++-------- .../rules/MerchantRules/AddCategoryPage.tsx | 36 +++------ .../rules/MerchantRules/AddTagPage.tsx | 36 +++------ .../rules/MerchantRules/AddTaxPage.tsx | 36 +++------ 6 files changed, 124 insertions(+), 143 deletions(-) create mode 100644 src/components/Rule/RuleSelectionBase.tsx diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx new file mode 100644 index 000000000000..045b9902788b --- /dev/null +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {TranslationPaths} from '@src/languages/types'; + +type SelectionItem = { + name: string; + value: string; +}; + +type RuleSelectionBaseProps = { + /** The translation key for the page title */ + titleKey: TranslationPaths; + + /** Test ID for the screen wrapper */ + testID: string; + + /** The currently selected item */ + selectedItem?: SelectionItem; + + /** The list of items to display */ + items: SelectionItem[]; + + /** Callback when a value is selected */ + onSave: (value?: string) => void; + + /** Callback to go back */ + onBack: () => void; + + /** The route to navigate back to */ + backToRoute: string; + + /** Optional wrapper component for the content */ + ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; +}; + +function RuleSelectionBase({titleKey, testID, selectedItem, items, onSave, onBack, backToRoute, ContentWrapper}: RuleSelectionBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const content = ( + + + + + + + ); + + if (ContentWrapper) { + return {content}; + } + + return content; +} + +export default RuleSelectionBase; diff --git a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx index 4d9a50507179..39311266e597 100644 --- a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx +++ b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx @@ -1,13 +1,8 @@ import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftRule} from '@libs/actions/User'; import {getAvailableNonPersonalPolicyCategories, getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -22,9 +17,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; type AddCategoryPageProps = PlatformStackScreenProps; function AddCategoryPage({route}: AddCategoryPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const availableNonPersonalPolicyCategoriesSelector = useCallback( @@ -62,28 +54,16 @@ function AddCategoryPage({route}: AddCategoryPageProps) { }; return ( - - - Navigation.goBack(backToRoute)} - /> - - - - - + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + ContentWrapper={({children}) => {children}} + /> ); } diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index a96648ecce14..8b815dd282db 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -1,13 +1,8 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -22,9 +17,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; type AddTagPageProps = PlatformStackScreenProps; function AddTagPage({route}: AddTagPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, { canBeMissing: true, @@ -51,28 +43,16 @@ function AddTagPage({route}: AddTagPageProps) { }; return ( - - - Navigation.goBack(backToRoute)} - /> - - - - - + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + ContentWrapper={({children}) => {children}} + /> ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx index 0fe59bffaa38..48e57731f530 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -1,11 +1,6 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -18,8 +13,6 @@ import type SCREENS from '@src/SCREENS'; type AddCategoryPageProps = PlatformStackScreenProps; function AddCategoryPage({route}: AddCategoryPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); const policyID = route.params.policyID; const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); @@ -43,26 +36,15 @@ function AddCategoryPage({route}: AddCategoryPageProps) { }; return ( - - Navigation.goBack(backToRoute)} - /> - - - - + selectedItem={selectedCategoryItem} + items={categoryItems} + onSave={onSave} + onBack={() => Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + /> ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx index 902ef168e21b..8d8cd62d37ad 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -1,11 +1,6 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -18,8 +13,6 @@ import type SCREENS from '@src/SCREENS'; type AddTagPageProps = PlatformStackScreenProps; function AddTagPage({route}: AddTagPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); const policyID = route.params.policyID; const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); @@ -49,26 +42,15 @@ function AddTagPage({route}: AddTagPageProps) { }; return ( - - Navigation.goBack(backToRoute)} - /> - - - - + selectedItem={selectedTagItem} + items={tagItems} + onSave={onSave} + onBack={() => Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + /> ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx index d1e1fdd5dbfa..b5dd110ce9a5 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -1,11 +1,6 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -17,8 +12,6 @@ import type SCREENS from '@src/SCREENS'; type AddTaxPageProps = PlatformStackScreenProps; function AddTaxPage({route}: AddTaxPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); const policyID = route.params.policyID; const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); @@ -43,26 +36,15 @@ function AddTaxPage({route}: AddTaxPageProps) { }; return ( - - Navigation.goBack(backToRoute)} - /> - - - - + selectedItem={selectedTaxItem} + items={taxItems} + onSave={onSave} + onBack={() => Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + /> ); } From 1d41a3e9fabb7388e4cbc3ff9e271f79f7be526b Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:13:12 -0800 Subject: [PATCH 20/49] Fix tax, category, and tag display names in AddMerchantRulePage Display human-readable names instead of IDs for category, tag, and tax fields --- package-lock.json | 24 +++++++++--------- .../MerchantRules/AddMerchantRulePage.tsx | 25 ++++++++++++++----- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 768bc5bdfd93..522307097b38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,8 +74,8 @@ "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", "expo-store-review": "~9.0.8", - "expo-video": "^3.0.12", "expo-task-manager": "~14.0.9", + "expo-video": "^3.0.12", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", @@ -23595,17 +23595,6 @@ "react-native": "*" } }, - "node_modules/expo-video": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/expo-video/-/expo-video-3.0.12.tgz", - "integrity": "sha512-L+E+zmNp3RxUBk2ugMSjxVposP70uIgGCZio5PiiUXme2KQ1eAEta2vcUPWrf4a+udrp6Xzr7bO1H9vkUXF3pg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-task-manager": { "version": "14.0.9", "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz", @@ -23619,6 +23608,17 @@ "react-native": "*" } }, + "node_modules/expo-video": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/expo-video/-/expo-video-3.0.12.tgz", + "integrity": "sha512-L+E+zmNp3RxUBk2ugMSjxVposP70uIgGCZio5PiiUXme2KQ1eAEta2vcUPWrf4a+udrp6Xzr7bO1H9vkUXF3pg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.8", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.8.tgz", diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 0de866295253..81612a045a28 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -13,9 +13,11 @@ import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {setPolicyMerchantRule} from '@libs/actions/Policy/Rules'; import {clearDraftMerchantRule} from '@libs/actions/User'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getCleanedTagName} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -67,6 +69,17 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const isTaxTrackingEnabled = !!policy?.tax?.trackingEnabled; const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; + // Get display names for category, tag, and tax + const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; + const tagDisplayName = form?.tag ? getCleanedTagName(form.tag) : undefined; + const taxDisplayName = useMemo(() => { + if (!form?.tax || !policy?.taxRates?.taxes) { + return undefined; + } + const tax = policy.taxRates.taxes[form.tax]; + return tax ? `${tax.name} (${tax.value})` : undefined; + }, [form?.tax, policy?.taxRates?.taxes]); + const errorMessage = getErrorMessage(translate, form); const handleSubmit = () => { @@ -106,21 +119,21 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { areCategoriesEnabled ? { descriptionTranslationKey: 'common.category', - title: form?.category, + title: categoryDisplayName, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), } : undefined, areTagsEnabled ? { descriptionTranslationKey: 'common.tag', - title: form?.tag, + title: tagDisplayName, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), } : undefined, isTaxTrackingEnabled ? { descriptionTranslationKey: 'common.tax', - title: form?.tax, + title: taxDisplayName, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), } : undefined, @@ -151,9 +164,9 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { isBillableEnabled, form?.merchantToMatch, form?.merchant, - form?.category, - form?.tag, - form?.tax, + categoryDisplayName, + tagDisplayName, + taxDisplayName, form?.comment, form?.reimbursable, form?.billable, From cb1f840f9794593cebf3e6616a8e3472f61072ef Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:18:14 -0800 Subject: [PATCH 21/49] Update AddTaxRatePage to use RuleSelectionBase DRY up the personal rules tax page with the shared base component --- src/components/Rule/TextBase.tsx | 2 +- .../settings/Rules/Fields/AddTaxRatePage.tsx | 42 +++++-------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/src/components/Rule/TextBase.tsx b/src/components/Rule/TextBase.tsx index 265b7fb614c5..96cd08063365 100644 --- a/src/components/Rule/TextBase.tsx +++ b/src/components/Rule/TextBase.tsx @@ -3,7 +3,6 @@ import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import type {OnyxFormKey} from '@src/ONYXKEYS'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; @@ -11,6 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {isRequiredFulfilled, isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; type TextBaseProps = { diff --git a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx index 8b799e9bf16e..6e480673cbd8 100644 --- a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx +++ b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx @@ -1,12 +1,7 @@ import React from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -19,9 +14,6 @@ import type SCREENS from '@src/SCREENS'; type AddTaxRatePageProps = PlatformStackScreenProps; function AddTaxRatePage({route}: AddTaxRatePageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); const [allTaxRates] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true, selector: getAllTaxRatesNamesAndValues}); @@ -39,28 +31,16 @@ function AddTaxRatePage({route}: AddTaxRatePageProps) { }; return ( - - - Navigation.goBack(backToRoute)} - /> - - - - - + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + ContentWrapper={({children}) => {children}} + /> ); } From 2b3a78be39a4621482f2f71d2ccec13d16bbeed8 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:23:13 -0800 Subject: [PATCH 22/49] Fix backToRoute type in RuleSelectionBase Use Route type instead of string for backToRoute prop --- src/components/Rule/RuleSelectionBase.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx index 045b9902788b..3ebae80c1001 100644 --- a/src/components/Rule/RuleSelectionBase.tsx +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -6,6 +6,7 @@ import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectio import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TranslationPaths} from '@src/languages/types'; +import type {Route} from '@src/ROUTES'; type SelectionItem = { name: string; @@ -32,7 +33,7 @@ type RuleSelectionBaseProps = { onBack: () => void; /** The route to navigate back to */ - backToRoute: string; + backToRoute: Route; /** Optional wrapper component for the content */ ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; From faa093b9ca98493043dbd322b16f0bd778ada9dc Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:24:41 -0800 Subject: [PATCH 23/49] DRY up field mapping in setPolicyMerchantRule Extract mapFormFieldsToRule helper to avoid duplication between optimisticRule and parameters objects --- src/libs/actions/Policy/Rules.ts | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 3c682ec1d1c9..386ddf0f00a0 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -25,6 +25,21 @@ function parseStringBoolean(value: string | undefined): boolean | undefined { return undefined; } +/** + * Maps form fields to rule properties + */ +function mapFormFieldsToRule(form: MerchantRuleForm) { + return { + merchant: form.merchant || undefined, + category: form.category || undefined, + tag: form.tag || undefined, + tax: form.tax || undefined, + comment: form.comment || undefined, + reimbursable: parseStringBoolean(form.reimbursable), + billable: parseStringBoolean(form.billable), + }; +} + /** * Fetches policy rules data when the rules page is opened. * @param policyID - The ID of the policy to fetch rules for @@ -52,18 +67,15 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { const optimisticRuleID = NumberUtils.rand64(); + const ruleFields = mapFormFieldsToRule(form); + const optimisticRule: CodingRule = { filters: { left: 'merchant', operator: 'eq', right: form.merchantToMatch, }, - merchant: form.merchant || undefined, - category: form.category || undefined, - tag: form.tag || undefined, - comment: form.comment || undefined, - reimbursable: parseStringBoolean(form.reimbursable), - billable: parseStringBoolean(form.billable), + ...ruleFields, created: new Date().toISOString(), }; @@ -122,13 +134,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { const parameters: SetPolicyMerchantRuleParams = { policyID, merchantToMatch: form.merchantToMatch, - merchant: form.merchant || undefined, - category: form.category || undefined, - tag: form.tag || undefined, - tax: form.tax || undefined, - comment: form.comment || undefined, - reimbursable: parseStringBoolean(form.reimbursable), - billable: parseStringBoolean(form.billable), + ...ruleFields, }; API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); From 379450fafd966f071f3ddc2fbd2d26451eeddfbe Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 18:25:46 -0800 Subject: [PATCH 24/49] Hoist constant outside iteration in getErrorMessage Avoid redundant property access inside .some() callback --- .../workspace/rules/MerchantRules/AddMerchantRulePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 81612a045a28..0e816688fa94 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -40,7 +40,8 @@ type SectionType = { }; const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { - const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => key !== CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH && !!value); + const merchantToMatchField = CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH; + const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => key !== merchantToMatchField && !!value); if (form?.merchantToMatch && hasAtLeastOneUpdate) { return ''; } From 1cb2a259b865e628165436953e0a8b2822493f7f Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 08:57:41 -0800 Subject: [PATCH 25/49] Update font sizes in MerchantRulesSection - Description: 15px using fontSizeNormal - Title: 13px using fontSizeLabel --- src/pages/workspace/rules/MerchantRulesSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 31e709a8e67a..f808b5e3fb42 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -133,8 +133,8 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { description={matchDescription} title={ruleDescription} wrapperStyle={[styles.sectionMenuItemTopDescription]} - descriptionTextStyle={[styles.textStrong, styles.themeTextColor]} - titleStyle={[styles.textLabelSupporting]} + descriptionTextStyle={[styles.textStrong, styles.themeTextColor, styles.fontSizeNormal]} + titleStyle={[styles.textLabelSupporting, styles.fontSizeLabel]} shouldShowRightIcon /> From f17d95c738d742b880cba49a65ecc434722e0329 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:05:01 -0800 Subject: [PATCH 26/49] Extract getBooleanTitle helper in AddMerchantRulePage DRY up boolean translation logic for reimbursable and billable fields --- .../rules/MerchantRules/AddMerchantRulePage.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 0e816688fa94..0c90bf0d2784 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -39,6 +39,13 @@ type SectionType = { items: Array; }; +const getBooleanTitle = (value: string | undefined, translate: LocalizedTranslate): string => { + if (!value) { + return ''; + } + return translate(value === 'true' ? 'common.yes' : 'common.no'); +}; + const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { const merchantToMatchField = CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH; const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => key !== merchantToMatchField && !!value); @@ -145,13 +152,13 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { }, { descriptionTranslationKey: 'common.reimbursable', - title: form?.reimbursable ? translate(form.reimbursable === 'true' ? 'common.yes' : 'common.no') : '', + title: getBooleanTitle(form?.reimbursable, translate), onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), }, isBillableEnabled ? { descriptionTranslationKey: 'common.billable', - title: form?.billable ? translate(form.billable === 'true' ? 'common.yes' : 'common.no') : '', + title: getBooleanTitle(form?.billable, translate), onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), } : undefined, From 31df6dd58b364a55c914ec986b5e4550ebd8765e Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:06:49 -0800 Subject: [PATCH 27/49] Optimize RuleBooleanBase with useMemo and hoisted constants Hoist CONST.SEARCH.BOOLEAN values outside iteration and memoize selectedItem --- src/components/Rule/RuleBooleanBase.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 2a56ef9169f3..cf3909edf588 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -46,14 +46,16 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, ContentWr const formValue = (form as Record)?.[fieldID]; - const selectedItem = - booleanValues.find((value) => { - if (!formValue) { - return false; - } - const booleanValue = formValue === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; - return booleanValue === value; - }) ?? null; + const selectedItem = useMemo(() => { + if (!formValue) { + return null; + } + const yesValue = CONST.SEARCH.BOOLEAN.YES; + const noValue = CONST.SEARCH.BOOLEAN.NO; + const booleanValue = formValue === 'true' ? yesValue : noValue; + + return booleanValues.find((value) => booleanValue === value) ?? null; + }, [formValue]); const items = booleanValues.map((value) => ({ value, From 5836397243ac300b1569a511e0b43a7c84324d0e Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:09:02 -0800 Subject: [PATCH 28/49] Memoize items array in RuleBooleanBase Avoid recalculating items on every render --- src/components/Rule/RuleBooleanBase.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index cf3909edf588..99756c785bd4 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -57,12 +57,16 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, ContentWr return booleanValues.find((value) => booleanValue === value) ?? null; }, [formValue]); - const items = booleanValues.map((value) => ({ - value, - keyForList: value, - text: translate(`common.${value}`), - isSelected: selectedItem === value, - })); + const items = useMemo( + () => + booleanValues.map((value) => ({ + value, + keyForList: value, + text: translate(`common.${value}`), + isSelected: selectedItem === value, + })), + [selectedItem, translate], + ); const onSelectItem = (selectedValue: BooleanFilterItem) => { const newValue = selectedValue.isSelected ? null : selectedValue.value; From 5827e99cfec118603298093d08b8b0d9f7c8ecf0 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:12:15 -0800 Subject: [PATCH 29/49] Fix tax type in SetPolicyMerchantRuleParams - Use CodingRuleTax type instead of string - Add buildTaxObject helper to construct proper tax object - Update setPolicyMerchantRule to accept policy parameter --- .../parameters/SetPolicyMerchantRuleParams.ts | 4 ++- src/libs/actions/Policy/Rules.ts | 34 ++++++++++++++++--- .../MerchantRules/AddMerchantRulePage.tsx | 2 +- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts index c884086c236f..0392e3ebe91d 100644 --- a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts +++ b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts @@ -1,10 +1,12 @@ +import type {CodingRuleTax} from '@src/types/onyx/Policy'; + type SetPolicyMerchantRuleParams = { policyID: string; merchantToMatch: string; merchant?: string; category?: string; tag?: string; - tax?: string; + tax?: CodingRuleTax; comment?: string; reimbursable?: boolean; billable?: boolean; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 386ddf0f00a0..36ce1c6322e4 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -9,7 +9,8 @@ import * as NumberUtils from '@libs/NumberUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; -import type {CodingRule} from '@src/types/onyx/Policy'; +import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; +import type Policy from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; /** @@ -25,15 +26,37 @@ function parseStringBoolean(value: string | undefined): boolean | undefined { return undefined; } +/** + * Builds the CodingRuleTax object from a tax key and policy + */ +function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { + if (!taxKey || !policy?.taxRates?.taxes) { + return undefined; + } + + const tax = policy.taxRates.taxes[taxKey]; + if (!tax) { + return undefined; + } + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxKey, + value: `${tax.name} (${tax.value})`, + }, + }; +} + /** * Maps form fields to rule properties */ -function mapFormFieldsToRule(form: MerchantRuleForm) { +function mapFormFieldsToRule(form: MerchantRuleForm, policy: Policy | undefined) { return { merchant: form.merchant || undefined, category: form.category || undefined, tag: form.tag || undefined, - tax: form.tax || undefined, + tax: buildTaxObject(form.tax, policy), comment: form.comment || undefined, reimbursable: parseStringBoolean(form.reimbursable), billable: parseStringBoolean(form.billable), @@ -58,8 +81,9 @@ function openPolicyRulesPage(policyID: string | undefined) { * Creates a new merchant rule for the given policy * @param policyID - The ID of the policy to create the rule for * @param form - The form data for the merchant rule + * @param policy - The policy object (needed to build tax data) */ -function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { +function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined) { if (!policyID || !form.merchantToMatch) { Log.warn('Invalid params for setPolicyMerchantRule', {policyID, merchantToMatch: form.merchantToMatch}); return; @@ -67,7 +91,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm) { const optimisticRuleID = NumberUtils.rand64(); - const ruleFields = mapFormFieldsToRule(form); + const ruleFields = mapFormFieldsToRule(form, policy); const optimisticRule: CodingRule = { filters: { diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 0c90bf0d2784..8e2ad187f1a4 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -99,7 +99,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { return; } - setPolicyMerchantRule(policyID, form); + setPolicyMerchantRule(policyID, form, policy); Navigation.goBack(); }; From 8296d9a6c8678669845927929877375478a733e4 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:25:30 -0800 Subject: [PATCH 30/49] Use correct tax format for API vs Onyx - buildOnyxTaxObject: CodingRuleTax format for optimistic Onyx data - buildApiTaxObject: PolicyRuleTaxRate format for API request - Separate mapFormFieldsForOnyx and mapFormFieldsForApi --- src/libs/actions/Policy/Rules.ts | 53 +++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 36ce1c6322e4..bcadcaba4ca8 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -8,6 +8,7 @@ import Log from '@libs/Log'; import * as NumberUtils from '@libs/NumberUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyRuleTaxRate} from '@libs/API/parameters/SetPolicyMerchantRuleParams'; import type {MerchantRuleForm} from '@src/types/form'; import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; @@ -27,9 +28,9 @@ function parseStringBoolean(value: string | undefined): boolean | undefined { } /** - * Builds the CodingRuleTax object from a tax key and policy + * Builds the CodingRuleTax object for Onyx optimistic data */ -function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { +function buildOnyxTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { if (!taxKey || !policy?.taxRates?.taxes) { return undefined; } @@ -49,14 +50,49 @@ function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): } /** - * Maps form fields to rule properties + * Builds the PolicyRuleTaxRate object for API request */ -function mapFormFieldsToRule(form: MerchantRuleForm, policy: Policy | undefined) { +function buildApiTaxObject(taxKey: string | undefined, policy: Policy | undefined): PolicyRuleTaxRate | undefined { + if (!taxKey || !policy?.taxRates?.taxes) { + return undefined; + } + + const tax = policy.taxRates.taxes[taxKey]; + if (!tax) { + return undefined; + } + + return { + externalID: taxKey, + value: tax.value, + name: tax.name, + }; +} + +/** + * Maps form fields to rule properties for Onyx optimistic data + */ +function mapFormFieldsForOnyx(form: MerchantRuleForm, policy: Policy | undefined) { + return { + merchant: form.merchant || undefined, + category: form.category || undefined, + tag: form.tag || undefined, + tax: buildOnyxTaxObject(form.tax, policy), + comment: form.comment || undefined, + reimbursable: parseStringBoolean(form.reimbursable), + billable: parseStringBoolean(form.billable), + }; +} + +/** + * Maps form fields to rule properties for API request + */ +function mapFormFieldsForApi(form: MerchantRuleForm, policy: Policy | undefined) { return { merchant: form.merchant || undefined, category: form.category || undefined, tag: form.tag || undefined, - tax: buildTaxObject(form.tax, policy), + tax: buildApiTaxObject(form.tax, policy), comment: form.comment || undefined, reimbursable: parseStringBoolean(form.reimbursable), billable: parseStringBoolean(form.billable), @@ -91,7 +127,8 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: const optimisticRuleID = NumberUtils.rand64(); - const ruleFields = mapFormFieldsToRule(form, policy); + const onyxFields = mapFormFieldsForOnyx(form, policy); + const apiFields = mapFormFieldsForApi(form, policy); const optimisticRule: CodingRule = { filters: { @@ -99,7 +136,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: operator: 'eq', right: form.merchantToMatch, }, - ...ruleFields, + ...onyxFields, created: new Date().toISOString(), }; @@ -158,7 +195,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: const parameters: SetPolicyMerchantRuleParams = { policyID, merchantToMatch: form.merchantToMatch, - ...ruleFields, + ...apiFields, }; API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); From d4f810948b5b96795bdbd8cba633f96853d73065 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:33:07 -0800 Subject: [PATCH 31/49] Use separate tax formats for API and Onyx - buildApiTaxObject: PolicyRuleTaxRate format for API request - buildOnyxTaxObject: CodingRuleTax format with field_id_TAX for Onyx - mapFormFieldsForOnyx and mapFormFieldsForApi for each use case --- .../parameters/SetPolicyMerchantRuleParams.ts | 16 +++++-- src/libs/actions/Policy/Rules.ts | 44 +++++++++---------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts index 0392e3ebe91d..319ab449de88 100644 --- a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts +++ b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts @@ -1,4 +1,13 @@ -import type {CodingRuleTax} from '@src/types/onyx/Policy'; +type PolicyRuleTaxRate = { + /** The tax ID */ + externalID: string; + + /** The tax rate */ + value: string; + + /** The name of the tax rate */ + name: string; +}; type SetPolicyMerchantRuleParams = { policyID: string; @@ -6,10 +15,11 @@ type SetPolicyMerchantRuleParams = { merchant?: string; category?: string; tag?: string; - tax?: CodingRuleTax; + tax?: PolicyRuleTaxRate; comment?: string; reimbursable?: boolean; billable?: boolean; }; -export default SetPolicyMerchantRuleParams; +export type {PolicyRuleTaxRate}; +export default SetPolicyMerchantRuleParams; \ No newline at end of file diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index bcadcaba4ca8..01d588940da7 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -2,17 +2,16 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyMerchantRuleParams from '@libs/API/parameters/SetPolicyMerchantRuleParams'; +import type {PolicyRuleTaxRate} from '@libs/API/parameters/SetPolicyMerchantRuleParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import * as NumberUtils from '@libs/NumberUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyRuleTaxRate} from '@libs/API/parameters/SetPolicyMerchantRuleParams'; import type {MerchantRuleForm} from '@src/types/form'; -import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; -import type {OnyxData} from '@src/types/onyx/Request'; +import type {CodingRuleTax} from '@src/types/onyx/Policy'; /** * Converts a string boolean value ('true'/'false') to a boolean or undefined @@ -28,9 +27,9 @@ function parseStringBoolean(value: string | undefined): boolean | undefined { } /** - * Builds the CodingRuleTax object for Onyx optimistic data + * Builds the PolicyRuleTaxRate object for API requests */ -function buildOnyxTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { +function buildApiTaxObject(taxKey: string | undefined, policy: Policy | undefined): PolicyRuleTaxRate | undefined { if (!taxKey || !policy?.taxRates?.taxes) { return undefined; } @@ -41,18 +40,16 @@ function buildOnyxTaxObject(taxKey: string | undefined, policy: Policy | undefin } return { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: { - externalID: taxKey, - value: `${tax.name} (${tax.value})`, - }, + externalID: taxKey, + value: tax.value, + name: tax.name, }; } /** - * Builds the PolicyRuleTaxRate object for API request + * Builds the CodingRuleTax object for Onyx optimistic data */ -function buildApiTaxObject(taxKey: string | undefined, policy: Policy | undefined): PolicyRuleTaxRate | undefined { +function buildOnyxTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { if (!taxKey || !policy?.taxRates?.taxes) { return undefined; } @@ -63,9 +60,11 @@ function buildApiTaxObject(taxKey: string | undefined, policy: Policy | undefine } return { - externalID: taxKey, - value: tax.value, - name: tax.name, + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxKey, + value: tax.value, + }, }; } @@ -126,11 +125,9 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: } const optimisticRuleID = NumberUtils.rand64(); - const onyxFields = mapFormFieldsForOnyx(form, policy); - const apiFields = mapFormFieldsForApi(form, policy); - const optimisticRule: CodingRule = { + const optimisticRule = { filters: { left: 'merchant', operator: 'eq', @@ -140,11 +137,13 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: created: new Date().toISOString(), }; - const onyxData: OnyxData = { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + + const onyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + key: policyKey, value: { rules: { codingRules: { @@ -160,7 +159,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + key: policyKey, value: { pendingFields: { rules: null, @@ -174,7 +173,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + key: policyKey, value: { rules: { codingRules: { @@ -192,6 +191,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: ], }; + const apiFields = mapFormFieldsForApi(form, policy); const parameters: SetPolicyMerchantRuleParams = { policyID, merchantToMatch: form.merchantToMatch, From 562e1b500afda8196b58aaea86147f4ff3cabef3 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:35:28 -0800 Subject: [PATCH 32/49] fix prettier --- src/libs/API/parameters/SetPolicyMerchantRuleParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts index 319ab449de88..40efad036b57 100644 --- a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts +++ b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts @@ -22,4 +22,4 @@ type SetPolicyMerchantRuleParams = { }; export type {PolicyRuleTaxRate}; -export default SetPolicyMerchantRuleParams; \ No newline at end of file +export default SetPolicyMerchantRuleParams; From f10f523847079e53b97008434347a522ddf3a183 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:48:51 -0800 Subject: [PATCH 33/49] Fix ESLint errors: remove unused import and use stable ContentWrapper - Remove unused ONYXKEYS import from TextBase.tsx - Use useMemo to create stable ContentWrapper components - Prevents react/no-unstable-nested-components error --- src/components/Rule/TextBase.tsx | 1 - src/pages/settings/Rules/Fields/AddBillablePage.tsx | 10 ++++++++-- src/pages/settings/Rules/Fields/AddCategoryPage.tsx | 8 +++++++- src/pages/settings/Rules/Fields/AddDescriptionPage.tsx | 10 ++++++++-- src/pages/settings/Rules/Fields/AddMerchantPage.tsx | 10 ++++++++-- .../settings/Rules/Fields/AddReimbursablePage.tsx | 10 ++++++++-- .../settings/Rules/Fields/AddRenameMerchantPage.tsx | 10 ++++++++-- src/pages/settings/Rules/Fields/AddReportPage.tsx | 10 ++++++++-- src/pages/settings/Rules/Fields/AddTagPage.tsx | 8 +++++++- src/pages/settings/Rules/Fields/AddTaxRatePage.tsx | 10 ++++++++-- 10 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/components/Rule/TextBase.tsx b/src/components/Rule/TextBase.tsx index 96cd08063365..848416ebeafe 100644 --- a/src/components/Rule/TextBase.tsx +++ b/src/components/Rule/TextBase.tsx @@ -11,7 +11,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isRequiredFulfilled, isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import type {OnyxFormKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; type TextBaseProps = { fieldID: string; diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index 98b5539174cb..c7b2730e6b99 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import {updateDraftRule} from '@libs/actions/User'; @@ -24,6 +24,12 @@ function AddBillablePage({route}: AddBillablePageProps) { goBack(); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx index 39311266e597..5446f9a7f8b7 100644 --- a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx +++ b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx @@ -53,6 +53,12 @@ function AddCategoryPage({route}: AddCategoryPageProps) { updateDraftRule({category: value}); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( Navigation.goBack(backToRoute)} backToRoute={backToRoute} - ContentWrapper={({children}) => {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx index 5c184e72722f..22cc7c6166c9 100644 --- a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx +++ b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; @@ -25,6 +25,12 @@ function AddDescriptionPage({route}: AddDescriptionPageProps) { goBack(); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx index b20bf4281fbf..cd3207855b4d 100644 --- a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; @@ -25,6 +25,12 @@ function AddMerchantPage({route}: AddMerchantPageProps) { goBack(); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx index 28feaecc9ccf..66680e1ea89c 100644 --- a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import {updateDraftRule} from '@libs/actions/User'; @@ -24,6 +24,12 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { goBack(); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx index 6dc84bf34b01..e771a729f47c 100644 --- a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; @@ -25,6 +25,12 @@ function AddRenameMerchantPage({route}: AddRenameMerchantPageProps) { goBack(); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddReportPage.tsx b/src/pages/settings/Rules/Fields/AddReportPage.tsx index 2f889eb5d290..ce7be76a9b88 100644 --- a/src/pages/settings/Rules/Fields/AddReportPage.tsx +++ b/src/pages/settings/Rules/Fields/AddReportPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; @@ -25,6 +25,12 @@ function AddReportPage({route}: AddReportPageProps) { goBack(); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index 8b815dd282db..fc7a91b7997c 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -42,6 +42,12 @@ function AddTagPage({route}: AddTagPageProps) { updateDraftRule({tag: value}); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( Navigation.goBack(backToRoute)} backToRoute={backToRoute} - ContentWrapper={({children}) => {children}} + ContentWrapper={ContentWrapper} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx index 6e480673cbd8..05825d675fab 100644 --- a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx +++ b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; @@ -30,6 +30,12 @@ function AddTaxRatePage({route}: AddTaxRatePageProps) { updateDraftRule({tax: value}); }; + const ContentWrapper = useMemo(() => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; + }, [hash]); + return ( Navigation.goBack(backToRoute)} backToRoute={backToRoute} - ContentWrapper={({children}) => {children}} + ContentWrapper={ContentWrapper} /> ); } From ee66a6607477a62b9e649721f55746dc9626465a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 09:58:26 -0800 Subject: [PATCH 34/49] Remove useMemo from RuleBooleanBase - React compiler handles memoization --- src/components/Rule/RuleBooleanBase.tsx | 34 ++++++++++--------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 99756c785bd4..6b858e940052 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -46,27 +46,21 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, ContentWr const formValue = (form as Record)?.[fieldID]; - const selectedItem = useMemo(() => { - if (!formValue) { - return null; - } - const yesValue = CONST.SEARCH.BOOLEAN.YES; - const noValue = CONST.SEARCH.BOOLEAN.NO; + const yesValue = CONST.SEARCH.BOOLEAN.YES; + const noValue = CONST.SEARCH.BOOLEAN.NO; + + let selectedItem = null; + if (formValue) { const booleanValue = formValue === 'true' ? yesValue : noValue; + selectedItem = booleanValues.find((value) => booleanValue === value) ?? null; + } - return booleanValues.find((value) => booleanValue === value) ?? null; - }, [formValue]); - - const items = useMemo( - () => - booleanValues.map((value) => ({ - value, - keyForList: value, - text: translate(`common.${value}`), - isSelected: selectedItem === value, - })), - [selectedItem, translate], - ); + const items = booleanValues.map((value) => ({ + value, + keyForList: value, + text: translate(`common.${value}`), + isSelected: selectedItem === value, + })); const onSelectItem = (selectedValue: BooleanFilterItem) => { const newValue = selectedValue.isSelected ? null : selectedValue.value; From 17d70d6c6f574619eb964921e2c0f33e43e473dd Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 10:22:11 -0800 Subject: [PATCH 35/49] Unify CodingRuleTax type for both Onyx and API - Update CodingRuleTax to flat structure (externalID, value, name) - Remove duplicate PolicyRuleTaxRate, use CodingRuleTax instead - Simplify Rules.ts to use single mapFormFieldsToRule function - Update MerchantRulesSection to use new tax format --- .../parameters/SetPolicyMerchantRuleParams.ts | 14 +---- src/libs/actions/Policy/Rules.ts | 55 +++---------------- .../settings/Rules/Fields/AddBillablePage.tsx | 2 +- .../workspace/rules/MerchantRulesSection.tsx | 4 +- src/types/onyx/Policy.ts | 15 +++-- 5 files changed, 20 insertions(+), 70 deletions(-) diff --git a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts index 40efad036b57..0392e3ebe91d 100644 --- a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts +++ b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts @@ -1,13 +1,4 @@ -type PolicyRuleTaxRate = { - /** The tax ID */ - externalID: string; - - /** The tax rate */ - value: string; - - /** The name of the tax rate */ - name: string; -}; +import type {CodingRuleTax} from '@src/types/onyx/Policy'; type SetPolicyMerchantRuleParams = { policyID: string; @@ -15,11 +6,10 @@ type SetPolicyMerchantRuleParams = { merchant?: string; category?: string; tag?: string; - tax?: PolicyRuleTaxRate; + tax?: CodingRuleTax; comment?: string; reimbursable?: boolean; billable?: boolean; }; -export type {PolicyRuleTaxRate}; export default SetPolicyMerchantRuleParams; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 01d588940da7..fe7a205f3d14 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -2,7 +2,6 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyMerchantRuleParams from '@libs/API/parameters/SetPolicyMerchantRuleParams'; -import type {PolicyRuleTaxRate} from '@libs/API/parameters/SetPolicyMerchantRuleParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; @@ -27,9 +26,9 @@ function parseStringBoolean(value: string | undefined): boolean | undefined { } /** - * Builds the PolicyRuleTaxRate object for API requests + * Builds the tax object from a tax key and policy */ -function buildApiTaxObject(taxKey: string | undefined, policy: Policy | undefined): PolicyRuleTaxRate | undefined { +function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { if (!taxKey || !policy?.taxRates?.taxes) { return undefined; } @@ -47,51 +46,14 @@ function buildApiTaxObject(taxKey: string | undefined, policy: Policy | undefine } /** - * Builds the CodingRuleTax object for Onyx optimistic data + * Maps form fields to rule properties */ -function buildOnyxTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { - if (!taxKey || !policy?.taxRates?.taxes) { - return undefined; - } - - const tax = policy.taxRates.taxes[taxKey]; - if (!tax) { - return undefined; - } - - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: { - externalID: taxKey, - value: tax.value, - }, - }; -} - -/** - * Maps form fields to rule properties for Onyx optimistic data - */ -function mapFormFieldsForOnyx(form: MerchantRuleForm, policy: Policy | undefined) { - return { - merchant: form.merchant || undefined, - category: form.category || undefined, - tag: form.tag || undefined, - tax: buildOnyxTaxObject(form.tax, policy), - comment: form.comment || undefined, - reimbursable: parseStringBoolean(form.reimbursable), - billable: parseStringBoolean(form.billable), - }; -} - -/** - * Maps form fields to rule properties for API request - */ -function mapFormFieldsForApi(form: MerchantRuleForm, policy: Policy | undefined) { +function mapFormFieldsToRule(form: MerchantRuleForm, policy: Policy | undefined) { return { merchant: form.merchant || undefined, category: form.category || undefined, tag: form.tag || undefined, - tax: buildApiTaxObject(form.tax, policy), + tax: buildTaxObject(form.tax, policy), comment: form.comment || undefined, reimbursable: parseStringBoolean(form.reimbursable), billable: parseStringBoolean(form.billable), @@ -125,7 +87,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: } const optimisticRuleID = NumberUtils.rand64(); - const onyxFields = mapFormFieldsForOnyx(form, policy); + const ruleFields = mapFormFieldsToRule(form, policy); const optimisticRule = { filters: { @@ -133,7 +95,7 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: operator: 'eq', right: form.merchantToMatch, }, - ...onyxFields, + ...ruleFields, created: new Date().toISOString(), }; @@ -191,11 +153,10 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: ], }; - const apiFields = mapFormFieldsForApi(form, policy); const parameters: SetPolicyMerchantRuleParams = { policyID, merchantToMatch: form.merchantToMatch, - ...apiFields, + ...ruleFields, }; API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index c7b2730e6b99..22a67fbb79af 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import {updateDraftRule} from '@libs/actions/User'; diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index f808b5e3fb42..a70f50990f1c 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -47,8 +47,8 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType Date: Tue, 27 Jan 2026 10:29:13 -0800 Subject: [PATCH 36/49] Fix ESLint: use arrow functions for ContentWrapper components --- src/pages/settings/Rules/Fields/AddBillablePage.tsx | 12 ++++++------ src/pages/settings/Rules/Fields/AddCategoryPage.tsx | 10 +++++----- .../settings/Rules/Fields/AddDescriptionPage.tsx | 10 +++++----- src/pages/settings/Rules/Fields/AddMerchantPage.tsx | 10 +++++----- .../settings/Rules/Fields/AddReimbursablePage.tsx | 10 +++++----- .../settings/Rules/Fields/AddRenameMerchantPage.tsx | 10 +++++----- src/pages/settings/Rules/Fields/AddReportPage.tsx | 10 +++++----- src/pages/settings/Rules/Fields/AddTagPage.tsx | 10 +++++----- src/pages/settings/Rules/Fields/AddTaxRatePage.tsx | 10 +++++----- 9 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index 22a67fbb79af..9186192f5593 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import {updateDraftRule} from '@libs/actions/User'; @@ -24,11 +24,11 @@ function AddBillablePage({route}: AddBillablePageProps) { goBack(); }; - const ContentWrapper = useMemo(() => { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( { - return function Wrapper({children}: {children: React.ReactNode}) { - return {children}; - }; - }, [hash]); + const ContentWrapper = useMemo( + () => + ({children}: {children: React.ReactNode}) => {children}, + [hash], + ); return ( Date: Tue, 27 Jan 2026 10:48:27 -0800 Subject: [PATCH 37/49] Refactor: move RuleNotFoundPageWrapper into base components - Base components now accept 'hash' prop instead of 'ContentWrapper' - RuleNotFoundPageWrapper is rendered inside the base components - Removes need for component factories in page components - Fixes react/no-unstable-nested-components ESLint error --- src/components/Rule/RuleBooleanBase.tsx | 55 ++++++++--------- src/components/Rule/RuleSelectionBase.tsx | 55 ++++++++--------- src/components/Rule/RuleTextBase.tsx | 61 +++++++++---------- .../settings/Rules/Fields/AddBillablePage.tsx | 11 +--- .../settings/Rules/Fields/AddCategoryPage.tsx | 9 +-- .../Rules/Fields/AddDescriptionPage.tsx | 11 +--- .../settings/Rules/Fields/AddMerchantPage.tsx | 11 +--- .../Rules/Fields/AddReimbursablePage.tsx | 11 +--- .../Rules/Fields/AddRenameMerchantPage.tsx | 11 +--- .../settings/Rules/Fields/AddReportPage.tsx | 11 +--- .../settings/Rules/Fields/AddTagPage.tsx | 9 +-- .../settings/Rules/Fields/AddTaxRatePage.tsx | 11 +--- 12 files changed, 97 insertions(+), 169 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 6b858e940052..c0c8a454e83e 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -33,13 +34,13 @@ type RuleBooleanBaseProps = { /** Callback to go back */ onBack: () => void; - /** Optional wrapper component for the content */ - ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; + /** Optional hash for rule not found validation */ + hash?: string; }; const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); -function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, ContentWrapper}: RuleBooleanBaseProps) { +function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash}: RuleBooleanBaseProps) { const {translate} = useLocalize(); const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); @@ -73,34 +74,30 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, ContentWr onSelect(fieldID, value); }; - const content = ( - - - - + + - - + + + + + ); - - if (ContentWrapper) { - return {content}; - } - - return content; } export default RuleBooleanBase; diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx index 3ebae80c1001..24df6bc68194 100644 --- a/src/components/Rule/RuleSelectionBase.tsx +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; @@ -35,42 +36,38 @@ type RuleSelectionBaseProps = { /** The route to navigate back to */ backToRoute: Route; - /** Optional wrapper component for the content */ - ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; + /** Optional hash for rule not found validation */ + hash?: string; }; -function RuleSelectionBase({titleKey, testID, selectedItem, items, onSave, onBack, backToRoute, ContentWrapper}: RuleSelectionBaseProps) { +function RuleSelectionBase({titleKey, testID, selectedItem, items, onSave, onBack, backToRoute, hash}: RuleSelectionBaseProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const content = ( - - - - + + - - + + + + + ); - - if (ContentWrapper) { - return {content}; - } - - return content; } export default RuleSelectionBase; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index f55b82ac4e5d..f4ab17a687fc 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -40,8 +41,8 @@ type RuleTextBaseProps = { /** Callback to go back */ onBack: () => void; - /** Optional wrapper component for the content */ - ContentWrapper?: React.ComponentType<{children: React.ReactNode}>; + /** Optional hash for rule not found validation */ + hash?: string; }; function RuleTextBase({ @@ -55,41 +56,37 @@ function RuleTextBase({ formID, onSave, onBack, - ContentWrapper, + hash, }: RuleTextBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const content = ( - - - - + return ( + + + + + + ); - - if (ContentWrapper) { - return {content}; - } - - return content; } export default RuleTextBase; diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index 9186192f5593..79ea82558d4c 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -1,6 +1,5 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -24,12 +23,6 @@ function AddBillablePage({route}: AddBillablePageProps) { goBack(); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx index 37ec33a993fd..17469b0aea0a 100644 --- a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx +++ b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useMemo} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; import {updateDraftRule} from '@libs/actions/User'; @@ -53,12 +52,6 @@ function AddCategoryPage({route}: AddCategoryPageProps) { updateDraftRule({category: value}); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( Navigation.goBack(backToRoute)} backToRoute={backToRoute} - ContentWrapper={ContentWrapper} + hash={hash} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx index ded17f0c0a0d..b910763b20fc 100644 --- a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx +++ b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx @@ -1,6 +1,5 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -25,12 +24,6 @@ function AddDescriptionPage({route}: AddDescriptionPageProps) { goBack(); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx index a650fba9afde..805c850c688e 100644 --- a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx @@ -1,6 +1,5 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -25,12 +24,6 @@ function AddMerchantPage({route}: AddMerchantPageProps) { goBack(); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx index f6d084bb297a..f36e984ad894 100644 --- a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx @@ -1,6 +1,5 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -24,12 +23,6 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { goBack(); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx index 918e2bcf2039..24fb50e2198d 100644 --- a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx @@ -1,6 +1,5 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -25,12 +24,6 @@ function AddRenameMerchantPage({route}: AddRenameMerchantPageProps) { goBack(); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddReportPage.tsx b/src/pages/settings/Rules/Fields/AddReportPage.tsx index 5416654ae2a3..2d6578688c18 100644 --- a/src/pages/settings/Rules/Fields/AddReportPage.tsx +++ b/src/pages/settings/Rules/Fields/AddReportPage.tsx @@ -1,6 +1,5 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleTextBase from '@components/Rule/RuleTextBase'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -25,12 +24,6 @@ function AddReportPage({route}: AddReportPageProps) { goBack(); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index 0f620e8b7b39..c331f29b5636 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -1,6 +1,5 @@ import React, {useMemo} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; import {updateDraftRule} from '@libs/actions/User'; @@ -42,12 +41,6 @@ function AddTagPage({route}: AddTagPageProps) { updateDraftRule({tag: value}); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( Navigation.goBack(backToRoute)} backToRoute={backToRoute} - ContentWrapper={ContentWrapper} + hash={hash} /> ); } diff --git a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx index 2929fbfaa778..73469a95f21f 100644 --- a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx +++ b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx @@ -1,5 +1,4 @@ -import React, {useMemo} from 'react'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; +import React from 'react'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; import {updateDraftRule} from '@libs/actions/User'; @@ -30,12 +29,6 @@ function AddTaxRatePage({route}: AddTaxRatePageProps) { updateDraftRule({tax: value}); }; - const ContentWrapper = useMemo( - () => - ({children}: {children: React.ReactNode}) => {children}, - [hash], - ); - return ( Navigation.goBack(backToRoute)} backToRoute={backToRoute} - ContentWrapper={ContentWrapper} + hash={hash} /> ); } From d45262f4aa89068ddcc3d269c59e17f92d20df11 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 10:58:57 -0800 Subject: [PATCH 38/49] Rename API to SetPolicyCodingRule with new params - policyID: The policy ID - ruleID: The existing or optimistic rule ID - value: JSON stringified CodingRule - updateMatchingTransactions: Whether to update matching transactions Also adds ruleID field to CodingRule type --- .../parameters/SetPolicyCodingRuleParams.ts | 15 +++++++++ .../parameters/SetPolicyMerchantRuleParams.ts | 15 --------- src/libs/API/parameters/index.ts | 2 +- src/libs/API/types.ts | 6 ++-- src/libs/actions/Policy/Rules.ts | 32 +++++++++++-------- .../MerchantRules/AddMerchantRulePage.tsx | 4 +-- src/types/onyx/Policy.ts | 3 ++ 7 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 src/libs/API/parameters/SetPolicyCodingRuleParams.ts delete mode 100644 src/libs/API/parameters/SetPolicyMerchantRuleParams.ts diff --git a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts new file mode 100644 index 000000000000..7bccf7ac8274 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts @@ -0,0 +1,15 @@ +type SetPolicyCodingRuleParams = { + /** The policy ID that the rule will be created or updated for */ + policyID: string; + + /** The existing ruleID, or an optimistic one to create the rule */ + ruleID: string; + + /** The JSON value of the merchant rule, stringified */ + value: string; + + /** Whether to update the transactions that match the rule */ + updateMatchingTransactions: boolean; +}; + +export default SetPolicyCodingRuleParams; diff --git a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts b/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts deleted file mode 100644 index 0392e3ebe91d..000000000000 --- a/src/libs/API/parameters/SetPolicyMerchantRuleParams.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type {CodingRuleTax} from '@src/types/onyx/Policy'; - -type SetPolicyMerchantRuleParams = { - policyID: string; - merchantToMatch: string; - merchant?: string; - category?: string; - tag?: string; - tax?: CodingRuleTax; - comment?: string; - reimbursable?: boolean; - billable?: boolean; -}; - -export default SetPolicyMerchantRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index a7267e478bcf..b409eb168ffe 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -467,7 +467,7 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; -export type {default as SetPolicyMerchantRuleParams} from './SetPolicyMerchantRuleParams'; +export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; export type {default as RequestAuthenticationChallengeParams} from './RequestAuthenticationChallengeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e77f2fe5d9a4..510ee28f01cd 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -254,7 +254,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', ENABLE_POLICY_TIME_TRACKING: 'EnablePolicyTimeTracking', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', - SET_POLICY_MERCHANT_RULE: 'SetPolicyMerchantRule', + SET_POLICY_CODING_RULE: 'SetPolicyCodingRule', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'SetPolicyExpenseMaxAmountNoItemizedReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', @@ -800,7 +800,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; - [WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE]: Parameters.SetPolicyMerchantRuleParams; + [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; @@ -839,7 +839,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION]: Parameters.CancelBillingSubscriptionParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; - [WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE]: Parameters.SetPolicyMerchantRuleParams; + [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoItemizedReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index fe7a205f3d14..a2c0e2d64df2 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,7 +1,7 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; -import type SetPolicyMerchantRuleParams from '@libs/API/parameters/SetPolicyMerchantRuleParams'; +import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; import type Policy from '@src/types/onyx/Policy'; -import type {CodingRuleTax} from '@src/types/onyx/Policy'; +import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; /** * Converts a string boolean value ('true'/'false') to a boolean or undefined @@ -75,24 +75,27 @@ function openPolicyRulesPage(policyID: string | undefined) { } /** - * Creates a new merchant rule for the given policy - * @param policyID - The ID of the policy to create the rule for + * Creates or updates a coding rule for the given policy + * @param policyID - The ID of the policy to create/update the rule for * @param form - The form data for the merchant rule * @param policy - The policy object (needed to build tax data) + * @param ruleID - Optional existing rule ID for updates + * @param updateMatchingTransactions - Whether to update transactions that match the rule */ -function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined) { +function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined, ruleID?: string, updateMatchingTransactions = false) { if (!policyID || !form.merchantToMatch) { - Log.warn('Invalid params for setPolicyMerchantRule', {policyID, merchantToMatch: form.merchantToMatch}); + Log.warn('Invalid params for setPolicyCodingRule', {policyID, merchantToMatch: form.merchantToMatch}); return; } - const optimisticRuleID = NumberUtils.rand64(); + const optimisticRuleID = ruleID ?? NumberUtils.rand64(); const ruleFields = mapFormFieldsToRule(form, policy); - const optimisticRule = { + const optimisticRule: CodingRule = { + ruleID: optimisticRuleID, filters: { left: 'merchant', - operator: 'eq', + operator: 'contains', right: form.merchantToMatch, }, ...ruleFields, @@ -153,13 +156,14 @@ function setPolicyMerchantRule(policyID: string, form: MerchantRuleForm, policy: ], }; - const parameters: SetPolicyMerchantRuleParams = { + const parameters: SetPolicyCodingRuleParams = { policyID, - merchantToMatch: form.merchantToMatch, - ...ruleFields, + ruleID: optimisticRuleID, + value: JSON.stringify(optimisticRule), + updateMatchingTransactions, }; - API.write(WRITE_COMMANDS.SET_POLICY_MERCHANT_RULE, parameters, onyxData); + API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } -export {openPolicyRulesPage, setPolicyMerchantRule}; +export {openPolicyRulesPage, setPolicyCodingRule}; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 8e2ad187f1a4..3749dd68ae23 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -11,7 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setPolicyMerchantRule} from '@libs/actions/Policy/Rules'; +import {setPolicyCodingRule} from '@libs/actions/Policy/Rules'; import {clearDraftMerchantRule} from '@libs/actions/User'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -99,7 +99,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { return; } - setPolicyMerchantRule(policyID, form, policy); + setPolicyCodingRule(policyID, form, policy); Navigation.goBack(); }; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e39e5caeec57..0a5f7d3e38ed 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1711,6 +1711,9 @@ type CodingRuleTax = { /** Policy coding rule data model */ type CodingRule = { + /** Unique identifier for the rule */ + ruleID?: string; + /** Filter conditions for when this rule applies */ filters: CodingRuleFilter; From 174c531996ad8c040f71370eea168dca29713671 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 11:09:11 -0800 Subject: [PATCH 39/49] Fix imports: use relative path for RuleNotFoundPageWrapper --- src/components/Rule/RuleBooleanBase.tsx | 2 +- src/components/Rule/RuleSelectionBase.tsx | 2 +- src/components/Rule/RuleTextBase.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index c0c8a454e83e..0777655444c2 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; +import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx index 24df6bc68194..9dc7969005a5 100644 --- a/src/components/Rule/RuleSelectionBase.tsx +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; +import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index f4ab17a687fc..c3396dd6e806 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; +import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; From 2609db3b61fa3d43219385ed4e24288586585df8 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 11:10:36 -0800 Subject: [PATCH 40/49] Fix styles --- src/components/Rule/RuleBooleanBase.tsx | 2 +- src/components/Rule/RuleSelectionBase.tsx | 2 +- src/components/Rule/RuleTextBase.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 0777655444c2..ead01f3bbcfa 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -2,7 +2,6 @@ import React from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -13,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {OnyxFormKey} from '@src/ONYXKEYS'; +import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; type BooleanFilterItem = ListItem & { value: ValueOf; diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx index 9dc7969005a5..2b2500ee45de 100644 --- a/src/components/Rule/RuleSelectionBase.tsx +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -1,13 +1,13 @@ import React from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TranslationPaths} from '@src/languages/types'; import type {Route} from '@src/ROUTES'; +import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; type SelectionItem = { name: string; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index c3396dd6e806..188cdda0f31a 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -1,13 +1,13 @@ import React from 'react'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {OnyxFormKey} from '@src/ONYXKEYS'; +import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import TextBase from './TextBase'; type RuleTextBaseProps = { From d26949784e08ebf87c50a1084fadb46b2ccbe46c Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 11:50:55 -0800 Subject: [PATCH 41/49] Remove useMemo and useCallback from React compiled component --- src/components/Rule/RuleBooleanBase.tsx | 5 +- src/components/Rule/RuleTextBase.tsx | 2 +- .../settings/Rules/Fields/AddCategoryPage.tsx | 14 +- .../settings/Rules/Fields/AddTagPage.tsx | 8 +- .../MerchantRules/AddMerchantRulePage.tsx | 146 ++++++++---------- .../rules/MerchantRules/AddTaxPage.tsx | 10 +- 6 files changed, 81 insertions(+), 104 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index ead01f3bbcfa..c5fd17062ed3 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -47,12 +47,9 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash}: Ru const formValue = (form as Record)?.[fieldID]; - const yesValue = CONST.SEARCH.BOOLEAN.YES; - const noValue = CONST.SEARCH.BOOLEAN.NO; - let selectedItem = null; if (formValue) { - const booleanValue = formValue === 'true' ? yesValue : noValue; + const booleanValue = formValue === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; selectedItem = booleanValues.find((value) => booleanValue === value) ?? null; } diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index 188cdda0f31a..92b7da108611 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -52,7 +52,7 @@ function RuleTextBase({ titleKey, labelKey, testID, - characterLimit = CONST.MERCHANT_NAME_MAX_BYTES, + characterLimit, formID, onSave, onBack, diff --git a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx index 17469b0aea0a..52200a612ac4 100644 --- a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx +++ b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; @@ -18,10 +18,8 @@ type AddCategoryPageProps = PlatformStackScreenProps) => getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID), - [personalPolicyID], - ); + const availableNonPersonalPolicyCategoriesSelector = (allPolicyCategories: OnyxCollection) => + getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID); const [allPolicyCategories = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, { canBeMissing: true, selector: availableNonPersonalPolicyCategoriesSelector, @@ -29,7 +27,7 @@ function AddCategoryPage({route}: AddCategoryPageProps) { const selectedCategoryItem = form?.category ? {name: getDecodedCategoryName(form.category), value: form.category} : undefined; - const categoryItems = useMemo(() => { + const categoryItems = () => { const uniqueCategoryNames = new Set(); const categories = Object.values(allPolicyCategories ?? {}).flatMap((policyCategories) => Object.values(policyCategories ?? {})); @@ -43,7 +41,7 @@ function AddCategoryPage({route}: AddCategoryPageProps) { const decodedCategoryName = getDecodedCategoryName(categoryName); return {name: decodedCategoryName, value: categoryName}; }); - }, [allPolicyCategories]); + }; const hash = route.params?.hash; const backToRoute = hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute(); @@ -57,7 +55,7 @@ function AddCategoryPage({route}: AddCategoryPageProps) { titleKey="common.category" testID="AddCategoryPage" selectedItem={selectedCategoryItem} - items={categoryItems} + items={categoryItems()} onSave={onSave} onBack={() => Navigation.goBack(backToRoute)} backToRoute={backToRoute} diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index c331f29b5636..290bb6d16c8b 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; @@ -23,7 +23,7 @@ function AddTagPage({route}: AddTagPageProps) { const selectedTagItem = form?.tag ? {name: getCleanedTagName(form.tag), value: form.tag} : undefined; - const tagItems = useMemo(() => { + const tagItems = () => { const uniqueTagNames = new Set(); const tagListsUnpacked = Object.values(allPolicyTagLists ?? {}).filter((item) => !!item); @@ -32,7 +32,7 @@ function AddTagPage({route}: AddTagPageProps) { } return Array.from(uniqueTagNames).map((tagName) => ({name: getCleanedTagName(tagName), value: tagName})); - }, [allPolicyTagLists]); + }; const hash = route.params?.hash; const backToRoute = hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute(); @@ -46,7 +46,7 @@ function AddTagPage({route}: AddTagPageProps) { titleKey="common.tag" testID="AddTagPage" selectedItem={selectedTagItem} - items={tagItems} + items={tagItems()} onSave={onSave} onBack={() => Navigation.goBack(backToRoute)} backToRoute={backToRoute} diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 3749dd68ae23..b6780d0156c5 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -80,13 +80,13 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { // Get display names for category, tag, and tax const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; const tagDisplayName = form?.tag ? getCleanedTagName(form.tag) : undefined; - const taxDisplayName = useMemo(() => { + const taxDisplayName = () => { if (!form?.tax || !policy?.taxRates?.taxes) { return undefined; } const tax = policy.taxRates.taxes[form.tax]; return tax ? `${tax.name} (${tax.value})` : undefined; - }, [form?.tax, policy?.taxRates?.taxes]); + }; const errorMessage = getErrorMessage(translate, form); @@ -103,85 +103,67 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { Navigation.goBack(); }; - const sections: SectionType[] = useMemo( - () => [ - { - titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', - items: [ - { - descriptionTranslationKey: 'common.merchant', - required: true, - title: form?.merchantToMatch, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID)), - }, - ], - }, - { - titleTranslationKey: 'workspace.rules.merchantRules.applyUpdates', - items: [ - { - descriptionTranslationKey: 'common.merchant', - title: form?.merchant, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), - }, - areCategoriesEnabled - ? { - descriptionTranslationKey: 'common.category', - title: categoryDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), - } - : undefined, - areTagsEnabled - ? { - descriptionTranslationKey: 'common.tag', - title: tagDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), - } - : undefined, - isTaxTrackingEnabled - ? { - descriptionTranslationKey: 'common.tax', - title: taxDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), - } - : undefined, - { - descriptionTranslationKey: 'common.description', - title: form?.comment, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID)), - }, - { - descriptionTranslationKey: 'common.reimbursable', - title: getBooleanTitle(form?.reimbursable, translate), - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), - }, - isBillableEnabled - ? { - descriptionTranslationKey: 'common.billable', - title: getBooleanTitle(form?.billable, translate), - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), - } - : undefined, - ], - }, - ], - [ - areCategoriesEnabled, - areTagsEnabled, - isTaxTrackingEnabled, - isBillableEnabled, - form?.merchantToMatch, - form?.merchant, - categoryDisplayName, - tagDisplayName, - taxDisplayName, - form?.comment, - form?.reimbursable, - form?.billable, - policyID, - translate, - ], - ); + const sections: SectionType[] = [ + { + titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', + items: [ + { + descriptionTranslationKey: 'common.merchant', + required: true, + title: form?.merchantToMatch, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID)), + }, + ], + }, + { + titleTranslationKey: 'workspace.rules.merchantRules.applyUpdates', + items: [ + { + descriptionTranslationKey: 'common.merchant', + title: form?.merchant, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), + }, + areCategoriesEnabled + ? { + descriptionTranslationKey: 'common.category', + title: categoryDisplayName, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), + } + : undefined, + areTagsEnabled + ? { + descriptionTranslationKey: 'common.tag', + title: tagDisplayName, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), + } + : undefined, + isTaxTrackingEnabled + ? { + descriptionTranslationKey: 'common.tax', + title: taxDisplayName(), + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), + } + : undefined, + { + descriptionTranslationKey: 'common.description', + title: form?.comment, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.reimbursable', + title: getBooleanTitle(form?.reimbursable, translate), + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), + }, + isBillableEnabled + ? { + descriptionTranslationKey: 'common.billable', + title: getBooleanTitle(form?.billable, translate), + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), + } + : undefined, + ], + }, + ]; return ( { + const taxItems = () => { const taxes = policy?.taxRates?.taxes ?? {}; return Object.entries(taxes) .filter(([, tax]) => !tax.isDisabled) @@ -25,9 +25,9 @@ function AddTaxPage({route}: AddTaxPageProps) { name: `${tax.name} (${tax.value})`, value: taxKey, })); - }, [policy?.taxRates?.taxes]); + }; - const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; + const selectedTaxItem = form?.tax ? taxItems().find(({value}) => value === form.tax) : undefined; const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); @@ -40,7 +40,7 @@ function AddTaxPage({route}: AddTaxPageProps) { titleKey="common.tax" testID="AddTaxPage" selectedItem={selectedTaxItem} - items={taxItems} + items={taxItems()} onSave={onSave} onBack={() => Navigation.goBack(backToRoute)} backToRoute={backToRoute} From 5ed56322039c01d39e6587d3c4e39c69cc2ac27c Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 11:55:49 -0800 Subject: [PATCH 42/49] Display category,tag and taxes only if they are available --- src/components/Rule/RuleTextBase.tsx | 14 +------- .../MerchantRules/AddMerchantRulePage.tsx | 36 ++++++++++++++----- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index 92b7da108611..8d05986ff633 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -45,19 +45,7 @@ type RuleTextBaseProps = { hash?: string; }; -function RuleTextBase({ - fieldID, - hintKey, - isRequired, - titleKey, - labelKey, - testID, - characterLimit, - formID, - onSave, - onBack, - hash, -}: RuleTextBaseProps) { +function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, characterLimit, formID, onSave, onBack, hash}: RuleTextBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index b6780d0156c5..e9fe4d4d363a 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -17,7 +17,7 @@ import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getCleanedTagName} from '@libs/PolicyUtils'; +import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -68,16 +68,36 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); const [shouldShowError, setShouldShowError] = useState(false); useEffect(() => () => clearDraftMerchantRule(), []); - const areCategoriesEnabled = !!policy?.areCategoriesEnabled; - const areTagsEnabled = !!policy?.areTagsEnabled; - const isTaxTrackingEnabled = !!policy?.tax?.trackingEnabled; + const hasCategories = () => { + if (!policy?.areCategoriesEnabled) { + return false; + } + return Object.keys(policyCategories ?? {}).length > 0; + }; + + const hasTags = () => { + if (!policy?.areTagsEnabled) { + return false; + } + const tagNames = getTagNamesFromTagsLists(policyTags ?? {}); + return tagNames.length > 0; + }; + + const hasTaxes = () => { + if (!policy?.tax?.trackingEnabled) { + return false; + } + return Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; + }; + const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; - // Get display names for category, tag, and tax const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; const tagDisplayName = form?.tag ? getCleanedTagName(form.tag) : undefined; const taxDisplayName = () => { @@ -123,21 +143,21 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { title: form?.merchant, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), }, - areCategoriesEnabled + hasCategories() ? { descriptionTranslationKey: 'common.category', title: categoryDisplayName, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), } : undefined, - areTagsEnabled + hasTags() ? { descriptionTranslationKey: 'common.tag', title: tagDisplayName, onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), } : undefined, - isTaxTrackingEnabled + hasTaxes() ? { descriptionTranslationKey: 'common.tax', title: taxDisplayName(), From 6f34508a0ca477ed01f4ea6d92e7db5ee4c327df Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 12:14:40 -0800 Subject: [PATCH 43/49] Fix ESLint --- src/components/Rule/RuleTextBase.tsx | 1 - src/pages/settings/Rules/Fields/AddCategoryPage.tsx | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index 8d05986ff633..98ed7f2c8476 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -4,7 +4,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {OnyxFormKey} from '@src/ONYXKEYS'; import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; diff --git a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx index 52200a612ac4..47e2cd93fe45 100644 --- a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx +++ b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; @@ -18,8 +18,10 @@ type AddCategoryPageProps = PlatformStackScreenProps) => - getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID); + const availableNonPersonalPolicyCategoriesSelector = useCallback( + (allPolicyCategories: OnyxCollection) => getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID), + [personalPolicyID], + ); const [allPolicyCategories = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, { canBeMissing: true, selector: availableNonPersonalPolicyCategoriesSelector, From 4509a8097e0af2045c218888b72af7af3222c726 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 13:26:14 -0800 Subject: [PATCH 44/49] Rename param to shouldUpdateMatchingTransactions --- src/libs/API/parameters/SetPolicyCodingRuleParams.ts | 2 +- src/libs/actions/Policy/Rules.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts index 7bccf7ac8274..d0f8f5ac9c1a 100644 --- a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts +++ b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts @@ -9,7 +9,7 @@ type SetPolicyCodingRuleParams = { value: string; /** Whether to update the transactions that match the rule */ - updateMatchingTransactions: boolean; + shouldUpdateMatchingTransactions: boolean; }; export default SetPolicyCodingRuleParams; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index a2c0e2d64df2..62d4733a354e 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -80,9 +80,9 @@ function openPolicyRulesPage(policyID: string | undefined) { * @param form - The form data for the merchant rule * @param policy - The policy object (needed to build tax data) * @param ruleID - Optional existing rule ID for updates - * @param updateMatchingTransactions - Whether to update transactions that match the rule + * @param shouldUpdateMatchingTransactions - Whether to update transactions that match the rule */ -function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined, ruleID?: string, updateMatchingTransactions = false) { +function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined, ruleID?: string, shouldUpdateMatchingTransactions = false) { if (!policyID || !form.merchantToMatch) { Log.warn('Invalid params for setPolicyCodingRule', {policyID, merchantToMatch: form.merchantToMatch}); return; @@ -160,7 +160,7 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P policyID, ruleID: optimisticRuleID, value: JSON.stringify(optimisticRule), - updateMatchingTransactions, + shouldUpdateMatchingTransactions, }; API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); From db5111c46c0380f980a03eefbb0eb062294d9795 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 13:38:15 -0800 Subject: [PATCH 45/49] Use booleans, remove parseBooleanString, update form types --- src/components/Rule/RuleBooleanBase.tsx | 16 +++++++-------- src/components/Rule/TextBase.tsx | 2 +- src/libs/actions/Policy/Rules.ts | 19 +++--------------- .../settings/Rules/Fields/AddBillablePage.tsx | 2 +- .../Rules/Fields/AddReimbursablePage.tsx | 2 +- .../rules/MerchantRules/AddBillablePage.tsx | 2 +- .../MerchantRules/AddReimbursablePage.tsx | 2 +- .../rules/MerchantRules/AddTaxPage.tsx | 20 +++++++++---------- src/types/form/ExpenseRuleForm.ts | 4 ++-- src/types/form/MerchantRuleForm.ts | 4 ++-- 10 files changed, 29 insertions(+), 44 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index c5fd17062ed3..e735c6a0b9d2 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -29,7 +29,7 @@ type RuleBooleanBaseProps = { formID: OnyxFormKey; /** Callback when a value is selected */ - onSelect: (fieldID: string, value: string) => void; + onSelect: (fieldID: string, value: boolean | undefined) => void; /** Callback to go back */ onBack: () => void; @@ -45,11 +45,11 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash}: Ru const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const formValue = (form as Record)?.[fieldID]; + const formValue = (form as Record)?.[fieldID]; let selectedItem = null; - if (formValue) { - const booleanValue = formValue === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + if (formValue !== undefined) { + const booleanValue = formValue ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; selectedItem = booleanValues.find((value) => booleanValue === value) ?? null; } @@ -61,12 +61,12 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash}: Ru })); const onSelectItem = (selectedValue: BooleanFilterItem) => { - const newValue = selectedValue.isSelected ? null : selectedValue.value; - let value = ''; + const newValue = selectedValue.isSelected ? undefined : selectedValue.value; + let value: boolean | undefined; if (newValue === CONST.SEARCH.BOOLEAN.YES) { - value = 'true'; + value = true; } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { - value = 'false'; + value = false; } onSelect(fieldID, value); }; diff --git a/src/components/Rule/TextBase.tsx b/src/components/Rule/TextBase.tsx index 848416ebeafe..78690011e3b8 100644 --- a/src/components/Rule/TextBase.tsx +++ b/src/components/Rule/TextBase.tsx @@ -33,7 +33,7 @@ function TextBase({fieldID, hint, isRequired, title const validate = (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - const fieldValue = (values as Record)[fieldID] ?? ''; + const fieldValue = values[fieldID as keyof FormOnyxValues] ?? ''; if (typeof fieldValue !== 'string') { return errors; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 62d4733a354e..b9697a2358c6 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -12,19 +12,6 @@ import type {MerchantRuleForm} from '@src/types/form'; import type Policy from '@src/types/onyx/Policy'; import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; -/** - * Converts a string boolean value ('true'/'false') to a boolean or undefined - */ -function parseStringBoolean(value: string | undefined): boolean | undefined { - if (value === 'true') { - return true; - } - if (value === 'false') { - return false; - } - return undefined; -} - /** * Builds the tax object from a tax key and policy */ @@ -55,8 +42,8 @@ function mapFormFieldsToRule(form: MerchantRuleForm, policy: Policy | undefined) tag: form.tag || undefined, tax: buildTaxObject(form.tax, policy), comment: form.comment || undefined, - reimbursable: parseStringBoolean(form.reimbursable), - billable: parseStringBoolean(form.billable), + reimbursable: form.reimbursable, + billable: form.billable, }; } @@ -95,7 +82,7 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P ruleID: optimisticRuleID, filters: { left: 'merchant', - operator: 'contains', + operator: 'eq', right: form.merchantToMatch, }, ...ruleFields, diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index 79ea82558d4c..ea46f6a97de4 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -18,7 +18,7 @@ function AddBillablePage({route}: AddBillablePageProps) { Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); }; - const onSelect = (fieldID: string, value: string) => { + const onSelect = (fieldID: string, value: boolean | undefined) => { updateDraftRule({[fieldID]: value}); goBack(); }; diff --git a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx index f36e984ad894..80bcf29358c3 100644 --- a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx @@ -18,7 +18,7 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); }; - const onSelect = (fieldID: string, value: string) => { + const onSelect = (fieldID: string, value: boolean | undefined) => { updateDraftRule({[fieldID]: value}); goBack(); }; diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx index 94b3c135e9b2..e74a2013ddf6 100644 --- a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -18,7 +18,7 @@ function AddBillablePage({route}: AddBillablePageProps) { Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; - const onSelect = (fieldID: string, value: string) => { + const onSelect = (fieldID: string, value: boolean | undefined) => { updateDraftMerchantRule({[fieldID]: value}); goBack(); }; diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx index 92f0c76458c0..398c178ce3a6 100644 --- a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -18,7 +18,7 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; - const onSelect = (fieldID: string, value: string) => { + const onSelect = (fieldID: string, value: boolean | undefined) => { updateDraftMerchantRule({[fieldID]: value}); goBack(); }; diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx index 98b266513296..4d936107069f 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -17,17 +17,15 @@ function AddTaxPage({route}: AddTaxPageProps) { const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); - const taxItems = () => { - const taxes = policy?.taxRates?.taxes ?? {}; - return Object.entries(taxes) - .filter(([, tax]) => !tax.isDisabled) - .map(([taxKey, tax]) => ({ - name: `${tax.name} (${tax.value})`, - value: taxKey, - })); - }; + const taxes = policy?.taxRates?.taxes ?? {}; + const taxItems = Object.entries(taxes) + .filter(([, tax]) => !tax.isDisabled) + .map(([taxKey, tax]) => ({ + name: `${tax.name} (${tax.value})`, + value: taxKey, + })); - const selectedTaxItem = form?.tax ? taxItems().find(({value}) => value === form.tax) : undefined; + const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); @@ -40,7 +38,7 @@ function AddTaxPage({route}: AddTaxPageProps) { titleKey="common.tax" testID="AddTaxPage" selectedItem={selectedTaxItem} - items={taxItems()} + items={taxItems} onSave={onSave} onBack={() => Navigation.goBack(backToRoute)} backToRoute={backToRoute} diff --git a/src/types/form/ExpenseRuleForm.ts b/src/types/form/ExpenseRuleForm.ts index 597d6d832dbf..b5671c043505 100644 --- a/src/types/form/ExpenseRuleForm.ts +++ b/src/types/form/ExpenseRuleForm.ts @@ -9,13 +9,13 @@ type InputID = ValueOf; type ExpenseRuleForm = Form< InputID, { - [INPUT_IDS.BILLABLE]: 'true' | 'false'; + [INPUT_IDS.BILLABLE]: boolean; [INPUT_IDS.CATEGORY]: string; [INPUT_IDS.DESCRIPTION]: string; [INPUT_IDS.CREATE_REPORT]: boolean; [INPUT_IDS.RENAME_MERCHANT]: string; [INPUT_IDS.MERCHANT]: string; - [INPUT_IDS.REIMBURSABLE]: 'true' | 'false'; + [INPUT_IDS.REIMBURSABLE]: boolean; [INPUT_IDS.REPORT]: string; [INPUT_IDS.TAG]: string; [INPUT_IDS.TAX]: string; diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index 60361f9f09df..43e3e87c1135 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -9,12 +9,12 @@ type InputID = ValueOf; type MerchantRuleForm = Form< InputID, { - [INPUT_IDS.BILLABLE]: 'true' | 'false'; + [INPUT_IDS.BILLABLE]: boolean; [INPUT_IDS.CATEGORY]: string; [INPUT_IDS.DESCRIPTION]: string; [INPUT_IDS.MERCHANT]: string; [INPUT_IDS.MERCHANT_TO_MATCH]: string; - [INPUT_IDS.REIMBURSABLE]: 'true' | 'false'; + [INPUT_IDS.REIMBURSABLE]: boolean; [INPUT_IDS.TAG]: string; [INPUT_IDS.TAX]: string; } From f64e88df1e222f16812285b8e8cd9ca98305f195 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 13:41:59 -0800 Subject: [PATCH 46/49] Fix prettier --- .../workspace/rules/MerchantRulesSection.tsx | 2 +- src/types/onyx/Policy.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index a70f50990f1c..a2baa04ed2fd 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -47,7 +47,7 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType Date: Tue, 27 Jan 2026 13:47:24 -0800 Subject: [PATCH 47/49] Fix TS --- src/libs/actions/Policy/Rules.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index b9697a2358c6..8a368d346bbf 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -26,9 +26,12 @@ function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): } return { - externalID: taxKey, - value: tax.value, - name: tax.name, + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxKey, + value: tax.value, + name: tax.name, + }, }; } From c347e9da9aee4392560aaf6090d443bfd51bfc4a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 13:57:23 -0800 Subject: [PATCH 48/49] Fix TS --- src/libs/ExpenseRuleUtils.ts | 2 +- .../workspace/rules/MerchantRules/AddMerchantRulePage.tsx | 6 +++--- src/pages/workspace/rules/MerchantRulesSection.tsx | 2 +- src/types/form/ExpenseRuleForm.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/ExpenseRuleUtils.ts b/src/libs/ExpenseRuleUtils.ts index 918d649c4c9d..ef106bb07dd9 100644 --- a/src/libs/ExpenseRuleUtils.ts +++ b/src/libs/ExpenseRuleUtils.ts @@ -54,7 +54,7 @@ function formatExpenseRuleChanges(rule: ExpenseRule, translate: LocaleContextPro if (rule.tag) { addChange('tag', getCleanedTagName(rule.tag)); } - if (rule.tax?.field_id_TAX) { + if (rule.tax?.field_id_TAX?.value) { addChange('tax', rule.tax.field_id_TAX.value); } if (rule.report) { diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index e9fe4d4d363a..1f0da99badaa 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -39,11 +39,11 @@ type SectionType = { items: Array; }; -const getBooleanTitle = (value: string | undefined, translate: LocalizedTranslate): string => { - if (!value) { +const getBooleanTitle = (value: boolean | undefined, translate: LocalizedTranslate): string => { + if (value === undefined) { return ''; } - return translate(value === 'true' ? 'common.yes' : 'common.no'); + return translate(value ? 'common.yes' : 'common.no'); }; const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index a2baa04ed2fd..af0f84c4506b 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -48,7 +48,7 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType; type ExpenseRuleForm = Form< InputID, { - [INPUT_IDS.BILLABLE]: boolean; + [INPUT_IDS.BILLABLE]: 'true' | 'false'; [INPUT_IDS.CATEGORY]: string; [INPUT_IDS.DESCRIPTION]: string; [INPUT_IDS.CREATE_REPORT]: boolean; [INPUT_IDS.RENAME_MERCHANT]: string; [INPUT_IDS.MERCHANT]: string; - [INPUT_IDS.REIMBURSABLE]: boolean; + [INPUT_IDS.REIMBURSABLE]: 'true' | 'false'; [INPUT_IDS.REPORT]: string; [INPUT_IDS.TAG]: string; [INPUT_IDS.TAX]: string; From fb2d8d12a84011b6a3eca799a429372ab8220c3e Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 27 Jan 2026 14:26:06 -0800 Subject: [PATCH 49/49] Fix bool select --- src/components/Rule/RuleBooleanBase.tsx | 32 ++++++++++++------- .../settings/Rules/Fields/AddBillablePage.tsx | 3 +- .../Rules/Fields/AddReimbursablePage.tsx | 3 +- .../rules/MerchantRules/AddBillablePage.tsx | 2 +- .../MerchantRules/AddReimbursablePage.tsx | 2 +- 5 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index e735c6a0b9d2..7800c80e486d 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -28,28 +28,33 @@ type RuleBooleanBaseProps = { /** The form ID to read from Onyx */ formID: OnyxFormKey; - /** Callback when a value is selected */ - onSelect: (fieldID: string, value: boolean | undefined) => void; + /** Callback when a value is selected - receives boolean for merchant rules, string for personal rules */ + onSelect: (fieldID: string, value: boolean | 'true' | 'false' | null) => void; /** Callback to go back */ onBack: () => void; /** Optional hash for rule not found validation */ hash?: string; + + /** Whether to use string values ('true'/'false') instead of boolean values (for ExpenseRuleForm compatibility) */ + useStringValues?: boolean; }; const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); -function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash}: RuleBooleanBaseProps) { +function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash, useStringValues = false}: RuleBooleanBaseProps) { const {translate} = useLocalize(); const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const formValue = (form as Record)?.[fieldID]; + const formValue = (form as Record)?.[fieldID]; let selectedItem = null; if (formValue !== undefined) { - const booleanValue = formValue ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + // Handle both string ('true'/'false') and boolean (true/false) values + const isTruthy = useStringValues ? formValue === 'true' : formValue === true; + const booleanValue = isTruthy ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; selectedItem = booleanValues.find((value) => booleanValue === value) ?? null; } @@ -61,12 +66,17 @@ function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash}: Ru })); const onSelectItem = (selectedValue: BooleanFilterItem) => { - const newValue = selectedValue.isSelected ? undefined : selectedValue.value; - let value: boolean | undefined; - if (newValue === CONST.SEARCH.BOOLEAN.YES) { - value = true; - } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { - value = false; + // If clicking on already-selected item, unselect it (set to undefined) + if (selectedValue.isSelected) { + onSelect(fieldID, null); + return; + } + const isYes = selectedValue.value === CONST.SEARCH.BOOLEAN.YES; + let value: boolean | 'true' | 'false'; + if (useStringValues) { + value = isYes ? 'true' : 'false'; + } else { + value = isYes; } onSelect(fieldID, value); }; diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index ea46f6a97de4..42a64acc9ac1 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -18,7 +18,7 @@ function AddBillablePage({route}: AddBillablePageProps) { Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); }; - const onSelect = (fieldID: string, value: boolean | undefined) => { + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { updateDraftRule({[fieldID]: value}); goBack(); }; @@ -31,6 +31,7 @@ function AddBillablePage({route}: AddBillablePageProps) { onSelect={onSelect} onBack={goBack} hash={hash} + useStringValues /> ); } diff --git a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx index 80bcf29358c3..bef1e0a8358f 100644 --- a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx @@ -18,7 +18,7 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); }; - const onSelect = (fieldID: string, value: boolean | undefined) => { + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { updateDraftRule({[fieldID]: value}); goBack(); }; @@ -31,6 +31,7 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { onSelect={onSelect} onBack={goBack} hash={hash} + useStringValues /> ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx index e74a2013ddf6..8284708709f5 100644 --- a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -18,7 +18,7 @@ function AddBillablePage({route}: AddBillablePageProps) { Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; - const onSelect = (fieldID: string, value: boolean | undefined) => { + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { updateDraftMerchantRule({[fieldID]: value}); goBack(); }; diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx index 398c178ce3a6..843bcd7a4fd6 100644 --- a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -18,7 +18,7 @@ function AddReimbursablePage({route}: AddReimbursablePageProps) { Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; - const onSelect = (fieldID: string, value: boolean | undefined) => { + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { updateDraftMerchantRule({[fieldID]: value}); goBack(); };