Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
3eb1a09
Add AddMerchantRulePage and field sub-pages for merchant rules
luacmartins Jan 26, 2026
c6599f3
Always show category, tag, and tax options in AddMerchantRulePage
luacmartins Jan 26, 2026
05b79ef
Conditionally show category, tag, tax, and billable based on policy s…
luacmartins Jan 26, 2026
f78eb95
add page to form types
luacmartins Jan 26, 2026
971c5a3
Use workspace-specific Onyx collections for categories, tags, and taxes
luacmartins Jan 26, 2026
60571ee
apply translations
luacmartins Jan 26, 2026
6d0d4ae
Add SetPolicyMerchantRule API call
luacmartins Jan 26, 2026
a82da08
Fix lint errors in MerchantRules pages
luacmartins Jan 27, 2026
18d62df
fix prettier
luacmartins Jan 27, 2026
c7674f7
Fix OnyxData import path in Rules.ts
luacmartins Jan 27, 2026
5adaf6d
Fix lint errors in Rules.ts
luacmartins Jan 27, 2026
f513c46
resolve conflicts
luacmartins Jan 27, 2026
be25915
Fix TypeScript errors in Rules.ts
luacmartins Jan 27, 2026
ccf233c
fix ts
luacmartins Jan 27, 2026
35cf978
Merge branch 'cmartins-merchantRules' into cmartins-createRule
luacmartins Jan 27, 2026
f6a68fa
Fix pendingFields/errorFields key in Rules.ts
luacmartins Jan 27, 2026
ffebc83
resolve conflicts
luacmartins Jan 27, 2026
b3a5b7b
Rename trackBillables to isBillableEnabled
luacmartins Jan 27, 2026
d85d81c
Use NumberUtils.rand64() for optimistic rule ID
luacmartins Jan 27, 2026
2b7c42c
generate proper optimistic id
luacmartins Jan 27, 2026
d8fffb4
Refactor Rule components to be reusable for merchant rules
luacmartins Jan 27, 2026
4c5ec75
Add RuleSelectionBase for reusable category/tag/tax selection
luacmartins Jan 27, 2026
1d41a3e
Fix tax, category, and tag display names in AddMerchantRulePage
luacmartins Jan 27, 2026
cb1f840
Update AddTaxRatePage to use RuleSelectionBase
luacmartins Jan 27, 2026
2b3a78b
Fix backToRoute type in RuleSelectionBase
luacmartins Jan 27, 2026
faa093b
DRY up field mapping in setPolicyMerchantRule
luacmartins Jan 27, 2026
379450f
Hoist constant outside iteration in getErrorMessage
luacmartins Jan 27, 2026
2a0e6a8
Merge branch 'main' into cmartins-createRule
luacmartins Jan 27, 2026
1cb2a25
Update font sizes in MerchantRulesSection
luacmartins Jan 27, 2026
f17d95c
Extract getBooleanTitle helper in AddMerchantRulePage
luacmartins Jan 27, 2026
31df6dd
Optimize RuleBooleanBase with useMemo and hoisted constants
luacmartins Jan 27, 2026
5836397
Memoize items array in RuleBooleanBase
luacmartins Jan 27, 2026
5827e99
Fix tax type in SetPolicyMerchantRuleParams
luacmartins Jan 27, 2026
8296d9a
Use correct tax format for API vs Onyx
luacmartins Jan 27, 2026
d4f8109
Use separate tax formats for API and Onyx
luacmartins Jan 27, 2026
562e1b5
fix prettier
luacmartins Jan 27, 2026
f10f523
Fix ESLint errors: remove unused import and use stable ContentWrapper
luacmartins Jan 27, 2026
ee66a66
Remove useMemo from RuleBooleanBase - React compiler handles memoization
luacmartins Jan 27, 2026
17d70d6
Unify CodingRuleTax type for both Onyx and API
luacmartins Jan 27, 2026
e5fa4f4
Fix ESLint: use arrow functions for ContentWrapper components
luacmartins Jan 27, 2026
a9cba1c
Refactor: move RuleNotFoundPageWrapper into base components
luacmartins Jan 27, 2026
d45262f
Rename API to SetPolicyCodingRule with new params
luacmartins Jan 27, 2026
174c531
Fix imports: use relative path for RuleNotFoundPageWrapper
luacmartins Jan 27, 2026
2609db3
Fix styles
luacmartins Jan 27, 2026
d269497
Remove useMemo and useCallback from React compiled component
luacmartins Jan 27, 2026
5ed5632
Display category,tag and taxes only if they are available
luacmartins Jan 27, 2026
6f34508
Fix ESLint
luacmartins Jan 27, 2026
6d7aa25
Merge branch 'main' into cmartins-createRule
luacmartins Jan 27, 2026
4509a80
Rename param to shouldUpdateMatchingTransactions
luacmartins Jan 27, 2026
db5111c
Use booleans, remove parseBooleanString, update form types
luacmartins Jan 27, 2026
f64e88d
Fix prettier
luacmartins Jan 27, 2026
e47e5aa
Fix TS
luacmartins Jan 27, 2026
c347e9d
Fix TS
luacmartins Jan 27, 2026
fb2d8d1
Fix bool select
luacmartins Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3758,6 +3758,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 {
Expand Down
3 changes: 3 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,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',
ADD_DOMAIN_MEMBER_FORM: 'addDomainMemberForm',
ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft',
},
Expand Down Expand Up @@ -1103,6 +1105,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;
[ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm;
};

