Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1c6f6fd
Expense rules phase 2
situchan Jan 15, 2026
11c3849
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 15, 2026
913bcbe
tax support, hash key for duplicated merchantToMatch
situchan Jan 15, 2026
defe53a
edit rule suport
situchan Jan 15, 2026
fe1a35c
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 15, 2026
d44315b
edit support fot category, tag, tax rate
situchan Jan 15, 2026
e93c87c
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 16, 2026
4c49b61
minor fix
situchan Jan 16, 2026
c0fc0f0
add translations
situchan Jan 16, 2026
26166d9
address feedback
situchan Jan 16, 2026
fc816ae
address feedback
situchan Jan 16, 2026
56d2c6e
simplify form error logic
situchan Jan 16, 2026
a6388a0
fix exhaustive-deps lint
situchan Jan 16, 2026
3fa0316
hide Expense Rules section on Account settings
situchan Jan 16, 2026
b30e6e4
auto save when select option
situchan Jan 16, 2026
693d23b
fix typo
situchan Jan 16, 2026
30b2b7f
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 16, 2026
6beea65
address feedback
situchan Jan 16, 2026
677eb6d
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 20, 2026
2a47ac9
show not found page when rule changed
situchan Jan 20, 2026
a4b1f89
handle duplicated rules
situchan Jan 21, 2026
041375c
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 21, 2026
235b28e
add unit tests
situchan Jan 21, 2026
eb7f1d6
update section title style
situchan Jan 21, 2026
9f35ddd
fix test
situchan Jan 21, 2026
b4ed717
remove "Then apply these updates:"
situchan Jan 21, 2026
3c79aaa
fix not found page briefly shows
situchan Jan 21, 2026
a0eb317
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 21, 2026
7215c1a
Revert "remove "Then apply these updates:""
situchan Jan 21, 2026
521d175
update copies
situchan Jan 21, 2026
7e4efdb
update translations
situchan Jan 21, 2026
f3b0dda
minor update
situchan Jan 21, 2026
5a905f4
Merge branch 'main' of https://github.com/situchan/Expensify into fea…
situchan Jan 22, 2026
4decaf6
remove isLoading
situchan Jan 22, 2026
cf826a9
handle encoding of special characters
situchan Jan 22, 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
18 changes: 18 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3702,6 +3702,24 @@ const CONST = {
INVOICING: 'invoicing2018',
},
},
EXPENSE_RULES: {
FIELDS: {
BILLABLE: 'billable',
CATEGORY: 'category',
DESCRIPTION: 'comment',
CREATE_REPORT: 'createReport',
MERCHANT: 'merchantToMatch',
RENAME_MERCHANT: 'merchant',
REIMBURSABLE: 'reimbursable',
REPORT: 'report',
TAG: 'tag',
TAX: 'tax',
},
BULK_ACTION_TYPES: {
EDIT: 'edit',
DELETE: 'delete',
},
},

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 @@ -979,6 +979,8 @@ const ONYXKEYS = {
CREATE_DOMAIN_FORM_DRAFT: 'createDomainFormDraft',
SPLIT_EXPENSE_EDIT_DATES: 'splitExpenseEditDates',
SPLIT_EXPENSE_EDIT_DATES_DRAFT: 'splitExpenseEditDatesDraft',
EXPENSE_RULE_FORM: 'expenseRuleForm',
EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft',
},
DERIVED: {
REPORT_ATTRIBUTES: 'reportAttributes',
Expand Down Expand Up @@ -1096,6 +1098,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS]: FormTypes.EnableGlobalReimbursementsForm;
[ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm;
[ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm;
[ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm;
};

type OnyxFormDraftValuesMapping = {
Expand Down
13 changes: 13 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {IOURequestType} from './libs/actions/IOU';
import Log from './libs/Log';
import type {RootNavigatorParamList} from './libs/Navigation/types';
import type {ReimbursementAccountStepToOpen} from './libs/ReimbursementAccountUtils';
import StringUtils from './libs/StringUtils';
import {getUrlWithParams} from './libs/Url';
import SCREENS from './SCREENS';
import type {Screen} from './SCREENS';
Expand Down Expand Up @@ -387,6 +388,18 @@ const ROUTES = {
getRoute: (cardID: string) => `settings/wallet/card/${cardID}/activate` as const,
},
SETTINGS_RULES: 'settings/rules',
SETTINGS_RULES_ADD: {
route: 'settings/rules/new/:field?',
getRoute: (field?: ValueOf<typeof CONST.EXPENSE_RULES.FIELDS>) => {
return `settings/rules/new/${field ? StringUtils.camelToHyphenCase(field) : ''}` as const;
},
},
SETTINGS_RULES_EDIT: {
route: 'settings/rules/edit/:hash/:field?',
getRoute: (hash?: string, field?: ValueOf<typeof CONST.EXPENSE_RULES.FIELDS>) => {
return `settings/rules/edit/${hash ?? ':hash'}/${field ? StringUtils.camelToHyphenCase(field) : ''}` as const;
},
},
SETTINGS_LEGAL_NAME: 'settings/profile/legal-name',
SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth',
SETTINGS_PHONE_NUMBER: 'settings/profile/phone',
Expand Down
20 changes: 20 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ const SCREENS = {

RULES: {
ROOT: 'Settings_Rules',
ADD: 'Settings_Rules_Add',
ADD_MERCHANT: 'Settings_Rules_Add_Merchant',
ADD_RENAME_MERCHANT: 'Settings_Rules_Add_Rename_Merchant',
ADD_CATEGORY: 'Settings_Rules_Add_Category',
ADD_TAG: 'Settings_Rules_Add_Tag',
ADD_TAX: 'Settings_Rules_Add_Tax',
ADD_DESCRIPTION: 'Settings_Rules_Add_Description',
ADD_REIMBURSABLE: 'Settings_Rules_Add_Reimbursable',
ADD_BILLABLE: 'Settings_Rules_Add_Billable',
ADD_REPORT: 'Settings_Rules_Add_Report',
EDIT: 'Settings_Rules_Edit',
EDIT_MERCHANT: 'Settings_Rules_Edit_Merchant',
EDIT_RENAME_MERCHANT: 'Settings_Rules_Edit_Rename_Merchant',
EDIT_CATEGORY: 'Settings_Rules_Edit_Category',
EDIT_TAG: 'Settings_Rules_Edit_Tag',
EDIT_TAX: 'Settings_Rules_Edit_Tax',
EDIT_DESCRIPTION: 'Settings_Rules_Edit_Description',
EDIT_REIMBURSABLE: 'Settings_Rules_Edit_Reimbursable',
EDIT_BILLABLE: 'Settings_Rules_Edit_Billable',
EDIT_REPORT: 'Settings_Rules_Edit_Report',
},

WALLET: {
Expand Down
120 changes: 120 additions & 0 deletions src/components/Rule/RuleBooleanBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SearchFilterPageFooterButtons from '@components/Search/SearchFilterPageFooterButtons';
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 usePrevious from '@hooks/usePrevious';
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 isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

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

type RuleBooleanBasePageProps = {
/** The key from boolean-based InputID */
fieldID: InputID;

/** The translation key for the page title */
titleKey: TranslationPaths;

/** The rule identifier */
hash?: string;
};

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

function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps) {
Comment thread
situchan marked this conversation as resolved.
const {translate} = useLocalize();
const [form, formMetadata] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true});
const styles = useThemeStyles();

const isLoading = isLoadingOnyxValue(formMetadata);
const prevIsLoading = usePrevious(isLoading);

// Called only first time when Onyx loading finished
// eslint-disable-next-line react-hooks/exhaustive-deps
Comment thread
situchan marked this conversation as resolved.
Outdated
const getInitialValue = () =>
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 [selectedItem, setSelectedItem] = useState<string | null>(getInitialValue);

Comment thread
situchan marked this conversation as resolved.
Outdated
useEffect(() => {
if (isLoading || !prevIsLoading) {
return;
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedItem(getInitialValue());
Comment thread
JS00001 marked this conversation as resolved.
Outdated
}, [isLoading, prevIsLoading, getInitialValue]);

const items = useMemo(() => {
Comment thread
JS00001 marked this conversation as resolved.
Outdated
return booleanValues.map((value) => ({
value,
keyForList: value,
text: translate(`common.${value}`),
isSelected: selectedItem === value,
}));
}, [selectedItem, translate]);

const onSelectItem = useCallback((selectedValue: BooleanFilterItem) => {
const newValue = selectedValue.isSelected ? null : selectedValue.value;
setSelectedItem(newValue);
}, []);

const goBack = () => {
Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute());
};

const applyChanges = () => {
updateDraftRule({[fieldID]: selectedItem === CONST.SEARCH.BOOLEAN.YES ? 'true' : 'false'});
goBack();
Comment thread
situchan marked this conversation as resolved.
Outdated
};

return (
<ScreenWrapper
testID="RuleBooleanBasePage"
shouldShowOfflineIndicatorInWideScreen
offlineIndicatorStyle={styles.mtAuto}
includeSafeAreaPaddingBottom
shouldEnableMaxHeight
>
<HeaderWithBackButton
title={translate(titleKey)}
onBackButtonPress={goBack}
/>
<View style={[styles.flex1]}>
<SelectionList
shouldSingleExecuteRowSelect
data={items}
ListItem={SingleSelectListItem}
onSelectRow={onSelectItem}
/>
</View>
<FixedFooter style={styles.mtAuto}>
<SearchFilterPageFooterButtons applyChanges={applyChanges} />
</FixedFooter>
</ScreenWrapper>
);
}

export default RuleBooleanBasePage;
81 changes: 81 additions & 0 deletions src/components/Rule/RuleTextBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import type {FormOnyxValues} from '@components/Form/types';
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 TextBase from './TextBase';

// Text-based field IDs that accept string input
type RuleTextBaseProps = {
/** The key from text-based 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 rule identifier */
hash?: string;
};

function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, hash, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: RuleTextBaseProps) {
Comment thread
situchan marked this conversation as resolved.
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 (
<ScreenWrapper
testID={testID}
shouldShowOfflineIndicatorInWideScreen
offlineIndicatorStyle={styles.mtAuto}
includeSafeAreaPaddingBottom
shouldEnableMaxHeight
>
<HeaderWithBackButton
title={translate(titleKey)}
onBackButtonPress={goBack}
/>
<TextBase
fieldID={fieldID}
hint={hintKey ? translate(hintKey) : undefined}
isRequired={isRequired}
label={translate(labelKey ?? titleKey)}
onSubmit={onSave}
title={translate(titleKey)}
characterLimit={characterLimit}
/>
</ScreenWrapper>
);
}

export default RuleTextBase;
90 changes: 90 additions & 0 deletions src/components/Rule/TextBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
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 TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
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';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

type TextBaseProps = {
fieldID: InputID;
hint?: string;
isRequired?: boolean;
title: string;
label: string;
characterLimit?: number;
onSubmit: (values: FormOnyxValues<typeof ONYXKEYS.FORMS.EXPENSE_RULE_FORM>) => void;
};

function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) {
const {translate} = useLocalize();
const [form, formMetadata] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true});
const styles = useThemeStyles();

