Skip to content

Commit 05c5a3f

Browse files
authored
feat: rewards state and selectors (#37875)
<!-- 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** https://consensyssoftware.atlassian.net/browse/RWDS-268 Part 3 of #36827 pertaining to ui state and selectors <!-- 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? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37875?quickstart=1) ## **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: null ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > Introduces a new `rewards` UI state slice with selectors and background-thunk actions, integrates it into the root reducer, and adds comprehensive tests plus minor config updates. > > - **State (Redux)**: > - Add `ui/ducks/rewards/` slice (`index.ts`) defining onboarding, geo, subscription/season status, feature flag, and error toast state with actions (e.g., `setOnboardingModalOpen`, `setRewardsGeoMetadata`, `setSeasonStatus`). > - Integrate reducer in `ui/ducks/index.js` as `rewards`. > - **Selectors**: > - Add `ui/ducks/rewards/selectors.ts` (e.g., `selectRewardsEnabled` with version-gated flag support, and basic state selectors). > - **Async actions (background thunks)**: > - Extend `ui/store/actions.ts` with rewards-related thunks: `validateRewardsReferralCode`, `getRewardsGeoMetadata`, `rewardsOptIn`, `rewardsIsOptInSupported`, `rewardsGetOptInStatus`, `rewardsLinkAccountsToSubscriptionCandidate`, `getRewardsSeasonStatus`, `getRewardsSeasonMetadata`, `estimateRewardsPoints`. > - Add `updateMetaMetricsTraits` helper. > - **Tests**: > - Add unit tests for rewards reducer/actions (`ui/ducks/rewards/index.test.ts`), selectors (`ui/ducks/rewards/selectors.test.ts`), and store actions (`ui/store/actions.test.js`). > - **Configs/fixtures**: > - Update E2E UI state snapshot to include `rewards`. > - Tweak circular deps tracking and `.madgerc` allowed globs. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bd27c29. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 864bf3d commit 05c5a3f

File tree

11 files changed

+1476
-100
lines changed

11 files changed

+1476
-100
lines changed

.madgerc

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
"ui/pages/confirmations/**",
2727
"ui/ducks/**",
2828
"ui/selectors/**",
29-
"ui/hooks/**",
3029
"ui/store/**",
31-
"ui/helpers/utils/token-util.js",
3230
"ui/components/multichain/**",
3331
"ui/components/app/**",
3432
"ui/components/component-library/**",

development/circular-deps.jsonc

Lines changed: 4 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -196,29 +196,6 @@
196196
"ui/components/multichain/index.js"
197197
],
198198
["ui/ducks/alerts/unconnected-account.js", "ui/store/actions.ts"],
199-
[
200-
"ui/ducks/confirm-transaction/confirm-transaction.duck.js",
201-
"ui/ducks/index.js",
202-
"ui/ducks/metamask/metamask.js",
203-
"ui/ducks/send/send.js",
204-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
205-
"ui/pages/confirmations/confirmation/templates/error.js",
206-
"ui/pages/confirmations/confirmation/templates/index.js",
207-
"ui/selectors/selectors.js",
208-
"ui/store/actions.ts",
209-
"ui/store/store.ts"
210-
],
211-
[
212-
"ui/ducks/confirm-transaction/confirm-transaction.duck.js",
213-
"ui/ducks/index.js",
214-
"ui/ducks/metamask/metamask.js",
215-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
216-
"ui/pages/confirmations/confirmation/templates/error.js",
217-
"ui/pages/confirmations/confirmation/templates/index.js",
218-
"ui/selectors/selectors.js",
219-
"ui/store/actions.ts",
220-
"ui/store/store.ts"
221-
],
222199
[
223200
"ui/ducks/confirm-transaction/confirm-transaction.duck.js",
224201
"ui/ducks/metamask/metamask.js",
@@ -231,88 +208,18 @@
231208
"ui/store/actions.ts"
232209
],
233210
[
234-
"ui/ducks/domains.js",
235-
"ui/ducks/index.js",
236-
"ui/store/actions.ts",
237-
"ui/store/store.ts"
238-
],
239-
[
240-
"ui/ducks/index.js",
241-
"ui/ducks/metamask/metamask.js",
242-
"ui/ducks/ramps/ramps.ts",
243-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
244-
"ui/pages/confirmations/confirmation/templates/error.js",
245-
"ui/pages/confirmations/confirmation/templates/index.js",
246-
"ui/selectors/multichain.ts",
247-
"ui/selectors/selectors.js",
248-
"ui/store/actions.ts",
249-
"ui/store/store.ts"
250-
],
251-
[
252-
"ui/ducks/index.js",
253-
"ui/ducks/metamask/metamask.js",
254-
"ui/ducks/swaps/swaps.js",
255-
"ui/hooks/useTokenFiatAmount.js",
256-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
257-
"ui/pages/confirmations/confirmation/templates/error.js",
258-
"ui/pages/confirmations/confirmation/templates/index.js",
259-
"ui/selectors/selectors.js",
260-
"ui/store/actions.ts",
261-
"ui/store/store.ts"
262-
],
263-
[
264-
"ui/ducks/index.js",
265211
"ui/ducks/metamask/metamask.js",
266-
"ui/ducks/swaps/swaps.js",
267212
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
268213
"ui/pages/confirmations/confirmation/templates/error.js",
269214
"ui/pages/confirmations/confirmation/templates/index.js",
270215
"ui/selectors/selectors.js",
271-
"ui/store/actions.ts",
272-
"ui/store/store.ts"
216+
"ui/store/actions.ts"
273217
],
274218
[
275-
"ui/ducks/index.js",
276219
"ui/ducks/metamask/metamask.js",
277-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
278-
"ui/pages/confirmations/confirmation/templates/error.js",
279-
"ui/pages/confirmations/confirmation/templates/index.js",
280-
"ui/selectors/selectors.js",
281-
"ui/store/actions.ts",
282-
"ui/store/store.ts"
283-
],
284-
[
285-
"ui/ducks/index.js",
286-
"ui/ducks/ramps/ramps.ts",
287-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
288-
"ui/pages/confirmations/confirmation/templates/error.js",
289-
"ui/pages/confirmations/confirmation/templates/index.js",
290220
"ui/selectors/multichain.ts",
291-
"ui/selectors/selectors.js",
292-
"ui/store/actions.ts",
293-
"ui/store/store.ts"
294-
],
295-
[
296-
"ui/ducks/index.js",
297-
"ui/ducks/swaps/swaps.js",
298-
"ui/helpers/utils/token-util.js",
299-
"ui/hooks/useTokenFiatAmount.js",
300-
"ui/store/actions.ts",
301-
"ui/store/store.ts"
302-
],
303-
[
304-
"ui/ducks/index.js",
305-
"ui/ducks/swaps/swaps.js",
306-
"ui/store/actions.ts",
307-
"ui/store/store.ts"
308-
],
309-
[
310-
"ui/ducks/metamask/metamask.js",
311-
"ui/pages/confirmations/confirmation/ResultTemplate.ts",
312-
"ui/pages/confirmations/confirmation/templates/error.js",
313-
"ui/pages/confirmations/confirmation/templates/index.js",
314-
"ui/selectors/selectors.js",
315-
"ui/store/actions.ts"
221+
"ui/selectors/selectors.js"
316222
],
317-
["ui/ducks/metamask/metamask.js", "ui/selectors/selectors.js"]
223+
["ui/ducks/metamask/metamask.js", "ui/selectors/selectors.js"],
224+
["ui/selectors/multichain.ts", "ui/selectors/selectors.js"]
318225
]

