Skip to content

Commit aed2881

Browse files
authored
feat: MUSD-673 conditionally render Money hub via feature flag (#28934)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Adds new `selectMoneyHubEnabledFlag` selector to control conditional rendering of the new Money hub screen. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: added new `selectMoneyHubEnabledFlag` selector to control conditional rendering of the new Money hub screen. ## **Related issues** Fixes: [MUSD-673: Create feature flag for Money Hub](https://consensyssoftware.atlassian.net/browse/MUSD-673) ## **Manual testing steps** ```gherkin Feature: Money Hub feature flag Scenario: user with Money Hub enabled sees the full view Given the MM_MONEY_HUB_ENABLED remote feature flag is enabled When user navigates to the Cash Tokens full view Then the Money Hub component is rendered Scenario: user with Money Hub disabled sees the fallback Given the MM_MONEY_HUB_ENABLED remote feature flag is disabled When user navigates to the Cash Tokens full view Then the Money Hub component is not rendered ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** Money hub always rendered <!-- [screenshots/recordings] --> ### **After** When enabled <img width="494" height="1019" alt="image" src="https://github.com/user-attachments/assets/50d64ae2-ef87-45d4-80c7-900cb930004f" /> When disabled <!-- [screenshots/recordings] --> <img width="494" height="1019" alt="image" src="https://github.com/user-attachments/assets/099b9d93-0a63-4dfb-ac0e-2535742b02bc" /> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Feature-flag-gated UI rendering changes with no new data handling or critical logic; primary risk is accidentally hiding/showing Money Hub sections due to flag wiring. > > **Overview** > Adds a new `selectMoneyHubEnabledFlag` selector (remote version-gated flag with `MM_MONEY_HUB_ENABLED` env fallback) and registers the corresponding remote flag `earnMoneyHubEnabled` for test defaults. > > Updates `CashTokensFullView` to conditionally render the bonus/convert sections and bottom CTAs (Convert vs Swap/Buy) only when Money Hub is enabled, with expanded unit tests covering the enabled/disabled UI states and CTA behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1434111. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8f8ffa4 commit aed2881

6 files changed

Lines changed: 205 additions & 30 deletions

File tree

.js.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01"
135135

136136
# Money Home Screen
137137
export MM_MONEY_HOME_SCREEN_ENABLED="false"
138+
export MM_MONEY_HUB_ENABLED="false"
138139

139140
# Activates remote feature flag override mode.
140141
# Remote feature flag values won't be updated,

app/components/UI/Money/selectors/featureFlags.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
selectMoneyActivityMockDataEnabledFlag,
55
selectMoneyHomeScreenEnabledFlag,
66
selectMoneyEnableMoneyAccountFlag,
7+
selectMoneyHubEnabledFlag,
78
} from './featureFlags';
89