const isLoading = isLoadingOnyxValue(formMetadata);

const currentValue = form?.[fieldID] ?? '';
const {inputCallbackRef} = useAutoFocusInput();

const validate = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.EXPENSE_RULE_FORM>) => {
const errors: FormInputErrors<typeof ONYXKEYS.FORMS.EXPENSE_RULE_FORM> = {};
const fieldValue = values[fieldID] ?? '';

if (typeof fieldValue !== 'string') {
return errors;
}

const trimmedValue = fieldValue.trim();

if (isRequired && !isRequiredFulfilled(fieldValue)) {
errors[fieldID] = translate('common.error.fieldRequired');
} else {
const {isValid, byteLength} = isValidInputLength(trimmedValue, characterLimit);

if (!isValid) {
errors[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit);
}
}

return errors;
};

if (isLoading) {
return null;
Comment thread
situchan marked this conversation as resolved.
Outdated
}

return (
<FormProvider
style={[styles.flex1, styles.ph5]}
formID={ONYXKEYS.FORMS.EXPENSE_RULE_FORM}
validate={validate}
onSubmit={onSubmit}
submitButtonText={translate('common.save')}
enabledWhenOffline
>
<View style={styles.mb5}>
<InputWrapper
hint={hint}
InputComponent={TextInput}
inputID={fieldID}
name={fieldID}
defaultValue={typeof currentValue === 'string' ? currentValue : undefined}
label={label}
accessibilityLabel={title}
role={CONST.ROLE.PRESENTATION}
ref={inputCallbackRef}
/>
</View>
</FormProvider>
);
}

export default TextBase;
Loading
Loading