Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
03bc82f
add the new routes
JS00001 Jun 2, 2026
6267c32
add page content
JS00001 Jun 2, 2026
04ae927
create the page
JS00001 Jun 2, 2026
e72ba4c
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 8, 2026
a473ea5
cleanup types
JS00001 Jun 8, 2026
1f8f6fb
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 9, 2026
3847f71
add null to spend rule toggle
JS00001 Jun 9, 2026
87519dc
update to hide/shjow based ona ction
JS00001 Jun 9, 2026
9f25d5f
add new lang
JS00001 Jun 9, 2026
f695d36
update currency page & Cat base
JS00001 Jun 9, 2026
8076929
update to have the titles
JS00001 Jun 9, 2026
e50eda8
add currencies to ast
JS00001 Jun 9, 2026
08dffd7
add new screens
JS00001 Jun 9, 2026
a265ae6
Merge branches 'jsenyitko-currency-in-rules' and 'main' of github.com…
JS00001 Jun 10, 2026
dd8801d
cleanup pages
JS00001 Jun 10, 2026
8e2deb3
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 11, 2026
aafe45b
cleanup more code, remove 'off'
JS00001 Jun 11, 2026
72e2775
fix not sending rules
JS00001 Jun 11, 2026
6a81dd3
add translations
JS00001 Jun 11, 2026
1d7f2ab
add translations
JS00001 Jun 11, 2026
be4554c
add logic to issuing
JS00001 Jun 11, 2026
5dd23b0
cleanup logic for set spend rules step
JS00001 Jun 11, 2026
248f4f3
fix bugs with issuing
JS00001 Jun 11, 2026
ec47ed3
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 12, 2026
2e7029e
add settlenment currency
JS00001 Jun 12, 2026
cbc9888
start adding header
JS00001 Jun 12, 2026
7049790
add select all
JS00001 Jun 12, 2026
461907c
fix bugs with selection
JS00001 Jun 12, 2026
9c0b29c
add to translations
JS00001 Jun 12, 2026
fff5373
update langs for specific modals
JS00001 Jun 12, 2026
d453c24
use the correct translations
JS00001 Jun 12, 2026
cd6c6c4
add other langs
JS00001 Jun 12, 2026
cf8acb1
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 12, 2026
20b74bd
translation fix
JS00001 Jun 12, 2026
310392d
fix language types
JS00001 Jun 12, 2026
dea9660
address comments
JS00001 Jun 12, 2026
b912d92
fix lint
JS00001 Jun 12, 2026
0b0d62e
address PR comments
JS00001 Jun 12, 2026
668030b
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 15, 2026
8533875
add subtitles
JS00001 Jun 15, 2026
3d3f390
polyglot parrot
JS00001 Jun 15, 2026
bce0ef5
address comments
JS00001 Jun 15, 2026
b1d5bfb
Merge branch 'main' of github.com:Expensify/App into jsenyitko-curren…
JS00001 Jun 16, 2026
d627627
address comments
JS00001 Jun 16, 2026
ad25c5e
add translations
JS00001 Jun 16, 2026
72a891e
address comments
JS00001 Jun 18, 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
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4780,6 +4780,11 @@ const CONST = {
},
},
SPEND_RULES: {
BADGE_VARIANTS: {
SUCCESS: 'success',
ERROR: 'error',
NEUTRAL: 'neutral',
},
CATEGORIES: {
AIRLINES: 'airlines',
ALCOHOL_AND_BARS: 'alcoholAndBars',
Expand Down Expand Up @@ -4811,6 +4816,7 @@ const CONST = {
MERCHANT_MATCH_TYPES: 'merchantMatchTypes',
CATEGORIES: 'categories',
MAX_AMOUNT: 'maxAmount',
CURRENCIES: 'currencies',
},
},
ACTION: {
Expand Down Expand Up @@ -8513,6 +8519,7 @@ const CONST = {
MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches',
MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete',
CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector',
CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector',
SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem',
SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave',
SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType',
Expand Down
8 changes: 8 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,10 @@ const DYNAMIC_ROUTES = {
path: 'rules/category',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW],
},
WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CURRENCY: {
path: 'rules/currency',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW],
},
WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MAX_AMOUNT: {
path: 'rules/max-amount',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW],
Expand Down Expand Up @@ -3173,6 +3177,10 @@ const ROUTES = {
route: 'workspaces/:policyID/rules/spend-rules/:ruleID/merchants',
getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/merchants` as const,
},
RULES_SPEND_CURRENCIES: {
route: 'workspaces/:policyID/rules/spend-rules/:ruleID/currencies',
getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/currencies` as const,
},
RULES_SPEND_MERCHANT_EDIT: {
route: 'workspaces/:policyID/rules/spend-rules/:ruleID/merchants/:merchantIndex',
getRoute: (policyID: string, ruleID: string, merchantIndex: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID}/merchants/${merchantIndex}` as const,
Expand Down
2 changes: 2 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ const SCREENS = {
DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MERCHANT_EDIT: 'Workspace_ExpensifyCard_Rule_Merchant_Edit',
DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CATEGORY: 'Workspace_ExpensifyCard_Rule_Category',
DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MAX_AMOUNT: 'Workspace_ExpensifyCard_Rule_Max_Amount',
DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CURRENCY: 'Workspace_ExpensifyCard_Rule_Currency',
EXPENSIFY_CARD_VERIFY_WORK_EMAIL: 'Workspace_ExpensifyCard_Verify_Work_Email',
EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount',
EXPENSIFY_CARD_SETTINGS: 'Workspace_ExpensifyCard_Settings',
Expand Down Expand Up @@ -900,6 +901,7 @@ const SCREENS = {
RULES_SPEND_CARD: 'Rules_Spend_Card',
RULES_SPEND_CATEGORY: 'Rules_Spend_Category',
RULES_SPEND_MAX_AMOUNT: 'Rules_Spend_Max_Amount',
RULES_SPEND_CURRENCIES: 'Rules_Spend_Currencies',
RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match',
RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type',
RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant',
Expand Down
5 changes: 2 additions & 3 deletions src/components/SelectionList/ListItem/SpendRuleListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ function SpendRuleListItem<TItem extends ListItem>({item, onSelectRow}: SpendRul
const {getMinimumWidth} = useStyleUtils();

const cardRule = item as unknown as SpendRuleListItemType;
const isBlockingRule = cardRule.action === CONST.SPEND_RULES.ACTION.BLOCK;

const rightHandSideComponent = () => (
<Checkbox
Expand Down Expand Up @@ -52,8 +51,8 @@ function SpendRuleListItem<TItem extends ListItem>({item, onSelectRow}: SpendRul
<Badge
isCondensed
text={part.badgeLabel}
error={!part.isNeutral && isBlockingRule}
success={!part.isNeutral && !isBlockingRule}
error={part.variant === CONST.SPEND_RULES.BADGE_VARIANTS.ERROR}
success={part.variant === CONST.SPEND_RULES.BADGE_VARIANTS.SUCCESS}
badgeStyles={[styles.ml0, styles.justifyContentCenter, getMinimumWidth(40)]}
/>
<Text
Expand Down
33 changes: 26 additions & 7 deletions src/components/SpendRules/SpendRuleRestrictionTypeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,64 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';

type SpendRuleRestrictionTypeToggleProps = {
restrictionAction: ValueOf<typeof CONST.SPEND_RULES.ACTION>;
onSelect: (action: ValueOf<typeof CONST.SPEND_RULES.ACTION>) => void;
restrictionAction: ValueOf<typeof CONST.SPEND_RULES.ACTION> | null;
onSelect: (action: ValueOf<typeof CONST.SPEND_RULES.ACTION> | null) => void;
};

function SpendRuleRestrictionTypeToggle({restrictionAction, onSelect}: SpendRuleRestrictionTypeToggleProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();

const isOffSelected = restrictionAction === null;
const isAllowSelected = restrictionAction === CONST.SPEND_RULES.ACTION.ALLOW;
const isBlockSelected = restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK;

const restrictionTypeHelperText = isAllowSelected ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') : translate('workspace.rules.spendRules.restrictionTypeHelpBlock');
const restrictionTypeHelperText = (() => {
if (isAllowSelected) {
return translate('workspace.rules.spendRules.restrictMerchantsAllowSubtitle');
}
if (isBlockSelected) {
return translate('workspace.rules.spendRules.restrictMerchantsBlockSubtitle');
}
return translate('workspace.rules.spendRules.restrictMerchantsOffSubtitle');
})();

return (
<>
<View style={[styles.flexRow]}>
<Text style={[styles.flex1, styles.pr3, styles.alignSelfCenter]}>{translate('workspace.rules.spendRules.restrictionType')}</Text>
<View style={[styles.flexRow, styles.justifyContentBetween]}>
<Text style={[styles.flex1, styles.alignSelfCenter]}>{translate('workspace.rules.spendRules.restrictMerchants')}</Text>
<View style={[styles.flexRow, styles.border, styles.borderRadiusNormal]}>
<Button
text={translate('common.off')}
small
style={styles.ph0}
innerStyles={!isOffSelected ? styles.bgTransparent : undefined}
textStyles={[styles.alignSelfCenter, !isOffSelected ? styles.textSupporting : undefined]}
accessibilityLabel={translate('common.off')}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE}
onPress={() => onSelect(null)}
/>
<Button
text={translate('workspace.rules.spendRules.allow')}
onPress={() => onSelect(CONST.SPEND_RULES.ACTION.ALLOW)}
success={isAllowSelected}
small
style={styles.ph0}
innerStyles={!isAllowSelected ? styles.bgTransparent : undefined}
textStyles={[styles.alignSelfCenter, !isAllowSelected ? styles.textSupporting : undefined]}
accessibilityLabel={translate('workspace.rules.spendRules.allow')}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE}
onPress={() => onSelect(CONST.SPEND_RULES.ACTION.ALLOW)}
/>
<Button
text={translate('workspace.rules.spendRules.block')}
onPress={() => onSelect(CONST.SPEND_RULES.ACTION.BLOCK)}
danger={isBlockSelected}
small
style={styles.ph0}
innerStyles={!isBlockSelected ? styles.bgTransparent : undefined}
textStyles={[styles.alignSelfCenter, !isBlockSelected ? styles.textSupporting : undefined]}
accessibilityLabel={translate('workspace.rules.spendRules.block')}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE}
onPress={() => onSelect(CONST.SPEND_RULES.ACTION.BLOCK)}
/>
</View>
</View>
Expand Down
4 changes: 2 additions & 2 deletions src/components/SpendRules/SpendRulesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ function SpendRulesSection({policyID, canWriteRules, showReadOnlyModal}: SpendRu
<Badge
text={part.badgeLabel}
badgeStyles={[styles.ml0, styles.justifyContentCenter, StyleUtils.getMinimumWidth(40)]}
error={!part.isNeutral && rule.isBlock}
success={!part.isNeutral && !rule.isBlock}
error={part.variant === CONST.SPEND_RULES.BADGE_VARIANTS.ERROR}
success={part.variant === CONST.SPEND_RULES.BADGE_VARIANTS.SUCCESS}
isCondensed
/>
<Text
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function SpendRuleCategoryBase({categories, onCategoriesChange}:
includeSafeAreaPaddingBottom
>
<HeaderWithBackButton
title={translate('workspace.rules.spendRules.spendCategory')}
title={translate('workspace.rules.spendRules.merchantTypes')}
onBackButtonPress={goBack}
/>
<SelectionList
Expand Down
209 changes: 209 additions & 0 deletions src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import React, {useState} from 'react';
import {View} from 'react-native';
import {useCurrencyListActions, useCurrencyListState} from '@components/CurrencyListContextProvider';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Icon from '@components/Icon';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useSearchResults from '@hooks/useSearchResults';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import {getCurrencyOptions} from '@libs/SearchUIUtils';

type SpendRulesCurrencyBaseProps = {
/** The currently selected currencies */
currencies: string[];

/** The settlement currency of the currently selected cards */
settlementCurrency: string;

/** Handle the currencies changing */
onCurrenciesChange: (currencies: string[]) => void;
};
Comment thread
JS00001 marked this conversation as resolved.

type CurrencyListItem = ListItem & {
value: string;
};

export default function SpendRulesCurrencyBase({currencies, settlementCurrency, onCurrenciesChange}: SpendRulesCurrencyBaseProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate, localeCompare} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['Lock']);

const {currencyList} = useCurrencyListState();
const {getCurrencySymbol} = useCurrencyListActions();
const currencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol);
const validCurrencyOptions = currencyOptions.filter((option) => option.value !== settlementCurrency);

const [selectedCurrencies, setSelectedCurrencies] = useState<string[]>(() => {
if (currencies.length > 0) {
return currencies.filter((currency) => currency !== settlementCurrency);
}

return validCurrencyOptions.map((option) => option.value);
});

const currencyItems: CurrencyListItem[] = [];
const selectedCurrenciesSet = new Set(selectedCurrencies);

let areAllCurrenciesSelected = true;
let settlementCurrencyLabel = settlementCurrency;

for (const currencyOption of currencyOptions) {
if (currencyOption.value === settlementCurrency) {
settlementCurrencyLabel = currencyOption.text;
continue;
}

const isSelected = selectedCurrenciesSet.has(currencyOption.value);

currencyItems.push({
isSelected,
keyForList: currencyOption.value,
text: currencyOption.text,
value: currencyOption.value,
});

if (!isSelected) {
areAllCurrenciesSelected = false;
}
}

const filterCurrency = (item: CurrencyListItem, searchInput: string) => {
return (item.text ?? '').toLowerCase().includes(searchInput);
Comment thread
JS00001 marked this conversation as resolved.
};

const sortCurrencies = (items: CurrencyListItem[]) => {
return items.sort((a, b) => localeCompare(a.text ?? '', b.text ?? ''));
};

const [inputValue, setInputValue, filteredCurrencyItems] = useSearchResults(currencyItems, filterCurrency, sortCurrencies);

const toggleCurrency = (item: CurrencyListItem) => {
setSelectedCurrencies((prev) => {
if (prev.includes(item.value)) {
return prev.filter((currency) => currency !== item.value);
}
return [...prev, item.value];
});
};

const toggleSelectAll = () => {
const visibleValues = filteredCurrencyItems.map((item) => item.value);
const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedCurrenciesSet.has(value));

if (allVisibleSelected) {
const visibleSet = new Set(visibleValues);
setSelectedCurrencies((prev) => prev.filter((currency) => !visibleSet.has(currency)));
return;
}

setSelectedCurrencies((prev) => {
const next = new Set([...prev, ...visibleValues]);
return Array.from(next);
});
};

const goBack = () => {
Navigation.goBack();
};

const saveChanges = () => {
if (selectedCurrencies.length === currencyOptions.length - 1) {
onCurrenciesChange([]);
} else {
onCurrenciesChange([...selectedCurrencies, settlementCurrency]);
}

goBack();
};

const ListHeaderContent = (
<View style={[styles.flexColumn]}>
<MultiSelectListItem
isFocused={false}
showTooltip={false}
keyForList="select-all"
item={{keyForList: 'select-all', text: translate('workspace.rules.spendRules.allCurrencies'), isSelected: areAllCurrenciesSelected}}
onSelectRow={toggleSelectAll}
/>
<View style={[styles.borderBottom, styles.mh5, styles.mv2]} />
<View style={[styles.flexRow, styles.alignItemsCenter, styles.mv4, styles.mh5]}>
<View style={[styles.flex1, styles.gap1]}>
<Text
style={styles.textStrong}
numberOfLines={1}
>
{settlementCurrencyLabel}
</Text>
<Text
style={[styles.textLabel, styles.textSupporting]}
numberOfLines={1}
>
{translate('workspace.rules.spendRules.settlementCurrencyPermittedSubtitle')}
</Text>
</View>
<Icon
medium
src={icons.Lock}
fill={theme.icon}
/>
</View>
</View>
);

return (
<ScreenWrapper
testID="SpendRuleCurrenciesPage"
shouldEnableMaxHeight
includeSafeAreaPaddingBottom
offlineIndicatorStyle={styles.mtAuto}
>
<HeaderWithBackButton
title={translate('workspace.rules.spendRules.permittedCurrencies')}
onBackButtonPress={goBack}
/>

<Text style={[styles.textLabel, styles.textSupporting, styles.ph5, styles.pb4]}>{translate('workspace.rules.spendRules.permittedCurrenciesSubtitle')}</Text>

<SelectionList
canSelectMultiple
shouldUpdateFocusedIndex
customListHeaderContent={ListHeaderContent}
ListItem={MultiSelectListItem}
data={filteredCurrencyItems}
selectedItems={selectedCurrencies}
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
onSelectRow={toggleCurrency}
onSelectionButtonPress={toggleCurrency}
textInputOptions={{
value: inputValue,
label: translate('common.search'),
onChangeText: setInputValue,
}}
style={{
listHeaderWrapperStyle: [styles.pt5, styles.pb2],
listHeaderSelectAllTextStyle: [styles.textLabelSupporting],
Comment thread
JS00001 marked this conversation as resolved.
}}
footerContent={
<FormAlertWithSubmitButton
buttonText={translate('common.save')}
isAlertVisible={false}
onSubmit={saveChanges}
enabledWhenOffline
containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]}
/>
}
/>
</ScreenWrapper>
);
}
Loading
Loading