910
jest.mock('../../../../core/Engine', () => ({
@@ -165,3 +166,62 @@ describe('selectMoneyEnableMoneyAccountFlag', () => {
165166
expect(result).toBe(true);
166167
});
167168
});
169+
170+
describe('selectMoneyHubEnabledFlag', () => {
171+
const originalEnv = process.env;
172+
173+
beforeEach(() => {
174+
jest.clearAllMocks();
175+
process.env = { ...originalEnv };
176+
});
177+
178+
afterEach(() => {
179+
process.env = originalEnv;
180+
});
181+
182+
it('returns true when remote flag is enabled and version requirement is met', () => {
183+
mockedValidate.mockReturnValue(true);
184+
185+
const state = createState({
186+
earnMoneyHubEnabled: { enabled: true, minimumVersion: '1.0.0' },
187+
});
188+
189+
const result = selectMoneyHubEnabledFlag(state as never);
190+
191+
expect(result).toBe(true);
192+
});
193+
194+
it('returns false when remote flag is disabled', () => {
195+
mockedValidate.mockReturnValue(false);
196+
197+
const state = createState({
198+
earnMoneyHubEnabled: { enabled: false, minimumVersion: '1.0.0' },
199+
});
200+
201+
const result = selectMoneyHubEnabledFlag(state as never);
202+
203+
expect(result).toBe(false);
204+
});
205+
206+
it('falls back to local env var when remote flag returns undefined', () => {
207+
mockedValidate.mockReturnValue(undefined);
208+
process.env.MM_MONEY_HUB_ENABLED = 'true';
209+
210+
const state = createState({ _unique: 'hub-fallback-true' });
211+
212+
const result = selectMoneyHubEnabledFlag(state as never);
213+
214+
expect(result).toBe(true);
215+
});
216+
217+
it('returns false when both remote and local flags are unavailable', () => {
218+
mockedValidate.mockReturnValue(undefined);
219+
delete process.env.MM_MONEY_HUB_ENABLED;
220+
221+
const state = createState({ _unique: 'hub-fallback-false' });
222+
223+
const result = selectMoneyHubEnabledFlag(state as never);
224+
225+
expect(result).toBe(false);
226+
});
227+
});

app/components/UI/Money/selectors/featureFlags.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,14 @@ export const selectMoneyEnableMoneyAccountFlag = createSelector(
3333
selectRemoteFeatureFlags,
3434
isMoneyAccountEnabled,
3535
);
36+
37+
export const selectMoneyHubEnabledFlag = createSelector(
38+
selectRemoteFeatureFlags,
39+
(remoteFeatureFlags) => {
40+
const localFlag = process.env.MM_MONEY_HUB_ENABLED === 'true';
41+
const remoteFlag =
42+
remoteFeatureFlags?.earnMoneyHubEnabled as unknown as VersionGatedFeatureFlag;
43+
44+
return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag;
45+
},
46+
);

app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { fireEvent, screen } from '@testing-library/react-native';
33
import renderWithProvider from '../../../util/test/renderWithProvider';
44
import CashTokensFullView from './CashTokensFullView';
55
import { useMerklBonusClaim } from '../../UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim';
6+
import { selectMoneyHubEnabledFlag } from '../../UI/Money/selectors/featureFlags';
7+
import { AssetType } from '../confirmations/types/token';
68

79
const mockGoBack = jest.fn();
810

@@ -35,8 +37,11 @@ jest.mock('../../UI/Earn/hooks/useMusdConversion', () => ({
3537
hasSeenConversionEducationScreen: true,
3638
}),
3739
}));
40+
const mockUseMusdConversionTokens = jest.fn(() => ({
41+
tokens: [] as AssetType[],
42+
}));
3843
jest.mock('../../UI/Earn/hooks/useMusdConversionTokens', () => ({
39-
useMusdConversionTokens: () => ({ tokens: [] }),
44+
useMusdConversionTokens: () => mockUseMusdConversionTokens(),
4045
}));
4146
jest.mock('../../UI/Bridge/hooks/useSwapBridgeNavigation', () => ({
4247
useSwapBridgeNavigation: () => ({ goToSwaps: jest.fn() }),
@@ -85,6 +90,7 @@ jest.mock(
8590
);
8691

8792
const mockUseMerklBonusClaim = jest.mocked(useMerklBonusClaim);
93+
const mockSelectMoneyHubEnabledFlag = jest.mocked(selectMoneyHubEnabledFlag);
8894
jest.mock('../../../core/Engine', () => ({
8995
context: {},
9096
}));
@@ -94,6 +100,9 @@ jest.mock('../../Views/confirmations/hooks/useNetworkName', () => ({
94100
jest.mock('../../UI/Earn/selectors/featureFlags', () => ({
95101
selectMusdQuickConvertEnabledFlag: jest.fn(() => false),
96102
}));
103+
jest.mock('../../UI/Money/selectors/featureFlags', () => ({
104+
selectMoneyHubEnabledFlag: jest.fn(),
105+
}));
97106
jest.mock('../../UI/Tokens', () => {
98107
const { createElement } = jest.requireActual('react');
99108
const { View, Text } = jest.requireActual('react-native');
@@ -120,6 +129,8 @@ describe('CashTokensFullView', () => {
120129
beforeEach(() => {
121130
jest.clearAllMocks();
122131
mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false });
132+
mockUseMusdConversionTokens.mockReturnValue({ tokens: [] });
133+
mockSelectMoneyHubEnabledFlag.mockReturnValue(false);
123134
mockUseMerklBonusClaim.mockReturnValue({
124135
claimableReward: null,
125136
lifetimeBonusClaimed: null,
@@ -156,4 +167,75 @@ describe('CashTokensFullView', () => {
156167
fireEvent.press(screen.getByTestId('back-button'));
157168
expect(mockGoBack).toHaveBeenCalled();
158169
});
170+
171+
it('does not render bonus section when Money Hub flag is disabled', () => {
172+
mockSelectMoneyHubEnabledFlag.mockReturnValue(false);
173+
174+
renderWithProvider(<CashTokensFullView />);
175+
176+
expect(
177+
screen.queryByTestId('asset-overview-claim-bonus'),
178+
).not.toBeOnTheScreen();
179+
});
180+
181+
it('renders bonus section when Money Hub flag is enabled', () => {
182+
mockSelectMoneyHubEnabledFlag.mockReturnValue(true);
183+
184+
renderWithProvider(<CashTokensFullView />);
185+
186+
expect(screen.getByTestId('asset-overview-claim-bonus')).toBeOnTheScreen();
187+
});
188+
189+
it('does not render convert stablecoins section when Money Hub flag is disabled', () => {
190+
mockSelectMoneyHubEnabledFlag.mockReturnValue(false);
191+
192+
renderWithProvider(<CashTokensFullView />);
193+
194+
expect(
195+
screen.queryByTestId('money-convert-stablecoins-container'),
196+
).not.toBeOnTheScreen();
197+
});
198+
199+
it('renders convert stablecoins section when Money Hub flag is enabled', () => {
200+
mockSelectMoneyHubEnabledFlag.mockReturnValue(true);
201+
202+
renderWithProvider(<CashTokensFullView />);
203+
204+
expect(
205+
screen.getByTestId('money-convert-stablecoins-container'),
206+
).toBeOnTheScreen();
207+
});
208+
209+
it('does not render CTA buttons when Money Hub flag is disabled', () => {
210+
mockSelectMoneyHubEnabledFlag.mockReturnValue(false);
211+
212+
renderWithProvider(<CashTokensFullView />);
213+
214+
expect(screen.queryByText('Convert to mUSD')).not.toBeOnTheScreen();
215+
expect(screen.queryByText('Swap')).not.toBeOnTheScreen();
216+
expect(screen.queryByText('Buy')).not.toBeOnTheScreen();
217+
});
218+
219+
it('renders Convert CTA when Money Hub is enabled and conversion tokens exist', () => {
220+
mockSelectMoneyHubEnabledFlag.mockReturnValue(true);
221+
mockUseMusdConversionTokens.mockReturnValue({
222+
tokens: [{ address: '0xabc', chainId: '0x1' } as AssetType],
223+
});
224+
225+
renderWithProvider(<CashTokensFullView />);
226+
227+
expect(screen.getByText('Convert to mUSD')).toBeOnTheScreen();
228+
expect(screen.queryByText('Swap')).not.toBeOnTheScreen();
229+
expect(screen.queryByText('Buy')).not.toBeOnTheScreen();
230+
});
231+
232+
it('renders Swap and Buy CTAs when Money Hub is enabled and no conversion tokens exist', () => {
233+
mockSelectMoneyHubEnabledFlag.mockReturnValue(true);
234+
235+
renderWithProvider(<CashTokensFullView />);
236+
237+
expect(screen.getByText('Swap')).toBeOnTheScreen();
238+
expect(screen.getByText('Buy')).toBeOnTheScreen();
239+
expect(screen.queryByText('Convert to mUSD')).not.toBeOnTheScreen();
240+
});
159241
});

app/components/Views/CashTokensFullView/CashTokensFullView.tsx

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ import SectionRow from '../Homepage/components/SectionRow/SectionRow';
3434
import { AssetType } from '../confirmations/types/token';
3535
import Logger from '../../../util/Logger';
3636
import AppConstants from '../../../core/AppConstants';
37+
import { selectMoneyHubEnabledFlag } from '../../UI/Money/selectors/featureFlags';
38+
import { useSelector } from 'react-redux';
3739

3840
const CashTokensFullView = () => {
3941
const navigation = useNavigation();
4042
const tw = useTailwind();
4143
const { hasMusdBalanceOnAnyChain } = useMusdBalance();
4244
const { tokens: conversionTokens } = useMusdConversionTokens();
4345

46+
const isMoneyHubEnabled = useSelector(selectMoneyHubEnabledFlag);
47+
4448
const hasConversionTokens = conversionTokens.length > 0;
4549

4650
const { initiateMaxConversion, initiateCustomConversion } =
@@ -144,51 +148,57 @@ const CashTokensFullView = () => {
144148
isFullView
145149
showOnlyMusd
146150
hasMusdBalanceOnAnyChain={hasMusdBalanceOnAnyChain}
147-
listFooterComponent={bonusAndConvertSections}
151+
listFooterComponent={
152+
isMoneyHubEnabled ? bonusAndConvertSections : undefined
153+
}
148154
/>
149155
) : (
150156
<ScrollView style={tw`flex-1`} showsVerticalScrollIndicator={false}>
151157
<SectionRow>
152158
<CashGetMusdEmptyState isFullView />
153159
</SectionRow>
154-
{bonusAndConvertSections}
160+
{isMoneyHubEnabled ? bonusAndConvertSections : undefined}
155161
</ScrollView>
156162
)}
157-
{hasConversionTokens ? (
158-
<Box twClassName="px-4 pt-4">
159-
<Button
160-
variant={ButtonVariant.Primary}
161-
size={ButtonSize.Lg}
162-
isFullWidth
163-
onPress={handleConvertPress}
164-
>
165-
{strings('money.convert_stablecoins.convert_cta')}
166-
</Button>
167-
</Box>
168-
) : (
169-
<Box flexDirection={BoxFlexDirection.Row} twClassName="px-4 pt-4 gap-2">
170-
<Box twClassName="flex-1">
163+
{isMoneyHubEnabled &&
164+
(hasConversionTokens ? (
165+
<Box twClassName="px-4 pt-4">
171166
<Button
172167
variant={ButtonVariant.Primary}
173168
size={ButtonSize.Lg}
174169
isFullWidth
175-
onPress={() => goToSwaps()}
170+
onPress={handleConvertPress}
176171
>
177-
{strings('money.convert_stablecoins.swap')}
172+
{strings('money.convert_stablecoins.convert_cta')}
178173
</Button>
179174
</Box>
180-
<Box twClassName="flex-1">
181-
<Button
182-
variant={ButtonVariant.Primary}
183-
size={ButtonSize.Lg}
184-
isFullWidth
185-
onPress={() => goToBuy()}
186-
>
187-
{strings('money.convert_stablecoins.buy')}
188-
</Button>
175+
) : (
176+
<Box
177+
flexDirection={BoxFlexDirection.Row}
178+
twClassName="px-4 pt-4 gap-2"
179+
>
180+
<Box twClassName="flex-1">
181+
<Button
182+
variant={ButtonVariant.Primary}
183+
size={ButtonSize.Lg}
184+
isFullWidth
185+
onPress={() => goToSwaps()}
186+
>
187+
{strings('money.convert_stablecoins.swap')}
188+
</Button>
189+
</Box>
190+
<Box twClassName="flex-1">
191+
<Button
192+
variant={ButtonVariant.Primary}
193+
size={ButtonSize.Lg}
194+
isFullWidth
195+
onPress={() => goToBuy()}
196+
>
197+
{strings('money.convert_stablecoins.buy')}
198+
</Button>
199+
</Box>
189200
</Box>
190-
</Box>
191-
)}
201+
))}
192202
</SafeAreaView>
193203
);
194204
};

tests/feature-flags/feature-flag-registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2770,6 +2770,17 @@ export const FEATURE_FLAG_REGISTRY: Record<string, FeatureFlagRegistryEntry> = {
27702770
status: FeatureFlagStatus.Active,
27712771
},
27722772

2773+
earnMoneyHubEnabled: {
2774+
name: 'earnMoneyHubEnabled',
2775+
type: FeatureFlagType.Remote,
2776+
inProd: false,
2777+
productionDefault: {
2778+
enabled: false,
2779+
minimumVersion: '0.0.0',
2780+
},
2781+
status: FeatureFlagStatus.Active,
2782+
},
2783+
27732784
earnMusdConversionAssetOverviewCtaEnabled: {
27742785
name: 'earnMusdConversionAssetOverviewCtaEnabled',
27752786
type: FeatureFlagType.Remote,

0 commit comments

Comments
 (0)