test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@
428428
"srpSessionData": "object"
429429
},
430430
"ramps": "object",
431+
"rewards": "object",
431432
"send": "object",
432433
"smartAccounts": "object",
433434
"swaps": "object",

ui/ducks/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import historyReducer from './history/history';
1414
import rampsReducer from './ramps/ramps';
1515
import confirmAlertsReducer from './confirm-alerts/confirm-alerts';
1616
import smartAccountsReducer from './smart-accounts/smart-accounts';
17+
import rewardsReducer from './rewards';
1718

1819
export default combineReducers({
1920
[AlertTypes.invalidCustomNetwork]: invalidCustomNetwork,
@@ -32,4 +33,5 @@ export default combineReducers({
3233
gas: gasReducer,
3334
localeMessages: localeMessagesReducer,
3435
smartAccounts: smartAccountsReducer,
36+
rewards: rewardsReducer,
3537
});

ui/ducks/rewards/index.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import configureMockStore from 'redux-mock-store';
2+
import thunk from 'redux-thunk';
3+
4+
import { OnboardingStep } from './types';
5+
import rewardsReducer, {
6+
initialState,
7+
resetRewardsState,
8+
setOnboardingModalOpen,
9+
setOnboardingActiveStep,
10+
setRewardsGeoMetadata,
11+
setRewardsGeoMetadataLoading,
12+
setRewardsGeoMetadataError,
13+
setCandidateSubscriptionId,
14+
setSeasonStatus,
15+
setSeasonStatusLoading,
16+
setSeasonStatusError,
17+
setErrorToast,
18+
} from '.';
19+
20+
describe('Ducks - Rewards', () => {
21+
const middleware = [thunk];
22+
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31973
23+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
24+
const store = configureMockStore<any>(middleware)({ rewards: initialState });
25+
26+
beforeEach(() => {
27+
store.clearActions();
28+
});
29+
30+
describe('reducer', () => {
31+
it('initializes state', () => {
32+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33+
expect(rewardsReducer(undefined as any, {} as any)).toStrictEqual(
34+
initialState,
35+
);
36+
});
37+
38+
it('returns state unchanged for unknown action type', () => {
39+
const mockState = {
40+
...initialState,
41+
onboardingModalOpen: true,
42+
};
43+
expect(
44+
rewardsReducer(mockState, {
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46+
type: 'unknown' as any,
47+
}),
48+
).toStrictEqual(mockState);
49+
});
50+
});
51+
52+
describe('actions + reducer', () => {
53+
it('resetRewardsState resets to initialState', () => {
54+
const mutated = {
55+
...initialState,
56+
onboardingModalOpen: true,
57+
geoLocation: 'US',
58+
optinAllowedForGeo: true,
59+
};
60+
const action = resetRewardsState();
61+
expect(action.type).toBe('rewards/resetRewardsState');
62+
const newState = rewardsReducer(mutated, action);
63+
expect(newState).toStrictEqual(initialState);
64+
});
65+
66+
it('setOnboardingModalOpen updates onboardingModalOpen', () => {
67+
store.dispatch(setOnboardingModalOpen(true));
68+
const actions = store.getActions();
69+
expect(actions[0].type).toBe('rewards/setOnboardingModalOpen');
70+
const newState = rewardsReducer(initialState, actions[0]);
71+
expect(newState.onboardingModalOpen).toBe(true);
72+
});
73+
74+
it('setOnboardingActiveStep updates onboardingActiveStep', () => {
75+
store.dispatch(setOnboardingActiveStep(OnboardingStep.STEP1));
76+
const actions = store.getActions();
77+
expect(actions[0].type).toBe('rewards/setOnboardingActiveStep');
78+
const newState = rewardsReducer(initialState, actions[0]);
79+
expect(newState.onboardingActiveStep).toBe(OnboardingStep.STEP1);
80+
});
81+
82+
it('setRewardsGeoMetadata sets location and opt-in flags when payload provided', () => {
83+
const payload = { geoLocation: 'US', optinAllowedForGeo: true };
84+
store.dispatch(setRewardsGeoMetadata(payload));
85+
const actions = store.getActions();
86+
expect(actions[0].type).toBe('rewards/setRewardsGeoMetadata');
87+
const newState = rewardsReducer(
88+
{ ...initialState, optinAllowedForGeoLoading: true },
89+
actions[0],
90+
);
91+
expect(newState.geoLocation).toBe('US');
92+
expect(newState.optinAllowedForGeo).toBe(true);
93+
expect(newState.optinAllowedForGeoLoading).toBe(false);
94+
});
95+
96+
it('setRewardsGeoMetadata clears fields when payload is null', () => {
97+
store.dispatch(setRewardsGeoMetadata(null));
98+
const actions = store.getActions();
99+
expect(actions[0].type).toBe('rewards/setRewardsGeoMetadata');
100+
const existing = {
101+
...initialState,
102+
geoLocation: 'CA-ON',
103+
optinAllowedForGeo: false,
104+
optinAllowedForGeoLoading: true,
105+
};
106+
const newState = rewardsReducer(existing, actions[0]);
107+
expect(newState.geoLocation).toBeNull();
108+
expect(newState.optinAllowedForGeo).toBeNull();
109+
expect(newState.optinAllowedForGeoLoading).toBe(false);
110+
});
111+
112+
it('setRewardsGeoMetadataLoading updates loading flag', () => {
113+
store.dispatch(setRewardsGeoMetadataLoading(true));
114+
const actions = store.getActions();
115+
expect(actions[0].type).toBe('rewards/setRewardsGeoMetadataLoading');
116+
const newState = rewardsReducer(initialState, actions[0]);
117+
expect(newState.optinAllowedForGeoLoading).toBe(true);
118+
});
119+
120+
it('setRewardsGeoMetadataError updates error flag', () => {
121+
store.dispatch(setRewardsGeoMetadataError(true));
122+
const actions = store.getActions();
123+
expect(actions[0].type).toBe('rewards/setRewardsGeoMetadataError');
124+
const newState = rewardsReducer(initialState, actions[0]);
125+
expect(newState.optinAllowedForGeoError).toBe(true);
126+
});
127+
128+
it('setCandidateSubscriptionId sets id', () => {
129+
store.dispatch(setCandidateSubscriptionId('sub_123'));
130+
const actions = store.getActions();
131+
expect(actions[0].type).toBe('rewards/setCandidateSubscriptionId');
132+
const newState = rewardsReducer(initialState, actions[0]);
133+
expect(newState.candidateSubscriptionId).toBe('sub_123');
134+
});
135+
136+
it('setSeasonStatusLoading updates loading flag', () => {
137+
store.dispatch(setSeasonStatusLoading(true));
138+
const actions = store.getActions();
139+
expect(actions[0].type).toBe('rewards/setSeasonStatusLoading');
140+
const newState = rewardsReducer(initialState, actions[0]);
141+
expect(newState.seasonStatusLoading).toBe(true);
142+
});
143+
144+
it('setSeasonStatus sets season status', () => {
145+
const payload = {
146+
season: {
147+
id: 's1',
148+
name: 'Season 1',
149+
startDate: 0,
150+
endDate: 1,
151+
tiers: [],
152+
},
153+
balance: { total: 100 },
154+
tier: {
155+
currentTier: {
156+
id: 't1',
157+
name: 'Tier 1',
158+
pointsNeeded: 0,
159+
image: { lightModeUrl: '', darkModeUrl: '' },
160+
levelNumber: '1',
161+
rewards: [],
162+
},
163+
nextTier: null,
164+
nextTierPointsNeeded: null,
165+
},
166+
};
167+
store.dispatch(setSeasonStatus(payload));
168+
const actions = store.getActions();
169+
expect(actions[0].type).toBe('rewards/setSeasonStatus');
170+
const newState = rewardsReducer(initialState, actions[0]);
171+
expect(newState.seasonStatus).toStrictEqual(payload);
172+
});
173+
174+
it('setSeasonStatusError sets error string', () => {
175+
store.dispatch(setSeasonStatusError('oops'));
176+
const actions = store.getActions();
177+
expect(actions[0].type).toBe('rewards/setSeasonStatusError');
178+
const newState = rewardsReducer(initialState, actions[0]);
179+
expect(newState.seasonStatusError).toBe('oops');
180+
});
181+
182+
it('setErrorToast updates toast properties', () => {
183+
const toast = {
184+
isOpen: true,
185+
title: 'Error',
186+
description: 'Something went wrong',
187+
actionText: 'Retry',
188+
onActionClick: jest.fn(),
189+
};
190+
store.dispatch(setErrorToast(toast));
191+
const actions = store.getActions();
192+
expect(actions[0].type).toBe('rewards/setErrorToast');
193+
const newState = rewardsReducer(initialState, actions[0]);
194+
expect(newState.errorToast).toStrictEqual(toast);
195+
});
196+
});
197+
});

0 commit comments

Comments
 (0)