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 './custom-gas'; |
||||
export * from './first-time-flow'; |
||||
export * from './metametrics'; |
||||
export * from './permissions'; |
||||
export * from './selectors'; |
||||
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