Implement event fragments (#12251)
parent
ff27f24ef9
commit
b820ef131b
@ -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, |
||||||
|
}; |
||||||
|
} |
@ -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,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'; |
||||||
|
@ -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, |
||||||
|
); |
@ -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'); |
||||||
|
}); |
||||||
|
}); |
Loading…
Reference in new issue