Expand Down
36 changes: 36 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2670,6 +2670,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',
Expand Down
9 changes: 9 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,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',
Expand Down
77 changes: 43 additions & 34 deletions src/components/Rule/RuleBooleanBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,54 @@ 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 type {OnyxFormKey} from '@src/ONYXKEYS';
import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper';

type BooleanFilterItem = ListItem & {
value: ValueOf<typeof CONST.SEARCH.BOOLEAN>;
};

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 */
/** The form ID to read from Onyx */
formID: OnyxFormKey;

/** 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;
Comment on lines +37 to 38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: This is actually rule ID so original jsdoc The rule identifier is also correct.
Personal rule doesn't have its own id.


/** Whether to use string values ('true'/'false') instead of boolean values (for ExpenseRuleForm compatibility) */
useStringValues?: boolean;
Comment on lines +40 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any plan to use real boolean for personal rule? Or we always use "true", "false" string?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not right now because that'd involve migrating data we have stored, which is not a priority

};

const booleanValues = Object.values(CONST.SEARCH.BOOLEAN);

function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps) {
function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash, useStringValues = false}: RuleBooleanBaseProps) {
const {translate} = useLocalize();
const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true});
const [form] = useOnyx(formID, {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 formValue = (form as Record<string, boolean | string | undefined>)?.[fieldID];

let selectedItem = null;
if (formValue !== undefined) {
// 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;
}

const items = booleanValues.map((value) => ({
value,
Expand All @@ -56,34 +65,34 @@ 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 = '';
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;
}
updateDraftRule({[fieldID]: value});
goBack();
onSelect(fieldID, value);
};

return (
<RuleNotFoundPageWrapper hash={hash}>
<ScreenWrapper
testID="RuleBooleanBasePage"
testID="RuleBooleanBase"
shouldShowOfflineIndicatorInWideScreen
offlineIndicatorStyle={styles.mtAuto}
includeSafeAreaPaddingBottom
shouldEnableMaxHeight
>
<HeaderWithBackButton
title={translate(titleKey)}
onBackButtonPress={goBack}
onBackButtonPress={onBack}
/>
<View style={[styles.flex1]}>
<SelectionList
Expand All @@ -98,4 +107,4 @@ function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps
);
}

export default RuleBooleanBasePage;
export default RuleBooleanBase;
73 changes: 73 additions & 0 deletions src/components/Rule/RuleSelectionBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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';
import type {Route} from '@src/ROUTES';
import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper';

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: Route;

/** Optional hash for rule not found validation */
hash?: string;
};

function RuleSelectionBase({titleKey, testID, selectedItem, items, onSave, onBack, backToRoute, hash}: RuleSelectionBaseProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();

return (
<RuleNotFoundPageWrapper hash={hash}>
<ScreenWrapper
testID={testID}
shouldShowOfflineIndicatorInWideScreen
offlineIndicatorStyle={styles.mtAuto}
shouldEnableMaxHeight
>
<HeaderWithBackButton
title={translate(titleKey)}
onBackButtonPress={onBack}
/>
<View style={[styles.flex1]}>
<SearchSingleSelectionPicker
backToRoute={backToRoute}
initiallySelectedItem={selectedItem}
items={items}
onSaveSelection={onSave}
shouldAutoSave
/>
</View>
</ScreenWrapper>
</RuleNotFoundPageWrapper>
);
}

export default RuleSelectionBase;
37 changes: 16 additions & 21 deletions src/components/Rule/RuleTextBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 type {OnyxFormKey} from '@src/ONYXKEYS';
import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper';
import TextBase from './TextBase';

// Text-based field IDs that accept string input
type RuleTextBaseProps = {
type RuleTextBaseProps<TFormID extends OnyxFormKey> = {
/** 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;
Expand All @@ -37,23 +31,23 @@ type RuleTextBaseProps = {
/** The character limit for the input */
characterLimit?: number;

/** The rule identifier */
/** The form ID to read from Onyx */
formID: TFormID;

/** Callback when the form is saved */
onSave: (values: FormOnyxValues<TFormID>) => void;

/** Callback to go back */
onBack: () => void;

/** Optional hash for rule not found validation */
hash?: string;
};

function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, hash, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: RuleTextBaseProps) {
function RuleTextBase<TFormID extends OnyxFormKey>({fieldID, hintKey, isRequired, titleKey, labelKey, testID, characterLimit, formID, onSave, onBack, hash}: RuleTextBaseProps<TFormID>) {
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<typeof ONYXKEYS.FORMS.EXPENSE_RULE_FORM>) => {
updateDraftRule(values);
goBack();
};

return (
<RuleNotFoundPageWrapper hash={hash}>
<ScreenWrapper
Expand All @@ -65,10 +59,11 @@ function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID,
>
<HeaderWithBackButton
title={translate(titleKey)}
onBackButtonPress={goBack}
onBackButtonPress={onBack}
/>
<TextBase
fieldID={fieldID}
formID={formID}
hint={hintKey ? translate(hintKey) : undefined}
isRequired={isRequired}
label={translate(labelKey ?? titleKey)}
Expand Down
Loading
Loading