feat: MUSD-776 create money account onboarding flow with rive animation#30137
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
…g-flow-with-rive-animation
| return ( | ||
| <> | ||
| <Text variant={TextVariant.HeadingLg} style={styles.heading}> | ||
| {'Money UI'} |
There was a problem hiding this comment.
Note: We don't need locale strings here since this is for internal use only.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6864a98. Configure here.
6864a98 to
f9fda76
Compare
…g-flow-with-rive-animation
vinnyhoward
left a comment
There was a problem hiding this comment.
Very cool! I love the reusable component for future stepper animations. Well done
vinnyhoward
left a comment
There was a problem hiding this comment.
Was it intentional to show Tether in the designs @Matt561 ?
…g-flow-with-rive-animation
Do you mean in the Rive animation? If yes, it's intentional. |
| } from '@metamask/design-system-react-native'; | ||
| import styleSheet from '../../../Views/Settings/DeveloperOptions/DeveloperOptions.styles'; | ||
|
|
||
| export const MoneyUiDeveloperOptionsSection = () => { |
There was a problem hiding this comment.
How would you feel about tagging this on @Matt561 ?
diff --git a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx
index 9c16eb18a9..f02c2114e5 100644
--- a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx
+++ b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx
@@ -1,7 +1,9 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { render, fireEvent, act } from '@testing-library/react-native';
import { MoneyUiDeveloperOptionsSection } from './MoneyUiDeveloperOptionsSection';
import { UserActionType } from '../../../../actions/user/types';
+import { selectMoneyOnboardingSeen } from '../../../../reducers/user/selectors';
+import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController';
const mockDispatch = jest.fn();
const mockUseSelector = jest.fn();
@@ -19,10 +21,49 @@ jest.mock('../../../../util/theme', () => {
};
});
+const mockSetString = jest.fn(() => Promise.resolve());
+
+jest.mock('../../../../core/ClipboardManager', () => ({
+ __esModule: true,
+ default: {
+ setString: (...args: unknown[]) => mockSetString(...args),
+ },
+}));
+
+const MOCK_ADDRESS = '0xABCDEF1234567890ABCDEF1234567890ABCDEF12';
+
+interface SelectorMockOptions {
+ hasSeenMoneyOnboarding?: boolean;
+ /** Pass `null` to simulate no money account being available. */
+ moneyAccount?: { address: string } | null;
+}
+
+/**
+ * Configures the useSelector mock to return appropriate values for each selector
+ * used by MoneyUiDeveloperOptionsSection.
+ *
+ * Default: onboarding not seen, primary money account present with MOCK_ADDRESS.
+ * Pass `moneyAccount: null` to simulate the account being unavailable.
+ */
+function setupSelectorMocks(options: SelectorMockOptions = {}) {
+ const hasSeenMoneyOnboarding = options.hasSeenMoneyOnboarding ?? false;
+ // `null` means "no account", `undefined` (omitted) means use the default.
+ const moneyAccount =
+ options.moneyAccount === null
+ ? undefined
+ : (options.moneyAccount ?? { address: MOCK_ADDRESS });
+
+ mockUseSelector.mockImplementation((selector: unknown) => {
+ if (selector === selectMoneyOnboardingSeen) return hasSeenMoneyOnboarding;
+ if (selector === selectPrimaryMoneyAccount) return moneyAccount;
+ return undefined;
+ });
+}
+
describe('MoneyUiDeveloperOptionsSection', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseSelector.mockReturnValue(false);
+ setupSelectorMocks();
});
it('renders the "Money UI" heading', () => {
@@ -31,40 +72,89 @@ describe('MoneyUiDeveloperOptionsSection', () => {
expect(getByText('Money UI')).toBeOnTheScreen();
});
- it('displays onboarding seen as false when not seen', () => {
- mockUseSelector.mockReturnValue(false);
+ describe('onboarding seen state', () => {
+ it('displays onboarding seen as false when not seen', () => {
+ setupSelectorMocks({ hasSeenMoneyOnboarding: false });
- const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
- expect(getByText('Onboarding seen: false')).toBeOnTheScreen();
- });
+ expect(getByText('Onboarding seen: false')).toBeOnTheScreen();
+ });
- it('displays onboarding seen as true when seen', () => {
- mockUseSelector.mockReturnValue(true);
+ it('displays onboarding seen as true when seen', () => {
+ setupSelectorMocks({ hasSeenMoneyOnboarding: true });
- const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
- expect(getByText('Onboarding seen: true')).toBeOnTheScreen();
- });
+ expect(getByText('Onboarding seen: true')).toBeOnTheScreen();
+ });
- it('renders the reset button', () => {
- const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+ it('renders the reset button', () => {
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+
+ expect(getByText('Reset onboarding screen')).toBeOnTheScreen();
+ });
+
+ it('dispatches setMoneyOnboardingSeen(false) when reset button is pressed', () => {
+ setupSelectorMocks({ hasSeenMoneyOnboarding: true });
+
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
- expect(getByText('Reset onboarding screen')).toBeOnTheScreen();
+ fireEvent.press(getByText('Reset onboarding screen'));
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: UserActionType.SET_MONEY_ONBOARDING_SEEN,
+ payload: { seen: false },
+ }),
+ );
+ });
});
- it('dispatches setMoneyOnboardingSeen(false) when reset button is pressed', () => {
- mockUseSelector.mockReturnValue(true);
+ describe('Money Account Address', () => {
+ it('displays the money account address when available', () => {
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
- const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+ expect(
+ getByText(`Money Account Address: ${MOCK_ADDRESS}`),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays N/A when the money account address is unavailable', () => {
+ setupSelectorMocks({ moneyAccount: null });
+
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+
+ expect(getByText('Money Account Address: N/A')).toBeOnTheScreen();
+ });
+
+ it('renders the copy address button', () => {
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+
+ expect(getByText('Copy Money Account Address')).toBeOnTheScreen();
+ });
+
+ it('copies the address to clipboard when the copy button is pressed', async () => {
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
+
+ await act(async () => {
+ fireEvent.press(getByText('Copy Money Account Address'));
+ });
+
+ expect(mockSetString).toHaveBeenCalledTimes(1);
+ expect(mockSetString).toHaveBeenCalledWith(MOCK_ADDRESS);
+ });
+
+ it('does not copy to clipboard when the address is unavailable', async () => {
+ setupSelectorMocks({ moneyAccount: null });
+
+ const { getByText } = render(<MoneyUiDeveloperOptionsSection />);
- fireEvent.press(getByText('Reset onboarding screen'));
+ await act(async () => {
+ fireEvent.press(getByText('Copy Money Account Address'));
+ });
- expect(mockDispatch).toHaveBeenCalledWith(
- expect.objectContaining({
- type: UserActionType.SET_MONEY_ONBOARDING_SEEN,
- payload: { seen: false },
- }),
- );
+ expect(mockSetString).not.toHaveBeenCalled();
+ });
});
});
diff --git a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx
index a087e8e65e..b4b15d000e 100644
--- a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx
+++ b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from '../../../../util/theme';
import { setMoneyOnboardingSeen } from '../../../../actions/user';
import { selectMoneyOnboardingSeen } from '../../../../reducers/user/selectors';
+import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController';
import { useStyles } from '../../../../component-library/hooks';
import {
Text,
@@ -14,6 +15,7 @@ import {
ButtonSize,
} from '@metamask/design-system-react-native';
import styleSheet from '../../../Views/Settings/DeveloperOptions/DeveloperOptions.styles';
+import ClipboardManager from '../../../../core/ClipboardManager';
export const MoneyUiDeveloperOptionsSection = () => {
const dispatch = useDispatch();
@@ -21,11 +23,19 @@ export const MoneyUiDeveloperOptionsSection = () => {
const { styles } = useStyles(styleSheet, { theme });
const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen);
+ const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount);
+ const moneyAccountAddress = primaryMoneyAccount?.address;
const handleResetOnboardingSeenState = useCallback(() => {
dispatch(setMoneyOnboardingSeen(false));
}, [dispatch]);
+ const handleCopyAddress = useCallback(async () => {
+ if (moneyAccountAddress) {
+ await ClipboardManager.setString(moneyAccountAddress);
+ }
+ }, [moneyAccountAddress]);
+
return (
<>
<Text variant={TextVariant.HeadingLg} style={styles.heading}>
@@ -47,6 +57,23 @@ export const MoneyUiDeveloperOptionsSection = () => {
>
{'Reset onboarding screen'}
</Button>
+ <Text
+ color={TextColor.TextAlternative}
+ variant={TextVariant.BodyMd}
+ style={styles.desc}
+ >
+ {`Money Account Address: ${moneyAccountAddress ?? 'N/A'}`}
+ </Text>
+ <Button
+ variant={ButtonVariant.Secondary}
+ style={styles.accessory}
+ size={ButtonSize.Lg}
+ onPress={handleCopyAddress}
+ isDisabled={!moneyAccountAddress}
+ isFullWidth
+ >
+ {'Copy Money Account Address'}
+ </Button>
</>
);
};
There was a problem hiding this comment.
Great idea. I'll add this 👍
|
|
||
| const handleContinue = useCallback(() => { | ||
| if (isLastStep) { | ||
| onComplete(); |
There was a problem hiding this comment.
should we set hasCompletedRef.current to true here?
| }); | ||
| }, [steps.length, progress]); | ||
|
|
||
| const restartProgress = useCallback(() => { |
There was a problem hiding this comment.
Looks like this isn't used
| __getLastMockedMethods, | ||
| __clearLastMockedMethods, | ||
| __mockRiveFireState, | ||
| } from '../.././../__mocks__/rive-react-native'; |
There was a problem hiding this comment.
| } from '../.././../__mocks__/rive-react-native'; | |
| } from '../../../__mocks__/rive-react-native'; |
| </Box> | ||
| {/* Footer button */} | ||
| <Box | ||
| twClassName={`px-4${!isRiveReady || !currentStep?.buttonLabel ? ' opacity-0' : ''}`} |
There was a problem hiding this comment.
We make the button invisible if there's no label - but I guess it still accepts touches? Could we have also add isDisabled if there's no label
…g-flow-with-rive-animation
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
SmokeMoney is selected because: The TabBar Money tab navigation is directly modified, MoneyBalanceCard behavior changed, and existing card/ramps tests navigate through the Money tab which could be intercepted by the new onboarding flow. SmokeWalletPlatform is selected because: Per SmokeMoney tag description, 'When changes touch wallet home or actions entry to buy/sell, also select SmokeWalletPlatform.' The TabBar is a shared navigation component and WalletPlatform tests may interact with Money-related navigation. Other tags (SmokeConfirmations, SmokeSwap, SmokeAccounts, etc.) are not selected because the TabBar change is scoped only to the Performance Test Selection: |
|




Description
This PR creates the Money account onboarding flow and wires it up for first time users.
Changes
<RiveOnboardingStepper/>to support future onboarding flows.MoneyOnboardingViewwhich uses<RiveOnboardingStepper/>MoneyOnboardingViewfor first time users when:Changelog
CHANGELOG entry: added Money account onboarding flow
Related issues
Fixes: MUSD-776: Create Money Account Onboarding with Rive Animation
Manual testing steps
Screenshots/Recordings
Before
N/A - Onboarding flow didn't exist
After
money-onboarding-v0.1.mov
Pre-merge author checklist
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
Medium Risk
Adds a new multi-step onboarding screen and changes Money entry navigation to conditionally redirect based on a new persisted
moneyOnboardingSeenflag, which could affect user routing if mis-set. Risk is mitigated by reducer/selector coverage and component-level tests but still touches core navigation paths (TabBar, Home, Money card).Overview
Introduces a new Money account onboarding flow (
Routes.MONEY.ONBOARDING) backed by a reusableRiveOnboardingStepper, including progress UI, step timing, close handling, and optional auto-complete on the final step.Adds a new persisted user flag (
moneyOnboardingSeen) with action/reducer/selector plumbing, and updates Money entry points (TabBar Money tab andMoneyBalanceCard) to use a shareduseMoneyNavigation.navigateToMoneyHome()redirect that sends first-time users to onboarding before allowing navigation to Money home.Extends Developer Options (behind the Money feature flag) with a Money UI section to reset onboarding state and copy the primary money account address, and adds i18n strings plus targeted tests for the new components and navigation behavior.
Reviewed by Cursor Bugbot for commit 381fd23. Bugbot is set up for automated code reviews on this repo. Configure here.