diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7aa056864273..2b6e2f223bae 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3705,6 +3705,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 { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 37cf5fb7cac2..97d06c697306 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -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', @@ -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 = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 259c0fc3c786..50d6749e940f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -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'; @@ -391,6 +392,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) => { + return `settings/rules/new/${field ? StringUtils.camelToHyphenCase(field) : ''}` as const; + }, + }, + SETTINGS_RULES_EDIT: { + route: 'settings/rules/edit/:hash/:field?', + getRoute: (hash?: string, field?: ValueOf) => { + 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', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9744c56eac72..739c5ac47279 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -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: { diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx new file mode 100644 index 000000000000..1e06c4fa3137 --- /dev/null +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -0,0 +1,101 @@ +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 {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'; + +type BooleanFilterItem = ListItem & { + value: ValueOf; +}; + +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) { + const {translate} = useLocalize(); + const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_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(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'; + } + updateDraftRule({[fieldID]: value}); + goBack(); + }; + + return ( + + + + + + + + + ); +} + +export default RuleBooleanBasePage; diff --git a/src/components/Rule/RuleNotFoundPageWrapper.tsx b/src/components/Rule/RuleNotFoundPageWrapper.tsx new file mode 100644 index 000000000000..567100a7bd12 --- /dev/null +++ b/src/components/Rule/RuleNotFoundPageWrapper.tsx @@ -0,0 +1,46 @@ +/* eslint-disable rulesdir/no-negated-variables */ +import React from 'react'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useOnyx from '@hooks/useOnyx'; +import {getKeyForRule} from '@libs/ExpenseRuleUtils'; +import Navigation from '@navigation/Navigation'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {ExpenseRule} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type RuleNotFoundPageWrapperProps = { + children: React.ReactNode; + hash?: string; + shouldPreventShow?: boolean; +}; + +function RuleNotFoundPageWrapper({children, hash, shouldPreventShow}: RuleNotFoundPageWrapperProps) { + const [expenseRules = getEmptyArray(), rulesMetadata] = useOnyx(ONYXKEYS.NVP_EXPENSE_RULES, {canBeMissing: true}); + const doesRuleExist = !!hash && expenseRules.some((rule) => getKeyForRule(rule) === hash); + + const shouldShowFullScreenLoadingIndicator = isLoadingOnyxValue(rulesMetadata); + const shouldShowNotFoundPage = !!hash && !doesRuleExist; + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (!shouldPreventShow && shouldShowNotFoundPage) { + return ( + { + Navigation.goBack(ROUTES.SETTINGS_RULES); + }} + shouldShowBackButton + shouldShowOfflineIndicator={false} + /> + ); + } + + return children; +} + +export default RuleNotFoundPageWrapper; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx new file mode 100644 index 000000000000..9775aa2027c2 --- /dev/null +++ b/src/components/Rule/RuleTextBase.tsx @@ -0,0 +1,84 @@ +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 RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; +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) { + 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 ( + + + + + + + ); +} + +export default RuleTextBase; diff --git a/src/components/Rule/TextBase.tsx b/src/components/Rule/TextBase.tsx new file mode 100644 index 000000000000..eb05f4760f08 --- /dev/null +++ b/src/components/Rule/TextBase.tsx @@ -0,0 +1,83 @@ +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'; + +type TextBaseProps = { + fieldID: InputID; + hint?: string; + isRequired?: boolean; + title: string; + label: string; + characterLimit?: number; + onSubmit: (values: FormOnyxValues) => void; +}; + +function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { + const {translate} = useLocalize(); + const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const styles = useThemeStyles(); + + const currentValue = form?.[fieldID] ?? ''; + const {inputCallbackRef} = useAutoFocusInput(); + + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + 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; + }; + + return ( + + + + + + ); +} + +export default TextBase; diff --git a/src/components/Search/SearchFilterPageFooterButtons.tsx b/src/components/Search/SearchFilterPageFooterButtons.tsx index dc6b23a0709a..ef93781a8d1f 100644 --- a/src/components/Search/SearchFilterPageFooterButtons.tsx +++ b/src/components/Search/SearchFilterPageFooterButtons.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; type SearchFilterPageFooterButtonsProps = { /** Function to reset changes made in the filter */ - resetChanges: () => void; + resetChanges?: () => void; /** Function to apply changes made in the filter */ applyChanges: () => void; @@ -18,12 +18,14 @@ function SearchFilterPageFooterButtons({resetChanges, applyChanges}: SearchFilte return ( -