mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 01:47:00 +01:00
Implement event fragments (#12251)
This commit is contained in:
parent
ff27f24ef9
commit
b820ef131b
@ -1,11 +1,13 @@
|
|||||||
import { merge, omit } from 'lodash';
|
import { merge, omit, omitBy } from 'lodash';
|
||||||
import { ObservableStore } from '@metamask/obs-store';
|
import { ObservableStore } from '@metamask/obs-store';
|
||||||
import { bufferToHex, keccak } from 'ethereumjs-util';
|
import { bufferToHex, keccak } from 'ethereumjs-util';
|
||||||
|
import { generateUUID } from 'pubnub';
|
||||||
import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
|
import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
|
||||||
import {
|
import {
|
||||||
METAMETRICS_ANONYMOUS_ID,
|
METAMETRICS_ANONYMOUS_ID,
|
||||||
METAMETRICS_BACKGROUND_PAGE_OBJECT,
|
METAMETRICS_BACKGROUND_PAGE_OBJECT,
|
||||||
} from '../../../shared/constants/metametrics';
|
} from '../../../shared/constants/metametrics';
|
||||||
|
import { SECOND } from '../../../shared/constants/time';
|
||||||
|
|
||||||
const defaultCaptureException = (err) => {
|
const defaultCaptureException = (err) => {
|
||||||
// throw error on clean stack so its captured by platform integrations (eg sentry)
|
// 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').SegmentInterface} SegmentInterface
|
||||||
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsPagePayload} MetaMetricsPagePayload
|
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsPagePayload} MetaMetricsPagePayload
|
||||||
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsPageOptions} MetaMetricsPageOptions
|
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsPageOptions} MetaMetricsPageOptions
|
||||||
|
* @typedef {import('../../../shared/constants/metametrics').MetaMetricsEventFragment} MetaMetricsEventFragment
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} MetaMetricsControllerState
|
* @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
|
* 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
|
* participating in the MetaMetrics analytics program. This setting controls
|
||||||
* whether or not events are tracked
|
* whether or not events are tracked
|
||||||
|
* @property {{[string]: MetaMetricsEventFragment}} [fragments] - Object keyed
|
||||||
|
* by UUID with stored fragments as values.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default class MetaMetricsController {
|
export default class MetaMetricsController {
|
||||||
@ -81,10 +86,15 @@ export default class MetaMetricsController {
|
|||||||
this.version =
|
this.version =
|
||||||
environment === 'production' ? version : `${version}-${environment}`;
|
environment === 'production' ? version : `${version}-${environment}`;
|
||||||
|
|
||||||
|
const abandonedFragments = omitBy(initState?.fragments, 'persist');
|
||||||
|
|
||||||
this.store = new ObservableStore({
|
this.store = new ObservableStore({
|
||||||
participateInMetaMetrics: null,
|
participateInMetaMetrics: null,
|
||||||
metaMetricsId: null,
|
metaMetricsId: null,
|
||||||
...initState,
|
...initState,
|
||||||
|
fragments: {
|
||||||
|
...initState?.fragments,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
preferencesStore.subscribe(({ currentLocale }) => {
|
preferencesStore.subscribe(({ currentLocale }) => {
|
||||||
@ -96,6 +106,32 @@ export default class MetaMetricsController {
|
|||||||
this.network = getNetworkIdentifier();
|
this.network = getNetworkIdentifier();
|
||||||
});
|
});
|
||||||
this.segment = segment;
|
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() {
|
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
|
* Setter for the `participateInMetaMetrics` property
|
||||||
*
|
*
|
||||||
|
@ -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({
|
function getMetaMetricsController({
|
||||||
participateInMetaMetrics = true,
|
participateInMetaMetrics = true,
|
||||||
metaMetricsId = TEST_META_METRICS_ID,
|
metaMetricsId = TEST_META_METRICS_ID,
|
||||||
@ -105,12 +127,29 @@ function getMetaMetricsController({
|
|||||||
initState: {
|
initState: {
|
||||||
participateInMetaMetrics,
|
participateInMetaMetrics,
|
||||||
metaMetricsId,
|
metaMetricsId,
|
||||||
|
fragments: {
|
||||||
|
testid: SAMPLE_PERSISTED_EVENT,
|
||||||
|
testid2: SAMPLE_NON_PERSISTED_EVENT,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
describe('MetaMetricsController', function () {
|
describe('MetaMetricsController', function () {
|
||||||
describe('constructor', function () {
|
describe('constructor', function () {
|
||||||
it('should properly initialize', 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();
|
const metaMetricsController = getMetaMetricsController();
|
||||||
assert.strictEqual(metaMetricsController.version, VERSION);
|
assert.strictEqual(metaMetricsController.version, VERSION);
|
||||||
assert.strictEqual(metaMetricsController.network, NETWORK);
|
assert.strictEqual(metaMetricsController.network, NETWORK);
|
||||||
@ -127,6 +166,10 @@ describe('MetaMetricsController', function () {
|
|||||||
metaMetricsController.locale,
|
metaMetricsController.locale,
|
||||||
LOCALE.replace('_', '-'),
|
LOCALE.replace('_', '-'),
|
||||||
);
|
);
|
||||||
|
assert.deepStrictEqual(metaMetricsController.state.fragments, {
|
||||||
|
testid: SAMPLE_PERSISTED_EVENT,
|
||||||
|
});
|
||||||
|
mock.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update when network changes', function () {
|
it('should update when network changes', function () {
|
||||||
|
@ -1408,6 +1408,15 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
trackMetaMetricsPage: metaMetricsController.trackPage.bind(
|
trackMetaMetricsPage: metaMetricsController.trackPage.bind(
|
||||||
metaMetricsController,
|
metaMetricsController,
|
||||||
),
|
),
|
||||||
|
createEventFragment: metaMetricsController.createEventFragment.bind(
|
||||||
|
metaMetricsController,
|
||||||
|
),
|
||||||
|
updateEventFragment: metaMetricsController.updateEventFragment.bind(
|
||||||
|
metaMetricsController,
|
||||||
|
),
|
||||||
|
finalizeEventFragment: metaMetricsController.finalizeEventFragment.bind(
|
||||||
|
metaMetricsController,
|
||||||
|
),
|
||||||
|
|
||||||
// approval controller
|
// approval controller
|
||||||
resolvePendingApproval: approvalController.accept.bind(
|
resolvePendingApproval: approvalController.accept.bind(
|
||||||
|
@ -82,6 +82,39 @@
|
|||||||
* segment source that marks the event data as not conforming to our schema
|
* 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.
|
* Represents the shape of data sent to the segment.track method.
|
||||||
*
|
*
|
||||||
|
@ -3,7 +3,6 @@ import React, {
|
|||||||
createContext,
|
createContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@ -125,10 +124,6 @@ export function MetaMetricsProvider({ children }) {
|
|||||||
|
|
||||||
MetaMetricsProvider.propTypes = { children: PropTypes.node };
|
MetaMetricsProvider.propTypes = { children: PropTypes.node };
|
||||||
|
|
||||||
export function useMetaMetricsContext() {
|
|
||||||
return useContext(MetaMetricsContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LegacyMetaMetricsProvider extends Component {
|
export class LegacyMetaMetricsProvider extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { TRANSACTION_TYPES } from '../../shared/constants/transaction';
|
import { TRANSACTION_TYPES } from '../../shared/constants/transaction';
|
||||||
import { getMethodName } from '../helpers/utils/metrics';
|
import { getMethodName } from '../helpers/utils/metrics';
|
||||||
import { useGasFeeContext } from './gasFee';
|
import { useGasFeeContext } from './gasFee';
|
||||||
import { useMetaMetricsContext } from './metametrics';
|
import { MetaMetricsContext } from './metametrics';
|
||||||
|
|
||||||
export const TransactionModalContext = createContext({});
|
export const TransactionModalContext = createContext({});
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export const TransactionModalContextProvider = ({
|
|||||||
captureEventEnabled = true,
|
captureEventEnabled = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [openModals, setOpenModals] = useState([]);
|
const [openModals, setOpenModals] = useState([]);
|
||||||
const metricsEvent = useMetaMetricsContext();
|
const metricsEvent = useContext(MetaMetricsContext);
|
||||||
const { transaction: { origin } = {} } = useGasFeeContext();
|
const { transaction: { origin } = {} } = useGasFeeContext();
|
||||||
|
|
||||||
const captureEvent = () => {
|
const captureEvent = () => {
|
||||||
|
100
ui/hooks/useEventFragment.js
Normal file
100
ui/hooks/useEventFragment.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
211
ui/hooks/useEventFragment.test.js
Normal file
211
ui/hooks/useEventFragment.test.js
Normal file
@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,6 +1,10 @@
|
|||||||
import { useContext, useCallback } from 'react';
|
import { useContext, useCallback } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
import { MetaMetricsContext } from '../contexts/metametrics';
|
import { MetaMetricsContext } from '../contexts/metametrics';
|
||||||
import { MetaMetricsContext as NewMetaMetricsContext } from '../contexts/metametrics.new';
|
import { MetaMetricsContext as NewMetaMetricsContext } from '../contexts/metametrics.new';
|
||||||
|
import { PATH_NAME_MAP } from '../helpers/constants/routes';
|
||||||
|
import { txDataSelector } from '../selectors';
|
||||||
import { useEqualityCheck } from './useEqualityCheck';
|
import { useEqualityCheck } from './useEqualityCheck';
|
||||||
|
|
||||||
// Type imports
|
// Type imports
|
||||||
@ -38,3 +42,45 @@ export function useNewMetricEvent(payload, options) {
|
|||||||
memoizedOptions,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export * from './confirm-transaction';
|
export * from './confirm-transaction';
|
||||||
export * from './custom-gas';
|
export * from './custom-gas';
|
||||||
export * from './first-time-flow';
|
export * from './first-time-flow';
|
||||||
|
export * from './metametrics';
|
||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
export * from './selectors';
|
export * from './selectors';
|
||||||
export * from './transactions';
|
export * from './transactions';
|
||||||
|
36
ui/selectors/metametrics.js
Normal file
36
ui/selectors/metametrics.js
Normal file
@ -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,
|
||||||
|
);
|
71
ui/selectors/metametrics.test.js
Normal file
71
ui/selectors/metametrics.test.js
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -3072,6 +3072,18 @@ export function trackMetaMetricsEvent(payload, options) {
|
|||||||
return promisifiedBackground.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 {MetaMetricsPagePayload} payload - details of the page viewed
|
||||||
* @param {MetaMetricsPageOptions} options - options for handling the page view
|
* @param {MetaMetricsPageOptions} options - options for handling the page view
|
||||||
|
Loading…
Reference in New Issue
Block a user