From b820ef131b06d606c9e049d0dfada0d042bdc7ff Mon Sep 17 00:00:00 2001 From: Brad Decker Date: Wed, 12 Jan 2022 13:31:54 -0600 Subject: [PATCH] Implement event fragments (#12251) --- app/scripts/controllers/metametrics.js | 146 +++++++++++++- app/scripts/controllers/metametrics.test.js | 43 ++++ app/scripts/metamask-controller.js | 9 + shared/constants/metametrics.js | 33 +++ ui/contexts/metametrics.js | 5 - ui/contexts/transaction-modal.js | 4 +- ui/hooks/useEventFragment.js | 100 ++++++++++ ui/hooks/useEventFragment.test.js | 211 ++++++++++++++++++++ ui/hooks/useMetricEvent.js | 46 +++++ ui/selectors/index.js | 1 + ui/selectors/metametrics.js | 36 ++++ ui/selectors/metametrics.test.js | 71 +++++++ ui/store/actions.js | 12 ++ 13 files changed, 707 insertions(+), 10 deletions(-) create mode 100644 ui/hooks/useEventFragment.js create mode 100644 ui/hooks/useEventFragment.test.js create mode 100644 ui/selectors/metametrics.js create mode 100644 ui/selectors/metametrics.test.js diff --git a/app/scripts/controllers/metametrics.js b/app/scripts/controllers/metametrics.js index 4352a09cf..6eacb8772 100644 --- a/app/scripts/controllers/metametrics.js +++ b/app/scripts/controllers/metametrics.js @@ -1,11 +1,13 @@ -import { merge, omit } from 'lodash'; +import { merge, omit, omitBy } from 'lodash'; import { ObservableStore } from '@metamask/obs-store'; import { bufferToHex, keccak } from 'ethereumjs-util'; +import { generateUUID } from 'pubnub'; import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app'; import { METAMETRICS_ANONYMOUS_ID, METAMETRICS_BACKGROUND_PAGE_OBJECT, } from '../../../shared/constants/metametrics'; +import { SECOND } from '../../../shared/constants/time'; const defaultCaptureException = (err) => { // throw error on clean stack so its captured by platform integrations (eg sentry) @@ -27,15 +29,18 @@ const exceptionsToFilter = { * @typedef {import('../../../shared/constants/metametrics').SegmentInterface} SegmentInterface * @typedef {import('../../../shared/constants/metametrics').MetaMetricsPagePayload} MetaMetricsPagePayload * @typedef {import('../../../shared/constants/metametrics').MetaMetricsPageOptions} MetaMetricsPageOptions + * @typedef {import('../../../shared/constants/metametrics').MetaMetricsEventFragment} MetaMetricsEventFragment */ /** * @typedef {Object} MetaMetricsControllerState - * @property {?string} metaMetricsId - The user's metaMetricsId that will be + * @property {string} [metaMetricsId] - The user's metaMetricsId that will be * attached to all non-anonymized event payloads - * @property {?boolean} participateInMetaMetrics - The user's preference for + * @property {boolean} [participateInMetaMetrics] - The user's preference for * participating in the MetaMetrics analytics program. This setting controls * whether or not events are tracked + * @property {{[string]: MetaMetricsEventFragment}} [fragments] - Object keyed + * by UUID with stored fragments as values. */ export default class MetaMetricsController { @@ -81,10 +86,15 @@ export default class MetaMetricsController { this.version = environment === 'production' ? version : `${version}-${environment}`; + const abandonedFragments = omitBy(initState?.fragments, 'persist'); + this.store = new ObservableStore({ participateInMetaMetrics: null, metaMetricsId: null, ...initState, + fragments: { + ...initState?.fragments, + }, }); preferencesStore.subscribe(({ currentLocale }) => { @@ -96,6 +106,32 @@ export default class MetaMetricsController { this.network = getNetworkIdentifier(); }); this.segment = segment; + + // Track abandoned fragments that weren't properly cleaned up. + // Abandoned fragments are those that were stored in persistent memory + // and are available at controller instance creation, but do not have the + // 'persist' flag set. This means anytime the extension is unlocked, any + // fragments that are not marked as persistent will be purged and the + // failure event will be emitted. + Object.values(abandonedFragments).forEach((fragment) => { + this.finalizeEventFragment(fragment.id, { abandoned: true }); + }); + + // Close out event fragments that were created but not progressed. An + // interval is used to routinely check if a fragment has not been updated + // within the fragment's timeout window. When creating a new event fragment + // a timeout can be specified that will cause an abandoned event to be + // tracked if the event isn't progressed within that amount of time. + setInterval(() => { + this.store.getState().fragments.forEach((fragment) => { + if ( + fragment.timeout && + Date.now() - fragment.lastUpdated / 1000 > fragment.timeout + ) { + this.finalizeEventFragment(fragment.id, { abandoned: true }); + } + }); + }, SECOND * 30); } generateMetaMetricsId() { @@ -109,6 +145,110 @@ export default class MetaMetricsController { ); } + /** + * Create an event fragment in state and returns the event fragment object. + * + * @param {MetaMetricsFunnel} options - Fragment settings and properties + * to initiate the fragment with. + * @returns {MetaMetricsFunnel} + */ + createEventFragment(options) { + if (!options.successEvent || !options.category) { + throw new Error( + `Must specify success event and category. Success event was: ${ + options.event + }. Category was: ${options.category}. Payload keys were: ${Object.keys( + options, + )}. ${ + typeof options.properties === 'object' + ? `Payload property keys were: ${Object.keys(options.properties)}` + : '' + }`, + ); + } + const { fragments } = this.store.getState(); + + const id = generateUUID(); + const fragment = { + id, + ...options, + lastUpdated: Date.now(), + }; + this.store.updateState({ + fragments: { + ...fragments, + [id]: fragment, + }, + }); + return fragment; + } + + /** + * Updates an event fragment in state + * + * @param {string} id - The fragment id to update + * @param {MetaMetricsEventFragment} payload - Fragment settings and + * properties to initiate the fragment with. + */ + updateEventFragment(id, payload) { + const { fragments } = this.store.getState(); + + const fragment = fragments[id]; + + if (!fragment) { + throw new Error(`Event fragment with id ${id} does not exist.`); + } + + this.store.updateState({ + fragments: { + ...fragments, + [id]: merge(fragments[id], { + ...payload, + lastUpdated: Date.now(), + }), + }, + }); + } + + /** + * Finalizes a fragment, tracking either a success event or failure Event + * and then removes the fragment from state. + * + * @param {string} id - UUID of the event fragment to be closed + * @param {object} options + * @param {boolean} [options.abandoned] - if true track the failure + * event instead of the success event + * @param {MetaMetricsContext.page} [options.page] - page the final event + * occurred on. This will override whatever is set on the fragment + * @param {MetaMetricsContext.referrer} [options.referrer] - Dapp that + * originated the fragment. This is for fallback only, the fragment referrer + * property will take precedence. + */ + finalizeEventFragment(id, { abandoned = false, page, referrer }) { + const fragment = this.store.getState().fragments[id]; + if (!fragment) { + throw new Error(`Funnel with id ${id} does not exist.`); + } + + const eventName = abandoned ? fragment.failureEvent : fragment.successEvent; + + this.trackEvent({ + event: eventName, + category: fragment.category, + properties: fragment.properties, + sensitiveProperties: fragment.sensitiveProperties, + page: page ?? fragment.page, + referrer: fragment.referrer ?? referrer, + revenue: fragment.revenue, + value: fragment.value, + currency: fragment.currency, + environmentType: fragment.environmentType, + }); + const { fragments } = this.store.getState(); + delete fragments[id]; + this.store.updateState({ fragments }); + } + /** * Setter for the `participateInMetaMetrics` property * diff --git a/app/scripts/controllers/metametrics.test.js b/app/scripts/controllers/metametrics.test.js index 9918d6319..a5c2dc1f5 100644 --- a/app/scripts/controllers/metametrics.test.js +++ b/app/scripts/controllers/metametrics.test.js @@ -81,6 +81,28 @@ function getMockPreferencesStore({ currentLocale = LOCALE } = {}) { }; } +const SAMPLE_PERSISTED_EVENT = { + id: 'testid', + persist: true, + category: 'Unit Test', + successEvent: 'sample persisted event success', + failureEvent: 'sample persisted event failure', + properties: { + test: true, + }, +}; + +const SAMPLE_NON_PERSISTED_EVENT = { + id: 'testid2', + persist: false, + category: 'Unit Test', + successEvent: 'sample non-persisted event success', + failureEvent: 'sample non-persisted event failure', + properties: { + test: true, + }, +}; + function getMetaMetricsController({ participateInMetaMetrics = true, metaMetricsId = TEST_META_METRICS_ID, @@ -105,12 +127,29 @@ function getMetaMetricsController({ initState: { participateInMetaMetrics, metaMetricsId, + fragments: { + testid: SAMPLE_PERSISTED_EVENT, + testid2: SAMPLE_NON_PERSISTED_EVENT, + }, }, }); } describe('MetaMetricsController', function () { describe('constructor', function () { it('should properly initialize', function () { + const mock = sinon.mock(segment); + mock + .expects('track') + .once() + .withArgs({ + event: 'sample non-persisted event failure', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + test: true, + }, + }); const metaMetricsController = getMetaMetricsController(); assert.strictEqual(metaMetricsController.version, VERSION); assert.strictEqual(metaMetricsController.network, NETWORK); @@ -127,6 +166,10 @@ describe('MetaMetricsController', function () { metaMetricsController.locale, LOCALE.replace('_', '-'), ); + assert.deepStrictEqual(metaMetricsController.state.fragments, { + testid: SAMPLE_PERSISTED_EVENT, + }); + mock.verify(); }); it('should update when network changes', function () { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index f5ef9cfef..205472581 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1408,6 +1408,15 @@ export default class MetamaskController extends EventEmitter { trackMetaMetricsPage: metaMetricsController.trackPage.bind( metaMetricsController, ), + createEventFragment: metaMetricsController.createEventFragment.bind( + metaMetricsController, + ), + updateEventFragment: metaMetricsController.updateEventFragment.bind( + metaMetricsController, + ), + finalizeEventFragment: metaMetricsController.finalizeEventFragment.bind( + metaMetricsController, + ), // approval controller resolvePendingApproval: approvalController.accept.bind( diff --git a/shared/constants/metametrics.js b/shared/constants/metametrics.js index e0f503234..3ea3052e0 100644 --- a/shared/constants/metametrics.js +++ b/shared/constants/metametrics.js @@ -82,6 +82,39 @@ * segment source that marks the event data as not conforming to our schema */ +/** + * @typedef {Object} MetaMetricsEventFragment + * @property {string} successEvent - The event name to fire when the fragment + * is closed in an affirmative action. + * @property {string} [failureEvent] - The event name to fire when the fragment + * is closed with a rejection. + * @property {string} category - the event category to use for both the success + * and failure events + * @property {boolean} [persist] - Should this fragment be persisted in + * state and progressed after the extension is locked and unlocked. + * @property {number} [timeout] - Time in seconds the event should be persisted + * for. After the timeout the fragment will be closed as abandoned. if not + * supplied the fragment is stored indefinitely. + * @property {number} [lastUpdated] - Date.now() when the fragment was last + * updated. Used to determine if the timeout has expired and the fragment + * should be closed. + * @property {object} [properties] - Object of custom values to track, keys in + * this object must be in snake_case. + * @property {object} [sensitiveProperties] - Object of sensitive values to + * track. Keys in this object must be in snake_case. These properties will be + * sent in an additional event that excludes the user's metaMetricsId + * @property {number} [revenue] - amount of currency that event creates in + * revenue for MetaMask if fragment is successful. + * @property {string} [currency] - ISO 4127 format currency for events with + * revenue, defaults to US dollars + * @property {number} [value] - Abstract business "value" attributable to + * customers who successfully complete this fragment + * @property {MetaMetricsPageObject} [page] - the page/route that the event + * occurred on + * @property {MetaMetricsReferrerObject} [referrer] - the origin of the dapp + * that initiated the event fragment. + */ + /** * Represents the shape of data sent to the segment.track method. * diff --git a/ui/contexts/metametrics.js b/ui/contexts/metametrics.js index 3e2b9786a..bb23afef8 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -3,7 +3,6 @@ import React, { createContext, useEffect, useCallback, - useContext, useState, } from 'react'; import { useSelector } from 'react-redux'; @@ -125,10 +124,6 @@ export function MetaMetricsProvider({ children }) { MetaMetricsProvider.propTypes = { children: PropTypes.node }; -export function useMetaMetricsContext() { - return useContext(MetaMetricsContext); -} - export class LegacyMetaMetricsProvider extends Component { static propTypes = { children: PropTypes.node, diff --git a/ui/contexts/transaction-modal.js b/ui/contexts/transaction-modal.js index 626e623d5..d7967ba7f 100644 --- a/ui/contexts/transaction-modal.js +++ b/ui/contexts/transaction-modal.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { TRANSACTION_TYPES } from '../../shared/constants/transaction'; import { getMethodName } from '../helpers/utils/metrics'; import { useGasFeeContext } from './gasFee'; -import { useMetaMetricsContext } from './metametrics'; +import { MetaMetricsContext } from './metametrics'; export const TransactionModalContext = createContext({}); @@ -15,7 +15,7 @@ export const TransactionModalContextProvider = ({ captureEventEnabled = true, }) => { const [openModals, setOpenModals] = useState([]); - const metricsEvent = useMetaMetricsContext(); + const metricsEvent = useContext(MetaMetricsContext); const { transaction: { origin } = {} } = useGasFeeContext(); const captureEvent = () => { diff --git a/ui/hooks/useEventFragment.js b/ui/hooks/useEventFragment.js new file mode 100644 index 000000000..abdbb4244 --- /dev/null +++ b/ui/hooks/useEventFragment.js @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { getEnvironmentType } from '../../app/scripts/lib/util'; +import { selectMatchingFragment } from '../selectors'; +import { + finalizeEventFragment, + createEventFragment, + updateEventFragment, +} from '../store/actions'; +import { useMetaMetricsContext } from './useMetricEvent'; + +/** + * Retrieves a fragment from memory or initializes new fragment if one does not + * exist. Returns three methods that are tied to the fragment, as well as the + * fragment id. + * + * @param {string} existingId + * @param {Object} fragmentOptions + * @returns + */ +export function useEventFragment(existingId, fragmentOptions) { + // To prevent overcalling the createEventFragment background method a ref + // is used to store a boolean value of whether we have already called the + // method. + const createEventFragmentCalled = useRef(false); + + // In order to immediately return a created fragment, instead of waiting for + // background state to update and find the newly created fragment, we have a + // state element that is updated with the fragmentId returned from the + // call into the background process. + const [createdFragmentId, setCreatedFragmentId] = useState(undefined); + + // Select a matching fragment from state if one exists that matches the + // criteria. If an existingId is passed in it is preferred, if not and the + // fragmentOptions has the persist key set to true, a fragment with matching + // successEvent will be pulled from memory if it exists. + const fragment = useSelector((state) => + selectMatchingFragment(state, { + fragmentOptions, + existingId: existingId ?? createdFragmentId, + }), + ); + + // If no valid existing fragment can be found, a new one must be created that + // will then be found by the selector above. To do this, invoke the + // createEventFragment method with the fragmentOptions and current sessionId. + // As soon as we call the background method we also update the + // createEventFragmentCalled ref's current value to true so that future calls + // are suppressed. + useEffect(() => { + if (fragment === undefined && createEventFragmentCalled.current === false) { + createEventFragmentCalled.current = true; + createEventFragment({ + ...fragmentOptions, + environmentType: getEnvironmentType(), + }).then((createdFragment) => { + setCreatedFragmentId(createdFragment.id); + }); + } + }, [fragment, fragmentOptions]); + + const context = useMetaMetricsContext(); + + /** + * trackSuccess is used to close a fragment with the affirmative action. This + * method is just a thin wrapper around the background method that sets the + * necessary values. + */ + const trackSuccess = useCallback(() => { + finalizeEventFragment(fragment.id, { context }); + }, [fragment, context]); + + /** + * trackFailure is used to close a fragment as abandoned. This method is just a + * thin wrapper around the background method that sets the necessary values. + */ + const trackFailure = useCallback(() => { + finalizeEventFragment(fragment.id, { abandoned: true, context }); + }, [fragment, context]); + + /** + * updateEventFragmentProperties is a thin wrapper around updateEventFragment + * that supplies the fragment id as the first parameter. This function will + * be passed back from the hook as 'updateEventFragment', but is named + * updateEventFragmentProperties to avoid naming conflicts. + */ + const updateEventFragmentProperties = useCallback( + (payload) => { + updateEventFragment(fragment.id, payload); + }, + [fragment], + ); + + return { + trackSuccess, + trackFailure, + updateEventFragment: updateEventFragmentProperties, + fragment, + }; +} diff --git a/ui/hooks/useEventFragment.test.js b/ui/hooks/useEventFragment.test.js new file mode 100644 index 000000000..f40febd82 --- /dev/null +++ b/ui/hooks/useEventFragment.test.js @@ -0,0 +1,211 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { + finalizeEventFragment, + createEventFragment, + updateEventFragment, +} from '../store/actions'; +import { useEventFragment } from './useEventFragment'; + +jest.mock('../store/actions', () => ({ + finalizeEventFragment: jest.fn(), + updateEventFragment: jest.fn(), + createEventFragment: jest.fn(), +})); + +jest.mock('./useMetricEvent', () => ({ + useMetaMetricsContext: jest.fn(() => ({ page: '/' })), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +describe('useEventFragment', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('return shape', () => { + let value; + beforeAll(async () => { + useSelector.mockImplementation((selector) => + selector({ metamask: { fragments: { testid: { id: 'testid' } } } }), + ); + createEventFragment.mockImplementation(() => + Promise.resolve({ + id: 'testid', + }), + ); + const { result, waitForNextUpdate } = renderHook(() => + useEventFragment(undefined, { + successEvent: 'success', + failureEvent: 'failure', + persist: true, + }), + ); + await waitForNextUpdate(); + value = result.current; + }); + + it('should have trackSuccess method', () => { + expect(value).toHaveProperty('trackSuccess'); + expect(typeof value.trackSuccess).toBe('function'); + }); + + it('should have trackFailure method', () => { + expect(value).toHaveProperty('trackFailure'); + expect(typeof value.trackFailure).toBe('function'); + }); + + it('should have updateEventFragment method', () => { + expect(value).toHaveProperty('updateEventFragment'); + expect(typeof value.updateEventFragment).toBe('function'); + }); + + it('should have fragment property', () => { + expect(value).toHaveProperty('fragment'); + expect(value.fragment).toMatchObject({ + id: 'testid', + }); + }); + }); + + describe('identifying appropriate fragment', () => { + it('should create a new fragment when a matching fragment does not exist', async () => { + useSelector.mockImplementation((selector) => + selector({ + metamask: { + fragments: { + testid: { + id: 'testid', + successEvent: 'success', + failureEvent: 'failure', + }, + }, + }, + }), + ); + createEventFragment.mockImplementation(() => + Promise.resolve({ + id: 'testid', + }), + ); + const { result, waitForNextUpdate } = renderHook(() => + useEventFragment(undefined, { + successEvent: 'success', + failureEvent: 'failure', + }), + ); + await waitForNextUpdate(); + expect(createEventFragment).toHaveBeenCalledTimes(1); + const returnValue = result.current; + expect(returnValue.fragment).toMatchObject({ + id: 'testid', + successEvent: 'success', + failureEvent: 'failure', + }); + }); + + it('should return the matching fragment by id when existingId is provided', async () => { + useSelector.mockImplementation((selector) => + selector({ + metamask: { + fragments: { + testid: { + id: 'testid', + successEvent: 'success', + failureEvent: 'failure', + }, + }, + }, + }), + ); + const { result } = renderHook(() => + useEventFragment('testid', { + successEvent: 'success', + failureEvent: 'failure', + }), + ); + const returnValue = result.current; + expect(returnValue.fragment).toMatchObject({ + id: 'testid', + successEvent: 'success', + failureEvent: 'failure', + }); + }); + + it('should return matching fragment by successEvent when no id is provided, but persist is true', async () => { + useSelector.mockImplementation((selector) => + selector({ + metamask: { + fragments: { + testid: { + persist: true, + id: 'testid', + successEvent: 'track new event', + }, + }, + }, + }), + ); + const { result } = renderHook(() => + useEventFragment(undefined, { + successEvent: 'track new event', + persist: true, + }), + ); + const returnValue = result.current; + expect(returnValue.fragment).toMatchObject({ + id: 'testid', + persist: true, + successEvent: 'track new event', + }); + }); + }); + + describe('methods', () => { + let value; + beforeAll(async () => { + useSelector.mockImplementation((selector) => + selector({ metamask: { fragments: { testid: { id: 'testid' } } } }), + ); + createEventFragment.mockImplementation(() => + Promise.resolve({ + id: 'testid', + }), + ); + const { result, waitForNextUpdate } = renderHook(() => + useEventFragment(undefined, { + successEvent: 'success', + failureEvent: 'failure', + persist: true, + }), + ); + await waitForNextUpdate(); + value = result.current; + }); + + it('trackSuccess method should invoke the background finalizeEventFragment method', () => { + value.trackSuccess(); + expect(finalizeEventFragment).toHaveBeenCalledWith('testid', { + context: { page: '/' }, + }); + }); + + it('trackFailure method should invoke the background finalizeEventFragment method', () => { + value.trackFailure(); + expect(finalizeEventFragment).toHaveBeenCalledWith('testid', { + abandoned: true, + context: { page: '/' }, + }); + }); + + it('updateEventFragment method should invoke the background updateEventFragment method', () => { + value.updateEventFragment({ properties: { count: 1 } }); + expect(updateEventFragment).toHaveBeenCalledWith('testid', { + properties: { count: 1 }, + }); + }); + }); +}); diff --git a/ui/hooks/useMetricEvent.js b/ui/hooks/useMetricEvent.js index 4e9fb8323..f6e44574a 100644 --- a/ui/hooks/useMetricEvent.js +++ b/ui/hooks/useMetricEvent.js @@ -1,6 +1,10 @@ import { useContext, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useRouteMatch } from 'react-router-dom'; import { MetaMetricsContext } from '../contexts/metametrics'; import { MetaMetricsContext as NewMetaMetricsContext } from '../contexts/metametrics.new'; +import { PATH_NAME_MAP } from '../helpers/constants/routes'; +import { txDataSelector } from '../selectors'; import { useEqualityCheck } from './useEqualityCheck'; // Type imports @@ -38,3 +42,45 @@ export function useNewMetricEvent(payload, options) { memoizedOptions, ]); } + +const PATHS_TO_CHECK = Object.keys(PATH_NAME_MAP); + +/** + * Returns the current page if it matches our route map, as well as the origin + * if there is a confirmation that was triggered by a dapp. These values are + * not required but add valuable context to events, and should be included in + * the context object on the event payload. + * + * @returns {{ + * page?: MetaMetricsPageObject + * referrer?: MetaMetricsReferrerObject + * }} + */ +export function useMetaMetricsContext() { + const match = useRouteMatch({ + path: PATHS_TO_CHECK, + exact: true, + strict: true, + }); + const txData = useSelector(txDataSelector) || {}; + const confirmTransactionOrigin = txData.origin; + + const referrer = confirmTransactionOrigin + ? { + url: confirmTransactionOrigin, + } + : undefined; + + const page = match + ? { + path: match.path, + title: PATH_NAME_MAP[match.path], + url: match.path, + } + : undefined; + + return { + page, + referrer, + }; +} diff --git a/ui/selectors/index.js b/ui/selectors/index.js index 3f4ff3b0e..28d8e9e34 100644 --- a/ui/selectors/index.js +++ b/ui/selectors/index.js @@ -1,6 +1,7 @@ export * from './confirm-transaction'; export * from './custom-gas'; export * from './first-time-flow'; +export * from './metametrics'; export * from './permissions'; export * from './selectors'; export * from './transactions'; diff --git a/ui/selectors/metametrics.js b/ui/selectors/metametrics.js new file mode 100644 index 000000000..377181a3e --- /dev/null +++ b/ui/selectors/metametrics.js @@ -0,0 +1,36 @@ +import { createSelector } from 'reselect'; + +export const selectFragments = (state) => state.metamask.fragments; + +export const selectFragmentBySuccessEvent = createSelector( + selectFragments, + (_, fragmentOptions) => fragmentOptions, + (fragments, fragmentOptions) => { + if (fragmentOptions.persist) { + return Object.values(fragments).find( + (fragment) => fragment.successEvent === fragmentOptions.successEvent, + ); + } + return undefined; + }, +); + +export const selectFragmentById = createSelector( + selectFragments, + (_, fragmentId) => fragmentId, + (fragments, fragmentId) => { + // A valid existing fragment must exist in state. + // If these conditions are not meant we will create a new fragment. + if (fragmentId && fragments?.[fragmentId]) { + return fragments[fragmentId]; + } + return undefined; + }, +); + +export const selectMatchingFragment = createSelector( + (state, params) => + selectFragmentBySuccessEvent(state, params.fragmentOptions), + (state, params) => selectFragmentById(state, params.existingId), + (matchedBySuccessEvent, matchedById) => matchedById ?? matchedBySuccessEvent, +); diff --git a/ui/selectors/metametrics.test.js b/ui/selectors/metametrics.test.js new file mode 100644 index 000000000..13185a477 --- /dev/null +++ b/ui/selectors/metametrics.test.js @@ -0,0 +1,71 @@ +const { + selectFragmentBySuccessEvent, + selectFragmentById, + selectMatchingFragment, +} = require('.'); + +describe('selectFragmentBySuccessEvent', () => { + it('should find matching fragment in state by successEvent', () => { + const state = { + metamask: { + fragments: { + randomid: { + successEvent: 'example event', + persist: true, + id: 'randomid', + }, + }, + }, + }; + const selected = selectFragmentBySuccessEvent(state, { + successEvent: 'example event', + persist: true, + }); + expect(selected).toHaveProperty('id', 'randomid'); + }); +}); + +describe('selectFragmentById', () => { + it('should find matching fragment in state by id', () => { + const state = { + metamask: { + fragments: { + randomid: { + successEvent: 'example event', + persist: true, + id: 'randomid', + }, + }, + }, + }; + const selected = selectFragmentById(state, 'randomid'); + expect(selected).toHaveProperty('id', 'randomid'); + }); +}); + +describe('selectMatchingFragment', () => { + it('should find matching fragment in state by id', () => { + const state = { + metamask: { + fragments: { + notthecorrectid: { + successEvent: 'event name', + id: 'notthecorrectid', + }, + randomid: { + successEvent: 'example event', + persist: true, + id: 'randomid', + }, + }, + }, + }; + const selected = selectMatchingFragment(state, { + fragmentOptions: { + successEvent: 'event name', + }, + existingId: 'randomid', + }); + expect(selected).toHaveProperty('id', 'randomid'); + }); +}); diff --git a/ui/store/actions.js b/ui/store/actions.js index c6be03f9b..480c34ee0 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -3072,6 +3072,18 @@ export function trackMetaMetricsEvent(payload, options) { return promisifiedBackground.trackMetaMetricsEvent(payload, options); } +export function createEventFragment(options) { + return promisifiedBackground.createEventFragment(options); +} + +export function updateEventFragment(id, payload) { + return promisifiedBackground.updateEventFragment(id, payload); +} + +export function finalizeEventFragment(id, options) { + return promisifiedBackground.finalizeEventFragment(id, options); +} + /** * @param {MetaMetricsPagePayload} payload - details of the page viewed * @param {MetaMetricsPageOptions} options - options for handling the page view